refactor(db): migrate staff and app actions from Prisma facade to Drizzle

Co-authored-by: Cursor <[email protected]>
This commit is contained in:
SimoandCursor committed 2026-07-31 21:35:05 +02:00
1 parent 9854719cfd
commit ed9c23c702
85 files changed
+2343 -1563

No files matched your search

+23 -11
View File
@@ -4,15 +4,29 @@ import { redirect } from "next/navigation";
import { beforeEach, describe, expect, it, vi } from "vitest"; import { beforeEach, describe, expect, it, vi } from "vitest";
import { requirePermission } from "@/lib/admin/guard"; import { requirePermission } from "@/lib/admin/guard";
import { logger } from "@/lib/logger"; import { logger } from "@/lib/logger";
import { prisma } from "@/lib/prisma";
import { ActionError } from "@/lib/safe-action-shared"; import { ActionError } from "@/lib/safe-action-shared";
import { logStaffActivity } from "@/lib/services/staff-activity"; import { logStaffActivity } from "@/lib/services/staff-activity";
import { createAd, deleteAd } from "./admin-ads"; import { createAd, deleteAd } from "./admin-ads";
const { insertValues, deleteWhere } = vi.hoisted(() => {
const insertValues = vi.fn().mockResolvedValue([{ insertId: 1 }]);
const deleteWhere = vi.fn().mockResolvedValue([{ affectedRows: 1 }]);
return { insertValues, deleteWhere };
});
vi.mock("@/lib/admin/guard", () => ({ requirePermission: vi.fn() })); vi.mock("@/lib/admin/guard", () => ({ requirePermission: vi.fn() }));
vi.mock("@/lib/permissions", () => ({ PERMS: { PAGES_EDIT: "pages.edit" } })); vi.mock("@/lib/permissions", () => ({ PERMS: { PAGES_EDIT: "pages.edit" } }));
vi.mock("@/lib/prisma", () => ({ vi.mock("@/lib/db", () => ({
prisma: { websiteAds: { create: vi.fn(), update: vi.fn(), delete: vi.fn() } }, db: {
insert: vi.fn(() => ({ values: insertValues })),
update: vi.fn(() => ({
set: vi.fn(() => ({
where: vi.fn().mockResolvedValue([{ affectedRows: 1 }]),
})),
})),
delete: vi.fn(() => ({ where: deleteWhere })),
},
WebsiteAds: { id: "id" },
})); }));
vi.mock("@/lib/logger", () => ({ logger: { error: vi.fn() } })); vi.mock("@/lib/logger", () => ({ logger: { error: vi.fn() } }));
vi.mock("@/lib/safe-action", () => ({ vi.mock("@/lib/safe-action", () => ({
@@ -34,28 +48,27 @@ const fakeForm = (data: Record<string, string>) => ({
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
vi.mocked(requirePermission).mockResolvedValue(staff as never); vi.mocked(requirePermission).mockResolvedValue(staff as never);
insertValues.mockResolvedValue([{ insertId: 1 }]);
deleteWhere.mockResolvedValue([{ affectedRows: 1 }]);
}); });
describe("createAd", () => { describe("createAd", () => {
it("creates ad and redirects", async () => { it("creates ad and redirects", async () => {
vi.mocked(prisma.websiteAds.create).mockResolvedValue({
id: BigInt(1),
} as never);
await createAd( await createAd(
fakeForm({ image: "https://example.com/ad.png" }) as unknown as FormData, fakeForm({ image: "https://example.com/ad.png" }) as unknown as FormData,
); );
expect(prisma.websiteAds.create).toHaveBeenCalled(); expect(insertValues).toHaveBeenCalled();
expect(logStaffActivity).toHaveBeenCalled(); expect(logStaffActivity).toHaveBeenCalled();
expect(redirect).toHaveBeenCalledWith("/admin/ads"); expect(redirect).toHaveBeenCalledWith("/admin/ads");
}); });
it("returns early when image empty", async () => { it("returns early when image empty", async () => {
await createAd(fakeForm({ image: "" }) as unknown as FormData); await createAd(fakeForm({ image: "" }) as unknown as FormData);
expect(prisma.websiteAds.create).not.toHaveBeenCalled(); expect(insertValues).not.toHaveBeenCalled();
}); });
it("logs error on db failure", async () => { it("logs error on db failure", async () => {
vi.mocked(prisma.websiteAds.create).mockRejectedValue(new Error("db")); insertValues.mockRejectedValue(new Error("db"));
await createAd(fakeForm({ image: "x" }) as unknown as FormData); await createAd(fakeForm({ image: "x" }) as unknown as FormData);
expect(logger.error).toHaveBeenCalled(); expect(logger.error).toHaveBeenCalled();
}); });
@@ -63,7 +76,6 @@ describe("createAd", () => {
describe("deleteAd", () => { describe("deleteAd", () => {
it("deletes ad and returns ok", async () => { it("deletes ad and returns ok", async () => {
vi.mocked(prisma.websiteAds.delete).mockResolvedValue({} as never);
const h = deleteAd as unknown as (ctx: { const h = deleteAd as unknown as (ctx: {
data: { id: bigint }; data: { id: bigint };
session: { user: { id: string } }; session: { user: { id: string } };
@@ -74,7 +86,7 @@ describe("deleteAd", () => {
}); });
it("throws ActionError when not found", async () => { it("throws ActionError when not found", async () => {
vi.mocked(prisma.websiteAds.delete).mockRejectedValue(new Error("nf")); deleteWhere.mockResolvedValue([{ affectedRows: 0 }]);
const h = deleteAd as unknown as (ctx: { const h = deleteAd as unknown as (ctx: {
data: { id: bigint }; data: { id: bigint };
session: { user: { id: string } }; session: { user: { id: string } };
+23 -13
View File
@@ -1,13 +1,15 @@
"use server"; "use server";
import { eq } from "drizzle-orm";
import type { ResultSetHeader } from "mysql2";
import { revalidatePath } from "next/cache"; import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { z } from "zod"; import { z } from "zod";
import { requirePermission } from "@/lib/admin/guard"; import { requirePermission } from "@/lib/admin/guard";
import { db, WebsiteAds } from "@/lib/db";
import { formPositiveBigInt } from "@/lib/form-data"; import { formPositiveBigInt } from "@/lib/form-data";
import { logger } from "@/lib/logger"; import { logger } from "@/lib/logger";
import { PERMS } from "@/lib/permissions"; import { PERMS } from "@/lib/permissions";
import { prisma } from "@/lib/prisma";
import { adminAction } from "@/lib/safe-action"; import { adminAction } from "@/lib/safe-action";
import { ActionError, actionOk } from "@/lib/safe-action-shared"; import { ActionError, actionOk } from "@/lib/safe-action-shared";
import { logStaffActivity } from "@/lib/services/staff-activity"; import { logStaffActivity } from "@/lib/services/staff-activity";
@@ -25,15 +27,17 @@ export async function createAd(formData: FormData): Promise<void> {
const now = new Date(); const now = new Date();
try { try {
const ad = await prisma.websiteAds.create({ const [result] = (await db.insert(WebsiteAds).values({
data: { image, createdAt: now, updatedAt: now }, image,
}); createdAt: now,
updatedAt: now,
})) as unknown as [ResultSetHeader];
await logStaffActivity({ await logStaffActivity({
staffId: staff.id, staffId: staff.id,
action: "ad_create", action: "ad_create",
description: `Created advertisement #${ad.id} (${image})`, description: `Created advertisement #${result.insertId} (${image})`,
targetType: "website_ad", targetType: "website_ad",
targetId: Number(ad.id), targetId: Number(result.insertId),
}); });
} catch (err) { } catch (err) {
logger.error("Action failed: createAd", { logger.error("Action failed: createAd", {
@@ -58,10 +62,10 @@ export async function updateAd(formData: FormData): Promise<void> {
if (!image) return; if (!image) return;
try { try {
await prisma.websiteAds.update({ await db
where: { id }, .update(WebsiteAds)
data: { image, updatedAt: new Date() }, .set({ image, updatedAt: new Date() })
}); .where(eq(WebsiteAds.id, id));
await logStaffActivity({ await logStaffActivity({
staffId: staff.id, staffId: staff.id,
action: "ad_update", action: "ad_update",
@@ -92,8 +96,14 @@ export const deleteAd = adminAction(
async (ctx) => { async (ctx) => {
const id = ctx.data.id; const id = ctx.data.id;
try { try {
await prisma.websiteAds.delete({ where: { id } }); const [result] = (await db
} catch { .delete(WebsiteAds)
.where(eq(WebsiteAds.id, id))) as unknown as [ResultSetHeader];
if (!result.affectedRows) {
throw new ActionError("Advertisement not found");
}
} catch (err) {
if (err instanceof ActionError) throw err;
throw new ActionError("Advertisement not found"); throw new ActionError("Advertisement not found");
} }
await logStaffActivity({ await logStaffActivity({
@@ -115,7 +125,7 @@ export async function deleteAdForm(formData: FormData): Promise<void> {
if (!id) return; if (!id) return;
try { try {
await prisma.websiteAds.delete({ where: { id } }); await db.delete(WebsiteAds).where(eq(WebsiteAds.id, id));
await logStaffActivity({ await logStaffActivity({
staffId: staff.id, staffId: staff.id,
action: "ad_delete", action: "ad_delete",
+5 -2
View File
@@ -1,10 +1,11 @@
"use server"; "use server";
import { eq } from "drizzle-orm";
import { revalidatePath } from "next/cache"; import { revalidatePath } from "next/cache";
import { requirePermission } from "@/lib/admin/guard"; import { requirePermission } from "@/lib/admin/guard";
import { db, WebsiteStaffApplications } from "@/lib/db";
import { formPositiveBigInt } from "@/lib/form-data"; import { formPositiveBigInt } from "@/lib/form-data";
import { PERMS } from "@/lib/permissions"; import { PERMS } from "@/lib/permissions";
import { prisma } from "@/lib/prisma";
export async function dismissApplication(formData: FormData): Promise<void> { export async function dismissApplication(formData: FormData): Promise<void> {
await requirePermission(PERMS.USERS_EDIT); await requirePermission(PERMS.USERS_EDIT);
@@ -12,7 +13,9 @@ export async function dismissApplication(formData: FormData): Promise<void> {
if (!id) return; if (!id) return;
try { try {
await prisma.websiteStaffApplications.delete({ where: { id } }); await db
.delete(WebsiteStaffApplications)
.where(eq(WebsiteStaffApplications.id, id));
} catch { } catch {
// already gone / no DB — nothing to do // already gone / no DB — nothing to do
} }
+29 -21
View File
@@ -1,25 +1,31 @@
"use server"; "use server";
import { eq } from "drizzle-orm";
import { revalidatePath } from "next/cache"; import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { requirePermission } from "@/lib/admin/guard"; import { requirePermission } from "@/lib/admin/guard";
import {
db,
WebsiteArticleComments,
WebsiteArticleReactions,
WebsiteArticles,
} from "@/lib/db";
import { slugify } from "@/lib/format"; import { slugify } from "@/lib/format";
import { PERMS } from "@/lib/permissions"; import { PERMS } from "@/lib/permissions";
import { prisma } from "@/lib/prisma";
async function uniqueSlug(title: string): Promise<string> { async function uniqueSlug(title: string): Promise<string> {
const base = slugify(title); const base = slugify(title);
let slug = base; let slug = base;
let n = 2; let n = 2;
while ( for (;;) {
await prisma.websiteArticles.findUnique({ const [existing] = await db
where: { slug }, .select({ id: WebsiteArticles.id })
select: { id: true }, .from(WebsiteArticles)
}) .where(eq(WebsiteArticles.slug, slug))
) { .limit(1);
if (!existing) return slug;
slug = `${base}-${n++}`; slug = `${base}-${n++}`;
} }
return slug;
} }
export async function createArticle(formData: FormData): Promise<void> { export async function createArticle(formData: FormData): Promise<void> {
@@ -41,8 +47,7 @@ export async function createArticle(formData: FormData): Promise<void> {
try { try {
const now = new Date(); const now = new Date();
await prisma.websiteArticles.create({ await db.insert(WebsiteArticles).values({
data: {
slug: rawSlug ? await uniqueSlug(rawSlug) : await uniqueSlug(title), slug: rawSlug ? await uniqueSlug(rawSlug) : await uniqueSlug(title),
title: title.slice(0, 255), title: title.slice(0, 255),
shortStory: shortStory.slice(0, 255), shortStory: shortStory.slice(0, 255),
@@ -51,7 +56,6 @@ export async function createArticle(formData: FormData): Promise<void> {
userId: staff.id, userId: staff.id,
createdAt: now, createdAt: now,
updatedAt: now, updatedAt: now,
},
}); });
} catch { } catch {
// Database error — re-render unchanged with error. // Database error — re-render unchanged with error.
@@ -67,9 +71,9 @@ export async function updateArticle(formData: FormData): Promise<void> {
const id = BigInt(String(formData.get("id"))); const id = BigInt(String(formData.get("id")));
const rawSlug = String(formData.get("slug") ?? "").trim(); const rawSlug = String(formData.get("slug") ?? "").trim();
try { try {
await prisma.websiteArticles.update({ await db
where: { id }, .update(WebsiteArticles)
data: { .set({
title: String(formData.get("title") ?? "") title: String(formData.get("title") ?? "")
.normalize("NFC") .normalize("NFC")
.trim() .trim()
@@ -87,8 +91,8 @@ export async function updateArticle(formData: FormData): Promise<void> {
.trim() .trim()
.slice(0, 255), .slice(0, 255),
updatedAt: new Date(), updatedAt: new Date(),
}, })
}); .where(eq(WebsiteArticles.id, id));
} catch { } catch {
redirect("/admin/articles?error=Update failed"); redirect("/admin/articles?error=Update failed");
} }
@@ -100,11 +104,15 @@ export async function deleteArticle(formData: FormData): Promise<void> {
await requirePermission(PERMS.NEWS_EDIT); await requirePermission(PERMS.NEWS_EDIT);
const id = BigInt(String(formData.get("id"))); const id = BigInt(String(formData.get("id")));
try { try {
await prisma.$transaction([ await db.transaction(async (tx) => {
prisma.websiteArticleReactions.deleteMany({ where: { articleId: id } }), await tx
prisma.websiteArticleComments.deleteMany({ where: { articleId: id } }), .delete(WebsiteArticleReactions)
prisma.websiteArticles.delete({ where: { id } }), .where(eq(WebsiteArticleReactions.articleId, id));
]); await tx
.delete(WebsiteArticleComments)
.where(eq(WebsiteArticleComments.articleId, id));
await tx.delete(WebsiteArticles).where(eq(WebsiteArticles.id, id));
});
} catch { } catch {
redirect("/admin/articles?error=Delete failed"); redirect("/admin/articles?error=Delete failed");
} }
+15 -13
View File
@@ -1,9 +1,10 @@
"use server"; "use server";
import { and, eq, max } from "drizzle-orm";
import { revalidatePath } from "next/cache"; import { revalidatePath } from "next/cache";
import { requirePermission } from "@/lib/admin/guard"; import { requirePermission } from "@/lib/admin/guard";
import { db, UsersBadges } from "@/lib/db";
import { PERMS } from "@/lib/permissions"; import { PERMS } from "@/lib/permissions";
import { prisma } from "@/lib/prisma";
import { rcon } from "@/lib/services/rcon"; import { rcon } from "@/lib/services/rcon";
export async function giveBadge(formData: FormData): Promise<void> { export async function giveBadge(formData: FormData): Promise<void> {
@@ -23,19 +24,20 @@ export async function giveBadge(formData: FormData): Promise<void> {
// users_badges has no unique (user_id, badge_code) constraint, so guard // users_badges has no unique (user_id, badge_code) constraint, so guard
// against duplicates and compute the next free slot ourselves. // against duplicates and compute the next free slot ourselves.
try { try {
const existing = await prisma.usersBadges.findFirst({ const [existing] = await db
where: { userId, badgeCode: code }, .select({ id: UsersBadges.id })
select: { id: true }, .from(UsersBadges)
}); .where(
and(eq(UsersBadges.userId, userId), eq(UsersBadges.badgeCode, code)),
)
.limit(1);
if (!existing) { if (!existing) {
const max = await prisma.usersBadges.aggregate({ const [agg] = await db
where: { userId }, .select({ maxSlot: max(UsersBadges.slotId) })
_max: { slotId: true }, .from(UsersBadges)
}); .where(eq(UsersBadges.userId, userId));
const slotId = (max._max.slotId ?? 0) + 1; const slotId = (agg?.maxSlot ?? 0) + 1;
await prisma.usersBadges.create({ await db.insert(UsersBadges).values({ userId, slotId, badgeCode: code });
data: { userId, slotId, badgeCode: code },
});
} }
} catch { } catch {
// Best-effort: the RCON grant already succeeded for online users. // Best-effort: the RCON grant already succeeded for online users.
+28 -13
View File
@@ -2,17 +2,32 @@
import { revalidatePath } from "next/cache"; import { revalidatePath } from "next/cache";
import { beforeEach, describe, expect, it, vi } from "vitest"; import { beforeEach, describe, expect, it, vi } from "vitest";
import { requirePermissionRateLimited } from "@/lib/admin/guard"; import { requirePermissionRateLimited } from "@/lib/admin/guard";
import { prisma } from "@/lib/prisma";
import { rcon } from "@/lib/services/rcon"; import { rcon } from "@/lib/services/rcon";
import { createBan, liftBan } from "./admin-bans"; import { createBan, liftBan } from "./admin-bans";
const { selectLimit, insertValues, deleteWhere } = vi.hoisted(() => {
const selectLimit = vi.fn();
const insertValues = vi.fn().mockResolvedValue([{ insertId: 1 }]);
const deleteWhere = vi.fn().mockResolvedValue([{ affectedRows: 1 }]);
return { selectLimit, insertValues, deleteWhere };
});
vi.mock("@/lib/admin/guard", () => ({ requirePermissionRateLimited: vi.fn() })); vi.mock("@/lib/admin/guard", () => ({ requirePermissionRateLimited: vi.fn() }));
vi.mock("@/lib/permissions", () => ({ PERMS: { USERS_BAN: "users.ban" } })); vi.mock("@/lib/permissions", () => ({ PERMS: { USERS_BAN: "users.ban" } }));
vi.mock("@/lib/prisma", () => ({ vi.mock("@/lib/db", () => ({
prisma: { db: {
user: { findUnique: vi.fn() }, select: vi.fn(() => ({
ban: { create: vi.fn(), delete: vi.fn() }, from: vi.fn(() => ({
where: vi.fn(() => ({
limit: selectLimit,
})),
})),
})),
insert: vi.fn(() => ({ values: insertValues })),
delete: vi.fn(() => ({ where: deleteWhere })),
}, },
Ban: { id: "id", userId: "userId" },
User: { id: "id", username: "username" },
})); }));
vi.mock("@/lib/services/rcon", () => ({ rcon: { disconnectUser: vi.fn() } })); vi.mock("@/lib/services/rcon", () => ({ rcon: { disconnectUser: vi.fn() } }));
vi.mock("@/lib/services/staff-activity", () => ({ logStaffActivity: vi.fn() })); vi.mock("@/lib/services/staff-activity", () => ({ logStaffActivity: vi.fn() }));
@@ -26,13 +41,13 @@ const fakeForm = (data: Record<string, string>) => ({
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
vi.mocked(requirePermissionRateLimited).mockResolvedValue(staff as never); vi.mocked(requirePermissionRateLimited).mockResolvedValue(staff as never);
selectLimit.mockResolvedValue([{ username: "baduser" }]);
insertValues.mockResolvedValue([{ insertId: 1 }]);
deleteWhere.mockResolvedValue([{ affectedRows: 1 }]);
}); });
describe("createBan", () => { describe("createBan", () => {
it("creates a ban for valid inputs", async () => { it("creates a ban for valid inputs", async () => {
vi.mocked(prisma.user.findUnique).mockResolvedValue({
username: "baduser",
} as never);
await createBan( await createBan(
fakeForm({ fakeForm({
userId: "42", userId: "42",
@@ -41,9 +56,9 @@ describe("createBan", () => {
type: "account", type: "account",
}) as unknown as FormData, }) as unknown as FormData,
); );
expect(prisma.ban.create).toHaveBeenCalledWith({ expect(insertValues).toHaveBeenCalledWith(
data: expect.objectContaining({ userId: 42, type: "account" }), expect.objectContaining({ userId: 42, type: "account" }),
}); );
expect(rcon.disconnectUser).toHaveBeenCalledWith(42, "baduser"); expect(rcon.disconnectUser).toHaveBeenCalledWith(42, "baduser");
expect(revalidatePath).toHaveBeenCalledWith("/admin/bans"); expect(revalidatePath).toHaveBeenCalledWith("/admin/bans");
}); });
@@ -56,14 +71,14 @@ describe("createBan", () => {
type: "account", type: "account",
}) as unknown as FormData, }) as unknown as FormData,
); );
expect(prisma.ban.create).not.toHaveBeenCalled(); expect(insertValues).not.toHaveBeenCalled();
}); });
}); });
describe("liftBan", () => { describe("liftBan", () => {
it("deletes ban and revalidates", async () => { it("deletes ban and revalidates", async () => {
await liftBan(fakeForm({ id: "42" }) as unknown as FormData); await liftBan(fakeForm({ id: "42" }) as unknown as FormData);
expect(prisma.ban.delete).toHaveBeenCalledWith({ where: { id: 42 } }); expect(deleteWhere).toHaveBeenCalled();
expect(revalidatePath).toHaveBeenCalledWith("/admin/bans"); expect(revalidatePath).toHaveBeenCalledWith("/admin/bans");
}); });
}); });
+10 -10
View File
@@ -1,9 +1,10 @@
"use server"; "use server";
import { eq } from "drizzle-orm";
import { revalidatePath } from "next/cache"; import { revalidatePath } from "next/cache";
import { requirePermissionRateLimited } from "@/lib/admin/guard"; import { requirePermissionRateLimited } from "@/lib/admin/guard";
import { Ban, db, User } from "@/lib/db";
import { PERMS } from "@/lib/permissions"; import { PERMS } from "@/lib/permissions";
import { prisma } from "@/lib/prisma";
import { rcon } from "@/lib/services/rcon"; import { rcon } from "@/lib/services/rcon";
import { logStaffActivity } from "@/lib/services/staff-activity"; import { logStaffActivity } from "@/lib/services/staff-activity";
@@ -29,13 +30,13 @@ export async function createBan(formData: FormData): Promise<void> {
// Emulator convention: banExpire 0 = permanent (not a far-future timestamp). // Emulator convention: banExpire 0 = permanent (not a far-future timestamp).
const banExpire = hours > 0 ? now + Math.floor(hours) * 3600 : 0; const banExpire = hours > 0 ? now + Math.floor(hours) * 3600 : 0;
const user = await prisma.user.findUnique({ const [user] = await db
where: { id: userId }, .select({ username: User.username })
select: { username: true }, .from(User)
}); .where(eq(User.id, userId))
.limit(1);
await prisma.ban.create({ await db.insert(Ban).values({
data: {
userId, userId,
ip: "", ip: "",
machineId: "", machineId: "",
@@ -43,9 +44,8 @@ export async function createBan(formData: FormData): Promise<void> {
timestamp: now, timestamp: now,
banExpire, banExpire,
banReason: reason, banReason: reason,
type: type as any, type: type as "account" | "ip" | "machine" | "super",
cfhTopic: -1, cfhTopic: -1,
},
}); });
if (user) await rcon.disconnectUser(userId, user.username); if (user) await rcon.disconnectUser(userId, user.username);
@@ -63,7 +63,7 @@ export async function liftBan(formData: FormData): Promise<void> {
const staff = await requirePermissionRateLimited(PERMS.USERS_BAN); const staff = await requirePermissionRateLimited(PERMS.USERS_BAN);
const id = Number(formData.get("id")); const id = Number(formData.get("id"));
if (id > 0) { if (id > 0) {
await prisma.ban.delete({ where: { id } }); await db.delete(Ban).where(eq(Ban.id, id));
await logStaffActivity({ await logStaffActivity({
staffId: staff.id, staffId: staff.id,
action: "ban_lift", action: "ban_lift",
+9 -10
View File
@@ -1,10 +1,11 @@
"use server"; "use server";
import { eq } from "drizzle-orm";
import { revalidatePath } from "next/cache"; import { revalidatePath } from "next/cache";
import { requirePermission } from "@/lib/admin/guard"; import { requirePermission } from "@/lib/admin/guard";
import { db, EmailTemplates } from "@/lib/db";
import { formPositiveBigInt } from "@/lib/form-data"; import { formPositiveBigInt } from "@/lib/form-data";
import { PERMS } from "@/lib/permissions"; import { PERMS } from "@/lib/permissions";
import { prisma } from "@/lib/prisma";
export async function createEmailTemplate(formData: FormData): Promise<void> { export async function createEmailTemplate(formData: FormData): Promise<void> {
await requirePermission(PERMS.PAGES_EDIT); await requirePermission(PERMS.PAGES_EDIT);
@@ -23,14 +24,12 @@ export async function createEmailTemplate(formData: FormData): Promise<void> {
const isActive = formData.get("isActive") != null; const isActive = formData.get("isActive") != null;
if (!name || !subject || !body) return; if (!name || !subject || !body) return;
await prisma.emailTemplates.create({ await db.insert(EmailTemplates).values({
data: {
name, name,
subject, subject,
body, body,
variables: variablesRaw || null, variables: variablesRaw || null,
isActive, isActive,
},
}); });
revalidatePath("/admin/email-templates"); revalidatePath("/admin/email-templates");
} }
@@ -56,15 +55,15 @@ export async function updateEmailTemplate(formData: FormData): Promise<void> {
const isActive = formData.get("isActive") != null; const isActive = formData.get("isActive") != null;
if (!subject || !body) return; if (!subject || !body) return;
await prisma.emailTemplates.update({ await db
where: { id }, .update(EmailTemplates)
data: { .set({
subject, subject,
body, body,
variables: variablesRaw || null, variables: variablesRaw || null,
isActive, isActive,
}, })
}); .where(eq(EmailTemplates.id, id));
revalidatePath("/admin/email-templates"); revalidatePath("/admin/email-templates");
} }
@@ -72,6 +71,6 @@ export async function deleteEmailTemplate(formData: FormData): Promise<void> {
await requirePermission(PERMS.PAGES_EDIT); await requirePermission(PERMS.PAGES_EDIT);
const id = formPositiveBigInt(formData, "id"); const id = formPositiveBigInt(formData, "id");
if (!id) return; if (!id) return;
await prisma.emailTemplates.delete({ where: { id } }); await db.delete(EmailTemplates).where(eq(EmailTemplates.id, id));
revalidatePath("/admin/email-templates"); revalidatePath("/admin/email-templates");
} }
+9 -11
View File
@@ -2,8 +2,8 @@
import { revalidatePath } from "next/cache"; import { revalidatePath } from "next/cache";
import { requirePermission } from "@/lib/admin/guard"; import { requirePermission } from "@/lib/admin/guard";
import { db, EmulatorSettings, EmulatorTexts } from "@/lib/db";
import { PERMS } from "@/lib/permissions"; import { PERMS } from "@/lib/permissions";
import { prisma } from "@/lib/prisma";
// emulator_settings: PK is the string column `key`, payload is `value` (VarChar 512). // emulator_settings: PK is the string column `key`, payload is `value` (VarChar 512).
// emulator_texts: PK is the string column `key`, payload is `value` (VarChar 4096). // emulator_texts: PK is the string column `key`, payload is `value` (VarChar 4096).
@@ -20,11 +20,10 @@ export async function updateEmulatorSetting(formData: FormData): Promise<void> {
.normalize("NFC") .normalize("NFC")
.slice(0, 512); .slice(0, 512);
if (!key) return; if (!key) return;
await prisma.emulatorSettings.upsert({ await db
where: { key }, .insert(EmulatorSettings)
update: { value }, .values({ key, value })
create: { key, value }, .onDuplicateKeyUpdate({ set: { value } });
});
revalidatePath("/admin/emulator"); revalidatePath("/admin/emulator");
} }
@@ -38,10 +37,9 @@ export async function updateEmulatorText(formData: FormData): Promise<void> {
.normalize("NFC") .normalize("NFC")
.slice(0, 4096); .slice(0, 4096);
if (!key) return; if (!key) return;
await prisma.emulatorTexts.upsert({ await db
where: { key }, .insert(EmulatorTexts)
update: { value }, .values({ key, value })
create: { key, value }, .onDuplicateKeyUpdate({ set: { value } });
});
revalidatePath("/admin/emulator"); revalidatePath("/admin/emulator");
} }
File diff suppressed because it is too large. Load diff
+36 -19
View File
@@ -1,9 +1,19 @@
"use server"; "use server";
import { eq, inArray } from "drizzle-orm";
import { revalidatePath } from "next/cache"; import { revalidatePath } from "next/cache";
import { requirePermissionRateLimited } from "@/lib/admin/guard"; import { requirePermissionRateLimited } from "@/lib/admin/guard";
import {
db,
GuildForumViews,
Guilds,
GuildsForumsComments,
GuildsForumsThreads,
GuildsMembers,
Items,
Rooms,
} from "@/lib/db";
import { PERMS } from "@/lib/permissions"; import { PERMS } from "@/lib/permissions";
import { prisma } from "@/lib/prisma";
import { logStaffActivity } from "@/lib/services/staff-activity"; import { logStaffActivity } from "@/lib/services/staff-activity";
/** Disband a guild and clean related membership/forum rows. */ /** Disband a guild and clean related membership/forum rows. */
@@ -12,29 +22,36 @@ export async function disbandGuild(formData: FormData): Promise<void> {
const id = Number(formData.get("id")); const id = Number(formData.get("id"));
if (!(id > 0)) return; if (!(id > 0)) return;
const guild = await prisma.guilds.findUnique({ const [guild] = await db
where: { id }, .select({
select: { id: true, name: true, userId: true }, id: Guilds.id,
}); name: Guilds.name,
userId: Guilds.userId,
})
.from(Guilds)
.where(eq(Guilds.id, id))
.limit(1);
if (!guild) return; if (!guild) return;
await prisma.$transaction(async (tx) => { await db.transaction(async (tx) => {
const threads = await tx.guildsForumsThreads.findMany({ const threads = await tx
where: { guildId: id }, .select({ id: GuildsForumsThreads.id })
select: { id: true }, .from(GuildsForumsThreads)
}); .where(eq(GuildsForumsThreads.guildId, id));
const threadIds = threads.map((t) => t.id); const threadIds = threads.map((t) => t.id);
if (threadIds.length > 0) { if (threadIds.length > 0) {
await tx.guildsForumsComments.deleteMany({ await tx
where: { threadId: { in: threadIds } }, .delete(GuildsForumsComments)
}); .where(inArray(GuildsForumsComments.threadId, threadIds));
await tx.guildsForumsThreads.deleteMany({ where: { guildId: id } }); await tx
.delete(GuildsForumsThreads)
.where(eq(GuildsForumsThreads.guildId, id));
} }
await tx.guildForumViews.deleteMany({ where: { guildId: id } }); await tx.delete(GuildForumViews).where(eq(GuildForumViews.guildId, id));
await tx.guildsMembers.deleteMany({ where: { guildId: id } }); await tx.delete(GuildsMembers).where(eq(GuildsMembers.guildId, id));
await tx.rooms.updateMany({ where: { guildId: id }, data: { guildId: 0 } }); await tx.update(Rooms).set({ guildId: 0 }).where(eq(Rooms.guildId, id));
await tx.items.updateMany({ where: { guildId: id }, data: { guildId: 0 } }); await tx.update(Items).set({ guildId: 0 }).where(eq(Items.guildId, id));
await tx.guilds.delete({ where: { id } }); await tx.delete(Guilds).where(eq(Guilds.id, id));
}); });
await logStaffActivity({ await logStaffActivity({
File diff suppressed because it is too large. Load diff
+19 -20
View File
@@ -1,23 +1,28 @@
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { beforeEach, describe, expect, it, vi } from "vitest"; import { beforeEach, describe, expect, it, vi } from "vitest";
import { requirePermission } from "@/lib/admin/guard"; import { requirePermission } from "@/lib/admin/guard";
import { prisma } from "@/lib/prisma";
import { import {
createHelpQuestion, createHelpQuestion,
deleteHelpQuestion, deleteHelpQuestion,
updateHelpQuestion, updateHelpQuestion,
} from "./admin-help"; } from "./admin-help";
const { insertValues, updateWhere, deleteWhere } = vi.hoisted(() => {
const insertValues = vi.fn().mockResolvedValue([{ insertId: 5 }]);
const updateWhere = vi.fn().mockResolvedValue([{ affectedRows: 1 }]);
const deleteWhere = vi.fn().mockResolvedValue([{ affectedRows: 1 }]);
return { insertValues, updateWhere, deleteWhere };
});
vi.mock("@/lib/admin/guard", () => ({ requirePermission: vi.fn() })); vi.mock("@/lib/admin/guard", () => ({ requirePermission: vi.fn() }));
vi.mock("@/lib/permissions", () => ({ PERMS: { PAGES_EDIT: "pages.edit" } })); vi.mock("@/lib/permissions", () => ({ PERMS: { PAGES_EDIT: "pages.edit" } }));
vi.mock("@/lib/prisma", () => ({ vi.mock("@/lib/db", () => ({
prisma: { db: {
websiteHelpCenterCategories: { insert: vi.fn(() => ({ values: insertValues })),
create: vi.fn(), update: vi.fn(() => ({ set: vi.fn(() => ({ where: updateWhere })) })),
update: vi.fn(), delete: vi.fn(() => ({ where: deleteWhere })),
delete: vi.fn(),
},
}, },
WebsiteHelpCenterCategories: { id: "id" },
})); }));
vi.mock("@/lib/services/staff-activity", () => ({ logStaffActivity: vi.fn() })); vi.mock("@/lib/services/staff-activity", () => ({ logStaffActivity: vi.fn() }));
vi.mock("next/cache", () => ({ revalidatePath: vi.fn() })); vi.mock("next/cache", () => ({ revalidatePath: vi.fn() }));
@@ -31,29 +36,26 @@ const fakeForm = (data: Record<string, string | null>) => ({
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
vi.mocked(requirePermission).mockResolvedValue(staff as never); vi.mocked(requirePermission).mockResolvedValue(staff as never);
insertValues.mockResolvedValue([{ insertId: 5 }]);
updateWhere.mockResolvedValue([{ affectedRows: 1 }]);
deleteWhere.mockResolvedValue([{ affectedRows: 1 }]);
}); });
describe("createHelpQuestion", () => { describe("createHelpQuestion", () => {
it("creates a help question and redirects", async () => { it("creates a help question and redirects", async () => {
vi.mocked(prisma.websiteHelpCenterCategories.create).mockResolvedValue({
id: BigInt(5),
} as never);
await createHelpQuestion( await createHelpQuestion(
fakeForm({ fakeForm({
name: "FAQ", name: "FAQ",
content: "<p>Answer</p>", content: "<p>Answer</p>",
}) as unknown as FormData, }) as unknown as FormData,
); );
expect(prisma.websiteHelpCenterCategories.create).toHaveBeenCalled(); expect(insertValues).toHaveBeenCalled();
expect(redirect).toHaveBeenCalledWith("/admin/help-questions"); expect(redirect).toHaveBeenCalledWith("/admin/help-questions");
}); });
}); });
describe("updateHelpQuestion", () => { describe("updateHelpQuestion", () => {
it("updates and redirects", async () => { it("updates and redirects", async () => {
vi.mocked(prisma.websiteHelpCenterCategories.update).mockResolvedValue(
{} as never,
);
await updateHelpQuestion( await updateHelpQuestion(
fakeForm({ fakeForm({
id: "42", id: "42",
@@ -61,18 +63,15 @@ describe("updateHelpQuestion", () => {
content: "New", content: "New",
}) as unknown as FormData, }) as unknown as FormData,
); );
expect(prisma.websiteHelpCenterCategories.update).toHaveBeenCalled(); expect(updateWhere).toHaveBeenCalled();
expect(redirect).toHaveBeenCalledWith("/admin/help-questions"); expect(redirect).toHaveBeenCalledWith("/admin/help-questions");
}); });
}); });
describe("deleteHelpQuestion", () => { describe("deleteHelpQuestion", () => {
it("deletes and redirects", async () => { it("deletes and redirects", async () => {
vi.mocked(prisma.websiteHelpCenterCategories.delete).mockResolvedValue(
{} as never,
);
await deleteHelpQuestion(fakeForm({ id: "42" }) as unknown as FormData); await deleteHelpQuestion(fakeForm({ id: "42" }) as unknown as FormData);
expect(prisma.websiteHelpCenterCategories.delete).toHaveBeenCalled(); expect(deleteWhere).toHaveBeenCalled();
expect(redirect).toHaveBeenCalledWith("/admin/help-questions"); expect(redirect).toHaveBeenCalledWith("/admin/help-questions");
}); });
}); });
+15 -13
View File
@@ -1,12 +1,14 @@
"use server"; "use server";
import { eq } from "drizzle-orm";
import type { ResultSetHeader } from "mysql2";
import { revalidatePath } from "next/cache"; import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { requirePermission } from "@/lib/admin/guard"; import { requirePermission } from "@/lib/admin/guard";
import { db, WebsiteHelpCenterCategories } from "@/lib/db";
import { formPositiveBigInt } from "@/lib/form-data"; import { formPositiveBigInt } from "@/lib/form-data";
import { canonicalize, sanitizeField } from "@/lib/foundation/security"; import { canonicalize, sanitizeField } from "@/lib/foundation/security";
import { PERMS } from "@/lib/permissions"; import { PERMS } from "@/lib/permissions";
import { prisma } from "@/lib/prisma";
import { logStaffActivity } from "@/lib/services/staff-activity"; import { logStaffActivity } from "@/lib/services/staff-activity";
// CRUD for help-center FAQ entries (website_help_center_categories). Each entry // CRUD for help-center FAQ entries (website_help_center_categories). Each entry
@@ -32,8 +34,7 @@ export async function createHelpQuestion(formData: FormData): Promise<void> {
sanitizeField(formData.get("buttonBorderColor"), 16) || "#facc15"; sanitizeField(formData.get("buttonBorderColor"), 16) || "#facc15";
try { try {
const entry = await prisma.websiteHelpCenterCategories.create({ const [result] = (await db.insert(WebsiteHelpCenterCategories).values({
data: {
name, name,
content, content,
position: parsePosition(formData.get("position")), position: parsePosition(formData.get("position")),
@@ -43,14 +44,13 @@ export async function createHelpQuestion(formData: FormData): Promise<void> {
buttonColor, buttonColor,
buttonBorderColor, buttonBorderColor,
smallBox: formData.get("smallBox") != null, smallBox: formData.get("smallBox") != null,
}, })) as unknown as [ResultSetHeader];
});
await logStaffActivity({ await logStaffActivity({
staffId: staff.id, staffId: staff.id,
action: "help_create", action: "help_create",
description: `Created help-center entry #${entry.id} (${name})`, description: `Created help-center entry #${result.insertId} (${name})`,
targetType: "help_center_category", targetType: "help_center_category",
targetId: Number(entry.id), targetId: Number(result.insertId),
}); });
} catch { } catch {
// Unique name collision or DB error — re-render unchanged with error. // Unique name collision or DB error — re-render unchanged with error.
@@ -81,9 +81,9 @@ export async function updateHelpQuestion(formData: FormData): Promise<void> {
sanitizeField(formData.get("buttonBorderColor"), 16) || "#facc15"; sanitizeField(formData.get("buttonBorderColor"), 16) || "#facc15";
try { try {
await prisma.websiteHelpCenterCategories.update({ await db
where: { id }, .update(WebsiteHelpCenterCategories)
data: { .set({
name, name,
content, content,
position: parsePosition(formData.get("position")), position: parsePosition(formData.get("position")),
@@ -93,8 +93,8 @@ export async function updateHelpQuestion(formData: FormData): Promise<void> {
buttonColor, buttonColor,
buttonBorderColor, buttonBorderColor,
smallBox: formData.get("smallBox") != null, smallBox: formData.get("smallBox") != null,
}, })
}); .where(eq(WebsiteHelpCenterCategories.id, id));
await logStaffActivity({ await logStaffActivity({
staffId: staff.id, staffId: staff.id,
action: "help_update", action: "help_update",
@@ -116,7 +116,9 @@ export async function deleteHelpQuestion(formData: FormData): Promise<void> {
if (!id) return; if (!id) return;
try { try {
await prisma.websiteHelpCenterCategories.delete({ where: { id } }); await db
.delete(WebsiteHelpCenterCategories)
.where(eq(WebsiteHelpCenterCategories.id, id));
await logStaffActivity({ await logStaffActivity({
staffId: staff.id, staffId: staff.id,
action: "help_delete", action: "help_delete",
+25 -16
View File
@@ -2,7 +2,6 @@
import { revalidatePath } from "next/cache"; import { revalidatePath } from "next/cache";
import { beforeEach, describe, expect, it, vi } from "vitest"; import { beforeEach, describe, expect, it, vi } from "vitest";
import { requirePermission } from "@/lib/admin/guard"; import { requirePermission } from "@/lib/admin/guard";
import { prisma } from "@/lib/prisma";
import { import {
addBlacklist, addBlacklist,
addWhitelist, addWhitelist,
@@ -10,15 +9,23 @@ import {
deleteWhitelist, deleteWhitelist,
} from "./admin-ip"; } from "./admin-ip";
const { insertValues, deleteWhere } = vi.hoisted(() => {
const insertValues = vi.fn().mockResolvedValue([{ insertId: 1 }]);
const deleteWhere = vi.fn().mockResolvedValue([{ affectedRows: 1 }]);
return { insertValues, deleteWhere };
});
vi.mock("@/lib/admin/guard", () => ({ requirePermission: vi.fn() })); vi.mock("@/lib/admin/guard", () => ({ requirePermission: vi.fn() }));
vi.mock("@/lib/permissions", () => ({ vi.mock("@/lib/permissions", () => ({
PERMS: { SETTINGS_EDIT: "settings.edit" }, PERMS: { SETTINGS_EDIT: "settings.edit" },
})); }));
vi.mock("@/lib/prisma", () => ({ vi.mock("@/lib/db", () => ({
prisma: { db: {
websiteIpWhitelist: { create: vi.fn(), delete: vi.fn() }, insert: vi.fn(() => ({ values: insertValues })),
websiteIpBlacklist: { create: vi.fn(), delete: vi.fn() }, delete: vi.fn(() => ({ where: deleteWhere })),
}, },
WebsiteIpWhitelist: { id: "id" },
WebsiteIpBlacklist: { id: "id" },
})); }));
vi.mock("next/cache", () => ({ revalidatePath: vi.fn() })); vi.mock("next/cache", () => ({ revalidatePath: vi.fn() }));
@@ -30,6 +37,8 @@ const fakeForm = (data: Record<string, string>) => ({
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
vi.mocked(requirePermission).mockResolvedValue(staff as never); vi.mocked(requirePermission).mockResolvedValue(staff as never);
insertValues.mockResolvedValue([{ insertId: 1 }]);
deleteWhere.mockResolvedValue([{ affectedRows: 1 }]);
}); });
describe("addWhitelist", () => { describe("addWhitelist", () => {
@@ -37,24 +46,24 @@ describe("addWhitelist", () => {
await addWhitelist( await addWhitelist(
fakeForm({ ipAddress: "192.168.1.1" }) as unknown as FormData, fakeForm({ ipAddress: "192.168.1.1" }) as unknown as FormData,
); );
expect(prisma.websiteIpWhitelist.create).toHaveBeenCalledWith({ expect(insertValues).toHaveBeenCalledWith({
data: { ipAddress: "192.168.1.1", asn: null, whitelistAsn: false }, ipAddress: "192.168.1.1",
asn: null,
whitelistAsn: false,
}); });
expect(revalidatePath).toHaveBeenCalledWith("/admin/ip"); expect(revalidatePath).toHaveBeenCalledWith("/admin/ip");
}); });
it("returns early when ip is empty", async () => { it("returns early when ip is empty", async () => {
await addWhitelist(fakeForm({ ipAddress: "" }) as unknown as FormData); await addWhitelist(fakeForm({ ipAddress: "" }) as unknown as FormData);
expect(prisma.websiteIpWhitelist.create).not.toHaveBeenCalled(); expect(insertValues).not.toHaveBeenCalled();
}); });
}); });
describe("deleteWhitelist", () => { describe("deleteWhitelist", () => {
it("deletes whitelist entry", async () => { it("deletes whitelist entry", async () => {
await deleteWhitelist(fakeForm({ id: "42" }) as unknown as FormData); await deleteWhitelist(fakeForm({ id: "42" }) as unknown as FormData);
expect(prisma.websiteIpWhitelist.delete).toHaveBeenCalledWith({ expect(deleteWhere).toHaveBeenCalled();
where: { id: BigInt(42) },
});
}); });
}); });
@@ -63,8 +72,10 @@ describe("addBlacklist", () => {
await addBlacklist( await addBlacklist(
fakeForm({ ipAddress: "203.0.113.1" }) as unknown as FormData, fakeForm({ ipAddress: "203.0.113.1" }) as unknown as FormData,
); );
expect(prisma.websiteIpBlacklist.create).toHaveBeenCalledWith({ expect(insertValues).toHaveBeenCalledWith({
data: { ipAddress: "203.0.113.1", asn: null, blacklistAsn: false }, ipAddress: "203.0.113.1",
asn: null,
blacklistAsn: false,
}); });
}); });
}); });
@@ -72,8 +83,6 @@ describe("addBlacklist", () => {
describe("deleteBlacklist", () => { describe("deleteBlacklist", () => {
it("deletes blacklist entry", async () => { it("deletes blacklist entry", async () => {
await deleteBlacklist(fakeForm({ id: "99" }) as unknown as FormData); await deleteBlacklist(fakeForm({ id: "99" }) as unknown as FormData);
expect(prisma.websiteIpBlacklist.delete).toHaveBeenCalledWith({ expect(deleteWhere).toHaveBeenCalled();
where: { id: BigInt(99) },
});
}); });
}); });
+16 -7
View File
@@ -1,9 +1,10 @@
"use server"; "use server";
import { eq } from "drizzle-orm";
import { revalidatePath } from "next/cache"; import { revalidatePath } from "next/cache";
import { requirePermission } from "@/lib/admin/guard"; import { requirePermission } from "@/lib/admin/guard";
import { db, WebsiteIpBlacklist, WebsiteIpWhitelist } from "@/lib/db";
import { PERMS } from "@/lib/permissions"; import { PERMS } from "@/lib/permissions";
import { prisma } from "@/lib/prisma";
function parseIp(formData: FormData): string { function parseIp(formData: FormData): string {
return String(formData.get("ipAddress") ?? "") return String(formData.get("ipAddress") ?? "")
@@ -25,8 +26,10 @@ export async function addWhitelist(formData: FormData): Promise<void> {
const ipAddress = parseIp(formData); const ipAddress = parseIp(formData);
if (!ipAddress) return; if (!ipAddress) return;
const asn = parseAsn(formData); const asn = parseAsn(formData);
await prisma.websiteIpWhitelist.create({ await db.insert(WebsiteIpWhitelist).values({
data: { ipAddress, asn, whitelistAsn: asn != null }, ipAddress,
asn,
whitelistAsn: asn != null,
}); });
revalidatePath("/admin/ip"); revalidatePath("/admin/ip");
} }
@@ -37,7 +40,9 @@ export async function deleteWhitelist(formData: FormData): Promise<void> {
.normalize("NFC") .normalize("NFC")
.trim(); .trim();
if (!raw) return; if (!raw) return;
await prisma.websiteIpWhitelist.delete({ where: { id: BigInt(raw) } }); await db
.delete(WebsiteIpWhitelist)
.where(eq(WebsiteIpWhitelist.id, BigInt(raw)));
revalidatePath("/admin/ip"); revalidatePath("/admin/ip");
} }
@@ -46,8 +51,10 @@ export async function addBlacklist(formData: FormData): Promise<void> {
const ipAddress = parseIp(formData); const ipAddress = parseIp(formData);
if (!ipAddress) return; if (!ipAddress) return;
const asn = parseAsn(formData); const asn = parseAsn(formData);
await prisma.websiteIpBlacklist.create({ await db.insert(WebsiteIpBlacklist).values({
data: { ipAddress, asn, blacklistAsn: asn != null }, ipAddress,
asn,
blacklistAsn: asn != null,
}); });
revalidatePath("/admin/ip"); revalidatePath("/admin/ip");
} }
@@ -58,6 +65,8 @@ export async function deleteBlacklist(formData: FormData): Promise<void> {
.normalize("NFC") .normalize("NFC")
.trim(); .trim();
if (!raw) return; if (!raw) return;
await prisma.websiteIpBlacklist.delete({ where: { id: BigInt(raw) } }); await db
.delete(WebsiteIpBlacklist)
.where(eq(WebsiteIpBlacklist.id, BigInt(raw)));
revalidatePath("/admin/ip"); revalidatePath("/admin/ip");
} }
File diff suppressed because it is too large. Load diff
+9 -6
View File
@@ -2,8 +2,8 @@
import { revalidatePath } from "next/cache"; import { revalidatePath } from "next/cache";
import { requirePermission } from "@/lib/admin/guard"; import { requirePermission } from "@/lib/admin/guard";
import { db, WebsiteSetting } from "@/lib/db";
import { PERMS } from "@/lib/permissions"; import { PERMS } from "@/lib/permissions";
import { prisma } from "@/lib/prisma";
import { siteSettings } from "@/lib/services/site-settings"; import { siteSettings } from "@/lib/services/site-settings";
// Maintenance mode lives in three CMS-owned website_settings rows (mirrors // Maintenance mode lives in three CMS-owned website_settings rows (mirrors
@@ -27,12 +27,15 @@ const COMMENTS: Record<string, string> = {
}; };
async function upsertSetting(key: string, value: string): Promise<void> { async function upsertSetting(key: string, value: string): Promise<void> {
await prisma.websiteSetting.upsert({ await db
where: { key }, .insert(WebsiteSetting)
update: { value }, .values({
key,
value,
// eslint-disable-next-line security/detect-object-injection -- key is one of 3 known const values // eslint-disable-next-line security/detect-object-injection -- key is one of 3 known const values
create: { key, value, comment: COMMENTS[key] ?? null }, comment: COMMENTS[key] ?? null,
}); })
.onDuplicateKeyUpdate({ set: { value } });
} }
export async function saveMaintenance(formData: FormData): Promise<void> { export async function saveMaintenance(formData: FormData): Promise<void> {
+17 -9
View File
@@ -1,9 +1,10 @@
"use server"; "use server";
import { eq } from "drizzle-orm";
import { revalidatePath } from "next/cache"; import { revalidatePath } from "next/cache";
import { requirePermissionRateLimited } from "@/lib/admin/guard"; import { requirePermissionRateLimited } from "@/lib/admin/guard";
import { db, MarketplaceItems } from "@/lib/db";
import { PERMS } from "@/lib/permissions"; import { PERMS } from "@/lib/permissions";
import { prisma } from "@/lib/prisma";
import { logStaffActivity } from "@/lib/services/staff-activity"; import { logStaffActivity } from "@/lib/services/staff-activity";
/** Cancel an active marketplace listing (state 1 → 0). */ /** Cancel an active marketplace listing (state 1 → 0). */
@@ -14,16 +15,23 @@ export async function cancelMarketplaceListing(
const id = Number(formData.get("id")); const id = Number(formData.get("id"));
if (!(id > 0)) return; if (!(id > 0)) return;
const listing = await prisma.marketplaceItems.findUnique({ const [listing] = await db
where: { id }, .select({
select: { id: true, state: true, userId: true, itemId: true, price: true }, id: MarketplaceItems.id,
}); state: MarketplaceItems.state,
userId: MarketplaceItems.userId,
itemId: MarketplaceItems.itemId,
price: MarketplaceItems.price,
})
.from(MarketplaceItems)
.where(eq(MarketplaceItems.id, id))
.limit(1);
if (listing?.state !== 1) return; if (listing?.state !== 1) return;
await prisma.marketplaceItems.update({ await db
where: { id }, .update(MarketplaceItems)
data: { state: 0 }, .set({ state: 0 })
}); .where(eq(MarketplaceItems.id, id));
await logStaffActivity({ await logStaffActivity({
staffId: staff.id, staffId: staff.id,
+19 -15
View File
@@ -1,11 +1,12 @@
"use server"; "use server";
import { randomBytes } from "node:crypto"; import { randomBytes } from "node:crypto";
import { eq } from "drizzle-orm";
import { revalidatePath } from "next/cache"; import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { requirePermission } from "@/lib/admin/guard"; import { requirePermission } from "@/lib/admin/guard";
import { db, RadioApiKeys } from "@/lib/db";
import { PERMS } from "@/lib/permissions"; import { PERMS } from "@/lib/permissions";
import { prisma } from "@/lib/prisma";
import { logStaffActivity } from "@/lib/services/staff-activity"; import { logStaffActivity } from "@/lib/services/staff-activity";
// Radio API keys (radio_api_keys). External integrations (AzureCast bridges, // Radio API keys (radio_api_keys). External integrations (AzureCast bridges,
@@ -50,8 +51,7 @@ export async function createApiKey(formData: FormData): Promise<void> {
const now = new Date(); const now = new Date();
try { try {
const created = await prisma.radioApiKeys.create({ const [result] = await db.insert(RadioApiKeys).values({
data: {
name, name,
key, key,
allowedIps, allowedIps,
@@ -59,14 +59,14 @@ export async function createApiKey(formData: FormData): Promise<void> {
isActive: true, isActive: true,
createdAt: now, createdAt: now,
updatedAt: now, updatedAt: now,
},
}); });
const createdId = BigInt(result.insertId);
await logStaffActivity({ await logStaffActivity({
staffId: staff.id, staffId: staff.id,
action: "radio_api_key_create", action: "radio_api_key_create",
description: `Created radio API key "${name}" (#${created.id}, rate limit ${rateLimit})`, description: `Created radio API key "${name}" (#${createdId}, rate limit ${rateLimit})`,
targetType: "radio_api_key", targetType: "radio_api_key",
targetId: Number(created.id), targetId: Number(createdId),
}); });
} catch { } catch {
// Unique-key collision (astronomically unlikely) or DB down — fail soft. // Unique-key collision (astronomically unlikely) or DB down — fail soft.
@@ -84,17 +84,21 @@ export async function toggleApiKey(formData: FormData): Promise<void> {
if (id == null) return; if (id == null) return;
try { try {
const existing = await prisma.radioApiKeys.findUnique({ const [existing] = await db
where: { id }, .select({
select: { name: true, isActive: true }, name: RadioApiKeys.name,
}); isActive: RadioApiKeys.isActive,
})
.from(RadioApiKeys)
.where(eq(RadioApiKeys.id, id))
.limit(1);
if (!existing) return; if (!existing) return;
const next = !existing.isActive; const next = !existing.isActive;
await prisma.radioApiKeys.update({ await db
where: { id }, .update(RadioApiKeys)
data: { isActive: next, updatedAt: new Date() }, .set({ isActive: next, updatedAt: new Date() })
}); .where(eq(RadioApiKeys.id, id));
await logStaffActivity({ await logStaffActivity({
staffId: staff.id, staffId: staff.id,
action: "radio_api_key_toggle", action: "radio_api_key_toggle",
@@ -116,7 +120,7 @@ export async function deleteApiKey(formData: FormData): Promise<void> {
if (id == null) return; if (id == null) return;
try { try {
await prisma.radioApiKeys.delete({ where: { id } }); await db.delete(RadioApiKeys).where(eq(RadioApiKeys.id, id));
await logStaffActivity({ await logStaffActivity({
staffId: staff.id, staffId: staff.id,
action: "radio_api_key_delete", action: "radio_api_key_delete",
+10 -10
View File
@@ -1,9 +1,10 @@
"use server"; "use server";
import { eq } from "drizzle-orm";
import { revalidatePath } from "next/cache"; import { revalidatePath } from "next/cache";
import { requirePermission } from "@/lib/admin/guard"; import { requirePermission } from "@/lib/admin/guard";
import { db, RadioAutoDjPlaylist } from "@/lib/db";
import { PERMS } from "@/lib/permissions"; import { PERMS } from "@/lib/permissions";
import { prisma } from "@/lib/prisma";
import { logStaffActivity } from "@/lib/services/staff-activity"; import { logStaffActivity } from "@/lib/services/staff-activity";
// AutoDJ playlist CRUD (radio_auto_dj_playlist). CMS-owned table backing the // AutoDJ playlist CRUD (radio_auto_dj_playlist). CMS-owned table backing the
@@ -65,8 +66,7 @@ export async function createTrack(formData: FormData): Promise<void> {
const now = new Date(); const now = new Date();
try { try {
const created = await prisma.radioAutoDjPlaylist.create({ const [result] = await db.insert(RadioAutoDjPlaylist).values({
data: {
title, title,
artist: artist || null, artist: artist || null,
album: album || null, album: album || null,
@@ -76,14 +76,14 @@ export async function createTrack(formData: FormData): Promise<void> {
isActive, isActive,
createdAt: now, createdAt: now,
updatedAt: now, updatedAt: now,
},
}); });
const createdId = Number(result.insertId);
await logStaffActivity({ await logStaffActivity({
staffId: staff.id, staffId: staff.id,
action: "radio_autodj_create", action: "radio_autodj_create",
description: `Created AutoDJ track "${title}"${artist ? ` by ${artist}` : ""}`, description: `Created AutoDJ track "${title}"${artist ? ` by ${artist}` : ""}`,
targetType: "radio_auto_dj_track", targetType: "radio_auto_dj_track",
targetId: Number(created.id), targetId: createdId,
}); });
} catch { } catch {
// Fail soft — DB unavailable; re-render without throwing. // Fail soft — DB unavailable; re-render without throwing.
@@ -100,10 +100,10 @@ export async function toggleTrack(formData: FormData): Promise<void> {
const isActive = bool(formData.get("isActive")); const isActive = bool(formData.get("isActive"));
try { try {
await prisma.radioAutoDjPlaylist.update({ await db
where: { id }, .update(RadioAutoDjPlaylist)
data: { isActive, updatedAt: new Date() }, .set({ isActive, updatedAt: new Date() })
}); .where(eq(RadioAutoDjPlaylist.id, id));
await logStaffActivity({ await logStaffActivity({
staffId: staff.id, staffId: staff.id,
action: "radio_autodj_toggle", action: "radio_autodj_toggle",
@@ -123,7 +123,7 @@ export async function deleteTrack(formData: FormData): Promise<void> {
if (id === null) return; if (id === null) return;
try { try {
await prisma.radioAutoDjPlaylist.delete({ where: { id } }); await db.delete(RadioAutoDjPlaylist).where(eq(RadioAutoDjPlaylist.id, id));
await logStaffActivity({ await logStaffActivity({
staffId: staff.id, staffId: staff.id,
action: "radio_autodj_delete", action: "radio_autodj_delete",
File diff suppressed because it is too large. Load diff
+3 -2
View File
@@ -1,9 +1,10 @@
"use server"; "use server";
import { eq } from "drizzle-orm";
import { revalidatePath } from "next/cache"; import { revalidatePath } from "next/cache";
import { requirePermission } from "@/lib/admin/guard"; import { requirePermission } from "@/lib/admin/guard";
import { db, RadioShouts } from "@/lib/db";
import { PERMS } from "@/lib/permissions"; import { PERMS } from "@/lib/permissions";
import { prisma } from "@/lib/prisma";
import { logStaffActivity } from "@/lib/services/staff-activity"; import { logStaffActivity } from "@/lib/services/staff-activity";
/** Parse a FormData field into a positive BigInt id, or null when invalid. */ /** Parse a FormData field into a positive BigInt id, or null when invalid. */
@@ -28,7 +29,7 @@ export async function deleteShout(formData: FormData): Promise<void> {
if (id === null) return; if (id === null) return;
try { try {
await prisma.radioShouts.delete({ where: { id } }); await db.delete(RadioShouts).where(eq(RadioShouts.id, id));
await logStaffActivity({ await logStaffActivity({
staffId: staff.id, staffId: staff.id,
action: "radio.shout.delete", action: "radio.shout.delete",
+7 -6
View File
@@ -3,8 +3,8 @@
import { revalidatePath } from "next/cache"; import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { requirePermission } from "@/lib/admin/guard"; import { requirePermission } from "@/lib/admin/guard";
import { db, WebsiteSetting } from "@/lib/db";
import { PERMS } from "@/lib/permissions"; import { PERMS } from "@/lib/permissions";
import { prisma } from "@/lib/prisma";
import { siteSettings } from "@/lib/services/site-settings"; import { siteSettings } from "@/lib/services/site-settings";
import { logStaffActivity } from "@/lib/services/staff-activity"; import { logStaffActivity } from "@/lib/services/staff-activity";
@@ -67,14 +67,15 @@ export async function savePoints(formData: FormData): Promise<void> {
}; };
try { try {
await prisma.$transaction( await Promise.all(
POINTS_KEYS.map((key) => POINTS_KEYS.map((key) =>
prisma.websiteSetting.upsert({ db
where: { key }, .insert(WebsiteSetting)
// eslint-disable-next-line security/detect-object-injection -- key from POINTS_KEYS const // eslint-disable-next-line security/detect-object-injection -- key from POINTS_KEYS const
update: { value: values[key] }, .values({ key, value: values[key], comment: "Radio points" })
.onDuplicateKeyUpdate({
// eslint-disable-next-line security/detect-object-injection -- key from POINTS_KEYS const // eslint-disable-next-line security/detect-object-injection -- key from POINTS_KEYS const
create: { key, value: values[key], comment: "Radio points" }, set: { value: values[key] },
}), }),
), ),
); );
+14 -9
View File
@@ -1,10 +1,11 @@
"use server"; "use server";
import { eq } from "drizzle-orm";
import { revalidatePath } from "next/cache"; import { revalidatePath } from "next/cache";
import { requirePermission } from "@/lib/admin/guard"; import { requirePermission } from "@/lib/admin/guard";
import { db, WebsiteRareValueCategories, WebsiteRareValues } from "@/lib/db";
import { formPositiveBigInt } from "@/lib/form-data"; import { formPositiveBigInt } from "@/lib/form-data";
import { PERMS } from "@/lib/permissions"; import { PERMS } from "@/lib/permissions";
import { prisma } from "@/lib/prisma";
export async function createCategory(formData: FormData): Promise<void> { export async function createCategory(formData: FormData): Promise<void> {
await requirePermission(PERMS.SHOP_EDIT); await requirePermission(PERMS.SHOP_EDIT);
@@ -24,8 +25,10 @@ export async function createCategory(formData: FormData): Promise<void> {
if (!name || !badge) return; if (!name || !badge) return;
try { try {
await prisma.websiteRareValueCategories.create({ await db.insert(WebsiteRareValueCategories).values({
data: { name, badge, priority }, name,
badge,
priority,
}); });
} catch { } catch {
// Unique name collision or DB error — ignore, page will re-render unchanged. // Unique name collision or DB error — ignore, page will re-render unchanged.
@@ -40,8 +43,12 @@ export async function deleteCategory(formData: FormData): Promise<void> {
try { try {
// Remove the category's values first to avoid orphaned rows. // Remove the category's values first to avoid orphaned rows.
await prisma.websiteRareValues.deleteMany({ where: { categoryId: id } }); await db
await prisma.websiteRareValueCategories.delete({ where: { id } }); .delete(WebsiteRareValues)
.where(eq(WebsiteRareValues.categoryId, id));
await db
.delete(WebsiteRareValueCategories)
.where(eq(WebsiteRareValueCategories.id, id));
} catch { } catch {
// Not found or DB error — ignore. // Not found or DB error — ignore.
} }
@@ -81,8 +88,7 @@ export async function createValue(formData: FormData): Promise<void> {
.slice(0, 255) || "diamonds"; .slice(0, 255) || "diamonds";
try { try {
await prisma.websiteRareValues.create({ await db.insert(WebsiteRareValues).values({
data: {
categoryId, categoryId,
itemId, itemId,
name, name,
@@ -90,7 +96,6 @@ export async function createValue(formData: FormData): Promise<void> {
currencyValue: currencyValueRaw || null, currencyValue: currencyValueRaw || null,
currencyType, currencyType,
furnitureIcon, furnitureIcon,
},
}); });
} catch { } catch {
// DB error — ignore. // DB error — ignore.
@@ -104,7 +109,7 @@ export async function deleteValue(formData: FormData): Promise<void> {
if (!id) return; if (!id) return;
try { try {
await prisma.websiteRareValues.delete({ where: { id } }); await db.delete(WebsiteRareValues).where(eq(WebsiteRareValues.id, id));
} catch { } catch {
// Not found or DB error — ignore. // Not found or DB error — ignore.
} }
+15 -13
View File
@@ -1,16 +1,17 @@
"use server"; "use server";
import { eq } from "drizzle-orm";
import { revalidatePath } from "next/cache"; import { revalidatePath } from "next/cache";
import { z } from "zod"; import { z } from "zod";
import { MANAGED_SETTING_KEYS } from "@/app/admin/settings/cms-settings-config"; import { MANAGED_SETTING_KEYS } from "@/app/admin/settings/cms-settings-config";
import { requirePermissionRateLimited } from "@/lib/admin/guard"; import { requirePermissionRateLimited } from "@/lib/admin/guard";
import { db, WebsiteSetting } from "@/lib/db";
import { actionOk, adminAction } from "@/lib/foundation/action"; import { actionOk, adminAction } from "@/lib/foundation/action";
import { import {
HABBO_GAMEDATA_HOTEL_SETTING_KEY, HABBO_GAMEDATA_HOTEL_SETTING_KEY,
normalizeHabboGamedataHotel, normalizeHabboGamedataHotel,
} from "@/lib/habbo-gamedata-hotel"; } from "@/lib/habbo-gamedata-hotel";
import { PERMS } from "@/lib/permissions"; import { PERMS } from "@/lib/permissions";
import { prisma } from "@/lib/prisma";
import { clearHabboItCache } from "@/lib/services/habbo-furnidata-cache"; import { clearHabboItCache } from "@/lib/services/habbo-furnidata-cache";
import { clearBadgeCache } from "@/lib/services/habboassets"; import { clearBadgeCache } from "@/lib/services/habboassets";
import { siteSettings } from "@/lib/services/site-settings"; import { siteSettings } from "@/lib/services/site-settings";
@@ -48,11 +49,10 @@ export const saveManagedSettings = adminAction(
.map(([key, value]) => [key, normalizeSettingValue(key, value)] as const); .map(([key, value]) => [key, normalizeSettingValue(key, value)] as const);
await Promise.all( await Promise.all(
entries.map(([key, value]) => entries.map(([key, value]) =>
prisma.websiteSetting.upsert({ db
where: { key }, .insert(WebsiteSetting)
update: { value }, .values({ key, value })
create: { key, value }, .onDuplicateKeyUpdate({ set: { value } }),
}),
), ),
); );
await siteSettings.reload(); await siteSettings.reload();
@@ -76,7 +76,10 @@ export async function updateSetting(formData: FormData): Promise<void> {
String(formData.get("value") ?? "").normalize("NFC"), String(formData.get("value") ?? "").normalize("NFC"),
); );
if (!key) return; if (!key) return;
await prisma.websiteSetting.update({ where: { key }, data: { value } }); await db
.update(WebsiteSetting)
.set({ value })
.where(eq(WebsiteSetting.key, key));
await siteSettings.reload(); await siteSettings.reload();
bustGamedataCachesIfNeeded(key); bustGamedataCachesIfNeeded(key);
revalidatePath("/admin/settings"); revalidatePath("/admin/settings");
@@ -97,11 +100,10 @@ export async function createSetting(formData: FormData): Promise<void> {
.trim() .trim()
.slice(0, 255); .slice(0, 255);
if (!key) return; if (!key) return;
await prisma.websiteSetting.upsert({ await db
where: { key }, .insert(WebsiteSetting)
update: { value }, .values({ key, value, comment: comment || null })
create: { key, value, comment: comment || null }, .onDuplicateKeyUpdate({ set: { value } });
});
await siteSettings.reload(); await siteSettings.reload();
bustGamedataCachesIfNeeded(key); bustGamedataCachesIfNeeded(key);
revalidatePath("/admin/settings"); revalidatePath("/admin/settings");
@@ -113,7 +115,7 @@ export async function deleteSetting(formData: FormData): Promise<void> {
.normalize("NFC") .normalize("NFC")
.trim(); .trim();
if (!key) return; if (!key) return;
await prisma.websiteSetting.delete({ where: { key } }); await db.delete(WebsiteSetting).where(eq(WebsiteSetting.key, key));
await siteSettings.reload(); await siteSettings.reload();
bustGamedataCachesIfNeeded(key); bustGamedataCachesIfNeeded(key);
revalidatePath("/admin/settings"); revalidatePath("/admin/settings");
+15 -14
View File
@@ -1,11 +1,13 @@
"use server"; "use server";
import { eq } from "drizzle-orm";
import type { ResultSetHeader } from "mysql2";
import { revalidatePath } from "next/cache"; import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { requirePermission } from "@/lib/admin/guard"; import { requirePermission } from "@/lib/admin/guard";
import { db, WebsiteShopArticles } from "@/lib/db";
import { formPositiveBigInt } from "@/lib/form-data"; import { formPositiveBigInt } from "@/lib/form-data";
import { PERMS } from "@/lib/permissions"; import { PERMS } from "@/lib/permissions";
import { prisma } from "@/lib/prisma";
import { logServerError } from "@/lib/server-log"; import { logServerError } from "@/lib/server-log";
import { logStaffActivity } from "@/lib/services/staff-activity"; import { logStaffActivity } from "@/lib/services/staff-activity";
@@ -40,9 +42,9 @@ export async function createShopArticle(formData: FormData): Promise<void> {
if (!name) return; if (!name) return;
const now = new Date(); const now = new Date();
const costs = reqUInt(formData, "costs");
try { try {
const created = await prisma.websiteShopArticles.create({ const [result] = (await db.insert(WebsiteShopArticles).values({
data: {
name, name,
info: String(formData.get("info") ?? "") info: String(formData.get("info") ?? "")
.normalize("NFC") .normalize("NFC")
@@ -56,7 +58,7 @@ export async function createShopArticle(formData: FormData): Promise<void> {
.normalize("NFC") .normalize("NFC")
.trim() .trim()
.slice(0, 255), .slice(0, 255),
costs: reqUInt(formData, "costs"), costs,
giveRank: optUInt(formData, "giveRank"), giveRank: optUInt(formData, "giveRank"),
credits: optUInt(formData, "credits"), credits: optUInt(formData, "credits"),
duckets: optUInt(formData, "duckets"), duckets: optUInt(formData, "duckets"),
@@ -69,14 +71,13 @@ export async function createShopArticle(formData: FormData): Promise<void> {
position: reqUInt(formData, "position"), position: reqUInt(formData, "position"),
createdAt: now, createdAt: now,
updatedAt: now, updatedAt: now,
}, })) as unknown as [ResultSetHeader];
});
await logStaffActivity({ await logStaffActivity({
staffId: staff.id, staffId: staff.id,
action: "shop_create", action: "shop_create",
description: `Created shop package "${name}" (${created.costs} costs)`, description: `Created shop package "${name}" (${costs} costs)`,
targetType: "shop_article", targetType: "shop_article",
targetId: Number(created.id), targetId: Number(result.insertId),
}); });
} catch (error) { } catch (error) {
logServerError("admin.shop_create_failed", error, { logServerError("admin.shop_create_failed", error, {
@@ -103,9 +104,9 @@ export async function updateShopArticle(formData: FormData): Promise<void> {
if (!name) return; if (!name) return;
try { try {
await prisma.websiteShopArticles.update({ await db
where: { id }, .update(WebsiteShopArticles)
data: { .set({
name, name,
info: String(formData.get("info") ?? "") info: String(formData.get("info") ?? "")
.normalize("NFC") .normalize("NFC")
@@ -131,8 +132,8 @@ export async function updateShopArticle(formData: FormData): Promise<void> {
.slice(0, 255) || null, .slice(0, 255) || null,
position: reqUInt(formData, "position"), position: reqUInt(formData, "position"),
updatedAt: new Date(), updatedAt: new Date(),
}, })
}); .where(eq(WebsiteShopArticles.id, id));
await logStaffActivity({ await logStaffActivity({
staffId: staff.id, staffId: staff.id,
action: "shop_update", action: "shop_update",
@@ -159,7 +160,7 @@ export async function deleteShopArticle(formData: FormData): Promise<void> {
if (!id) return; if (!id) return;
try { try {
await prisma.websiteShopArticles.delete({ where: { id } }); await db.delete(WebsiteShopArticles).where(eq(WebsiteShopArticles.id, id));
await logStaffActivity({ await logStaffActivity({
staffId: staff.id, staffId: staff.id,
action: "shop_delete", action: "shop_delete",
File diff suppressed because it is too large. Load diff
+19 -14
View File
@@ -1,9 +1,11 @@
"use server"; "use server";
import { eq } from "drizzle-orm";
import type { ResultSetHeader } from "mysql2";
import { revalidatePath } from "next/cache"; import { revalidatePath } from "next/cache";
import { requirePermission } from "@/lib/admin/guard"; import { requirePermission } from "@/lib/admin/guard";
import { db, Taggables, Tags } from "@/lib/db";
import { PERMS } from "@/lib/permissions"; import { PERMS } from "@/lib/permissions";
import { prisma } from "@/lib/prisma";
import { logStaffActivity } from "@/lib/services/staff-activity"; import { logStaffActivity } from "@/lib/services/staff-activity";
// ── Helpers ──────────────────────────────────────────────────────────────── // ── Helpers ────────────────────────────────────────────────────────────────
@@ -40,15 +42,18 @@ export async function createTag(formData: FormData): Promise<void> {
const now = new Date(); const now = new Date();
try { try {
const created = await prisma.tags.create({ const [result] = (await db.insert(Tags).values({
data: { name, backgroundColor, createdAt: now, updatedAt: now }, name,
}); backgroundColor,
createdAt: now,
updatedAt: now,
})) as unknown as [ResultSetHeader];
await logStaffActivity({ await logStaffActivity({
staffId: staff.id, staffId: staff.id,
action: "tag_create", action: "tag_create",
description: `Created tag "${name}" (#${created.id})`, description: `Created tag "${name}" (#${result.insertId})`,
targetType: "tag", targetType: "tag",
targetId: Number(created.id), targetId: Number(result.insertId),
}); });
} catch { } catch {
// Fail soft — DB unavailable or duplicate. // Fail soft — DB unavailable or duplicate.
@@ -66,10 +71,10 @@ export async function updateTag(formData: FormData): Promise<void> {
if (!name) return; if (!name) return;
try { try {
await prisma.tags.update({ await db
where: { id }, .update(Tags)
data: { name, backgroundColor, updatedAt: new Date() }, .set({ name, backgroundColor, updatedAt: new Date() })
}); .where(eq(Tags.id, id));
await logStaffActivity({ await logStaffActivity({
staffId: staff.id, staffId: staff.id,
action: "tag_update", action: "tag_update",
@@ -90,10 +95,10 @@ export async function deleteTag(formData: FormData): Promise<void> {
try { try {
// Remove the tag and any taggable links pointing at it. // Remove the tag and any taggable links pointing at it.
await prisma.$transaction([ await db.transaction(async (tx) => {
prisma.taggables.deleteMany({ where: { tagId: id } }), await tx.delete(Taggables).where(eq(Taggables.tagId, id));
prisma.tags.delete({ where: { id } }), await tx.delete(Tags).where(eq(Tags.id, id));
]); });
await logStaffActivity({ await logStaffActivity({
staffId: staff.id, staffId: staff.id,
action: "tag_delete", action: "tag_delete",
+19 -11
View File
@@ -2,13 +2,22 @@
import { revalidatePath } from "next/cache"; import { revalidatePath } from "next/cache";
import { beforeEach, describe, expect, it, vi } from "vitest"; import { beforeEach, describe, expect, it, vi } from "vitest";
import { requirePermission } from "@/lib/admin/guard"; import { requirePermission } from "@/lib/admin/guard";
import { prisma } from "@/lib/prisma";
import { createTeam, deleteTeam } from "./admin-teams"; import { createTeam, deleteTeam } from "./admin-teams";
const { insertValues, deleteWhere } = vi.hoisted(() => {
const insertValues = vi.fn().mockResolvedValue([{ insertId: 1 }]);
const deleteWhere = vi.fn().mockResolvedValue([{ affectedRows: 1 }]);
return { insertValues, deleteWhere };
});
vi.mock("@/lib/admin/guard", () => ({ requirePermission: vi.fn() })); vi.mock("@/lib/admin/guard", () => ({ requirePermission: vi.fn() }));
vi.mock("@/lib/permissions", () => ({ PERMS: { USERS_EDIT: "users.edit" } })); vi.mock("@/lib/permissions", () => ({ PERMS: { USERS_EDIT: "users.edit" } }));
vi.mock("@/lib/prisma", () => ({ vi.mock("@/lib/db", () => ({
prisma: { websiteTeams: { create: vi.fn(), delete: vi.fn() } }, db: {
insert: vi.fn(() => ({ values: insertValues })),
delete: vi.fn(() => ({ where: deleteWhere })),
},
WebsiteTeams: { id: "id" },
})); }));
vi.mock("next/cache", () => ({ revalidatePath: vi.fn() })); vi.mock("next/cache", () => ({ revalidatePath: vi.fn() }));
@@ -20,6 +29,8 @@ const fakeForm = (data: Record<string, string | null>) => ({
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
vi.mocked(requirePermission).mockResolvedValue(staff as never); vi.mocked(requirePermission).mockResolvedValue(staff as never);
insertValues.mockResolvedValue([{ insertId: 1 }]);
deleteWhere.mockResolvedValue([{ affectedRows: 1 }]);
}); });
describe("createTeam", () => { describe("createTeam", () => {
@@ -27,25 +38,22 @@ describe("createTeam", () => {
await createTeam( await createTeam(
fakeForm({ rankName: "Moderator" }) as unknown as FormData, fakeForm({ rankName: "Moderator" }) as unknown as FormData,
); );
expect(prisma.websiteTeams.create).toHaveBeenCalledWith({ expect(insertValues).toHaveBeenCalledWith(
data: expect.objectContaining({ rankName: "Moderator" }), expect.objectContaining({ rankName: "Moderator" }),
}); );
expect(revalidatePath).toHaveBeenCalledWith("/admin/teams"); expect(revalidatePath).toHaveBeenCalledWith("/admin/teams");
}); });
it("returns early when rankName is empty", async () => { it("returns early when rankName is empty", async () => {
await createTeam(fakeForm({ rankName: "" }) as unknown as FormData); await createTeam(fakeForm({ rankName: "" }) as unknown as FormData);
expect(prisma.websiteTeams.create).not.toHaveBeenCalled(); expect(insertValues).not.toHaveBeenCalled();
}); });
}); });
describe("deleteTeam", () => { describe("deleteTeam", () => {
it("deletes a team entry", async () => { it("deletes a team entry", async () => {
vi.mocked(prisma.websiteTeams.delete).mockResolvedValue({} as never);
await deleteTeam(fakeForm({ id: "42" }) as unknown as FormData); await deleteTeam(fakeForm({ id: "42" }) as unknown as FormData);
expect(prisma.websiteTeams.delete).toHaveBeenCalledWith({ expect(deleteWhere).toHaveBeenCalled();
where: { id: BigInt(42) },
});
expect(revalidatePath).toHaveBeenCalledWith("/admin/teams"); expect(revalidatePath).toHaveBeenCalledWith("/admin/teams");
}); });
}); });
+4 -5
View File
@@ -1,9 +1,10 @@
"use server"; "use server";
import { eq } from "drizzle-orm";
import { revalidatePath } from "next/cache"; import { revalidatePath } from "next/cache";
import { requirePermission } from "@/lib/admin/guard"; import { requirePermission } from "@/lib/admin/guard";
import { db, WebsiteTeams } from "@/lib/db";
import { PERMS } from "@/lib/permissions"; import { PERMS } from "@/lib/permissions";
import { prisma } from "@/lib/prisma";
export async function createTeam(formData: FormData): Promise<void> { export async function createTeam(formData: FormData): Promise<void> {
await requirePermission(PERMS.USERS_EDIT); await requirePermission(PERMS.USERS_EDIT);
@@ -26,8 +27,7 @@ export async function createTeam(formData: FormData): Promise<void> {
const hiddenRank = formData.get("hiddenRank") === "on"; const hiddenRank = formData.get("hiddenRank") === "on";
const now = new Date(); const now = new Date();
await prisma.websiteTeams.create({ await db.insert(WebsiteTeams).values({
data: {
rankName: rankName.slice(0, 255), rankName: rankName.slice(0, 255),
badge: badge ? badge.slice(0, 255) : null, badge: badge ? badge.slice(0, 255) : null,
jobDescription: jobDescription ? jobDescription.slice(0, 255) : null, jobDescription: jobDescription ? jobDescription.slice(0, 255) : null,
@@ -35,7 +35,6 @@ export async function createTeam(formData: FormData): Promise<void> {
hiddenRank, hiddenRank,
createdAt: now, createdAt: now,
updatedAt: now, updatedAt: now,
},
}); });
revalidatePath("/admin/teams"); revalidatePath("/admin/teams");
@@ -45,7 +44,7 @@ export async function deleteTeam(formData: FormData): Promise<void> {
await requirePermission(PERMS.USERS_EDIT); await requirePermission(PERMS.USERS_EDIT);
const id = BigInt(String(formData.get("id"))); const id = BigInt(String(formData.get("id")));
await prisma.websiteTeams.delete({ where: { id } }); await db.delete(WebsiteTeams).where(eq(WebsiteTeams.id, id));
revalidatePath("/admin/teams"); revalidatePath("/admin/teams");
} }
+5 -6
View File
@@ -3,8 +3,8 @@
import { revalidatePath } from "next/cache"; import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { requirePermission } from "@/lib/admin/guard"; import { requirePermission } from "@/lib/admin/guard";
import { db, WebsiteSetting } from "@/lib/db";
import { PERMS } from "@/lib/permissions"; import { PERMS } from "@/lib/permissions";
import { prisma } from "@/lib/prisma";
import { siteSettings } from "@/lib/services/site-settings"; import { siteSettings } from "@/lib/services/site-settings";
import { logStaffActivity } from "@/lib/services/staff-activity"; import { logStaffActivity } from "@/lib/services/staff-activity";
import { ensureReadableThemeColors } from "@/lib/theme-contrast"; import { ensureReadableThemeColors } from "@/lib/theme-contrast";
@@ -24,11 +24,10 @@ const HEADING_KEYS = ["size_heading_h1", "size_heading_h2", "size_heading_h3"];
const CUSTOM_CSS_MAX = 20000; const CUSTOM_CSS_MAX = 20000;
async function writeSetting(key: string, value: string): Promise<void> { async function writeSetting(key: string, value: string): Promise<void> {
await prisma.websiteSetting.upsert({ await db
where: { key }, .insert(WebsiteSetting)
update: { value }, .values({ key, value, comment: "Theme (housekeeping)" })
create: { key, value, comment: "Theme (housekeeping)" }, .onDuplicateKeyUpdate({ set: { value } });
});
} }
export async function saveTheme(formData: FormData): Promise<void> { export async function saveTheme(formData: FormData): Promise<void> {
+7 -7
View File
@@ -1,10 +1,12 @@
"use server"; "use server";
import { eq } from "drizzle-orm";
import type { ResultSetHeader } from "mysql2";
import { revalidatePath } from "next/cache"; import { revalidatePath } from "next/cache";
import { requirePermission } from "@/lib/admin/guard"; import { requirePermission } from "@/lib/admin/guard";
import { positiveBigInt } from "@/lib/api"; import { positiveBigInt } from "@/lib/api";
import { db, WebsiteShopVouchers } from "@/lib/db";
import { PERMS } from "@/lib/permissions"; import { PERMS } from "@/lib/permissions";
import { prisma } from "@/lib/prisma";
import { import {
type ActionResult, type ActionResult,
actionError, actionError,
@@ -45,8 +47,7 @@ export async function createVoucher(input: {
const now = new Date(); const now = new Date();
try { try {
const created = await prisma.websiteShopVouchers.create({ const [result] = (await db.insert(WebsiteShopVouchers).values({
data: {
code, code,
amount: Math.floor(amount), amount: Math.floor(amount),
maxUses, maxUses,
@@ -54,10 +55,9 @@ export async function createVoucher(input: {
expiresAt, expiresAt,
createdAt: now, createdAt: now,
updatedAt: now, updatedAt: now,
}, })) as unknown as [ResultSetHeader];
});
revalidatePath("/admin/vouchers"); revalidatePath("/admin/vouchers");
return actionOk({ id: String(created.id) }); return actionOk({ id: String(result.insertId) });
} catch (error) { } catch (error) {
logServerError("admin.voucher_create_failed", error); logServerError("admin.voucher_create_failed", error);
return actionError("Could not create voucher (code may already exist)"); return actionError("Could not create voucher (code may already exist)");
@@ -73,7 +73,7 @@ export async function deleteVoucher(input: {
if (!id) return actionError("Missing voucher id"); if (!id) return actionError("Missing voucher id");
try { try {
await prisma.websiteShopVouchers.delete({ where: { id } }); await db.delete(WebsiteShopVouchers).where(eq(WebsiteShopVouchers.id, id));
revalidatePath("/admin/vouchers"); revalidatePath("/admin/vouchers");
return actionOk(); return actionOk();
} catch (error) { } catch (error) {
+19 -5
View File
@@ -1,16 +1,26 @@
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { beforeEach, describe, expect, it, vi } from "vitest"; import { beforeEach, describe, expect, it, vi } from "vitest";
import { requirePermission } from "@/lib/admin/guard"; import { requirePermission } from "@/lib/admin/guard";
import { prisma } from "@/lib/prisma";
import { siteSettings } from "@/lib/services/site-settings"; import { siteSettings } from "@/lib/services/site-settings";
import { saveVpn } from "./admin-vpn"; import { saveVpn } from "./admin-vpn";
const { mockValues, mockOnDuplicateKeyUpdate } = vi.hoisted(() => {
const mockOnDuplicateKeyUpdate = vi.fn().mockResolvedValue(undefined);
const mockValues = vi.fn(() => ({
onDuplicateKeyUpdate: mockOnDuplicateKeyUpdate,
}));
return { mockValues, mockOnDuplicateKeyUpdate };
});
vi.mock("@/lib/admin/guard", () => ({ requirePermission: vi.fn() })); vi.mock("@/lib/admin/guard", () => ({ requirePermission: vi.fn() }));
vi.mock("@/lib/permissions", () => ({ vi.mock("@/lib/permissions", () => ({
PERMS: { SETTINGS_EDIT: "settings.edit" }, PERMS: { SETTINGS_EDIT: "settings.edit" },
})); }));
vi.mock("@/lib/prisma", () => ({ vi.mock("@/lib/db", () => ({
prisma: { websiteSetting: { upsert: vi.fn() } }, db: {
insert: vi.fn(() => ({ values: mockValues })),
},
WebsiteSetting: { key: "key", value: "value" },
})); }));
vi.mock("@/lib/services/site-settings", () => ({ vi.mock("@/lib/services/site-settings", () => ({
siteSettings: { reload: vi.fn() }, siteSettings: { reload: vi.fn() },
@@ -27,7 +37,10 @@ const fakeForm = (data: Record<string, string | null>) => ({
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
vi.mocked(requirePermission).mockResolvedValue(staff as never); vi.mocked(requirePermission).mockResolvedValue(staff as never);
vi.mocked(prisma.websiteSetting.upsert).mockResolvedValue({} as never); mockValues.mockReturnValue({
onDuplicateKeyUpdate: mockOnDuplicateKeyUpdate,
});
mockOnDuplicateKeyUpdate.mockResolvedValue(undefined);
}); });
describe("saveVpn", () => { describe("saveVpn", () => {
@@ -39,7 +52,8 @@ describe("saveVpn", () => {
vpn_api_key: "abc123", vpn_api_key: "abc123",
}) as unknown as FormData, }) as unknown as FormData,
); );
expect(prisma.websiteSetting.upsert).toHaveBeenCalledTimes(4); expect(mockValues).toHaveBeenCalledTimes(4);
expect(mockOnDuplicateKeyUpdate).toHaveBeenCalledTimes(4);
expect(siteSettings.reload).toHaveBeenCalled(); expect(siteSettings.reload).toHaveBeenCalled();
expect(redirect).toHaveBeenCalledWith("/admin/vpn?saved=1"); expect(redirect).toHaveBeenCalledWith("/admin/vpn?saved=1");
}); });
+5 -6
View File
@@ -3,8 +3,8 @@
import { revalidatePath } from "next/cache"; import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { requirePermission } from "@/lib/admin/guard"; import { requirePermission } from "@/lib/admin/guard";
import { db, WebsiteSetting } from "@/lib/db";
import { PERMS } from "@/lib/permissions"; import { PERMS } from "@/lib/permissions";
import { prisma } from "@/lib/prisma";
import { siteSettings } from "@/lib/services/site-settings"; import { siteSettings } from "@/lib/services/site-settings";
import { logStaffActivity } from "@/lib/services/staff-activity"; import { logStaffActivity } from "@/lib/services/staff-activity";
@@ -21,11 +21,10 @@ async function writeSetting(
value: string, value: string,
comment: string, comment: string,
): Promise<void> { ): Promise<void> {
await prisma.websiteSetting.upsert({ await db
where: { key }, .insert(WebsiteSetting)
update: { value }, .values({ key, value, comment })
create: { key, value, comment }, .onDuplicateKeyUpdate({ set: { value } });
});
} }
export async function saveVpn(formData: FormData): Promise<void> { export async function saveVpn(formData: FormData): Promise<void> {
+10 -4
View File
@@ -1,9 +1,11 @@
"use server"; "use server";
import { eq } from "drizzle-orm";
import type { ResultSetHeader } from "mysql2";
import { revalidatePath } from "next/cache"; import { revalidatePath } from "next/cache";
import { requirePermission } from "@/lib/admin/guard"; import { requirePermission } from "@/lib/admin/guard";
import { db, WebsiteWordfilter } from "@/lib/db";
import { PERMS } from "@/lib/permissions"; import { PERMS } from "@/lib/permissions";
import { prisma } from "@/lib/prisma";
import { import {
type ActionResult, type ActionResult,
actionError, actionError,
@@ -23,11 +25,13 @@ export async function addWord(input: {
if (!word) return actionError("Word is required"); if (!word) return actionError("Word is required");
try { try {
const created = await prisma.websiteWordfilter.create({ data: { word } }); const [result] = (await db
.insert(WebsiteWordfilter)
.values({ word })) as unknown as [ResultSetHeader];
reloadWordFilter(); reloadWordFilter();
await rcon.updateWordFilter(); await rcon.updateWordFilter();
revalidatePath("/admin/wordfilter"); revalidatePath("/admin/wordfilter");
return actionOk({ id: String(created.id) }); return actionOk({ id: String(result.insertId) });
} catch { } catch {
return actionError("Could not add word (it may already exist)"); return actionError("Could not add word (it may already exist)");
} }
@@ -39,7 +43,9 @@ export async function deleteWord(input: { id: string }): Promise<ActionResult> {
if (!raw) return actionError("Missing word id"); if (!raw) return actionError("Missing word id");
try { try {
await prisma.websiteWordfilter.delete({ where: { id: BigInt(raw) } }); await db
.delete(WebsiteWordfilter)
.where(eq(WebsiteWordfilter.id, BigInt(raw)));
reloadWordFilter(); reloadWordFilter();
await rcon.updateWordFilter(); await rcon.updateWordFilter();
revalidatePath("/admin/wordfilter"); revalidatePath("/admin/wordfilter");
+20 -19
View File
@@ -1,9 +1,11 @@
"use server"; "use server";
import { eq } from "drizzle-orm";
import type { ResultSetHeader } from "mysql2";
import { revalidatePath } from "next/cache"; import { revalidatePath } from "next/cache";
import { requirePermission } from "@/lib/admin/guard"; import { requirePermission } from "@/lib/admin/guard";
import { db, WebsiteWriteableBoxes } from "@/lib/db";
import { PERMS } from "@/lib/permissions"; import { PERMS } from "@/lib/permissions";
import { prisma } from "@/lib/prisma";
import { logStaffActivity } from "@/lib/services/staff-activity"; import { logStaffActivity } from "@/lib/services/staff-activity";
// Writeable boxes (website_writeable_boxes). CMS-owned table backing the // Writeable boxes (website_writeable_boxes). CMS-owned table backing the
@@ -51,8 +53,7 @@ export async function createBox(formData: FormData): Promise<void> {
const now = new Date(); const now = new Date();
try { try {
const created = await prisma.websiteWriteableBoxes.create({ const [result] = (await db.insert(WebsiteWriteableBoxes).values({
data: {
title, title,
icon: icon:
String(formData.get("icon") ?? "") String(formData.get("icon") ?? "")
@@ -61,18 +62,16 @@ export async function createBox(formData: FormData): Promise<void> {
.slice(0, 255) || null, .slice(0, 255) || null,
content: String(formData.get("content") ?? "").normalize("NFC"), content: String(formData.get("content") ?? "").normalize("NFC"),
position: reqInt(formData, "position"), position: reqInt(formData, "position"),
isActive: isActive: String(formData.get("isActive") ?? "").normalize("NFC") === "1",
String(formData.get("isActive") ?? "").normalize("NFC") === "1",
createdAt: now, createdAt: now,
updatedAt: now, updatedAt: now,
}, })) as unknown as [ResultSetHeader];
});
await logStaffActivity({ await logStaffActivity({
staffId: staff.id, staffId: staff.id,
action: "writeable_box_create", action: "writeable_box_create",
description: `Created writeable box "${title}" (#${created.id})`, description: `Created writeable box "${title}" (#${result.insertId})`,
targetType: "writeable_box", targetType: "writeable_box",
targetId: Number(created.id), targetId: Number(result.insertId),
}); });
} catch { } catch {
// DB unavailable — swallow and re-render. // DB unavailable — swallow and re-render.
@@ -95,9 +94,9 @@ export async function updateBox(formData: FormData): Promise<void> {
if (!title) return; if (!title) return;
try { try {
await prisma.websiteWriteableBoxes.update({ await db
where: { id }, .update(WebsiteWriteableBoxes)
data: { .set({
title, title,
icon: icon:
String(formData.get("icon") ?? "") String(formData.get("icon") ?? "")
@@ -109,8 +108,8 @@ export async function updateBox(formData: FormData): Promise<void> {
isActive: isActive:
String(formData.get("isActive") ?? "").normalize("NFC") === "1", String(formData.get("isActive") ?? "").normalize("NFC") === "1",
updatedAt: new Date(), updatedAt: new Date(),
}, })
}); .where(eq(WebsiteWriteableBoxes.id, id));
await logStaffActivity({ await logStaffActivity({
staffId: staff.id, staffId: staff.id,
action: "writeable_box_update", action: "writeable_box_update",
@@ -132,7 +131,9 @@ export async function deleteBox(formData: FormData): Promise<void> {
if (id == null) return; if (id == null) return;
try { try {
await prisma.websiteWriteableBoxes.delete({ where: { id } }); await db
.delete(WebsiteWriteableBoxes)
.where(eq(WebsiteWriteableBoxes.id, id));
await logStaffActivity({ await logStaffActivity({
staffId: staff.id, staffId: staff.id,
action: "writeable_box_delete", action: "writeable_box_delete",
@@ -157,10 +158,10 @@ export async function toggleBox(formData: FormData): Promise<void> {
const next = String(formData.get("next") ?? "").normalize("NFC") === "1"; const next = String(formData.get("next") ?? "").normalize("NFC") === "1";
try { try {
await prisma.websiteWriteableBoxes.update({ await db
where: { id }, .update(WebsiteWriteableBoxes)
data: { isActive: next, updatedAt: new Date() }, .set({ isActive: next, updatedAt: new Date() })
}); .where(eq(WebsiteWriteableBoxes.id, id));
await logStaffActivity({ await logStaffActivity({
staffId: staff.id, staffId: staff.id,
action: "writeable_box_toggle", action: "writeable_box_toggle",
+34 -13
View File
@@ -1,9 +1,10 @@
"use server"; "use server";
import { and, eq } from "drizzle-orm";
import { revalidatePath } from "next/cache"; import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { auth } from "@/lib/auth"; import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma"; import { db, WebsiteStaffApplications } from "@/lib/db";
import { clientIp, rateLimit } from "@/lib/rate-limit"; import { clientIp, rateLimit } from "@/lib/rate-limit";
// AtomCMS validates the application body with `min:10`. Mirror that floor and // AtomCMS validates the application body with `min:10`. Mirror that floor and
@@ -67,16 +68,26 @@ export async function applyStaff(formData: FormData): Promise<void> {
if (content.length < CONTENT_MIN) { if (content.length < CONTENT_MIN) {
outcome = "empty"; outcome = "empty";
} else { } else {
const existing = await prisma.websiteStaffApplications.findFirst({ const [existing] = await db
where: { userId, rankId }, .select({ id: WebsiteStaffApplications.id })
select: { id: true }, .from(WebsiteStaffApplications)
}); .where(
and(
eq(WebsiteStaffApplications.userId, userId),
eq(WebsiteStaffApplications.rankId, rankId),
),
)
.limit(1);
if (existing) { if (existing) {
outcome = "duplicate"; outcome = "duplicate";
} else { } else {
const now = new Date(); const now = new Date();
await prisma.websiteStaffApplications.create({ await db.insert(WebsiteStaffApplications).values({
data: { userId, rankId, content, createdAt: now, updatedAt: now }, userId,
rankId,
content,
createdAt: now,
updatedAt: now,
}); });
outcome = "submitted"; outcome = "submitted";
} }
@@ -120,16 +131,26 @@ export async function applyTeam(formData: FormData): Promise<void> {
if (content.length < CONTENT_MIN) { if (content.length < CONTENT_MIN) {
outcome = "empty"; outcome = "empty";
} else { } else {
const existing = await prisma.websiteStaffApplications.findFirst({ const [existing] = await db
where: { userId, rankId }, .select({ id: WebsiteStaffApplications.id })
select: { id: true }, .from(WebsiteStaffApplications)
}); .where(
and(
eq(WebsiteStaffApplications.userId, userId),
eq(WebsiteStaffApplications.rankId, rankId),
),
)
.limit(1);
if (existing) { if (existing) {
outcome = "duplicate"; outcome = "duplicate";
} else { } else {
const now = new Date(); const now = new Date();
await prisma.websiteStaffApplications.create({ await db.insert(WebsiteStaffApplications).values({
data: { userId, rankId, content, createdAt: now, updatedAt: now }, userId,
rankId,
content,
createdAt: now,
updatedAt: now,
}); });
outcome = "submitted"; outcome = "submitted";
} }
+8 -8
View File
@@ -1,9 +1,10 @@
"use server"; "use server";
import { eq } from "drizzle-orm";
import { revalidatePath } from "next/cache"; import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { auth } from "@/lib/auth"; import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma"; import { db, WebsiteArticleComments, WebsiteArticles } from "@/lib/db";
import { clientIp, rateLimit } from "@/lib/rate-limit"; import { clientIp, rateLimit } from "@/lib/rate-limit";
import { isAllowed } from "@/lib/services/moderation"; import { isAllowed } from "@/lib/services/moderation";
@@ -83,23 +84,22 @@ export async function postComment(formData: FormData): Promise<void> {
outcome = "invalid"; outcome = "invalid";
} else { } else {
const articleId = BigInt(articleIdRaw); const articleId = BigInt(articleIdRaw);
const article = await prisma.websiteArticles.findUnique({ const [article] = await db
where: { id: articleId }, .select({ slug: WebsiteArticles.slug })
select: { slug: true }, .from(WebsiteArticles)
}); .where(eq(WebsiteArticles.id, articleId))
.limit(1);
if (!article) { if (!article) {
outcome = "not_found"; outcome = "not_found";
} else { } else {
slug = article.slug; slug = article.slug;
const now = new Date(); const now = new Date();
await prisma.websiteArticleComments.create({ await db.insert(WebsiteArticleComments).values({
data: {
articleId, articleId,
userId, userId,
comment, comment,
createdAt: now, createdAt: now,
updatedAt: now, updatedAt: now,
},
}); });
outcome = "posted"; outcome = "posted";
} }
+44 -23
View File
@@ -1,9 +1,10 @@
"use server"; "use server";
import { and, eq } from "drizzle-orm";
import { revalidatePath } from "next/cache"; import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { auth } from "@/lib/auth"; import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma"; import { db, WebsiteArticleReactions, WebsiteArticles } from "@/lib/db";
import { clientIp, rateLimit } from "@/lib/rate-limit"; import { clientIp, rateLimit } from "@/lib/rate-limit";
// The reaction set the UI offers. The action rejects anything outside this list // The reaction set the UI offers. The action rejects anything outside this list
@@ -75,39 +76,59 @@ export async function toggleReaction(formData: FormData): Promise<void> {
} else { } else {
const articleId = BigInt(articleIdRaw); const articleId = BigInt(articleIdRaw);
const article = await prisma.websiteArticles.findUnique({ const [article] = await db
where: { id: articleId }, .select({ slug: WebsiteArticles.slug })
select: { slug: true }, .from(WebsiteArticles)
}); .where(eq(WebsiteArticles.id, articleId))
.limit(1);
if (!article) { if (!article) {
outcome = "not_found"; outcome = "not_found";
} else { } else {
slug = article.slug; slug = article.slug;
const existing = await prisma.websiteArticleReactions.findFirst({ const [existing] = await db
where: { userId, articleId, reaction }, .select({
select: { id: true, active: true }, id: WebsiteArticleReactions.id,
}); active: WebsiteArticleReactions.active,
})
.from(WebsiteArticleReactions)
.where(
and(
eq(WebsiteArticleReactions.userId, userId),
eq(WebsiteArticleReactions.articleId, articleId),
eq(WebsiteArticleReactions.reaction, reaction),
),
)
.limit(1);
if (existing?.active) { if (existing?.active) {
await prisma.websiteArticleReactions.update({ await db
where: { id: existing.id }, .update(WebsiteArticleReactions)
data: { active: false }, .set({ active: false })
}); .where(eq(WebsiteArticleReactions.id, existing.id));
} else { } else {
await prisma.websiteArticleReactions.updateMany({ await db
where: { userId, articleId, active: true }, .update(WebsiteArticleReactions)
data: { active: false }, .set({ active: false })
}); .where(
and(
eq(WebsiteArticleReactions.userId, userId),
eq(WebsiteArticleReactions.articleId, articleId),
eq(WebsiteArticleReactions.active, true),
),
);
if (existing) { if (existing) {
await prisma.websiteArticleReactions.update({ await db
where: { id: existing.id }, .update(WebsiteArticleReactions)
data: { active: true }, .set({ active: true })
}); .where(eq(WebsiteArticleReactions.id, existing.id));
} else { } else {
await prisma.websiteArticleReactions.create({ await db.insert(WebsiteArticleReactions).values({
data: { userId, articleId, reaction, active: true }, userId,
articleId,
reaction,
active: true,
}); });
} }
} }
+13 -10
View File
@@ -1,8 +1,9 @@
"use server"; "use server";
import { eq } from "drizzle-orm";
import { env } from "@/env"; import { env } from "@/env";
import { checkLogin } from "@/lib/auth/password"; import { checkLogin } from "@/lib/auth/password";
import { prisma } from "@/lib/prisma"; import { db, User } from "@/lib/db";
import { clientIp, rateLimit } from "@/lib/rate-limit"; import { clientIp, rateLimit } from "@/lib/rate-limit";
import { captchaConfig, verifyCaptcha } from "@/lib/services/captcha"; import { captchaConfig, verifyCaptcha } from "@/lib/services/captcha";
import { siteSettings } from "@/lib/services/site-settings"; import { siteSettings } from "@/lib/services/site-settings";
@@ -45,15 +46,17 @@ export async function precheckLogin(
mailVerified: string; mailVerified: string;
} | null; } | null;
try { try {
user = await prisma.user.findUnique({ const [row] = await db
where: { username: u }, .select({
select: { password: User.password,
password: true, twoFactorConfirmedAt: User.twoFactorConfirmedAt,
twoFactorConfirmedAt: true, mail: User.mail,
mail: true, mailVerified: User.mailVerified,
mailVerified: true, })
}, .from(User)
}); .where(eq(User.username, u))
.limit(1);
user = row ?? null;
} catch { } catch {
return "invalid"; return "invalid";
} }
+22 -11
View File
@@ -1,16 +1,21 @@
"use server"; "use server";
import { eq } from "drizzle-orm";
import { revalidatePath } from "next/cache"; import { revalidatePath } from "next/cache";
import { requirePermission } from "@/lib/admin/guard"; import { requirePermission } from "@/lib/admin/guard";
import { db, WebsiteBadges } from "@/lib/db";
import { PERMS } from "@/lib/permissions"; import { PERMS } from "@/lib/permissions";
import { prisma } from "@/lib/prisma";
export async function getBadgeData({ code }: { code: string }) { export async function getBadgeData({ code }: { code: string }) {
await requirePermission(PERMS.CATALOG_EDIT); await requirePermission(PERMS.CATALOG_EDIT);
const badge = await prisma.websiteBadges.findUnique({ const [badge] = await db
where: { badgeKey: code }, .select({
select: { badgeName: true, badgeDescription: true }, badgeName: WebsiteBadges.badgeName,
}); badgeDescription: WebsiteBadges.badgeDescription,
})
.from(WebsiteBadges)
.where(eq(WebsiteBadges.badgeKey, code))
.limit(1);
if (!badge) return { ok: false as const, data: null }; if (!badge) return { ok: false as const, data: null };
return { return {
ok: true as const, ok: true as const,
@@ -28,15 +33,21 @@ export async function updateBadge({
desc: string; desc: string;
}) { }) {
await requirePermission(PERMS.CATALOG_EDIT); await requirePermission(PERMS.CATALOG_EDIT);
await prisma.websiteBadges.upsert({ const now = new Date();
where: { badgeKey: code }, await db
update: { badgeName: name, badgeDescription: desc, updatedAt: new Date() }, .insert(WebsiteBadges)
create: { .values({
badgeKey: code, badgeKey: code,
badgeName: name, badgeName: name,
badgeDescription: desc, badgeDescription: desc,
createdAt: new Date(), createdAt: now,
updatedAt: new Date(), updatedAt: now,
})
.onDuplicateKeyUpdate({
set: {
badgeName: name,
badgeDescription: desc,
updatedAt: now,
}, },
}); });
revalidatePath("/admin/import/badges"); revalidatePath("/admin/import/badges");
+17 -8
View File
@@ -1,8 +1,10 @@
"use server"; "use server";
import { eq } from "drizzle-orm";
import type { ResultSetHeader } from "mysql2";
import { z } from "zod"; import { z } from "zod";
import { db, WebsiteBanner } from "@/lib/db";
import { PERMS } from "@/lib/permissions"; import { PERMS } from "@/lib/permissions";
import { prisma } from "@/lib/prisma";
import { adminAction } from "@/lib/safe-action"; import { adminAction } from "@/lib/safe-action";
import { ActionError, actionOk } from "@/lib/safe-action-shared"; import { ActionError, actionOk } from "@/lib/safe-action-shared";
import { logAudit } from "@/lib/services/audit"; import { logAudit } from "@/lib/services/audit";
@@ -22,15 +24,18 @@ const bannerSchema = z.object({
export const createBanner = adminAction( export const createBanner = adminAction(
{ permission: PERMS.BANNERS_EDIT, schema: bannerSchema }, { permission: PERMS.BANNERS_EDIT, schema: bannerSchema },
async (ctx) => { async (ctx) => {
const banner = await prisma.websiteBanner.create({ data: ctx.data }); const [result] = (await db
.insert(WebsiteBanner)
.values(ctx.data)) as unknown as [ResultSetHeader];
const id = Number(result.insertId);
logAudit({ logAudit({
userId: ctx.session.user.id, userId: ctx.session.user.id,
action: "banner_create", action: "banner_create",
target: "WebsiteBanner", target: "WebsiteBanner",
targetId: banner.id, targetId: id,
after: { title: banner.title }, after: { title: ctx.data.title },
}); });
return actionOk({ id: banner.id }); return actionOk({ id });
}, },
); );
@@ -42,9 +47,13 @@ export const updateBanner = adminAction(
{ permission: PERMS.BANNERS_EDIT, schema: updateBannerInput }, { permission: PERMS.BANNERS_EDIT, schema: updateBannerInput },
async (ctx) => { async (ctx) => {
const { id, ...data } = ctx.data; const { id, ...data } = ctx.data;
const existing = await prisma.websiteBanner.findUnique({ where: { id } }); const [existing] = await db
.select({ id: WebsiteBanner.id })
.from(WebsiteBanner)
.where(eq(WebsiteBanner.id, id))
.limit(1);
if (!existing) throw new ActionError("Banner not found"); if (!existing) throw new ActionError("Banner not found");
await prisma.websiteBanner.update({ where: { id }, data }); await db.update(WebsiteBanner).set(data).where(eq(WebsiteBanner.id, id));
logAudit({ logAudit({
userId: ctx.session.user.id, userId: ctx.session.user.id,
action: "banner_update", action: "banner_update",
@@ -60,7 +69,7 @@ const deleteBannerInput = z.object({ id: z.coerce.number().int().positive() });
export const deleteBanner = adminAction( export const deleteBanner = adminAction(
{ permission: PERMS.BANNERS_EDIT, schema: deleteBannerInput }, { permission: PERMS.BANNERS_EDIT, schema: deleteBannerInput },
async (ctx) => { async (ctx) => {
await prisma.websiteBanner.delete({ where: { id: ctx.data.id } }); await db.delete(WebsiteBanner).where(eq(WebsiteBanner.id, ctx.data.id));
logAudit({ logAudit({
userId: ctx.session.user.id, userId: ctx.session.user.id,
action: "banner_delete", action: "banner_delete",
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+26 -22
View File
@@ -1,9 +1,10 @@
"use server"; "use server";
import { eq } from "drizzle-orm";
import { revalidatePath } from "next/cache"; import { revalidatePath } from "next/cache";
import { requirePermission } from "@/lib/admin/guard"; import { requirePermission } from "@/lib/admin/guard";
import { CatalogPages, db } from "@/lib/db";
import { PERMS } from "@/lib/permissions"; import { PERMS } from "@/lib/permissions";
import { prisma } from "@/lib/prisma";
import type { ActionResult } from "@/lib/safe-action-shared"; import type { ActionResult } from "@/lib/safe-action-shared";
import { deletePage, movePage } from "@/lib/services/catalog-tree"; import { deletePage, movePage } from "@/lib/services/catalog-tree";
import { rcon } from "@/lib/services/rcon"; import { rcon } from "@/lib/services/rcon";
@@ -54,10 +55,10 @@ export async function updateCatalogPage({
if (typeof data.caption === "string" && !data.captionSave) { if (typeof data.caption === "string" && !data.captionSave) {
data.captionSave = data.caption.slice(0, 25); data.captionSave = data.caption.slice(0, 25);
} }
await prisma.catalogPages.update({ await db
where: { id }, .update(CatalogPages)
data: data as any, .set(data as Partial<typeof CatalogPages.$inferInsert>)
}); .where(eq(CatalogPages.id, id));
await rcon.updateCatalog(); await rcon.updateCatalog();
await logStaffActivity({ await logStaffActivity({
staffId: staff.id, staffId: staff.id,
@@ -93,17 +94,21 @@ export async function toggleCatalogPage({
action: "toggleEnabled" | "toggleVisible"; action: "toggleEnabled" | "toggleVisible";
}) { }) {
await requirePermission(PERMS.CATALOG_EDIT); await requirePermission(PERMS.CATALOG_EDIT);
const page = await prisma.catalogPages.findUnique({ const [page] = await db
where: { id }, .select({
select: { enabled: true, visible: true }, enabled: CatalogPages.enabled,
}); visible: CatalogPages.visible,
})
.from(CatalogPages)
.where(eq(CatalogPages.id, id))
.limit(1);
if (!page) return { ok: false as const, error: "Catalog page not found" }; if (!page) return { ok: false as const, error: "Catalog page not found" };
const field = action === "toggleEnabled" ? "enabled" : "visible"; const field = action === "toggleEnabled" ? "enabled" : "visible";
const current = action === "toggleEnabled" ? page.enabled : page.visible; const current = action === "toggleEnabled" ? page.enabled : page.visible;
await prisma.catalogPages.update({ await db
where: { id }, .update(CatalogPages)
data: { [field]: current === "1" ? "0" : "1" }, .set({ [field]: current === "1" ? "0" : "1" })
}); .where(eq(CatalogPages.id, id));
await rcon.updateCatalog(); await rcon.updateCatalog();
revalidatePath("/admin/catalog"); revalidatePath("/admin/catalog");
return { ok: true as const, data: {} }; return { ok: true as const, data: {} };
@@ -121,8 +126,7 @@ export async function createCatalogPage(input: {
orderNum?: number; orderNum?: number;
}): Promise<ActionResult<{ id: number }>> { }): Promise<ActionResult<{ id: number }>> {
const staff = await requirePermission(PERMS.CATALOG_EDIT); const staff = await requirePermission(PERMS.CATALOG_EDIT);
const created = await prisma.catalogPages.create({ const [result] = await db.insert(CatalogPages).values({
data: {
caption: input.caption, caption: input.caption,
parentId: input.parentId, parentId: input.parentId,
pageLayout: input.pageLayout ?? "default_3x3", pageLayout: input.pageLayout ?? "default_3x3",
@@ -138,18 +142,18 @@ export async function createCatalogPage(input: {
pageHeadline: "", pageHeadline: "",
pageTeaser: "", pageTeaser: "",
includes: "", includes: "",
},
}); });
const createdId = Number(result.insertId);
await rcon.updateCatalog(); await rcon.updateCatalog();
await logStaffActivity({ await logStaffActivity({
staffId: staff.id, staffId: staff.id,
action: "catalog_page_create", action: "catalog_page_create",
description: `Created catalog page "${input.caption}"`, description: `Created catalog page "${input.caption}"`,
targetType: "catalog_page", targetType: "catalog_page",
targetId: created.id, targetId: createdId,
}); });
revalidatePath("/admin/catalog"); revalidatePath("/admin/catalog");
return { ok: true as const, data: { id: created.id } }; return { ok: true as const, data: { id: createdId } };
} }
export async function reorderTreePage(input: { export async function reorderTreePage(input: {
@@ -168,10 +172,10 @@ export async function reorderTreePage(input: {
}; };
} }
} }
await prisma.catalogPages.update({ await db
where: { id: input.pageId }, .update(CatalogPages)
data: { orderNum: input.newOrderNum }, .set({ orderNum: input.newOrderNum })
}); .where(eq(CatalogPages.id, input.pageId));
await rcon.updateCatalog(); await rcon.updateCatalog();
revalidatePath("/admin/catalog"); revalidatePath("/admin/catalog");
return { ok: true as const, data: {} }; return { ok: true as const, data: {} };
+20 -12
View File
@@ -1,9 +1,10 @@
"use server"; "use server";
import { eq, sql } from "drizzle-orm";
import { revalidatePath } from "next/cache"; import { revalidatePath } from "next/cache";
import { z } from "zod"; import { z } from "zod";
import { db, User } from "@/lib/db";
import { PERMS } from "@/lib/permissions"; import { PERMS } from "@/lib/permissions";
import { prisma } from "@/lib/prisma";
import { adminAction } from "@/lib/safe-action"; import { adminAction } from "@/lib/safe-action";
import { ActionError, actionOk } from "@/lib/safe-action-shared"; import { ActionError, actionOk } from "@/lib/safe-action-shared";
import { rcon } from "@/lib/services/rcon"; import { rcon } from "@/lib/services/rcon";
@@ -199,15 +200,22 @@ export const setRank = adminAction(
async (ctx) => { async (ctx) => {
const staffRank = Number(ctx.session.user.rank); const staffRank = Number(ctx.session.user.rank);
const isSuper = ctx.permissions.isSuperAdmin; const isSuper = ctx.permissions.isSuperAdmin;
const target = await prisma.user.findUnique({ const [target] = await db
where: { id: ctx.data.userId }, .select({ rank: User.rank })
select: { rank: true }, .from(User)
}); .where(eq(User.id, ctx.data.userId))
.limit(1);
if (!target) throw new ActionError("User not found"); if (!target) throw new ActionError("User not found");
const rankExists = await prisma.$queryRaw<{ id: number }[]>` let rankExists: { id: number }[] = [];
SELECT id FROM permission_ranks WHERE id = ${ctx.data.rank} LIMIT 1 try {
`.catch(() => [] as { id: number }[]); const [rows] = await db.execute(
sql`SELECT id FROM permission_ranks WHERE id = ${ctx.data.rank} LIMIT 1`,
);
rankExists = rows as unknown as { id: number }[];
} catch {
rankExists = [];
}
if (rankExists.length === 0) throw new ActionError("Rank does not exist"); if (rankExists.length === 0) throw new ActionError("Rank does not exist");
if (!isSuper) { if (!isSuper) {
@@ -222,10 +230,10 @@ export const setRank = adminAction(
} }
await requireRconOk(await rcon.setRank(ctx.data.userId, ctx.data.rank)); await requireRconOk(await rcon.setRank(ctx.data.userId, ctx.data.rank));
await prisma.user.update({ await db
where: { id: ctx.data.userId }, .update(User)
data: { rank: ctx.data.rank }, .set({ rank: ctx.data.rank })
}); .where(eq(User.id, ctx.data.userId));
revalidatePath(PATH); revalidatePath(PATH);
return actionOk(); return actionOk();
}, },
+40 -25
View File
@@ -1,9 +1,10 @@
"use server"; "use server";
import { and, eq, max, sql } from "drizzle-orm";
import { revalidatePath } from "next/cache"; import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { auth } from "@/lib/auth"; import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma"; import { db, User, UsersBadges, WebsiteDrawbadges } from "@/lib/db";
import { clientIp, rateLimit } from "@/lib/rate-limit"; import { clientIp, rateLimit } from "@/lib/rate-limit";
import { rcon } from "@/lib/services/rcon"; import { rcon } from "@/lib/services/rcon";
import { siteSettings } from "@/lib/services/site-settings"; import { siteSettings } from "@/lib/services/site-settings";
@@ -63,10 +64,15 @@ export async function buyBadge(formData: FormData): Promise<void> {
if (!(await rateLimit(`draw-badge-buy:${userId}`, 5, 60_000)).ok) { if (!(await rateLimit(`draw-badge-buy:${userId}`, 5, 60_000)).ok) {
outcome = "ratelimit"; outcome = "ratelimit";
} else { } else {
const badge = await prisma.websiteDrawbadges.findUnique({ const [badge] = await db
where: { id: BigInt(rawId) }, .select({
select: { id: true, badgePath: true, published: true }, id: WebsiteDrawbadges.id,
}); badgePath: WebsiteDrawbadges.badgePath,
published: WebsiteDrawbadges.published,
})
.from(WebsiteDrawbadges)
.where(eq(WebsiteDrawbadges.id, BigInt(rawId)))
.limit(1);
if (!badge?.published) { if (!badge?.published) {
outcome = "invalid"; outcome = "invalid";
@@ -78,34 +84,43 @@ export async function buyBadge(formData: FormData): Promise<void> {
const price = await resolvePrice(); const price = await resolvePrice();
// Re-read the buyer's live credit balance and verify it covers the cost. // Re-read the buyer's live credit balance and verify it covers the cost.
const buyer = await prisma.user.findUnique({ const [buyer] = await db
where: { id: userId }, .select({ credits: User.credits })
select: { credits: true }, .from(User)
}); .where(eq(User.id, userId))
.limit(1);
if (!buyer || buyer.credits < price) { if (!buyer || buyer.credits < price) {
outcome = "credits"; outcome = "credits";
} else { } else {
// Atomically deduct credits and persist the badge so a failure // Atomically deduct credits and persist the badge so a failure
// between the two operations cannot orphan the user. // between the two operations cannot orphan the user.
if (price > 0) { if (price > 0) {
await prisma.$transaction(async (tx) => { await db.transaction(async (tx) => {
await tx.user.update({ await tx
where: { id: userId }, .update(User)
data: { credits: { decrement: price } }, .set({ credits: sql`${User.credits} - ${price}` })
}); .where(eq(User.id, userId));
const existing = await tx.usersBadges.findFirst({ const [existing] = await tx
where: { userId, badgeCode: code }, .select({ id: UsersBadges.id })
select: { id: true }, .from(UsersBadges)
}); .where(
and(
eq(UsersBadges.userId, userId),
eq(UsersBadges.badgeCode, code),
),
)
.limit(1);
if (!existing) { if (!existing) {
const max = await tx.usersBadges.aggregate({ const [agg] = await tx
where: { userId }, .select({ maxSlot: max(UsersBadges.slotId) })
_max: { slotId: true }, .from(UsersBadges)
}); .where(eq(UsersBadges.userId, userId));
const slotId = (max._max.slotId ?? 0) + 1; const slotId = (agg?.maxSlot ?? 0) + 1;
await tx.usersBadges.create({ await tx.insert(UsersBadges).values({
data: { userId, slotId, badgeCode: code }, userId,
slotId,
badgeCode: code,
}); });
} }
}); });
+16 -6
View File
@@ -1,16 +1,27 @@
// @ts-nocheck // @ts-nocheck
import { describe, expect, it, vi } from "vitest"; import { describe, expect, it, vi } from "vitest";
import { prisma } from "@/lib/prisma";
import { rcon } from "@/lib/services/rcon"; import { rcon } from "@/lib/services/rcon";
const { insertValues } = vi.hoisted(() => {
const insertValues = vi.fn(() => ({
onDuplicateKeyUpdate: vi.fn().mockResolvedValue([{ affectedRows: 1 }]),
}));
return { insertValues };
});
vi.mock("@/lib/permissions", () => ({ vi.mock("@/lib/permissions", () => ({
PERMS: { SETTINGS_EDIT: "settings.edit" }, PERMS: { SETTINGS_EDIT: "settings.edit" },
})); }));
vi.mock("@/lib/prisma", () => ({ vi.mock("@/lib/db", () => ({
prisma: { emulatorSettings: { upsert: vi.fn() } }, db: {
insert: vi.fn(() => ({ values: insertValues })),
},
EmulatorSettings: { key: "key", value: "value" },
})); }));
vi.mock("@/lib/safe-action", () => ({ vi.mock("@/lib/safe-action", () => ({
adminAction: vi.fn((_opts: unknown, fn: (...args: unknown[]) => unknown) => fn), adminAction: vi.fn(
(_opts: unknown, fn: (...args: unknown[]) => unknown) => fn,
),
})); }));
vi.mock("@/lib/safe-action-shared", () => ({ actionOk: vi.fn(() => "ok") })); vi.mock("@/lib/safe-action-shared", () => ({ actionOk: vi.fn(() => "ok") }));
vi.mock("@/lib/services/audit", () => ({ logAudit: vi.fn() })); vi.mock("@/lib/services/audit", () => ({ logAudit: vi.fn() }));
@@ -18,7 +29,6 @@ vi.mock("@/lib/services/rcon", () => ({ rcon: { updateConfig: vi.fn() } }));
describe("saveEmulatorSettings", () => { describe("saveEmulatorSettings", () => {
it("saves settings and calls rcon update", async () => { it("saves settings and calls rcon update", async () => {
vi.mocked(prisma.emulatorSettings.upsert).mockResolvedValue({} as never);
const handler = (await import("./emulator").then( const handler = (await import("./emulator").then(
(m) => m.saveEmulatorSettings, (m) => m.saveEmulatorSettings,
)) as unknown as (ctx: { )) as unknown as (ctx: {
@@ -31,7 +41,7 @@ describe("saveEmulatorSettings", () => {
session: { user: { id: "1" } }, session: { user: { id: "1" } },
}); });
expect(prisma.emulatorSettings.upsert).toHaveBeenCalledTimes(2); expect(insertValues).toHaveBeenCalledTimes(2);
expect(rcon.updateConfig).toHaveBeenCalled(); expect(rcon.updateConfig).toHaveBeenCalled();
expect(result).toBe("ok"); expect(result).toBe("ok");
}); });
+5 -6
View File
@@ -1,8 +1,8 @@
"use server"; "use server";
import { z } from "zod"; import { z } from "zod";
import { db, EmulatorSettings } from "@/lib/db";
import { PERMS } from "@/lib/permissions"; import { PERMS } from "@/lib/permissions";
import { prisma } from "@/lib/prisma";
import { adminAction } from "@/lib/safe-action"; import { adminAction } from "@/lib/safe-action";
import { actionOk } from "@/lib/safe-action-shared"; import { actionOk } from "@/lib/safe-action-shared";
import { logAudit } from "@/lib/services/audit"; import { logAudit } from "@/lib/services/audit";
@@ -18,11 +18,10 @@ export const saveEmulatorSettings = adminAction(
const entries = Object.entries(ctx.data.settings); const entries = Object.entries(ctx.data.settings);
for (const [key, value] of entries) { for (const [key, value] of entries) {
await prisma.emulatorSettings.upsert({ await db
where: { key }, .insert(EmulatorSettings)
update: { value: String(value) }, .values({ key, value: String(value) })
create: { key, value: String(value) }, .onDuplicateKeyUpdate({ set: { value: String(value) } });
});
} }
await rcon.updateConfig(); await rcon.updateConfig();
+100 -53
View File
File diff suppressed because it is too large. Load diff
+2 -4
View File
@@ -3,7 +3,7 @@
import { revalidatePath } from "next/cache"; import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { auth } from "@/lib/auth"; import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma"; import { db, WebsiteUserGuestbooks } from "@/lib/db";
import { clientIp, rateLimit } from "@/lib/rate-limit"; import { clientIp, rateLimit } from "@/lib/rate-limit";
import { isAllowed } from "@/lib/services/moderation"; import { isAllowed } from "@/lib/services/moderation";
@@ -78,14 +78,12 @@ export async function postGuestbook(formData: FormData): Promise<void> {
outcome = "moderated"; outcome = "moderated";
} else { } else {
const now = new Date(); const now = new Date();
await prisma.websiteUserGuestbooks.create({ await db.insert(WebsiteUserGuestbooks).values({
data: {
profileId, profileId,
userId, userId,
message, message,
createdAt: now, createdAt: now,
updatedAt: now, updatedAt: now,
},
}); });
outcome = "posted"; outcome = "posted";
} }
File diff suppressed because it is too large. Load diff
+11 -9
View File
@@ -1,9 +1,10 @@
"use server"; "use server";
import { eq } from "drizzle-orm";
import { revalidatePath } from "next/cache"; import { revalidatePath } from "next/cache";
import { z } from "zod"; import { z } from "zod";
import { db, ItemsBase } from "@/lib/db";
import { PERMS } from "@/lib/permissions"; import { PERMS } from "@/lib/permissions";
import { prisma } from "@/lib/prisma";
import { adminAction } from "@/lib/safe-action"; import { adminAction } from "@/lib/safe-action";
import { ActionError, actionOk } from "@/lib/safe-action-shared"; import { ActionError, actionOk } from "@/lib/safe-action-shared";
import { rcon } from "@/lib/services/rcon"; import { rcon } from "@/lib/services/rcon";
@@ -63,10 +64,11 @@ export const updateItemsBase = adminAction(
throw new ActionError("No valid fields to update"); throw new ActionError("No valid fields to update");
} }
const existing = await prisma.itemsBase.findUnique({ const [existing] = await db
where: { id }, .select({ id: ItemsBase.id })
select: { id: true }, .from(ItemsBase)
}); .where(eq(ItemsBase.id, id))
.limit(1);
if (!existing) throw new ActionError("Item not found"); if (!existing) throw new ActionError("Item not found");
// Coerce common numeric / decimal fields from form strings. // Coerce common numeric / decimal fields from form strings.
@@ -94,10 +96,10 @@ export const updateItemsBase = adminAction(
data.stackHeight = Number(data.stackHeight); data.stackHeight = Number(data.stackHeight);
} }
await prisma.itemsBase.update({ await db
where: { id }, .update(ItemsBase)
data: data as any, .set(data as Partial<typeof ItemsBase.$inferInsert>)
}); .where(eq(ItemsBase.id, id));
await rcon.updateCatalog().catch(() => false); await rcon.updateCatalog().catch(() => false);
await logStaffActivity({ await logStaffActivity({
staffId: Number(ctx.session.user.id), staffId: Number(ctx.session.user.id),
+120 -72
View File
File diff suppressed because it is too large. Load diff
+27 -19
View File
@@ -1,10 +1,11 @@
"use server"; "use server";
import { eq } from "drizzle-orm";
import { z } from "zod"; import { z } from "zod";
import { db, SupportTickets } from "@/lib/db";
import { actionOk, adminAction } from "@/lib/foundation/action"; import { actionOk, adminAction } from "@/lib/foundation/action";
import { NotFoundError } from "@/lib/foundation/errors"; import { NotFoundError } from "@/lib/foundation/errors";
import { PERMS } from "@/lib/permissions"; import { PERMS } from "@/lib/permissions";
import { prisma } from "@/lib/prisma";
import { logAudit } from "@/lib/services/audit"; import { logAudit } from "@/lib/services/audit";
import { rcon } from "@/lib/services/rcon"; import { rcon } from "@/lib/services/rcon";
@@ -18,15 +19,17 @@ const MOD_ACTION_PERM = [PERMS.MODERATION_EDIT, PERMS.MOD_ACTIONS] as const;
export const assignCfhTicket = adminAction( export const assignCfhTicket = adminAction(
{ permission: CFH_PERM, schema: cfhIdSchema }, { permission: CFH_PERM, schema: cfhIdSchema },
async (ctx) => { async (ctx) => {
const ticket = await prisma.supportTickets.findUnique({ const [ticket] = await db
where: { id: ctx.data.ticketId }, .select({ id: SupportTickets.id })
}); .from(SupportTickets)
.where(eq(SupportTickets.id, ctx.data.ticketId))
.limit(1);
if (!ticket) throw new NotFoundError("SupportTicket", ctx.data.ticketId); if (!ticket) throw new NotFoundError("SupportTicket", ctx.data.ticketId);
await prisma.supportTickets.update({ await db
where: { id: ctx.data.ticketId }, .update(SupportTickets)
data: { modId: ctx.session.user.id, state: 1 }, .set({ modId: ctx.session.user.id, state: 1 })
}); .where(eq(SupportTickets.id, ctx.data.ticketId));
logAudit({ logAudit({
userId: ctx.session.user.id, userId: ctx.session.user.id,
@@ -47,15 +50,20 @@ const cfhStateSchema = z.object({
export const updateCfhState = adminAction( export const updateCfhState = adminAction(
{ permission: CFH_PERM, schema: cfhStateSchema }, { permission: CFH_PERM, schema: cfhStateSchema },
async (ctx) => { async (ctx) => {
const ticket = await prisma.supportTickets.findUnique({ const [ticket] = await db
where: { id: ctx.data.ticketId }, .select({
}); id: SupportTickets.id,
state: SupportTickets.state,
})
.from(SupportTickets)
.where(eq(SupportTickets.id, ctx.data.ticketId))
.limit(1);
if (!ticket) throw new NotFoundError("SupportTicket", ctx.data.ticketId); if (!ticket) throw new NotFoundError("SupportTicket", ctx.data.ticketId);
await prisma.supportTickets.update({ await db
where: { id: ctx.data.ticketId }, .update(SupportTickets)
data: { state: ctx.data.state, modId: ctx.session.user.id }, .set({ state: ctx.data.state, modId: ctx.session.user.id })
}); .where(eq(SupportTickets.id, ctx.data.ticketId));
logAudit({ logAudit({
userId: ctx.session.user.id, userId: ctx.session.user.id,
@@ -73,10 +81,10 @@ export const updateCfhState = adminAction(
export const closeCfhTicket = adminAction( export const closeCfhTicket = adminAction(
{ permission: CFH_PERM, schema: cfhIdSchema }, { permission: CFH_PERM, schema: cfhIdSchema },
async (ctx) => { async (ctx) => {
await prisma.supportTickets.update({ await db
where: { id: ctx.data.ticketId }, .update(SupportTickets)
data: { state: 2, modId: ctx.session.user.id }, .set({ state: 2, modId: ctx.session.user.id })
}); .where(eq(SupportTickets.id, ctx.data.ticketId));
logAudit({ logAudit({
userId: ctx.session.user.id, userId: ctx.session.user.id,
+25 -15
View File
@@ -1,8 +1,9 @@
"use server"; "use server";
import { asc, count, desc, eq, gte, ne, sql } from "drizzle-orm";
import { requirePermission } from "@/lib/admin/guard"; import { requirePermission } from "@/lib/admin/guard";
import { db, User } from "@/lib/db";
import { PERMS } from "@/lib/permissions"; import { PERMS } from "@/lib/permissions";
import { prisma } from "@/lib/prisma";
export interface MultiAccountCluster { export interface MultiAccountCluster {
key: string; key: string;
@@ -26,25 +27,34 @@ export async function detectMultiAccounts({
await requirePermission(PERMS.USERS_VIEW); await requirePermission(PERMS.USERS_VIEW);
const clusters: MultiAccountCluster[] = []; const clusters: MultiAccountCluster[] = [];
const ipGroups = await prisma.user.groupBy({ const accountCount = count(User.id);
by: ["ipCurrent"], const ipGroups = await db
where: { ipCurrent: { not: "" } }, .select({
_count: { id: true }, ipCurrent: User.ipCurrent,
having: { id: { _count: { gte: minAccounts } } }, accountCount,
orderBy: { _count: { id: "desc" } }, })
take: limit, .from(User)
}); .where(ne(User.ipCurrent, ""))
.groupBy(User.ipCurrent)
.having(gte(accountCount, minAccounts))
.orderBy(desc(sql`COUNT(${User.id})`))
.limit(limit);
for (const group of ipGroups) { for (const group of ipGroups) {
const users = await prisma.user.findMany({ const users = await db
where: { ipCurrent: group.ipCurrent }, .select({
select: { id: true, username: true, rank: true, online: true }, id: User.id,
orderBy: { id: "asc" }, username: User.username,
}); rank: User.rank,
online: User.online,
})
.from(User)
.where(eq(User.ipCurrent, group.ipCurrent))
.orderBy(asc(User.id));
clusters.push({ clusters.push({
key: group.ipCurrent, key: group.ipCurrent,
label: `IP: ${group.ipCurrent}`, label: `IP: ${group.ipCurrent}`,
accountCount: group._count.id, accountCount: Number(group.accountCount),
accounts: users.map((u) => ({ accounts: users.map((u) => ({
id: u.id, id: u.id,
username: u.username, username: u.username,
+30 -21
View File
@@ -1,10 +1,11 @@
"use server"; "use server";
import { createHash, randomBytes, timingSafeEqual } from "node:crypto"; import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
import { eq } from "drizzle-orm";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { env } from "@/env"; import { env } from "@/env";
import { hashPassword } from "@/lib/auth/password"; import { hashPassword } from "@/lib/auth/password";
import { prisma } from "@/lib/prisma"; import { db, PasswordReset, User } from "@/lib/db";
import { clientIp, rateLimit } from "@/lib/rate-limit"; import { clientIp, rateLimit } from "@/lib/rate-limit";
import { captchaConfig, verifyCaptcha } from "@/lib/services/captcha"; import { captchaConfig, verifyCaptcha } from "@/lib/services/captcha";
import { sendMail } from "@/lib/services/email"; import { sendMail } from "@/lib/services/email";
@@ -38,17 +39,19 @@ export async function requestReset(formData: FormData): Promise<void> {
// Always respond the same way so we don't reveal which emails exist. // Always respond the same way so we don't reveal which emails exist.
if (allowed && /^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(email)) { if (allowed && /^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(email)) {
try { try {
const user = await prisma.user.findFirst({ const [user] = await db
where: { mail: email }, .select({ id: User.id })
select: { id: true }, .from(User)
}); .where(eq(User.mail, email))
.limit(1);
if (user) { if (user) {
const token = randomBytes(32).toString("hex"); const token = randomBytes(32).toString("hex");
await prisma.passwordReset.upsert({ const hashed = sha256(token);
where: { email }, const createdAt = new Date();
update: { token: sha256(token), createdAt: new Date() }, await db
create: { email, token: sha256(token), createdAt: new Date() }, .insert(PasswordReset)
}); .values({ email, token: hashed, createdAt })
.onDuplicateKeyUpdate({ set: { token: hashed, createdAt } });
const link = `${env.APP_URL}/reset?email=${encodeURIComponent(email)}&token=${token}`; const link = `${env.APP_URL}/reset?email=${encodeURIComponent(email)}&token=${token}`;
await sendMail( await sendMail(
email, email,
@@ -86,7 +89,11 @@ export async function resetPassword(formData: FormData): Promise<void> {
if (!error) { if (!error) {
try { try {
const row = await prisma.passwordReset.findUnique({ where: { email } }); const [row] = await db
.select()
.from(PasswordReset)
.where(eq(PasswordReset.email, email))
.limit(1);
const fresh = row?.createdAt const fresh = row?.createdAt
? Date.now() - row.createdAt.getTime() < TOKEN_TTL_MS ? Date.now() - row.createdAt.getTime() < TOKEN_TTL_MS
: false; : false;
@@ -98,19 +105,21 @@ export async function resetPassword(formData: FormData): Promise<void> {
if (!row || !fresh || !match) { if (!row || !fresh || !match) {
error = "This reset link is invalid or has expired"; error = "This reset link is invalid or has expired";
} else { } else {
const user = await prisma.user.findFirst({ const [user] = await db
where: { mail: email }, .select({ id: User.id })
select: { id: true }, .from(User)
}); .where(eq(User.mail, email))
.limit(1);
if (!user) { if (!user) {
error = "Account not found"; error = "Account not found";
} else { } else {
await prisma.user.update({ await db
where: { id: user.id }, .update(User)
data: { password: await hashPassword(password) }, .set({ password: await hashPassword(password) })
}); .where(eq(User.id, user.id));
await prisma.passwordReset await db
.delete({ where: { email } }) .delete(PasswordReset)
.where(eq(PasswordReset.email, email))
.catch(() => {}); .catch(() => {});
} }
} }
+71 -38
View File
File diff suppressed because it is too large. Load diff
+24 -18
View File
@@ -1,14 +1,14 @@
"use server"; "use server";
import { eq, sql } from "drizzle-orm";
import { z } from "zod"; import { z } from "zod";
import { db, User } from "@/lib/db";
import { PERMS } from "@/lib/permissions"; import { PERMS } from "@/lib/permissions";
import { prisma } from "@/lib/prisma";
import { adminAction } from "@/lib/safe-action"; import { adminAction } from "@/lib/safe-action";
import { ActionError, actionOk } from "@/lib/safe-action-shared"; import { ActionError, actionOk } from "@/lib/safe-action-shared";
// Models custom_prefixes / custom_prefix_blacklist / custom_prefix_settings // Models custom_prefixes / custom_prefix_blacklist / custom_prefix_settings
// are not represented in prisma/schema.prisma yet — we use raw queries with // are not represented in src/db/schema.ts yet — we use parameterized raw SQL.
// tagged template literals, which parameterize all interpolated values.
// ── Create prefix ─────────────────────────────────────────────────── // ── Create prefix ───────────────────────────────────────────────────
@@ -26,15 +26,17 @@ export const createPrefix = adminAction(
async (ctx) => { async (ctx) => {
const { username, text, color, icon, effect, active } = ctx.data; const { username, text, color, icon, effect, active } = ctx.data;
const users = await prisma.$queryRaw<{ id: number }[]>` const [user] = await db
SELECT id FROM users WHERE username = ${username} LIMIT 1 .select({ id: User.id })
`; .from(User)
if (users.length === 0) throw new ActionError("User not found"); .where(eq(User.username, username))
.limit(1);
if (!user) throw new ActionError("User not found");
await prisma.$executeRaw` await db.execute(sql`
INSERT INTO custom_prefixes (user_id, text, color, icon, effect, active) INSERT INTO custom_prefixes (user_id, text, color, icon, effect, active)
VALUES (${users[0].id}, ${text}, ${color}, ${icon || ""}, ${effect || ""}, ${active}) VALUES (${user.id}, ${text}, ${color}, ${icon || ""}, ${effect || ""}, ${active})
`; `);
return actionOk(); return actionOk();
}, },
@@ -56,11 +58,11 @@ export const updatePrefix = adminAction(
async (ctx) => { async (ctx) => {
const { id, text, color, icon, effect, active } = ctx.data; const { id, text, color, icon, effect, active } = ctx.data;
await prisma.$executeRaw` await db.execute(sql`
UPDATE custom_prefixes UPDATE custom_prefixes
SET text = ${text}, color = ${color}, icon = ${icon || ""}, effect = ${effect || ""}, active = ${active ?? 1} SET text = ${text}, color = ${color}, icon = ${icon || ""}, effect = ${effect || ""}, active = ${active ?? 1}
WHERE id = ${id} WHERE id = ${id}
`; `);
return actionOk(); return actionOk();
}, },
@@ -75,7 +77,9 @@ const deletePrefixSchema = z.object({
export const deletePrefix = adminAction( export const deletePrefix = adminAction(
{ permission: PERMS.PREFIXES_EDIT, schema: deletePrefixSchema }, { permission: PERMS.PREFIXES_EDIT, schema: deletePrefixSchema },
async (ctx) => { async (ctx) => {
await prisma.$executeRaw`DELETE FROM custom_prefixes WHERE id = ${ctx.data.id}`; await db.execute(
sql`DELETE FROM custom_prefixes WHERE id = ${ctx.data.id}`,
);
return actionOk(); return actionOk();
}, },
); );
@@ -89,9 +93,9 @@ const addBlacklistWordSchema = z.object({
export const addBlacklistWord = adminAction( export const addBlacklistWord = adminAction(
{ permission: PERMS.PREFIXES_EDIT, schema: addBlacklistWordSchema }, { permission: PERMS.PREFIXES_EDIT, schema: addBlacklistWordSchema },
async (ctx) => { async (ctx) => {
await prisma.$executeRaw` await db.execute(sql`
INSERT INTO custom_prefix_blacklist (word) VALUES (${ctx.data.word.trim()}) INSERT INTO custom_prefix_blacklist (word) VALUES (${ctx.data.word.trim()})
`; `);
return actionOk(); return actionOk();
}, },
); );
@@ -105,7 +109,9 @@ const removeBlacklistWordSchema = z.object({
export const removeBlacklistWord = adminAction( export const removeBlacklistWord = adminAction(
{ permission: PERMS.PREFIXES_EDIT, schema: removeBlacklistWordSchema }, { permission: PERMS.PREFIXES_EDIT, schema: removeBlacklistWordSchema },
async (ctx) => { async (ctx) => {
await prisma.$executeRaw`DELETE FROM custom_prefix_blacklist WHERE id = ${ctx.data.id}`; await db.execute(
sql`DELETE FROM custom_prefix_blacklist WHERE id = ${ctx.data.id}`,
);
return actionOk(); return actionOk();
}, },
); );
@@ -135,11 +141,11 @@ export const updatePrefixSettings = adminAction(
async (ctx) => { async (ctx) => {
for (const [key, value] of Object.entries(ctx.data.settings)) { for (const [key, value] of Object.entries(ctx.data.settings)) {
if (!SETTINGS_WHITELIST.has(key)) continue; if (!SETTINGS_WHITELIST.has(key)) continue;
await prisma.$executeRaw` await db.execute(sql`
INSERT INTO custom_prefix_settings (\`key\`, \`value\`) INSERT INTO custom_prefix_settings (\`key\`, \`value\`)
VALUES (${key}, ${value}) VALUES (${key}, ${value})
ON DUPLICATE KEY UPDATE \`value\` = ${value} ON DUPLICATE KEY UPDATE \`value\` = ${value}
`; `);
} }
return actionOk(); return actionOk();
}, },
+14 -13
View File
@@ -1,11 +1,12 @@
"use server"; "use server";
import { count, eq } from "drizzle-orm";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { z } from "zod"; import { z } from "zod";
import { sendVerification } from "@/actions/email-verify"; import { sendVerification } from "@/actions/email-verify";
import { hashPassword } from "@/lib/auth/password"; import { hashPassword } from "@/lib/auth/password";
import { db, User } from "@/lib/db";
import { logger } from "@/lib/logger"; import { logger } from "@/lib/logger";
import { prisma } from "@/lib/prisma";
import { clientIp, rateLimit } from "@/lib/rate-limit"; import { clientIp, rateLimit } from "@/lib/rate-limit";
import { captchaConfig, verifyCaptcha } from "@/lib/services/captcha"; import { captchaConfig, verifyCaptcha } from "@/lib/services/captcha";
import { checkVpn } from "@/lib/services/ip-lookup"; import { checkVpn } from "@/lib/services/ip-lookup";
@@ -86,19 +87,22 @@ export async function register(
// Max accounts per IP (0 / unset = unlimited), mirrors AtomCMS. // Max accounts per IP (0 / unset = unlimited), mirrors AtomCMS.
const max = Number(await siteSettings.get("max_accounts_per_ip", "0")) || 0; const max = Number(await siteSettings.get("max_accounts_per_ip", "0")) || 0;
if (max > 0) { if (max > 0) {
const count = await prisma.user const [row] = await db
.count({ where: { ipRegister: ip } }) .select({ total: count() })
.catch(() => 0); .from(User)
if (count >= max) .where(eq(User.ipRegister, ip))
.catch(() => [{ total: 0 }]);
if (Number(row?.total ?? 0) >= max)
return "You have reached the maximum number of accounts for your connection."; return "You have reached the maximum number of accounts for your connection.";
} }
// Uniqueness check. // Uniqueness check.
try { try {
const existing = await prisma.user.findUnique({ const [existing] = await db
where: { username }, .select({ id: User.id })
select: { id: true }, .from(User)
}); .where(eq(User.username, username))
.limit(1);
if (existing) return "That username is already taken"; if (existing) return "That username is already taken";
} catch { } catch {
logger.warn("Username uniqueness check failed during registration"); logger.warn("Username uniqueness check failed during registration");
@@ -107,8 +111,7 @@ export async function register(
const now = Math.floor(Date.now() / 1000); const now = Math.floor(Date.now() / 1000);
try { try {
await prisma.user.create({ await db.insert(User).values({
data: {
username, username,
password: await hashPassword(password), password: await hashPassword(password),
mail: hasEmail ? mail : null, mail: hasEmail ? mail : null,
@@ -116,8 +119,6 @@ export async function register(
ipRegister: ip, ipRegister: ip,
ipCurrent: ip, ipCurrent: ip,
look, look,
},
select: { id: true },
}); });
if (hasEmail) { if (hasEmail) {
+12 -12
View File
@@ -1,9 +1,10 @@
"use server"; "use server";
import { and, eq, inArray } from "drizzle-orm";
import { revalidatePath } from "next/cache"; import { revalidatePath } from "next/cache";
import { requirePermission } from "@/lib/admin/guard"; import { requirePermission } from "@/lib/admin/guard";
import { db, Items, Rooms } from "@/lib/db";
import { PERMS } from "@/lib/permissions"; import { PERMS } from "@/lib/permissions";
import { prisma } from "@/lib/prisma";
import { rcon } from "@/lib/services/rcon"; import { rcon } from "@/lib/services/rcon";
import { logStaffActivity } from "@/lib/services/staff-activity"; import { logStaffActivity } from "@/lib/services/staff-activity";
@@ -14,10 +15,10 @@ export async function updateRoomItem(payload: Record<string, unknown>) {
itemId: number; itemId: number;
[key: string]: unknown; [key: string]: unknown;
}; };
await prisma.items.update({ await db
where: { id: itemId }, .update(Items)
data: data as any, .set(data as Partial<typeof Items.$inferInsert>)
}); .where(eq(Items.id, itemId));
await logStaffActivity({ await logStaffActivity({
staffId: staff.id, staffId: staff.id,
action: "room_item_update", action: "room_item_update",
@@ -36,7 +37,9 @@ export async function bulkDeleteRoomItems({
itemIds: number[]; itemIds: number[];
}) { }) {
const staff = await requirePermission(PERMS.ROOMS_EDIT); const staff = await requirePermission(PERMS.ROOMS_EDIT);
await prisma.items.deleteMany({ where: { id: { in: itemIds }, roomId } }); await db
.delete(Items)
.where(and(inArray(Items.id, itemIds), eq(Items.roomId, roomId)));
await logStaffActivity({ await logStaffActivity({
staffId: staff.id, staffId: staff.id,
action: "room_items_bulk_delete", action: "room_items_bulk_delete",
@@ -54,7 +57,7 @@ export async function deleteRoomItem({
itemId: number; itemId: number;
}) { }) {
const staff = await requirePermission(PERMS.ROOMS_EDIT); const staff = await requirePermission(PERMS.ROOMS_EDIT);
await prisma.items.delete({ where: { id: itemId } }); await db.delete(Items).where(eq(Items.id, itemId));
await logStaffActivity({ await logStaffActivity({
staffId: staff.id, staffId: staff.id,
action: "room_item_delete", action: "room_item_delete",
@@ -86,7 +89,7 @@ export async function roomRconAction({
export async function deleteRoom({ id }: { id: number }) { export async function deleteRoom({ id }: { id: number }) {
const staff = await requirePermission(PERMS.ROOMS_DELETE); const staff = await requirePermission(PERMS.ROOMS_DELETE);
await prisma.rooms.delete({ where: { id } }); await db.delete(Rooms).where(eq(Rooms.id, id));
await logStaffActivity({ await logStaffActivity({
staffId: staff.id, staffId: staff.id,
action: "room_delete", action: "room_delete",
@@ -108,10 +111,7 @@ export async function updateRoom({
usersMax?: number; usersMax?: number;
}) { }) {
const staff = await requirePermission(PERMS.ROOMS_EDIT); const staff = await requirePermission(PERMS.ROOMS_EDIT);
await prisma.rooms.update({ await db.update(Rooms).set(data).where(eq(Rooms.id, id));
where: { id },
data: data as any,
});
await logStaffActivity({ await logStaffActivity({
staffId: staff.id, staffId: staff.id,
action: "room_update", action: "room_update",
+13 -9
View File
@@ -2,9 +2,10 @@
import { mkdir, unlink, writeFile } from "node:fs/promises"; import { mkdir, unlink, writeFile } from "node:fs/promises";
import path from "node:path"; import path from "node:path";
import { eq } from "drizzle-orm";
import { revalidatePath } from "next/cache"; import { revalidatePath } from "next/cache";
import { db, WebsiteSetting } from "@/lib/db";
import { resolveMediaPath } from "@/lib/media-storage"; import { resolveMediaPath } from "@/lib/media-storage";
import { prisma } from "@/lib/prisma";
import { siteSettings } from "@/lib/services/site-settings"; import { siteSettings } from "@/lib/services/site-settings";
const FAVICON_DIR = resolveMediaPath("favicon"); const FAVICON_DIR = resolveMediaPath("favicon");
@@ -74,11 +75,10 @@ export async function saveFavicon(
} }
} }
await prisma.websiteSetting.upsert({ await db
where: { key: "cms_favicon" }, .insert(WebsiteSetting)
update: { value: url }, .values({ key: "cms_favicon", value: url, comment: "Favicon URL" })
create: { key: "cms_favicon", value: url, comment: "Favicon URL" }, .onDuplicateKeyUpdate({ set: { value: url } });
});
siteSettings.reload(); siteSettings.reload();
revalidatePath("/", "layout"); revalidatePath("/", "layout");
@@ -115,9 +115,13 @@ export async function deleteFavicon(): Promise<{
} }
} }
await prisma.websiteSetting try {
.delete({ where: { key: "cms_favicon" } }) await db
.catch(() => {}); .delete(WebsiteSetting)
.where(eq(WebsiteSetting.key, "cms_favicon"));
} catch {
/* ignore missing row */
}
siteSettings.reload(); siteSettings.reload();
revalidatePath("/", "layout"); revalidatePath("/", "layout");
revalidatePath("/admin/favicon"); revalidatePath("/admin/favicon");
+5 -6
View File
@@ -3,8 +3,8 @@
import { mkdir, writeFile } from "node:fs/promises"; import { mkdir, writeFile } from "node:fs/promises";
import path from "node:path"; import path from "node:path";
import { revalidatePath } from "next/cache"; import { revalidatePath } from "next/cache";
import { db, WebsiteSetting } from "@/lib/db";
import { resolveMediaPath } from "@/lib/media-storage"; import { resolveMediaPath } from "@/lib/media-storage";
import { prisma } from "@/lib/prisma";
import { siteSettings } from "@/lib/services/site-settings"; import { siteSettings } from "@/lib/services/site-settings";
const MEDIA_DIR = resolveMediaPath("logo"); const MEDIA_DIR = resolveMediaPath("logo");
@@ -41,11 +41,10 @@ export async function saveLogo(
const url = `/api/media/logo/${filename}`; const url = `/api/media/logo/${filename}`;
await prisma.websiteSetting.upsert({ await db
where: { key: "cms_logo" }, .insert(WebsiteSetting)
update: { value: url }, .values({ key: "cms_logo", value: url, comment: "Logo (generator)" })
create: { key: "cms_logo", value: url, comment: "Logo (generator)" }, .onDuplicateKeyUpdate({ set: { value: url } });
});
siteSettings.reload(); siteSettings.reload();
revalidatePath("/", "layout"); revalidatePath("/", "layout");
+17 -8
View File
@@ -1,10 +1,11 @@
"use server"; "use server";
import { and, eq, sql } from "drizzle-orm";
import { auth, signOut } from "@/lib/auth"; import { auth, signOut } from "@/lib/auth";
import { invalidateJwtVersionCache } from "@/lib/auth/jwt-version-cache"; import { invalidateJwtVersionCache } from "@/lib/auth/jwt-version-cache";
import { personalTokenScope } from "@/lib/auth/personal-token-scope"; import { personalTokenScope } from "@/lib/auth/personal-token-scope";
import { db, PersonalAccessTokens, User } from "@/lib/db";
import { logger } from "@/lib/logger"; import { logger } from "@/lib/logger";
import { prisma } from "@/lib/prisma";
/** /**
* Invalidate every CMS JWT for the signed-in user by bumping website_jwt_version, * Invalidate every CMS JWT for the signed-in user by bumping website_jwt_version,
@@ -19,10 +20,12 @@ export async function signOutEverywhere(): Promise<void> {
} }
try { try {
await prisma.user.update({ await db
where: { id: userId }, .update(User)
data: { websiteJwtVersion: { increment: 1 } }, .set({
}); websiteJwtVersion: sql`${User.websiteJwtVersion} + 1`,
})
.where(eq(User.id, userId));
await invalidateJwtVersionCache(userId); await invalidateJwtVersionCache(userId);
} catch (err) { } catch (err) {
logger.warn("Failed to bump JWT version during sign-out-everywhere", { logger.warn("Failed to bump JWT version during sign-out-everywhere", {
@@ -33,9 +36,15 @@ export async function signOutEverywhere(): Promise<void> {
// Revoke API bearer tokens (Sanctum / personal_access_tokens). // Revoke API bearer tokens (Sanctum / personal_access_tokens).
try { try {
await prisma.personalAccessTokens.deleteMany({ const scope = personalTokenScope(userId);
where: personalTokenScope(userId), await db
}); .delete(PersonalAccessTokens)
.where(
and(
eq(PersonalAccessTokens.tokenableId, scope.tokenableId),
eq(PersonalAccessTokens.tokenableType, scope.tokenableType),
),
);
} catch (err) { } catch (err) {
logger.warn( logger.warn(
"Failed to revoke personal access tokens during sign-out-everywhere", "Failed to revoke personal access tokens during sign-out-everywhere",
+53 -42
View File
File diff suppressed because it is too large. Load diff
+81 -51
View File
File diff suppressed because it is too large. Load diff
+7 -6
View File
@@ -1,13 +1,14 @@
"use server"; "use server";
import { eq } from "drizzle-orm";
import { revalidatePath } from "next/cache"; import { revalidatePath } from "next/cache";
import { requirePermission } from "@/lib/admin/guard"; import { requirePermission } from "@/lib/admin/guard";
import { db, Soundtracks } from "@/lib/db";
import { PERMS } from "@/lib/permissions"; import { PERMS } from "@/lib/permissions";
import { prisma } from "@/lib/prisma";
export async function deleteSoundtrack({ id }: { id: number }) { export async function deleteSoundtrack({ id }: { id: number }) {
await requirePermission(PERMS.CATALOG_EDIT); await requirePermission(PERMS.CATALOG_EDIT);
await prisma.soundtracks.delete({ where: { id } }); await db.delete(Soundtracks).where(eq(Soundtracks.id, id));
revalidatePath("/admin/sounds"); revalidatePath("/admin/sounds");
} }
@@ -25,9 +26,9 @@ export async function updateSoundtrack({
length: number; length: number;
}) { }) {
await requirePermission(PERMS.CATALOG_EDIT); await requirePermission(PERMS.CATALOG_EDIT);
await prisma.soundtracks.update({ await db
where: { id }, .update(Soundtracks)
data: { name, author, track, length }, .set({ name, author, track, length })
}); .where(eq(Soundtracks.id, id));
revalidatePath("/admin/sounds"); revalidatePath("/admin/sounds");
} }
+16 -8
View File
@@ -1,8 +1,9 @@
"use server"; "use server";
import { eq } from "drizzle-orm";
import { z } from "zod"; import { z } from "zod";
import { db, WebsiteTicketTemplate } from "@/lib/db";
import { PERMS } from "@/lib/permissions"; import { PERMS } from "@/lib/permissions";
import { prisma } from "@/lib/prisma";
import { adminAction } from "@/lib/safe-action"; import { adminAction } from "@/lib/safe-action";
import { ActionError, actionOk } from "@/lib/safe-action-shared"; import { ActionError, actionOk } from "@/lib/safe-action-shared";
@@ -16,8 +17,8 @@ const templateSchema = z.object({
export const createTemplate = adminAction( export const createTemplate = adminAction(
{ permission: PERMS.TICKETS_EDIT, schema: templateSchema }, { permission: PERMS.TICKETS_EDIT, schema: templateSchema },
async (ctx) => { async (ctx) => {
const tpl = await prisma.websiteTicketTemplate.create({ data: ctx.data }); const [result] = await db.insert(WebsiteTicketTemplate).values(ctx.data);
return actionOk({ id: tpl.id }); return actionOk({ id: Number(result.insertId) });
}, },
); );
@@ -29,11 +30,16 @@ export const updateTemplate = adminAction(
{ permission: PERMS.TICKETS_EDIT, schema: updateTemplateInput }, { permission: PERMS.TICKETS_EDIT, schema: updateTemplateInput },
async (ctx) => { async (ctx) => {
const { id, ...data } = ctx.data; const { id, ...data } = ctx.data;
const existing = await prisma.websiteTicketTemplate.findUnique({ const [existing] = await db
where: { id }, .select({ id: WebsiteTicketTemplate.id })
}); .from(WebsiteTicketTemplate)
.where(eq(WebsiteTicketTemplate.id, id))
.limit(1);
if (!existing) throw new ActionError("Template not found"); if (!existing) throw new ActionError("Template not found");
await prisma.websiteTicketTemplate.update({ where: { id }, data }); await db
.update(WebsiteTicketTemplate)
.set(data)
.where(eq(WebsiteTicketTemplate.id, id));
return actionOk({ id }); return actionOk({ id });
}, },
); );
@@ -45,7 +51,9 @@ const deleteTemplateInput = z.object({
export const deleteTemplate = adminAction( export const deleteTemplate = adminAction(
{ permission: PERMS.TICKETS_EDIT, schema: deleteTemplateInput }, { permission: PERMS.TICKETS_EDIT, schema: deleteTemplateInput },
async (ctx) => { async (ctx) => {
await prisma.websiteTicketTemplate.delete({ where: { id: ctx.data.id } }); await db
.delete(WebsiteTicketTemplate)
.where(eq(WebsiteTicketTemplate.id, ctx.data.id));
return actionOk(); return actionOk();
}, },
); );
+77 -62
View File
File diff suppressed because it is too large. Load diff
+24 -19
View File
@@ -1,13 +1,14 @@
"use server"; "use server";
import { randomBytes } from "node:crypto"; import { randomBytes } from "node:crypto";
import { eq } from "drizzle-orm";
import { revalidatePath } from "next/cache"; import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { env } from "@/env"; import { env } from "@/env";
import { auth } from "@/lib/auth"; import { auth } from "@/lib/auth";
import { LaravelEncrypter } from "@/lib/auth/laravel-encrypter"; import { LaravelEncrypter } from "@/lib/auth/laravel-encrypter";
import { generateTotpSecret, verifyTotp } from "@/lib/auth/totp"; import { generateTotpSecret, verifyTotp } from "@/lib/auth/totp";
import { prisma } from "@/lib/prisma"; import { db, User } from "@/lib/db";
import { rateLimit } from "@/lib/rate-limit"; import { rateLimit } from "@/lib/rate-limit";
async function sessionUserId(): Promise<number> { async function sessionUserId(): Promise<number> {
@@ -34,10 +35,14 @@ async function verifyTwoFactorCode(
userId: number, userId: number,
code: string, code: string,
): Promise<{ ok: boolean; updatedRecoveryCodes?: string | null }> { ): Promise<{ ok: boolean; updatedRecoveryCodes?: string | null }> {
const user = await prisma.user.findUnique({ const [user] = await db
where: { id: userId }, .select({
select: { twoFactorSecret: true, twoFactorRecoveryCodes: true }, twoFactorSecret: User.twoFactorSecret,
}); twoFactorRecoveryCodes: User.twoFactorRecoveryCodes,
})
.from(User)
.where(eq(User.id, userId))
.limit(1);
if (!user?.twoFactorSecret) return { ok: false }; if (!user?.twoFactorSecret) return { ok: false };
// Try TOTP first // Try TOTP first
@@ -76,14 +81,14 @@ export async function beginTwoFactor(): Promise<void> {
const secret = generateTotpSecret(); const secret = generateTotpSecret();
const encrypted = new LaravelEncrypter(env.APP_KEY).encrypt(secret); const encrypted = new LaravelEncrypter(env.APP_KEY).encrypt(secret);
const codes = generateRecoveryCodes(); const codes = generateRecoveryCodes();
await prisma.user.update({ await db
where: { id }, .update(User)
data: { .set({
twoFactorSecret: encrypted, twoFactorSecret: encrypted,
twoFactorConfirmedAt: null, twoFactorConfirmedAt: null,
twoFactorRecoveryCodes: JSON.stringify(codes), twoFactorRecoveryCodes: JSON.stringify(codes),
}, })
}); .where(eq(User.id, id));
revalidatePath("/settings/2fa"); revalidatePath("/settings/2fa");
} }
@@ -102,10 +107,10 @@ export async function confirmTwoFactor(formData: FormData): Promise<void> {
const { ok } = await verifyTwoFactorCode(id, code); const { ok } = await verifyTwoFactorCode(id, code);
if (!ok) redirect("/settings/2fa?error=badcode"); if (!ok) redirect("/settings/2fa?error=badcode");
await prisma.user.update({ await db
where: { id }, .update(User)
data: { twoFactorConfirmedAt: new Date() }, .set({ twoFactorConfirmedAt: new Date() })
}); .where(eq(User.id, id));
redirect("/settings/2fa?enabled=1"); redirect("/settings/2fa?enabled=1");
} }
@@ -123,13 +128,13 @@ export async function disableTwoFactor(formData: FormData): Promise<void> {
const { ok } = await verifyTwoFactorCode(id, code); const { ok } = await verifyTwoFactorCode(id, code);
if (!ok) redirect("/settings/2fa?error=badcode"); if (!ok) redirect("/settings/2fa?error=badcode");
await prisma.user.update({ await db
where: { id }, .update(User)
data: { .set({
twoFactorSecret: null, twoFactorSecret: null,
twoFactorRecoveryCodes: null, twoFactorRecoveryCodes: null,
twoFactorConfirmedAt: null, twoFactorConfirmedAt: null,
}, })
}); .where(eq(User.id, id));
redirect("/settings/2fa?disabled=1"); redirect("/settings/2fa?disabled=1");
} }
+6 -5
View File
@@ -1,10 +1,11 @@
"use server"; "use server";
import { eq } from "drizzle-orm";
import { revalidatePath } from "next/cache"; import { revalidatePath } from "next/cache";
import { z } from "zod"; import { z } from "zod";
import { db, User } from "@/lib/db";
import { actionOk, authAction } from "@/lib/foundation/action"; import { actionOk, authAction } from "@/lib/foundation/action";
import { DatabaseError } from "@/lib/foundation/errors"; import { DatabaseError } from "@/lib/foundation/errors";
import { prisma } from "@/lib/prisma";
import { rcon } from "@/lib/services/rcon"; import { rcon } from "@/lib/services/rcon";
const MOTTO_MAX = 127; const MOTTO_MAX = 127;
@@ -17,10 +18,10 @@ const mottoSchema = z.object({
const updateMottoAction = authAction({ schema: mottoSchema }, async (ctx) => { const updateMottoAction = authAction({ schema: mottoSchema }, async (ctx) => {
try { try {
await prisma.user.update({ await db
where: { id: ctx.session.user.id }, .update(User)
data: { motto: ctx.data.motto }, .set({ motto: ctx.data.motto })
}); .where(eq(User.id, ctx.session.user.id));
} catch { } catch {
throw new DatabaseError("Failed to update motto"); throw new DatabaseError("Failed to update motto");
} }
+101 -58
View File
File diff suppressed because it is too large. Load diff
+52 -30
View File
File diff suppressed because it is too large. Load diff
+17 -7
View File
@@ -1,9 +1,10 @@
"use server"; "use server";
import { and, eq } from "drizzle-orm";
import { revalidateTag } from "next/cache"; import { revalidateTag } from "next/cache";
import { z } from "zod"; import { z } from "zod";
import { db, UserWatch } from "@/lib/db";
import { PERMS } from "@/lib/permission-slugs"; import { PERMS } from "@/lib/permission-slugs";
import { prisma } from "@/lib/prisma";
import { adminAction } from "@/lib/safe-action"; import { adminAction } from "@/lib/safe-action";
import { actionOk } from "@/lib/safe-action-shared"; import { actionOk } from "@/lib/safe-action-shared";
@@ -24,18 +25,27 @@ export const toggleUserWatch = adminAction(
const staffId = ctx.session.user.id; const staffId = ctx.session.user.id;
const { targetUserId, reason } = ctx.data; const { targetUserId, reason } = ctx.data;
const existing = await prisma.userWatch.findUnique({ const [existing] = await db
where: { staffId_targetUserId: { staffId, targetUserId } }, .select({ id: UserWatch.id })
}); .from(UserWatch)
.where(
and(
eq(UserWatch.staffId, staffId),
eq(UserWatch.targetUserId, targetUserId),
),
)
.limit(1);
if (existing) { if (existing) {
await prisma.userWatch.delete({ where: { id: existing.id } }); await db.delete(UserWatch).where(eq(UserWatch.id, existing.id));
revalidateTag(`user-watch:${staffId}`, { expire: 0 }); revalidateTag(`user-watch:${staffId}`, { expire: 0 });
return actionOk({ watching: false }); return actionOk({ watching: false });
} }
await prisma.userWatch.create({ await db.insert(UserWatch).values({
data: { staffId, targetUserId, reason: reason ?? "" }, staffId,
targetUserId,
reason: reason ?? "",
}); });
revalidateTag(`user-watch:${staffId}`, { expire: 0 }); revalidateTag(`user-watch:${staffId}`, { expire: 0 });
return actionOk({ watching: true }); return actionOk({ watching: true });
+22 -16
View File
@@ -1,12 +1,20 @@
import { beforeEach, describe, expect, it, vi } from "vitest"; import { beforeEach, describe, expect, it, vi } from "vitest";
const findMany = vi.hoisted(() => vi.fn()); const selectFrom = vi.hoisted(() => vi.fn());
const create = vi.hoisted(() => vi.fn()); const insertValues = vi.hoisted(() => vi.fn());
const getBool = vi.hoisted(() => vi.fn()); const getBool = vi.hoisted(() => vi.fn());
const get = vi.hoisted(() => vi.fn()); const get = vi.hoisted(() => vi.fn());
vi.mock("@/lib/prisma", () => ({ vi.mock("@/lib/db", () => ({
prisma: { websiteIpBlacklist: { findMany, create } }, db: {
select: () => ({
from: () => Promise.resolve(selectFrom()),
}),
insert: () => ({
values: (data: unknown) => insertValues(data),
}),
},
WebsiteIpBlacklist: { ipAddress: "WebsiteIpBlacklist.ipAddress" },
})); }));
vi.mock("@/lib/services/alert", () => ({ vi.mock("@/lib/services/alert", () => ({
@@ -26,23 +34,23 @@ describe("isIpBlacklisted", () => {
expect(await isIpBlacklisted("127.0.0.1")).toBe(false); expect(await isIpBlacklisted("127.0.0.1")).toBe(false);
expect(await isIpBlacklisted("192.168.1.1")).toBe(false); expect(await isIpBlacklisted("192.168.1.1")).toBe(false);
expect(await isIpBlacklisted("10.0.0.1")).toBe(false); expect(await isIpBlacklisted("10.0.0.1")).toBe(false);
expect(findMany).not.toHaveBeenCalled(); expect(selectFrom).not.toHaveBeenCalled();
}); });
it("loads, caches, and correctly checks multiple IPs", async () => { it("loads, caches, and correctly checks multiple IPs", async () => {
findMany.mockResolvedValue([{ ipAddress: "1.2.3.4" }]); selectFrom.mockResolvedValue([{ ipAddress: "1.2.3.4" }]);
expect(await isIpBlacklisted("1.2.3.4")).toBe(true); expect(await isIpBlacklisted("1.2.3.4")).toBe(true);
expect(await isIpBlacklisted("1.2.3.4")).toBe(true); expect(await isIpBlacklisted("1.2.3.4")).toBe(true);
expect(await isIpBlacklisted("5.6.7.8")).toBe(false); expect(await isIpBlacklisted("5.6.7.8")).toBe(false);
expect(findMany).toHaveBeenCalledTimes(1); expect(selectFrom).toHaveBeenCalledTimes(1);
findMany.mockResolvedValue([]); selectFrom.mockResolvedValue([]);
expect(await isIpBlacklisted("1.2.3.4")).toBe(true); expect(await isIpBlacklisted("1.2.3.4")).toBe(true);
}); });
it("handles DB error gracefully (uses stale cache)", async () => { it("handles DB error gracefully (uses stale cache)", async () => {
findMany.mockRejectedValue(new Error("DB error")); selectFrom.mockRejectedValue(new Error("DB error"));
expect(await isIpBlacklisted("1.2.3.4")).toBe(true); expect(await isIpBlacklisted("1.2.3.4")).toBe(true);
expect(await isIpBlacklisted("9.9.9.9")).toBe(false); expect(await isIpBlacklisted("9.9.9.9")).toBe(false);
}); });
@@ -52,7 +60,7 @@ describe("recordRequest", () => {
beforeEach(() => { beforeEach(() => {
getBool.mockReset(); getBool.mockReset();
get.mockReset(); get.mockReset();
create.mockReset(); insertValues.mockReset();
}); });
it("ignores private IPs", async () => { it("ignores private IPs", async () => {
@@ -72,17 +80,15 @@ describe("recordRequest", () => {
if (_key === "abuse_guard_threshold") return "3"; if (_key === "abuse_guard_threshold") return "3";
return fallback; return fallback;
}); });
create.mockResolvedValue({}); insertValues.mockResolvedValue({});
await recordRequest("1.2.3.4"); await recordRequest("1.2.3.4");
await recordRequest("1.2.3.4"); await recordRequest("1.2.3.4");
expect(create).not.toHaveBeenCalled(); expect(insertValues).not.toHaveBeenCalled();
await recordRequest("1.2.3.4"); await recordRequest("1.2.3.4");
expect(create).toHaveBeenCalledWith( expect(insertValues).toHaveBeenCalledWith(
expect.objectContaining({ expect.objectContaining({ ipAddress: "1.2.3.4" }),
data: expect.objectContaining({ ipAddress: "1.2.3.4" }),
}),
); );
}); });
}); });
+8 -6
View File
@@ -1,4 +1,4 @@
import { prisma } from "@/lib/prisma"; import { db, WebsiteIpBlacklist } from "@/lib/db";
import { ddosDetected } from "@/lib/services/alert"; import { ddosDetected } from "@/lib/services/alert";
import { siteSettings } from "@/lib/services/site-settings"; import { siteSettings } from "@/lib/services/site-settings";
@@ -54,9 +54,9 @@ export async function isIpBlacklisted(ip: string): Promise<boolean> {
const now = Date.now(); const now = Date.now();
if (now - blacklistLoadedAt >= BLACKLIST_TTL) { if (now - blacklistLoadedAt >= BLACKLIST_TTL) {
try { try {
const rows = await prisma.websiteIpBlacklist.findMany({ const rows = await db
select: { ipAddress: true }, .select({ ipAddress: WebsiteIpBlacklist.ipAddress })
}); .from(WebsiteIpBlacklist);
blacklist = new Set(rows.map((r) => r.ipAddress)); blacklist = new Set(rows.map((r) => r.ipAddress));
blacklistLoadedAt = now; blacklistLoadedAt = now;
} catch { } catch {
@@ -91,8 +91,10 @@ export async function recordRequest(ip: string): Promise<void> {
recentlyBlocked.add(ip); recentlyBlocked.add(ip);
setTimeout(() => recentlyBlocked.delete(ip), 60_000); setTimeout(() => recentlyBlocked.delete(ip), 60_000);
try { try {
await prisma.websiteIpBlacklist.create({ await db.insert(WebsiteIpBlacklist).values({
data: { ipAddress: ip, createdAt: new Date(), updatedAt: new Date() }, ipAddress: ip,
createdAt: new Date(),
updatedAt: new Date(),
}); });
blacklistLoadedAt = 0; blacklistLoadedAt = 0;
await ddosDetected(ip, b.count); await ddosDetected(ip, b.count);
+15 -10
View File
@@ -1,9 +1,14 @@
import { beforeEach, describe, expect, it, vi } from "vitest"; import { beforeEach, describe, expect, it, vi } from "vitest";
const findMany = vi.hoisted(() => vi.fn()); const selectFrom = vi.hoisted(() => vi.fn());
vi.mock("@/lib/prisma", () => ({ vi.mock("@/lib/db", () => ({
prisma: { websiteWordfilter: { findMany } }, db: {
select: () => ({
from: () => Promise.resolve(selectFrom()),
}),
},
WebsiteWordfilter: { word: "WebsiteWordfilter.word" },
})); }));
vi.mock("@/env", () => ({ vi.mock("@/env", () => ({
@@ -15,19 +20,19 @@ vi.mock("@/env", () => ({
import { isAllowed, moderateOrThrow, reloadWordFilter } from "./moderation"; import { isAllowed, moderateOrThrow, reloadWordFilter } from "./moderation";
beforeEach(() => { beforeEach(() => {
findMany.mockReset(); selectFrom.mockReset();
reloadWordFilter(); reloadWordFilter();
}); });
describe("moderation", () => { describe("moderation", () => {
it("allows clean text", async () => { it("allows clean text", async () => {
findMany.mockResolvedValue([{ word: "badword" }]); selectFrom.mockResolvedValue([{ word: "badword" }]);
const result = await isAllowed("hello world"); const result = await isAllowed("hello world");
expect(result).toEqual({ ok: true }); expect(result).toEqual({ ok: true });
}); });
it("blocks text containing a filtered word", async () => { it("blocks text containing a filtered word", async () => {
findMany.mockResolvedValue([{ word: "badword" }]); selectFrom.mockResolvedValue([{ word: "badword" }]);
const result = await isAllowed("this contains badword here"); const result = await isAllowed("this contains badword here");
expect(result).toEqual({ expect(result).toEqual({
ok: false, ok: false,
@@ -41,21 +46,21 @@ describe("moderation", () => {
}); });
it("caches the word list and respects TTL", async () => { it("caches the word list and respects TTL", async () => {
findMany.mockResolvedValue([{ word: "first" }]); selectFrom.mockResolvedValue([{ word: "first" }]);
await isAllowed("test"); await isAllowed("test");
await isAllowed("test"); await isAllowed("test");
expect(findMany).toHaveBeenCalledTimes(1); expect(selectFrom).toHaveBeenCalledTimes(1);
}); });
it("moderateOrThrow throws on blocked content", async () => { it("moderateOrThrow throws on blocked content", async () => {
findMany.mockResolvedValue([{ word: "bad" }]); selectFrom.mockResolvedValue([{ word: "bad" }]);
await expect(moderateOrThrow("this is bad")).rejects.toThrow( await expect(moderateOrThrow("this is bad")).rejects.toThrow(
"Blocked by word filter", "Blocked by word filter",
); );
}); });
it("moderateOrThrow resolves on clean content", async () => { it("moderateOrThrow resolves on clean content", async () => {
findMany.mockResolvedValue([{ word: "bad" }]); selectFrom.mockResolvedValue([{ word: "bad" }]);
await expect(moderateOrThrow("clean text")).resolves.toBeUndefined(); await expect(moderateOrThrow("clean text")).resolves.toBeUndefined();
}); });
}); });
+5 -5
View File
@@ -1,11 +1,11 @@
import { env } from "@/env"; import { env } from "@/env";
import { prisma } from "@/lib/prisma"; import { db, WebsiteWordfilter } from "@/lib/db";
// AtomCMS-faithful content moderation, used by user-generated-content actions // AtomCMS-faithful content moderation, used by user-generated-content actions
// (article comments, mottos, registration, etc.) before they touch the DB. // (article comments, mottos, registration, etc.) before they touch the DB.
// //
// Two layers, evaluated in order: // Two layers, evaluated in order:
// 1. website_wordfilter — the CMS-owned blocklist (prisma.websiteWordfilter), // 1. website_wordfilter — the CMS-owned blocklist (WebsiteWordfilter),
// mirroring AtomCMS's word filter. Loaded once and cached in-process like // mirroring AtomCMS's word filter. Loaded once and cached in-process like
// site-settings, so the common path never hits the DB. // site-settings, so the common path never hits the DB.
// 2. OpenAI Moderations — only when OPENAI_API_KEY is set. A flagged result // 2. OpenAI Moderations — only when OPENAI_API_KEY is set. A flagged result
@@ -40,9 +40,9 @@ async function loadWordFilter(): Promise<string[]> {
now - wordFilterLoadedAt > WORD_FILTER_TTL_MS now - wordFilterLoadedAt > WORD_FILTER_TTL_MS
) { ) {
try { try {
const rows = await prisma.websiteWordfilter.findMany({ const rows = await db
select: { word: true }, .select({ word: WebsiteWordfilter.word })
}); .from(WebsiteWordfilter);
wordFilterCache = rows wordFilterCache = rows
.map((r) => r.word.trim().toLowerCase()) .map((r) => r.word.trim().toLowerCase())
.filter((w) => w.length > 0); .filter((w) => w.length > 0);
+28
View File
@@ -1,3 +1,5 @@
import { eq, sql } from "drizzle-orm";
import { db, User, UsersCurrency } from "@/lib/db";
import type { RconClient } from "@/lib/services/rcon"; import type { RconClient } from "@/lib/services/rcon";
export type CurrencyName = "credits" | "duckets" | "diamonds" | "points"; export type CurrencyName = "credits" | "duckets" | "diamonds" | "points";
@@ -25,6 +27,32 @@ export interface CurrencyDb {
}; };
} }
/** Drizzle-backed CurrencyDb used by shop / voucher / referral (and callers). */
export const currencyDb: CurrencyDb = {
user: {
async update({ where, data }) {
await db
.update(User)
.set({
credits: sql`${User.credits} + ${data.credits.increment}`,
})
.where(eq(User.id, where.id));
},
},
usersCurrency: {
async upsert({ update, create }) {
await db
.insert(UsersCurrency)
.values(create)
.onDuplicateKeyUpdate({
set: {
amount: sql`${UsersCurrency.amount} + ${update.amount.increment}`,
},
});
},
},
};
export interface SendCurrencyDeps { export interface SendCurrencyDeps {
rcon: Pick< rcon: Pick<
RconClient, RconClient,
+20 -10
View File
@@ -1,31 +1,41 @@
import { beforeEach, describe, expect, it, vi } from "vitest"; import { beforeEach, describe, expect, it, vi } from "vitest";
const { findMany } = vi.hoisted(() => ({ findMany: vi.fn() })); const { selectFrom } = vi.hoisted(() => ({ selectFrom: vi.fn() }));
vi.mock("@/lib/prisma", () => ({ vi.mock("@/lib/db", () => ({
prisma: { websiteSetting: { findMany } }, db: {
select: () => ({
from: () => Promise.resolve(selectFrom()),
}),
insert: () => ({
values: () => ({
onDuplicateKeyUpdate: () => Promise.resolve(),
}),
}),
},
WebsiteSetting: { key: "WebsiteSetting.key", value: "WebsiteSetting.value" },
})); }));
import { siteSettings } from "./site-settings"; import { siteSettings } from "./site-settings";
beforeEach(async () => { beforeEach(async () => {
findMany.mockReset(); selectFrom.mockReset();
await siteSettings.reload(); await siteSettings.reload();
}); });
describe("siteSettings", () => { describe("siteSettings", () => {
it("returns a value by key", async () => { it("returns a value by key", async () => {
findMany.mockResolvedValue([{ key: "hotel_name", value: "AtomHotel" }]); selectFrom.mockResolvedValue([{ key: "hotel_name", value: "AtomHotel" }]);
expect(await siteSettings.get("hotel_name")).toBe("AtomHotel"); expect(await siteSettings.get("hotel_name")).toBe("AtomHotel");
}); });
it("returns the fallback when key is missing", async () => { it("returns the fallback when key is missing", async () => {
findMany.mockResolvedValue([]); selectFrom.mockResolvedValue([]);
expect(await siteSettings.get("missing", "fallback")).toBe("fallback"); expect(await siteSettings.get("missing", "fallback")).toBe("fallback");
expect(await siteSettings.get("missing")).toBeNull(); expect(await siteSettings.get("missing")).toBeNull();
}); });
it("coerces '1'/'0' string booleans", async () => { it("coerces '1'/'0' string booleans", async () => {
findMany.mockResolvedValue([ selectFrom.mockResolvedValue([
{ key: "maintenance_enabled", value: "1" }, { key: "maintenance_enabled", value: "1" },
{ key: "radio_enabled", value: "0" }, { key: "radio_enabled", value: "0" },
]); ]);
@@ -35,12 +45,12 @@ describe("siteSettings", () => {
}); });
it("fetches once and caches until reload", async () => { it("fetches once and caches until reload", async () => {
findMany.mockResolvedValue([{ key: "hotel_name", value: "AtomHotel" }]); selectFrom.mockResolvedValue([{ key: "hotel_name", value: "AtomHotel" }]);
await siteSettings.get("hotel_name"); await siteSettings.get("hotel_name");
await siteSettings.getBool("hotel_name"); await siteSettings.getBool("hotel_name");
expect(findMany).toHaveBeenCalledTimes(1); expect(selectFrom).toHaveBeenCalledTimes(1);
await siteSettings.reload(); await siteSettings.reload();
await siteSettings.get("hotel_name"); await siteSettings.get("hotel_name");
expect(findMany).toHaveBeenCalledTimes(2); expect(selectFrom).toHaveBeenCalledTimes(2);
}); });
}); });
+8 -9
View File
@@ -1,8 +1,8 @@
import "server-only"; import "server-only";
import { FALLBACK_HOTEL_NAME } from "@/lib/brand"; import { FALLBACK_HOTEL_NAME } from "@/lib/brand";
import { db, WebsiteSetting } from "@/lib/db";
import { logger } from "@/lib/logger"; import { logger } from "@/lib/logger";
import { prisma } from "@/lib/prisma";
import { redis } from "@/lib/redis"; import { redis } from "@/lib/redis";
const DEFAULTS: Record<string, string> = { const DEFAULTS: Record<string, string> = {
@@ -21,9 +21,9 @@ class SiteSettings {
private async loadFromDb(): Promise<Map<string, string>> { private async loadFromDb(): Promise<Map<string, string>> {
try { try {
const rows = await prisma.websiteSetting.findMany({ const rows = await db
select: { key: true, value: true }, .select({ key: WebsiteSetting.key, value: WebsiteSetting.value })
}); .from(WebsiteSetting);
return new Map(rows.map((r) => [r.key, r.value])); return new Map(rows.map((r) => [r.key, r.value]));
} catch { } catch {
logger.warn("Failed to load site settings from database, using defaults"); logger.warn("Failed to load site settings from database, using defaults");
@@ -98,11 +98,10 @@ class SiteSettings {
} }
async update(key: string, value: string): Promise<void> { async update(key: string, value: string): Promise<void> {
await prisma.websiteSetting.upsert({ await db
where: { key }, .insert(WebsiteSetting)
create: { key, value }, .values({ key, value })
update: { value }, .onDuplicateKeyUpdate({ set: { value } });
});
await this.reload(); await this.reload();
} }
+2 -4
View File
@@ -1,5 +1,5 @@
import { headers } from "next/headers"; import { headers } from "next/headers";
import { prisma } from "@/lib/prisma"; import { db, StaffActivities } from "@/lib/db";
/** /**
* Append a staff-action audit entry (AtomCMS StaffActivity). Never throws — * Append a staff-action audit entry (AtomCMS StaffActivity). Never throws —
@@ -23,8 +23,7 @@ export async function logStaffActivity(opts: {
} catch { } catch {
ip = null; ip = null;
} }
await prisma.staffActivities.create({ await db.insert(StaffActivities).values({
data: {
userId: BigInt(opts.staffId), userId: BigInt(opts.staffId),
action: opts.action.slice(0, 50), action: opts.action.slice(0, 50),
description: opts.description, description: opts.description,
@@ -32,7 +31,6 @@ export async function logStaffActivity(opts: {
targetId: opts.targetId != null ? BigInt(opts.targetId) : null, targetId: opts.targetId != null ? BigInt(opts.targetId) : null,
ipAddress: ip, ipAddress: ip,
createdAt: new Date(), createdAt: new Date(),
},
}); });
} catch { } catch {
// swallow — audit logging is best-effort // swallow — audit logging is best-effort