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");
} }
+54 -33
View File
@@ -2,23 +2,41 @@
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 { logStaffActivity } from "@/lib/services/staff-activity"; import { logStaffActivity } from "@/lib/services/staff-activity";
import { disbandGuild } from "./admin-guilds"; import { disbandGuild } from "./admin-guilds";
const { selectLimit, transactionFn, deleteWhere, updateSet } = vi.hoisted(
() => {
const selectLimit = vi.fn();
const transactionFn = vi.fn();
const deleteWhere = vi.fn().mockResolvedValue([{ affectedRows: 1 }]);
const updateSet = vi.fn(() => ({ where: vi.fn().mockResolvedValue([]) }));
return { selectLimit, transactionFn, deleteWhere, updateSet };
},
);
vi.mock("@/lib/admin/guard", () => ({ requirePermissionRateLimited: vi.fn() })); vi.mock("@/lib/admin/guard", () => ({ requirePermissionRateLimited: 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: { db: {
guilds: { findUnique: vi.fn(), delete: vi.fn() }, select: vi.fn(() => ({
guildsForumsThreads: { findMany: vi.fn(), deleteMany: vi.fn() }, from: vi.fn(() => ({
guildsForumsComments: { deleteMany: vi.fn() }, where: vi.fn(() => ({
guildForumViews: { deleteMany: vi.fn() }, limit: selectLimit,
guildsMembers: { deleteMany: vi.fn() }, })),
rooms: { updateMany: vi.fn() }, })),
items: { updateMany: vi.fn() }, })),
$transaction: vi.fn(), transaction: transactionFn,
delete: vi.fn(() => ({ where: deleteWhere })),
update: vi.fn(() => ({ set: updateSet })),
}, },
Guilds: { id: "id", name: "name", userId: "userId" },
GuildsForumsThreads: { id: "id", guildId: "guildId" },
GuildsForumsComments: { threadId: "threadId" },
GuildForumViews: { guildId: "guildId" },
GuildsMembers: { guildId: "guildId" },
Rooms: { guildId: "guildId" },
Items: { guildId: "guildId" },
})); }));
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() }));
@@ -35,29 +53,32 @@ beforeEach(() => {
describe("disbandGuild", () => { describe("disbandGuild", () => {
it("disbands guild and cleans related data", async () => { it("disbands guild and cleans related data", async () => {
vi.mocked(prisma.guilds.findUnique).mockResolvedValue({ selectLimit.mockResolvedValue([{ id: 1, name: "TestGuild", userId: 42 }]);
id: 1, transactionFn.mockImplementation(
name: "TestGuild", async (fn: (tx: unknown) => Promise<void>) => {
userId: 42, const txSelectLimit = vi.fn().mockResolvedValue([{ id: 10 }]);
} as never);
vi.mocked(prisma.guildsForumsThreads.findMany).mockResolvedValue([
{ id: 10 },
] as never);
vi.mocked(prisma.$transaction).mockImplementation(async (fn: unknown) => {
const tx = { const tx = {
guildsForumsComments: { deleteMany: vi.fn().mockResolvedValue({}) }, select: vi.fn(() => ({
guildsForumsThreads: { from: vi.fn(() => ({
findMany: vi.fn().mockResolvedValue([{ id: 10 }]), where: vi.fn(() => ({
deleteMany: vi.fn().mockResolvedValue({}), limit: txSelectLimit,
})),
})),
})),
delete: vi.fn(() => ({ where: vi.fn().mockResolvedValue([]) })),
update: vi.fn(() => ({
set: vi.fn(() => ({ where: vi.fn().mockResolvedValue([]) })),
})),
};
// For threads findMany (no limit) — make where resolve to array
tx.select = vi.fn(() => ({
from: vi.fn(() => ({
where: vi.fn().mockResolvedValue([{ id: 10 }]),
})),
}));
await fn(tx);
}, },
guildForumViews: { deleteMany: vi.fn().mockResolvedValue({}) }, );
guildsMembers: { deleteMany: vi.fn().mockResolvedValue({}) },
rooms: { updateMany: vi.fn().mockResolvedValue({}) },
items: { updateMany: vi.fn().mockResolvedValue({}) },
guilds: { delete: vi.fn().mockResolvedValue({}) },
} as never;
await (fn as (tx: never) => Promise<void>)(tx);
});
await disbandGuild(fakeForm({ id: "1" }) as unknown as FormData); await disbandGuild(fakeForm({ id: "1" }) as unknown as FormData);
expect(logStaffActivity).toHaveBeenCalled(); expect(logStaffActivity).toHaveBeenCalled();
expect(revalidatePath).toHaveBeenCalledWith("/admin/guilds"); expect(revalidatePath).toHaveBeenCalledWith("/admin/guilds");
@@ -65,6 +86,6 @@ describe("disbandGuild", () => {
it("returns early when id is not positive", async () => { it("returns early when id is not positive", async () => {
await disbandGuild(fakeForm({ id: "0" }) as unknown as FormData); await disbandGuild(fakeForm({ id: "0" }) as unknown as FormData);
expect(prisma.guilds.findUnique).not.toHaveBeenCalled(); expect(selectLimit).not.toHaveBeenCalled();
}); });
}); });
+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({
+67 -44
View File
@@ -1,9 +1,15 @@
"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 {
Ban,
db,
WebsiteHelpCenterTicketReplies,
WebsiteHelpCenterTickets,
} 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";
@@ -38,25 +44,32 @@ export const liftBanFromHelpTicket = adminAction(
}, },
async (ctx) => { async (ctx) => {
const ticketId = ctx.data.ticketId; const ticketId = ctx.data.ticketId;
const ticket = await prisma.websiteHelpCenterTickets.findUnique({ const [ticket] = await db
where: { id: ticketId }, .select({
select: { id: true, userId: true, open: true, title: true }, id: WebsiteHelpCenterTickets.id,
}); userId: WebsiteHelpCenterTickets.userId,
open: WebsiteHelpCenterTickets.open,
title: WebsiteHelpCenterTickets.title,
})
.from(WebsiteHelpCenterTickets)
.where(eq(WebsiteHelpCenterTickets.id, ticketId))
.limit(1);
if (!ticket) throw new ActionError("Ticket not found"); if (!ticket) throw new ActionError("Ticket not found");
if (ticket.userId == null) { if (ticket.userId == null) {
throw new ActionError("Ticket has no requester to unban"); throw new ActionError("Ticket has no requester to unban");
} }
const removed = await prisma.ban.deleteMany({ const result = await db.delete(Ban).where(eq(Ban.userId, ticket.userId));
where: { userId: ticket.userId }, const removed = Number(
}); (result as unknown as [{ affectedRows: number }])[0]?.affectedRows ?? 0,
);
const now = new Date(); const now = new Date();
if (ticket.open) { if (ticket.open) {
await prisma.websiteHelpCenterTickets.update({ await db
where: { id: ticketId }, .update(WebsiteHelpCenterTickets)
data: { open: false, updatedAt: now }, .set({ open: false, updatedAt: now })
}); .where(eq(WebsiteHelpCenterTickets.id, ticketId));
} }
logAudit({ logAudit({
@@ -66,7 +79,7 @@ export const liftBanFromHelpTicket = adminAction(
targetId: ticket.userId, targetId: ticket.userId,
after: { after: {
ticketId: String(ticketId), ticketId: String(ticketId),
removedBans: removed.count, removedBans: removed,
title: ticket.title, title: ticket.title,
}, },
}); });
@@ -74,7 +87,7 @@ export const liftBanFromHelpTicket = adminAction(
revalidateHelpCenterTicketPaths(ticketId); revalidateHelpCenterTicketPaths(ticketId);
revalidatePath("/admin/bans"); revalidatePath("/admin/bans");
revalidatePath(`/admin/users/show/${ticket.userId}`); revalidatePath(`/admin/users/show/${ticket.userId}`);
return actionOk({ removed: removed.count, userId: ticket.userId }); return actionOk({ removed, userId: ticket.userId });
}, },
); );
@@ -84,31 +97,33 @@ export const replyHelpCenterTicket = adminAction(
{ permission: HELP_TICKET_EDIT, schema: replyHelpCenterTicketSchema }, { permission: HELP_TICKET_EDIT, schema: replyHelpCenterTicketSchema },
async (ctx) => { async (ctx) => {
const ticketId = ctx.data.ticketId; const ticketId = ctx.data.ticketId;
const ticket = await prisma.websiteHelpCenterTickets.findUnique({ const [ticket] = await db
where: { id: ticketId }, .select({
select: { id: true, open: true }, id: WebsiteHelpCenterTickets.id,
}); open: WebsiteHelpCenterTickets.open,
})
.from(WebsiteHelpCenterTickets)
.where(eq(WebsiteHelpCenterTickets.id, ticketId))
.limit(1);
if (!ticket) throw new ActionError("Ticket not found"); if (!ticket) throw new ActionError("Ticket not found");
const now = new Date(); const now = new Date();
const staffId = Number(ctx.session.user.id); const staffId = Number(ctx.session.user.id);
await prisma.$transaction([ await db.transaction(async (tx) => {
prisma.websiteHelpCenterTicketReplies.create({ await tx.insert(WebsiteHelpCenterTicketReplies).values({
data: {
ticketId, ticketId,
userId: staffId, userId: staffId,
content: ctx.data.content.trim(), content: ctx.data.content.trim(),
createdAt: now, createdAt: now,
updatedAt: now, updatedAt: now,
}, });
}), await tx
prisma.websiteHelpCenterTickets.update({ .update(WebsiteHelpCenterTickets)
where: { id: ticketId }, .set({ updatedAt: now })
data: { updatedAt: now }, .where(eq(WebsiteHelpCenterTickets.id, ticketId));
}), });
]);
logAudit({ logAudit({
userId: staffId, userId: staffId,
@@ -126,19 +141,23 @@ export const closeHelpCenterTicket = adminAction(
{ permission: HELP_TICKET_EDIT, schema: helpCenterTicketIdSchema }, { permission: HELP_TICKET_EDIT, schema: helpCenterTicketIdSchema },
async (ctx) => { async (ctx) => {
const ticketId = ctx.data.ticketId; const ticketId = ctx.data.ticketId;
const ticket = await prisma.websiteHelpCenterTickets.findUnique({ const [ticket] = await db
where: { id: ticketId }, .select({
select: { id: true, open: true }, id: WebsiteHelpCenterTickets.id,
}); open: WebsiteHelpCenterTickets.open,
})
.from(WebsiteHelpCenterTickets)
.where(eq(WebsiteHelpCenterTickets.id, ticketId))
.limit(1);
if (!ticket) throw new ActionError("Ticket not found"); if (!ticket) throw new ActionError("Ticket not found");
if (!ticket.open) throw new ActionError("Ticket is already closed"); if (!ticket.open) throw new ActionError("Ticket is already closed");
const now = new Date(); const now = new Date();
await prisma.websiteHelpCenterTickets.update({ await db
where: { id: ticketId }, .update(WebsiteHelpCenterTickets)
data: { open: false, updatedAt: now }, .set({ open: false, updatedAt: now })
}); .where(eq(WebsiteHelpCenterTickets.id, ticketId));
logAudit({ logAudit({
userId: Number(ctx.session.user.id), userId: Number(ctx.session.user.id),
@@ -158,19 +177,23 @@ export const reopenHelpCenterTicket = adminAction(
{ permission: HELP_TICKET_EDIT, schema: helpCenterTicketIdSchema }, { permission: HELP_TICKET_EDIT, schema: helpCenterTicketIdSchema },
async (ctx) => { async (ctx) => {
const ticketId = ctx.data.ticketId; const ticketId = ctx.data.ticketId;
const ticket = await prisma.websiteHelpCenterTickets.findUnique({ const [ticket] = await db
where: { id: ticketId }, .select({
select: { id: true, open: true }, id: WebsiteHelpCenterTickets.id,
}); open: WebsiteHelpCenterTickets.open,
})
.from(WebsiteHelpCenterTickets)
.where(eq(WebsiteHelpCenterTickets.id, ticketId))
.limit(1);
if (!ticket) throw new ActionError("Ticket not found"); if (!ticket) throw new ActionError("Ticket not found");
if (ticket.open) throw new ActionError("Ticket is already open"); if (ticket.open) throw new ActionError("Ticket is already open");
const now = new Date(); const now = new Date();
await prisma.websiteHelpCenterTickets.update({ await db
where: { id: ticketId }, .update(WebsiteHelpCenterTickets)
data: { open: true, updatedAt: now }, .set({ open: true, updatedAt: now })
}); .where(eq(WebsiteHelpCenterTickets.id, ticketId));
logAudit({ logAudit({
userId: Number(ctx.session.user.id), userId: Number(ctx.session.user.id),
+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");
} }
+41 -35
View File
@@ -1,12 +1,24 @@
import { beforeEach, describe, expect, it, vi } from "vitest"; import { beforeEach, describe, expect, it, vi } from "vitest";
const { mockUpsert, mockRequirePermission, mockReload, mockRevalidatePath } = const {
vi.hoisted(() => ({ mockValues,
mockUpsert: vi.fn(), mockOnDuplicateKeyUpdate,
mockRequirePermission,
mockReload,
mockRevalidatePath,
} = vi.hoisted(() => {
const mockOnDuplicateKeyUpdate = vi.fn().mockResolvedValue(undefined);
const mockValues = vi.fn(() => ({
onDuplicateKeyUpdate: mockOnDuplicateKeyUpdate,
}));
return {
mockValues,
mockOnDuplicateKeyUpdate,
mockRequirePermission: vi.fn(), mockRequirePermission: vi.fn(),
mockReload: vi.fn(), mockReload: vi.fn(),
mockRevalidatePath: vi.fn(), mockRevalidatePath: vi.fn(),
})); };
});
vi.mock("@/lib/permissions", () => ({ vi.mock("@/lib/permissions", () => ({
PERMS: { PERMS: {
@@ -16,10 +28,11 @@ vi.mock("@/lib/permissions", () => ({
}, },
})); }));
vi.mock("@/lib/prisma", () => ({ vi.mock("@/lib/db", () => ({
prisma: { db: {
websiteSetting: { upsert: mockUpsert }, insert: vi.fn(() => ({ values: mockValues })),
}, },
WebsiteSetting: { key: "key", value: "value" },
})); }));
vi.mock("@/lib/admin/guard", () => ({ vi.mock("@/lib/admin/guard", () => ({
@@ -38,6 +51,10 @@ import { saveMaintenance } from "./admin-maintenance";
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
mockValues.mockReturnValue({
onDuplicateKeyUpdate: mockOnDuplicateKeyUpdate,
});
mockOnDuplicateKeyUpdate.mockResolvedValue(undefined);
}); });
describe("saveMaintenance", () => { describe("saveMaintenance", () => {
@@ -57,37 +74,26 @@ describe("saveMaintenance", () => {
expect(mockRequirePermission).toHaveBeenCalled(); expect(mockRequirePermission).toHaveBeenCalled();
expect(mockUpsert).toHaveBeenCalledTimes(3); expect(mockValues).toHaveBeenCalledTimes(3);
expect(mockUpsert).toHaveBeenCalledWith( expect(mockValues).toHaveBeenCalledWith(
expect.objectContaining({ expect.objectContaining({
where: { key: "maintenance_enabled" },
update: { value: "1" },
create: expect.objectContaining({
key: "maintenance_enabled", key: "maintenance_enabled",
value: "1", value: "1",
}), }),
}),
); );
expect(mockUpsert).toHaveBeenCalledWith( expect(mockValues).toHaveBeenCalledWith(
expect.objectContaining({ expect.objectContaining({
where: { key: "maintenance_message" },
update: { value: "We will be back soon!" },
create: expect.objectContaining({
key: "maintenance_message", key: "maintenance_message",
value: "We will be back soon!", value: "We will be back soon!",
}), }),
}),
); );
expect(mockUpsert).toHaveBeenCalledWith( expect(mockValues).toHaveBeenCalledWith(
expect.objectContaining({ expect.objectContaining({
where: { key: "min_maintenance_login_rank" },
update: { value: "3" },
create: expect.objectContaining({
key: "min_maintenance_login_rank", key: "min_maintenance_login_rank",
value: "3", value: "3",
}), }),
}),
); );
expect(mockOnDuplicateKeyUpdate).toHaveBeenCalledTimes(3);
expect(mockReload).toHaveBeenCalledOnce(); expect(mockReload).toHaveBeenCalledOnce();
expect(mockRevalidatePath).toHaveBeenCalledWith("/admin/maintenance"); expect(mockRevalidatePath).toHaveBeenCalledWith("/admin/maintenance");
@@ -106,16 +112,16 @@ describe("saveMaintenance", () => {
await saveMaintenance(fd); await saveMaintenance(fd);
expect(mockUpsert).toHaveBeenCalledWith( expect(mockValues).toHaveBeenCalledWith(
expect.objectContaining({ expect.objectContaining({
where: { key: "maintenance_enabled" }, key: "maintenance_enabled",
update: { value: "0" }, value: "0",
}), }),
); );
expect(mockUpsert).toHaveBeenCalledWith( expect(mockValues).toHaveBeenCalledWith(
expect.objectContaining({ expect.objectContaining({
where: { key: "min_maintenance_login_rank" }, key: "min_maintenance_login_rank",
update: { value: "5" }, value: "5",
}), }),
); );
}); });
@@ -134,10 +140,10 @@ describe("saveMaintenance", () => {
await saveMaintenance(fd); await saveMaintenance(fd);
expect(mockUpsert).toHaveBeenCalledWith( expect(mockValues).toHaveBeenCalledWith(
expect.objectContaining({ expect.objectContaining({
where: { key: "min_maintenance_login_rank" }, key: "min_maintenance_login_rank",
update: { value: "5" }, value: "5",
}), }),
); );
}); });
@@ -156,10 +162,10 @@ describe("saveMaintenance", () => {
await saveMaintenance(fd); await saveMaintenance(fd);
expect(mockUpsert).toHaveBeenCalledWith( expect(mockValues).toHaveBeenCalledWith(
expect.objectContaining({ expect.objectContaining({
where: { key: "min_maintenance_login_rank" }, key: "min_maintenance_login_rank",
update: { value: "5" }, value: "5",
}), }),
); );
}); });
+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",
+25 -30
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, RadioBanners, RadioRanks, 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";
// ── Helpers ──────────────────────────────────────────────────────────────── // ── Helpers ────────────────────────────────────────────────────────────────
@@ -44,11 +45,10 @@ export async function saveRadioSetting(formData: FormData): Promise<void> {
if (!key) return; if (!key) return;
try { try {
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 } });
});
siteSettings.reload(); siteSettings.reload();
} catch { } catch {
// DB unavailable — fail soft so the action does not throw. // DB unavailable — fail soft so the action does not throw.
@@ -71,14 +71,13 @@ export async function saveRadioSettings(formData: FormData): Promise<void> {
if (keys.length === 0) return; if (keys.length === 0) return;
try { try {
await prisma.$transaction( await Promise.all(
keys.map((key) => { keys.map((key) => {
const value = str(formData.get(key)); const value = str(formData.get(key));
return prisma.websiteSetting.upsert({ return db
where: { key }, .insert(WebsiteSetting)
update: { value }, .values({ key, value, comment: null })
create: { key, value, comment: null }, .onDuplicateKeyUpdate({ set: { value } });
});
}), }),
); );
siteSettings.reload(); siteSettings.reload();
@@ -105,8 +104,7 @@ export async function createRadioBanner(formData: FormData): Promise<void> {
const now = new Date(); const now = new Date();
try { try {
await prisma.radioBanners.create({ await db.insert(RadioBanners).values({
data: {
userId: BigInt(staff.id), userId: BigInt(staff.id),
imagePath, imagePath,
title: title || null, title: title || null,
@@ -115,7 +113,6 @@ export async function createRadioBanner(formData: FormData): Promise<void> {
isActive, isActive,
createdAt: now, createdAt: now,
updatedAt: now, updatedAt: now,
},
}); });
} catch { } catch {
// Fail soft. // Fail soft.
@@ -139,17 +136,17 @@ export async function updateRadioBanner(formData: FormData): Promise<void> {
if (!imagePath) return; if (!imagePath) return;
try { try {
await prisma.radioBanners.update({ await db
where: { id }, .update(RadioBanners)
data: { .set({
imagePath, imagePath,
title: title || null, title: title || null,
description: description || null, description: description || null,
sortOrder, sortOrder,
isActive, isActive,
updatedAt: new Date(), updatedAt: new Date(),
}, })
}); .where(eq(RadioBanners.id, id));
} catch { } catch {
// Row may be gone; ignore. // Row may be gone; ignore.
} }
@@ -161,7 +158,7 @@ export async function deleteRadioBanner(formData: FormData): Promise<void> {
const id = parseId(formData.get("id")); const id = parseId(formData.get("id"));
if (id === null) return; if (id === null) return;
try { try {
await prisma.radioBanners.delete({ where: { id } }); await db.delete(RadioBanners).where(eq(RadioBanners.id, id));
} catch { } catch {
// Already deleted; ignore. // Already deleted; ignore.
} }
@@ -181,15 +178,13 @@ export async function createRadioRank(formData: FormData): Promise<void> {
const now = new Date(); const now = new Date();
try { try {
await prisma.radioRanks.create({ await db.insert(RadioRanks).values({
data: {
name, name,
description: description || null, description: description || null,
badgeCode: badgeCode || null, badgeCode: badgeCode || null,
isActive, isActive,
createdAt: now, createdAt: now,
updatedAt: now, updatedAt: now,
},
}); });
} catch { } catch {
// Fail soft. // Fail soft.
@@ -209,16 +204,16 @@ export async function updateRadioRank(formData: FormData): Promise<void> {
if (!name) return; if (!name) return;
try { try {
await prisma.radioRanks.update({ await db
where: { id }, .update(RadioRanks)
data: { .set({
name, name,
description: description || null, description: description || null,
badgeCode: badgeCode || null, badgeCode: badgeCode || null,
isActive, isActive,
updatedAt: new Date(), updatedAt: new Date(),
}, })
}); .where(eq(RadioRanks.id, id));
} catch { } catch {
// Row may be gone; ignore. // Row may be gone; ignore.
} }
@@ -230,7 +225,7 @@ export async function deleteRadioRank(formData: FormData): Promise<void> {
const id = parseId(formData.get("id")); const id = parseId(formData.get("id"));
if (id === null) return; if (id === null) return;
try { try {
await prisma.radioRanks.delete({ where: { id } }); await db.delete(RadioRanks).where(eq(RadioRanks.id, id));
} catch { } catch {
// Already deleted; ignore. // Already deleted; ignore.
} }
+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",
+42 -48
View File
@@ -2,18 +2,34 @@
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 { logStaffActivity } from "@/lib/services/staff-activity"; import { logStaffActivity } from "@/lib/services/staff-activity";
import { createTag, deleteTag, updateTag } from "./admin-tags"; import { createTag, deleteTag, updateTag } from "./admin-tags";
const { insertValues, updateWhere, deleteWhere, transaction } = vi.hoisted(
() => {
const insertValues = vi.fn().mockResolvedValue([{ insertId: 1 }]);
const updateWhere = vi.fn().mockResolvedValue([{ affectedRows: 1 }]);
const deleteWhere = vi.fn().mockResolvedValue([{ affectedRows: 1 }]);
const transaction = vi.fn(async (fn) =>
fn({
delete: vi.fn(() => ({ where: deleteWhere })),
}),
);
return { insertValues, updateWhere, deleteWhere, transaction };
},
);
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: {
tags: { create: vi.fn(), update: vi.fn(), delete: vi.fn() }, insert: vi.fn(() => ({ values: insertValues })),
taggables: { deleteMany: vi.fn() }, update: vi.fn(() => ({ set: vi.fn(() => ({ where: updateWhere })) })),
$transaction: vi.fn(), delete: vi.fn(() => ({ where: deleteWhere })),
transaction,
}, },
Tags: { id: "id", name: "name", backgroundColor: "backgroundColor" },
Taggables: { tagId: "tagId" },
})); }));
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() }));
@@ -26,12 +42,18 @@ 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 }]);
updateWhere.mockResolvedValue([{ affectedRows: 1 }]);
deleteWhere.mockResolvedValue([{ affectedRows: 1 }]);
transaction.mockImplementation(async (fn) =>
fn({
delete: vi.fn(() => ({ where: deleteWhere })),
}),
);
}); });
describe("createTag", () => { describe("createTag", () => {
it("creates a tag and revalidates", async () => { it("creates a tag and revalidates", async () => {
vi.mocked(prisma.tags.create).mockResolvedValue({ id: BigInt(1) } as never);
await createTag( await createTag(
fakeForm({ fakeForm({
name: "News", name: "News",
@@ -39,10 +61,8 @@ describe("createTag", () => {
}) as unknown as FormData, }) as unknown as FormData,
); );
expect(prisma.tags.create).toHaveBeenCalledWith( expect(insertValues).toHaveBeenCalledWith(
expect.objectContaining({ expect.objectContaining({ name: "News" }),
data: expect.objectContaining({ name: "News" }),
}),
); );
expect(logStaffActivity).toHaveBeenCalled(); expect(logStaffActivity).toHaveBeenCalled();
expect(revalidatePath).toHaveBeenCalledWith("/admin/tags"); expect(revalidatePath).toHaveBeenCalledWith("/admin/tags");
@@ -50,23 +70,19 @@ describe("createTag", () => {
it("returns early when name is empty", async () => { it("returns early when name is empty", async () => {
await createTag(fakeForm({ name: "" }) as unknown as FormData); await createTag(fakeForm({ name: "" }) as unknown as FormData);
expect(prisma.tags.create).not.toHaveBeenCalled(); expect(insertValues).not.toHaveBeenCalled();
}); });
it("uses default color when not provided", async () => { it("uses default color when not provided", async () => {
vi.mocked(prisma.tags.create).mockResolvedValue({ id: BigInt(1) } as never);
await createTag(fakeForm({ name: "Test" }) as unknown as FormData); await createTag(fakeForm({ name: "Test" }) as unknown as FormData);
expect(prisma.tags.create).toHaveBeenCalledWith( expect(insertValues).toHaveBeenCalledWith(
expect.objectContaining({ expect.objectContaining({ backgroundColor: "#888888" }),
data: expect.objectContaining({ backgroundColor: "#888888" }),
}),
); );
}); });
it("handles db error gracefully", async () => { it("handles db error gracefully", async () => {
vi.mocked(prisma.tags.create).mockRejectedValue(new Error("DB error")); insertValues.mockRejectedValue(new Error("DB error"));
await expect( await expect(
createTag(fakeForm({ name: "News" }) as unknown as FormData), createTag(fakeForm({ name: "News" }) as unknown as FormData),
@@ -77,8 +93,6 @@ describe("createTag", () => {
describe("updateTag", () => { describe("updateTag", () => {
it("updates a tag and revalidates", async () => { it("updates a tag and revalidates", async () => {
vi.mocked(prisma.tags.update).mockResolvedValue({} as never);
await updateTag( await updateTag(
fakeForm({ fakeForm({
id: "42", id: "42",
@@ -87,54 +101,34 @@ describe("updateTag", () => {
}) as unknown as FormData, }) as unknown as FormData,
); );
expect(prisma.tags.update).toHaveBeenCalledWith({ expect(updateWhere).toHaveBeenCalled();
where: { id: BigInt(42) },
data: expect.objectContaining({ name: "Updated" }),
});
expect(logStaffActivity).toHaveBeenCalled(); expect(logStaffActivity).toHaveBeenCalled();
expect(revalidatePath).toHaveBeenCalledWith("/admin/tags"); expect(revalidatePath).toHaveBeenCalledWith("/admin/tags");
}); });
it("returns early when id is invalid", async () => { it("returns early when id is invalid", async () => {
await updateTag(fakeForm({ id: "", name: "Test" }) as unknown as FormData); await updateTag(fakeForm({ id: "", name: "Test" }) as unknown as FormData);
expect(prisma.tags.update).not.toHaveBeenCalled(); expect(updateWhere).not.toHaveBeenCalled();
}); });
it("returns early when name is empty after update", async () => { it("returns early when name is empty after update", async () => {
await updateTag(fakeForm({ id: "42", name: "" }) as unknown as FormData); await updateTag(fakeForm({ id: "42", name: "" }) as unknown as FormData);
expect(prisma.tags.update).not.toHaveBeenCalled(); expect(updateWhere).not.toHaveBeenCalled();
}); });
}); });
describe("deleteTag", () => { describe("deleteTag", () => {
it("deletes a tag and its taggables", async () => { it("deletes a tag and its taggables", async () => {
vi.mocked(prisma.taggables.deleteMany).mockResolvedValue({
count: 1,
} as never);
vi.mocked(prisma.tags.delete).mockResolvedValue({} as never);
vi.mocked(prisma.$transaction).mockImplementation(async (ops: unknown) => {
const arr = ops as [
typeof prisma.taggables.deleteMany,
typeof prisma.tags.delete,
];
await arr[0];
await arr[1];
});
await deleteTag(fakeForm({ id: "42" }) as unknown as FormData); await deleteTag(fakeForm({ id: "42" }) as unknown as FormData);
expect(prisma.taggables.deleteMany).toHaveBeenCalledWith({ expect(transaction).toHaveBeenCalled();
where: { tagId: BigInt(42) }, expect(deleteWhere).toHaveBeenCalled();
});
expect(prisma.tags.delete).toHaveBeenCalledWith({
where: { id: BigInt(42) },
});
expect(logStaffActivity).toHaveBeenCalled(); expect(logStaffActivity).toHaveBeenCalled();
expect(revalidatePath).toHaveBeenCalledWith("/admin/tags"); expect(revalidatePath).toHaveBeenCalledWith("/admin/tags");
}); });
it("returns early when id is invalid", async () => { it("returns early when id is invalid", async () => {
await deleteTag(fakeForm({ id: "" }) as unknown as FormData); await deleteTag(fakeForm({ id: "" }) as unknown as FormData);
expect(prisma.$transaction).not.toHaveBeenCalled(); expect(transaction).not.toHaveBeenCalled();
}); });
}); });
+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",
+90 -17
View File
@@ -1,7 +1,6 @@
// @ts-nocheck // @ts-nocheck
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 { rcon } from "@/lib/services/rcon"; import { rcon } from "@/lib/services/rcon";
import { import {
bulkBan, bulkBan,
@@ -10,14 +9,78 @@ import {
bulkUnban, bulkUnban,
} from "./bulk-users"; } from "./bulk-users";
const {
deleteWhere,
insertValues,
updateWhere,
selectLimit,
selectWhereResolved,
onDuplicateKeyUpdate,
} = vi.hoisted(() => {
const deleteWhere = vi.fn().mockResolvedValue([{ affectedRows: 3 }]);
const onDuplicateKeyUpdate = vi.fn().mockResolvedValue([{ affectedRows: 1 }]);
const insertValues = vi.fn(() => ({
onDuplicateKeyUpdate,
// biome-ignore lint/suspicious/noThenProperty: Drizzle query thenable mock
then(resolve, reject) {
return Promise.resolve([{ insertId: 1 }]).then(resolve, reject);
},
}));
const updateWhere = vi.fn().mockResolvedValue([{ affectedRows: 1 }]);
const selectLimit = vi.fn().mockResolvedValue([]);
/** Rows returned when a select chain is awaited without `.limit()`. */
const selectWhereResolved = vi.fn().mockResolvedValue([]);
return {
deleteWhere,
insertValues,
updateWhere,
selectLimit,
selectWhereResolved,
onDuplicateKeyUpdate,
};
});
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: { db: {
ban: { deleteMany: vi.fn(), create: vi.fn() }, delete: vi.fn(() => ({ where: deleteWhere })),
user: { update: vi.fn() }, insert: vi.fn(() => ({ values: insertValues })),
usersCurrency: { upsert: vi.fn() }, update: vi.fn(() => ({
usersBadges: { findFirst: vi.fn(), aggregate: vi.fn(), create: vi.fn() }, set: vi.fn(() => ({ where: updateWhere })),
})),
select: vi.fn(() => ({
from: vi.fn(() => ({
where: vi.fn(() => ({
limit: selectLimit,
// biome-ignore lint/suspicious/noThenProperty: Drizzle query thenable mock
then(resolve, reject) {
return selectWhereResolved().then(resolve, reject);
},
})),
})),
})),
transaction: vi.fn(),
},
Ban: { userId: "userId", id: "id" },
User: {
id: "id",
credits: "credits",
username: "username",
online: "online",
},
UsersCurrency: { userId: "userId", type: "type", amount: "amount" },
UsersBadges: {
id: "id",
userId: "userId",
badgeCode: "badgeCode",
slotId: "slotId",
},
Sanctions: { id: "id", habboId: "habboId" },
UsersSettings: {
userId: "userId",
canTrade: "canTrade",
tradelockAmount: "tradelockAmount",
}, },
})); }));
vi.mock("@/lib/services/rcon", () => ({ vi.mock("@/lib/services/rcon", () => ({
@@ -35,11 +98,22 @@ const staff = { id: 1, rank: 7, username: "admin" };
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
vi.mocked(requirePermission).mockResolvedValue(staff as never); vi.mocked(requirePermission).mockResolvedValue(staff as never);
deleteWhere.mockResolvedValue([{ affectedRows: 3 }]);
insertValues.mockImplementation(() => ({
onDuplicateKeyUpdate,
// biome-ignore lint/suspicious/noThenProperty: Drizzle query thenable mock
then(resolve, reject) {
return Promise.resolve([{ insertId: 1 }]).then(resolve, reject);
},
}));
onDuplicateKeyUpdate.mockResolvedValue([{ affectedRows: 1 }]);
updateWhere.mockResolvedValue([{ affectedRows: 1 }]);
selectLimit.mockResolvedValue([]);
selectWhereResolved.mockResolvedValue([]);
}); });
describe("bulkUnban", () => { describe("bulkUnban", () => {
it("unbans users", async () => { it("unbans users", async () => {
vi.mocked(prisma.ban.deleteMany).mockResolvedValue({ count: 3 } as never);
const r = await bulkUnban({ userIds: [1, 2, 3] }); const r = await bulkUnban({ userIds: [1, 2, 3] });
expect(r.ok).toBe(true); expect(r.ok).toBe(true);
expect(r.data).toEqual({ unbanned: 3, total: 3 }); expect(r.data).toEqual({ unbanned: 3, total: 3 });
@@ -48,7 +122,6 @@ describe("bulkUnban", () => {
describe("bulkBan", () => { describe("bulkBan", () => {
it("bans users", async () => { it("bans users", async () => {
vi.mocked(prisma.ban.create).mockResolvedValue({} as never);
const r = await bulkBan({ const r = await bulkBan({
userIds: [1, 2], userIds: [1, 2],
reason: "Spam", reason: "Spam",
@@ -56,12 +129,12 @@ describe("bulkBan", () => {
}); });
expect(r.ok).toBe(true); expect(r.ok).toBe(true);
expect(r.data.banned).toBe(2); expect(r.data.banned).toBe(2);
expect(insertValues).toHaveBeenCalledTimes(2);
}); });
}); });
describe("bulkGiveCurrency", () => { describe("bulkGiveCurrency", () => {
it("gives credits", async () => { it("gives credits", async () => {
vi.mocked(prisma.user.update).mockResolvedValue({} as never);
const r = await bulkGiveCurrency({ const r = await bulkGiveCurrency({
userIds: [1], userIds: [1],
amount: 100, amount: 100,
@@ -69,10 +142,10 @@ describe("bulkGiveCurrency", () => {
}); });
expect(r.data.given).toBe(1); expect(r.data.given).toBe(1);
expect(rcon.giveCredits).toHaveBeenCalledWith(1, 100); expect(rcon.giveCredits).toHaveBeenCalledWith(1, 100);
expect(updateWhere).toHaveBeenCalled();
}); });
it("gives pixels", async () => { it("gives pixels", async () => {
vi.mocked(prisma.usersCurrency.upsert).mockResolvedValue({} as never);
const r = await bulkGiveCurrency({ const r = await bulkGiveCurrency({
userIds: [2], userIds: [2],
amount: 50, amount: 50,
@@ -80,10 +153,10 @@ describe("bulkGiveCurrency", () => {
}); });
expect(r.data.given).toBe(1); expect(r.data.given).toBe(1);
expect(rcon.giveDuckets).toHaveBeenCalledWith(2, 50); expect(rcon.giveDuckets).toHaveBeenCalledWith(2, 50);
expect(onDuplicateKeyUpdate).toHaveBeenCalled();
}); });
it("gives points", async () => { it("gives points", async () => {
vi.mocked(prisma.usersCurrency.upsert).mockResolvedValue({} as never);
const r = await bulkGiveCurrency({ const r = await bulkGiveCurrency({
userIds: [3], userIds: [3],
amount: 25, amount: 25,
@@ -91,17 +164,17 @@ describe("bulkGiveCurrency", () => {
}); });
expect(r.data.given).toBe(1); expect(r.data.given).toBe(1);
expect(rcon.givePointsGotw).toHaveBeenCalledWith(3, 25); expect(rcon.givePointsGotw).toHaveBeenCalledWith(3, 25);
expect(onDuplicateKeyUpdate).toHaveBeenCalled();
}); });
}); });
describe("bulkGiveBadge", () => { describe("bulkGiveBadge", () => {
it("gives badge to user", async () => { it("gives badge to user", async () => {
vi.mocked(prisma.usersBadges.findFirst).mockResolvedValue(null); selectLimit.mockResolvedValueOnce([]);
vi.mocked(prisma.usersBadges.aggregate).mockResolvedValue({ selectWhereResolved.mockResolvedValueOnce([{ maxSlot: 5 }]);
_max: { slotId: 5 },
} as never);
vi.mocked(prisma.usersBadges.create).mockResolvedValue({} as never);
const r = await bulkGiveBadge({ userIds: [1], badgeCode: "ADM" }); const r = await bulkGiveBadge({ userIds: [1], badgeCode: "ADM" });
expect(r.data.given).toBe(1); expect(r.data.given).toBe(1);
expect(insertValues).toHaveBeenCalled();
expect(rcon.giveBadge).toHaveBeenCalledWith(1, "ADM");
}); });
}); });
+67 -51
View File
@@ -1,10 +1,17 @@
"use server"; "use server";
import { eq, sql } from "drizzle-orm"; import { and, eq, inArray, max, sql } from "drizzle-orm";
import { requirePermission } from "@/lib/admin/guard"; import { requirePermission } from "@/lib/admin/guard";
import { db, Sanctions, User, UsersSettings } from "@/lib/db"; import {
Ban,
db,
Sanctions,
User,
UsersBadges,
UsersCurrency,
UsersSettings,
} 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 { 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";
@@ -15,18 +22,19 @@ export async function bulkUnban({
userIds: number[]; userIds: number[];
}): Promise<ActionResult<{ unbanned: number; total: number }>> { }): Promise<ActionResult<{ unbanned: number; total: number }>> {
const staff = await requirePermission(PERMS.USERS_EDIT); const staff = await requirePermission(PERMS.USERS_EDIT);
const result = await prisma.ban.deleteMany({ const result = await db.delete(Ban).where(inArray(Ban.userId, userIds));
where: { userId: { in: userIds } }, const unbanned = Number(
}); (result as unknown as [{ affectedRows: number }])[0]?.affectedRows ?? 0,
);
await logStaffActivity({ await logStaffActivity({
staffId: staff.id, staffId: staff.id,
action: "bulk_unban", action: "bulk_unban",
description: `Unbanned ${result.count} user(s)`, description: `Unbanned ${unbanned} user(s)`,
targetType: "user", targetType: "user",
}); });
return { return {
ok: true as const, ok: true as const,
data: { unbanned: result.count, total: userIds.length }, data: { unbanned, total: userIds.length },
}; };
} }
@@ -45,8 +53,7 @@ export async function bulkBan({
for (const userId of userIds) { for (const userId of userIds) {
try { try {
await prisma.ban.create({ await db.insert(Ban).values({
data: {
userId, userId,
ip: "", ip: "",
machineId: "", machineId: "",
@@ -55,7 +62,6 @@ export async function bulkBan({
banExpire: duration > 0 ? now + duration : 0, banExpire: duration > 0 ? now + duration : 0,
banReason: reason, banReason: reason,
type: "account", type: "account",
},
}); });
banned++; banned++;
} catch { } catch {
@@ -94,23 +100,25 @@ export async function bulkGiveCurrency({
for (const userId of userIds) { for (const userId of userIds) {
try { try {
if (type === "credits") { if (type === "credits") {
await prisma.user.update({ await db
where: { id: userId }, .update(User)
data: { credits: { increment: amount } }, .set({ credits: sql`${User.credits} + ${amount}` })
}); .where(eq(User.id, userId));
await rcon.giveCredits(userId, amount); await rcon.giveCredits(userId, amount);
} else if (type === "pixels") { } else if (type === "pixels") {
await prisma.usersCurrency.upsert({ await db
where: { userId_type: { userId, type: 0 } }, .insert(UsersCurrency)
update: { amount: { increment: amount } }, .values({ userId, type: 0, amount })
create: { userId, type: 0, amount }, .onDuplicateKeyUpdate({
set: { amount: sql`${UsersCurrency.amount} + ${amount}` },
}); });
await rcon.giveDuckets(userId, amount); await rcon.giveDuckets(userId, amount);
} else if (type === "points") { } else if (type === "points") {
await prisma.usersCurrency.upsert({ await db
where: { userId_type: { userId, type: 101 } }, .insert(UsersCurrency)
update: { amount: { increment: amount } }, .values({ userId, type: 101, amount })
create: { userId, type: 101, amount }, .onDuplicateKeyUpdate({
set: { amount: sql`${UsersCurrency.amount} + ${amount}` },
}); });
await rcon.givePointsGotw(userId, amount); await rcon.givePointsGotw(userId, amount);
} }
@@ -151,19 +159,23 @@ export async function bulkGiveBadge({
for (const userId of userIds) { for (const userId of userIds) {
try { try {
const existing = await prisma.usersBadges.findFirst({ const [existing] = await db
where: { userId, badgeCode }, .select({ id: UsersBadges.id })
select: { id: true }, .from(UsersBadges)
}); .where(
and(
eq(UsersBadges.userId, userId),
eq(UsersBadges.badgeCode, badgeCode),
),
)
.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 });
data: { userId, slotId, badgeCode },
});
await rcon.giveBadge(userId, badgeCode); await rcon.giveBadge(userId, badgeCode);
} }
given++; given++;
@@ -228,31 +240,35 @@ export async function bulkAdjustCurrency({
for (const userId of userIds) { for (const userId of userIds) {
try { try {
if (type === "credits") { if (type === "credits") {
const user = await prisma.user.findUnique({ const [user] = await db
where: { id: userId }, .select({ credits: User.credits })
select: { credits: true }, .from(User)
}); .where(eq(User.id, userId))
.limit(1);
if (!user) { if (!user) {
failedIds.push({ userId, reason: "Not found" }); failedIds.push({ userId, reason: "Not found" });
continue; continue;
} }
const next = Math.max(0, user.credits - take); const next = Math.max(0, user.credits - take);
await prisma.user.update({ await db.update(User).set({ credits: next }).where(eq(User.id, userId));
where: { id: userId },
data: { credits: next },
});
} else { } else {
const currencyType = type === "pixels" ? 0 : 101; const currencyType = type === "pixels" ? 0 : 101;
const row = await prisma.usersCurrency.findUnique({ const [row] = await db
where: { userId_type: { userId, type: currencyType } }, .select({ amount: UsersCurrency.amount })
}); .from(UsersCurrency)
.where(
and(
eq(UsersCurrency.userId, userId),
eq(UsersCurrency.type, currencyType),
),
)
.limit(1);
const current = row?.amount ?? 0; const current = row?.amount ?? 0;
const next = Math.max(0, current - take); const next = Math.max(0, current - take);
await prisma.usersCurrency.upsert({ await db
where: { userId_type: { userId, type: currencyType } }, .insert(UsersCurrency)
update: { amount: next }, .values({ userId, type: currencyType, amount: next })
create: { userId, type: currencyType, amount: next }, .onDuplicateKeyUpdate({ set: { amount: next } });
});
} }
adjusted++; adjusted++;
} catch { } catch {
+74 -65
View File
@@ -1,9 +1,10 @@
"use server"; "use server";
import { 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 { CatalogItemsBc, CatalogPagesBc, db } 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";
@@ -55,10 +56,10 @@ export async function updateBcPage({
if (Object.keys(data).length === 0) { if (Object.keys(data).length === 0) {
return { ok: false as const, error: "No valid fields to update" }; return { ok: false as const, error: "No valid fields to update" };
} }
await prisma.catalogPagesBc.update({ await db
where: { id }, .update(CatalogPagesBc)
data: data as any, .set(data as Partial<typeof CatalogPagesBc.$inferInsert>)
}); .where(eq(CatalogPagesBc.id, id));
await rcon.updateCatalog(); await rcon.updateCatalog();
await logStaffActivity({ await logStaffActivity({
staffId: staff.id, staffId: staff.id,
@@ -73,7 +74,7 @@ export async function updateBcPage({
export async function deleteBcItem({ id }: { id: number }) { export async function deleteBcItem({ id }: { id: number }) {
const staff = await requirePermission(PERMS.CATALOG_EDIT); const staff = await requirePermission(PERMS.CATALOG_EDIT);
await prisma.catalogItemsBc.delete({ where: { id } }); await db.delete(CatalogItemsBc).where(eq(CatalogItemsBc.id, id));
await rcon.updateCatalog(); await rcon.updateCatalog();
await logStaffActivity({ await logStaffActivity({
staffId: staff.id, staffId: staff.id,
@@ -101,10 +102,10 @@ export async function updateBcItem({
if (Object.keys(safe).length === 0) { if (Object.keys(safe).length === 0) {
return { ok: false as const, error: "No valid fields to update" }; return { ok: false as const, error: "No valid fields to update" };
} }
await prisma.catalogItemsBc.update({ await db
where: { id }, .update(CatalogItemsBc)
data: safe as any, .set(safe as Partial<typeof CatalogItemsBc.$inferInsert>)
}); .where(eq(CatalogItemsBc.id, id));
await rcon.updateCatalog(); await rcon.updateCatalog();
await logStaffActivity({ await logStaffActivity({
staffId: staff.id, staffId: staff.id,
@@ -128,19 +129,18 @@ export async function createBcItem({
extradata: string; extradata: string;
}) { }) {
const staff = await requirePermission(PERMS.CATALOG_EDIT); const staff = await requirePermission(PERMS.CATALOG_EDIT);
const created = await prisma.catalogItemsBc.create({ const [result] = await db.insert(CatalogItemsBc).values({ pageId, ...data });
data: { pageId, ...data }, const createdId = Number(result.insertId);
});
await rcon.updateCatalog(); await rcon.updateCatalog();
await logStaffActivity({ await logStaffActivity({
staffId: staff.id, staffId: staff.id,
action: "bc_item_create", action: "bc_item_create",
description: `Created BC catalog item #${created.id}`, description: `Created BC catalog item #${createdId}`,
targetType: "catalog_item_bc", targetType: "catalog_item_bc",
targetId: created.id, targetId: createdId,
}); });
revalidatePath("/admin/catalog/builder-club"); revalidatePath("/admin/catalog/builder-club");
return { ok: true as const, data: { id: created.id } }; return { ok: true as const, data: { id: createdId } };
} }
export async function toggleBcPage({ export async function toggleBcPage({
@@ -151,15 +151,19 @@ export async function toggleBcPage({
field: "enabled" | "visible"; field: "enabled" | "visible";
}) { }) {
await requirePermission(PERMS.CATALOG_EDIT); await requirePermission(PERMS.CATALOG_EDIT);
const page = await prisma.catalogPagesBc.findUnique({ const [page] = await db
where: { id }, .select({
select: { enabled: true, visible: true }, enabled: CatalogPagesBc.enabled,
}); visible: CatalogPagesBc.visible,
})
.from(CatalogPagesBc)
.where(eq(CatalogPagesBc.id, id))
.limit(1);
if (!page) return { ok: false as const, error: "Page not found" }; if (!page) return { ok: false as const, error: "Page not found" };
await prisma.catalogPagesBc.update({ await db
where: { id }, .update(CatalogPagesBc)
data: { [field]: page[field] === "1" ? "0" : "1" }, .set({ [field]: page[field] === "1" ? "0" : "1" })
}); .where(eq(CatalogPagesBc.id, id));
await rcon.updateCatalog(); await rcon.updateCatalog();
revalidatePath("/admin/catalog/builder-club"); revalidatePath("/admin/catalog/builder-club");
return { ok: true as const }; return { ok: true as const };
@@ -176,8 +180,7 @@ export async function createBcPage(input: {
orderNum?: number; orderNum?: number;
}) { }) {
const staff = await requirePermission(PERMS.CATALOG_EDIT); const staff = await requirePermission(PERMS.CATALOG_EDIT);
const created = await prisma.catalogPagesBc.create({ const [result] = await db.insert(CatalogPagesBc).values({
data: {
caption: input.caption, caption: input.caption,
parentId: input.parentId, parentId: input.parentId,
pageLayout: input.pageLayout ?? "default_3x3", pageLayout: input.pageLayout ?? "default_3x3",
@@ -188,19 +191,19 @@ export async function createBcPage(input: {
enabled: input.enabled ?? "1", enabled: input.enabled ?? "1",
pageHeadline: "", pageHeadline: "",
pageTeaser: "", pageTeaser: "",
},
}); });
const createdId = Number(result.insertId);
await rcon.updateCatalog(); await rcon.updateCatalog();
await logStaffActivity({ await logStaffActivity({
staffId: staff.id, staffId: staff.id,
action: "bc_page_create", action: "bc_page_create",
description: `Created BC catalog page "${input.caption}"`, description: `Created BC catalog page "${input.caption}"`,
targetType: "catalog_page_bc", targetType: "catalog_page_bc",
targetId: created.id, targetId: createdId,
}); });
revalidatePath("/admin/catalog"); revalidatePath("/admin/catalog");
revalidatePath("/admin/catalog/builder-club"); revalidatePath("/admin/catalog/builder-club");
return { ok: true as const, data: { id: created.id } }; return { ok: true as const, data: { id: createdId } };
} }
async function moveBcPage(pageId: number, newParentId: number): Promise<void> { async function moveBcPage(pageId: number, newParentId: number): Promise<void> {
@@ -210,18 +213,19 @@ async function moveBcPage(pageId: number, newParentId: number): Promise<void> {
if (currentId === pageId) { if (currentId === pageId) {
throw new Error("Cannot move page: would create a circular hierarchy"); throw new Error("Cannot move page: would create a circular hierarchy");
} }
const parent = await prisma.catalogPagesBc.findUnique({ const [parent] = await db
where: { id: currentId }, .select({ parentId: CatalogPagesBc.parentId })
select: { parentId: true }, .from(CatalogPagesBc)
}); .where(eq(CatalogPagesBc.id, currentId))
.limit(1);
if (!parent || parent.parentId <= 0) break; if (!parent || parent.parentId <= 0) break;
currentId = parent.parentId; currentId = parent.parentId;
} }
} }
await prisma.catalogPagesBc.update({ await db
where: { id: pageId }, .update(CatalogPagesBc)
data: { parentId: newParentId }, .set({ parentId: newParentId })
}); .where(eq(CatalogPagesBc.id, pageId));
} }
export async function reorderBcTreePage(input: { export async function reorderBcTreePage(input: {
@@ -240,10 +244,10 @@ export async function reorderBcTreePage(input: {
}; };
} }
} }
await prisma.catalogPagesBc.update({ await db
where: { id: input.pageId }, .update(CatalogPagesBc)
data: { orderNum: input.newOrderNum }, .set({ orderNum: input.newOrderNum })
}); .where(eq(CatalogPagesBc.id, input.pageId));
await rcon.updateCatalog(); await rcon.updateCatalog();
revalidatePath("/admin/catalog"); revalidatePath("/admin/catalog");
revalidatePath("/admin/catalog/builder-club"); revalidatePath("/admin/catalog/builder-club");
@@ -255,43 +259,48 @@ export async function deleteBcTreePage(input: {
mode: "reparent" | "cascade"; mode: "reparent" | "cascade";
}) { }) {
await requirePermission(PERMS.CATALOG_EDIT); await requirePermission(PERMS.CATALOG_EDIT);
const page = await prisma.catalogPagesBc.findUnique({ const [page] = await db
where: { id: input.pageId }, .select({ parentId: CatalogPagesBc.parentId })
select: { parentId: true }, .from(CatalogPagesBc)
}); .where(eq(CatalogPagesBc.id, input.pageId))
.limit(1);
if (!page) return { ok: false as const, error: "Page not found" }; if (!page) return { ok: false as const, error: "Page not found" };
if (input.mode === "reparent") { if (input.mode === "reparent") {
await prisma.$transaction([ await db.transaction(async (tx) => {
prisma.catalogPagesBc.updateMany({ await tx
where: { parentId: input.pageId }, .update(CatalogPagesBc)
data: { parentId: page.parentId }, .set({ parentId: page.parentId })
}), .where(eq(CatalogPagesBc.parentId, input.pageId));
prisma.catalogItemsBc.deleteMany({ where: { pageId: input.pageId } }), await tx
prisma.catalogPagesBc.delete({ where: { id: input.pageId } }), .delete(CatalogItemsBc)
]); .where(eq(CatalogItemsBc.pageId, input.pageId));
await tx
.delete(CatalogPagesBc)
.where(eq(CatalogPagesBc.id, input.pageId));
});
} else { } else {
const toDelete: number[] = [input.pageId]; const toDelete: number[] = [input.pageId];
const queue: number[] = [input.pageId]; const queue: number[] = [input.pageId];
while (queue.length > 0) { while (queue.length > 0) {
const children = await prisma.catalogPagesBc.findMany({ const children = await db
where: { parentId: { in: queue } }, .select({ id: CatalogPagesBc.id })
select: { id: true }, .from(CatalogPagesBc)
}); .where(inArray(CatalogPagesBc.parentId, queue));
queue.length = 0; queue.length = 0;
for (const child of children) { for (const child of children) {
toDelete.push(child.id); toDelete.push(child.id);
queue.push(child.id); queue.push(child.id);
} }
} }
await prisma.$transaction([ await db.transaction(async (tx) => {
prisma.catalogItemsBc.deleteMany({ await tx
where: { pageId: { in: toDelete } }, .delete(CatalogItemsBc)
}), .where(inArray(CatalogItemsBc.pageId, toDelete));
prisma.catalogPagesBc.deleteMany({ await tx
where: { id: { in: toDelete } }, .delete(CatalogPagesBc)
}), .where(inArray(CatalogPagesBc.id, toDelete));
]); });
} }
await rcon.updateCatalog(); await rcon.updateCatalog();
+74 -56
View File
@@ -1,10 +1,10 @@
"use server"; "use server";
import { sql } from "drizzle-orm"; import { eq, inArray, like, or, sql } 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 { CatalogItems, db, ItemsBase } from "@/lib/db";
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 { allocateCatalogItemId } from "@/lib/services/furni-import"; import { allocateCatalogItemId } from "@/lib/services/furni-import";
import { rcon } from "@/lib/services/rcon"; import { rcon } from "@/lib/services/rcon";
@@ -87,7 +87,7 @@ async function insertCatalogItemRow(data: {
}): Promise<number> { }): Promise<number> {
const pageIdStr = String(data.pageId); const pageIdStr = String(data.pageId);
return allocateCatalogItemId(async (nextId) => { return allocateCatalogItemId(async (nextId) => {
await prisma.$executeRaw` await db.execute(sql`
INSERT INTO catalog_items ( INSERT INTO catalog_items (
id, page_id, item_ids, catalog_name, id, page_id, item_ids, catalog_name,
cost_credits, cost_points, points_type, amount, cost_credits, cost_points, points_type, amount,
@@ -100,7 +100,7 @@ async function insertCatalogItemRow(data: {
${data.limitedSells}, ${data.limitedStack}, ${data.extradata}, ${data.limitedSells}, ${data.limitedStack}, ${data.extradata},
${data.haveOffer}, ${data.clubOnly} ${data.haveOffer}, ${data.clubOnly}
) )
`; `);
return nextId; return nextId;
}); });
} }
@@ -127,10 +127,14 @@ export async function createCatalogItem(data: {
if (!catalogName) { if (!catalogName) {
const firstId = Number.parseInt(data.itemIds.split(";")[0] || "", 10); const firstId = Number.parseInt(data.itemIds.split(";")[0] || "", 10);
if (firstId > 0) { if (firstId > 0) {
const base = await prisma.itemsBase.findUnique({ const [base] = await db
where: { id: firstId }, .select({
select: { publicName: true, itemName: true }, publicName: ItemsBase.publicName,
}); itemName: ItemsBase.itemName,
})
.from(ItemsBase)
.where(eq(ItemsBase.id, firstId))
.limit(1);
catalogName = base?.publicName || base?.itemName || String(firstId); catalogName = base?.publicName || base?.itemName || String(firstId);
} }
} }
@@ -169,10 +173,14 @@ export async function bulkCreateCatalogItems({
} }
const baseIds = [...new Set(rows.map((r) => r.baseId))]; const baseIds = [...new Set(rows.map((r) => r.baseId))];
const bases = await prisma.itemsBase.findMany({ const bases = await db
where: { id: { in: baseIds } }, .select({
select: { id: true, publicName: true, itemName: true }, id: ItemsBase.id,
}); publicName: ItemsBase.publicName,
itemName: ItemsBase.itemName,
})
.from(ItemsBase)
.where(inArray(ItemsBase.id, baseIds));
const baseMap = new Map(bases.map((b) => [b.id, b])); const baseMap = new Map(bases.map((b) => [b.id, b]));
let created = 0; let created = 0;
@@ -225,7 +233,7 @@ export async function bulkCreateCatalogItems({
export async function deleteCatalogItems({ ids }: { ids: number[] }) { export async function deleteCatalogItems({ ids }: { ids: number[] }) {
const staff = await requirePermission(PERMS.CATALOG_EDIT); const staff = await requirePermission(PERMS.CATALOG_EDIT);
await prisma.catalogItems.deleteMany({ where: { id: { in: ids } } }); await db.delete(CatalogItems).where(inArray(CatalogItems.id, ids));
await rcon.updateCatalog(); await rcon.updateCatalog();
await logStaffActivity({ await logStaffActivity({
staffId: staff.id, staffId: staff.id,
@@ -249,11 +257,14 @@ export async function moveCatalogItems({
return { ok: true as const, data: {} }; return { ok: true as const, data: {} };
} }
const pageIdStr = String(targetPageId); const pageIdStr = String(targetPageId);
await prisma.$executeRaw` await db.execute(sql`
UPDATE catalog_items UPDATE catalog_items
SET page_id = ${pageIdStr} SET page_id = ${pageIdStr}
WHERE id IN (${sql.join(ids, sql`, `)}) WHERE id IN (${sql.join(
`; ids.map((id) => sql`${id}`),
sql`, `,
)})
`);
await rcon.updateCatalog(); await rcon.updateCatalog();
revalidatePath("/admin/catalog"); revalidatePath("/admin/catalog");
return { ok: true as const, data: {} }; return { ok: true as const, data: {} };
@@ -266,7 +277,10 @@ export async function reorderCatalogItems({
}) { }) {
await requirePermission(PERMS.CATALOG_EDIT); await requirePermission(PERMS.CATALOG_EDIT);
for (const { id, orderNumber } of orders) { for (const { id, orderNumber } of orders) {
await prisma.catalogItems.update({ where: { id }, data: { orderNumber } }); await db
.update(CatalogItems)
.set({ orderNumber })
.where(eq(CatalogItems.id, id));
} }
await rcon.updateCatalog(); await rcon.updateCatalog();
revalidatePath("/admin/catalog"); revalidatePath("/admin/catalog");
@@ -292,25 +306,25 @@ export async function updateCatalogItem({
const pageIdRaw = safeCatalog.pageId; const pageIdRaw = safeCatalog.pageId;
if (pageIdRaw !== undefined) { if (pageIdRaw !== undefined) {
const pageIdStr = String(pageIdRaw); const pageIdStr = String(pageIdRaw);
await prisma.$executeRaw` await db.execute(sql`
UPDATE catalog_items SET page_id = ${pageIdStr} WHERE id = ${id} UPDATE catalog_items SET page_id = ${pageIdStr} WHERE id = ${id}
`; `);
delete safeCatalog.pageId; delete safeCatalog.pageId;
} }
if (Object.keys(safeCatalog).length > 0) { if (Object.keys(safeCatalog).length > 0) {
await prisma.catalogItems.update({ await db
where: { id }, .update(CatalogItems)
data: safeCatalog as any, .set(safeCatalog as Partial<typeof CatalogItems.$inferInsert>)
}); .where(eq(CatalogItems.id, id));
} }
if (baseItem) { if (baseItem) {
const safeBase = pickAllowed(baseItem.fields, ITEMS_BASE_FIELDS); const safeBase = pickAllowed(baseItem.fields, ITEMS_BASE_FIELDS);
if (Object.keys(safeBase).length > 0) { if (Object.keys(safeBase).length > 0) {
await prisma.itemsBase.update({ await db
where: { id: baseItem.id }, .update(ItemsBase)
data: safeBase as any, .set(safeBase as Partial<typeof ItemsBase.$inferInsert>)
}); .where(eq(ItemsBase.id, baseItem.id));
} }
} }
await rcon.updateCatalog(); await rcon.updateCatalog();
@@ -355,16 +369,17 @@ export async function translateCatalogItems(input: {
}> = []; }> = [];
for (const item of items) { for (const item of items) {
const base = await prisma.itemsBase.findUnique({ const [base] = await db
where: { id: item.id }, .select({
select: { id: ItemsBase.id,
id: true, publicName: ItemsBase.publicName,
publicName: true, itemName: ItemsBase.itemName,
itemName: true, type: ItemsBase.type,
type: true, spriteId: ItemsBase.spriteId,
spriteId: true, })
}, .from(ItemsBase)
}); .where(eq(ItemsBase.id, item.id))
.limit(1);
if (!base) continue; if (!base) continue;
const nextName = item.publicName?.trim() ?? ""; const nextName = item.publicName?.trim() ?? "";
@@ -372,28 +387,31 @@ export async function translateCatalogItems(input: {
const nameChanged = nextName !== "" && nextName !== (base.publicName ?? ""); const nameChanged = nextName !== "" && nextName !== (base.publicName ?? "");
if (nameChanged) { if (nameChanged) {
await prisma.itemsBase.update({ await db
where: { id: base.id }, .update(ItemsBase)
data: { publicName: nextName }, .set({ publicName: nextName })
}); .where(eq(ItemsBase.id, base.id));
const idStr = String(base.id); const idStr = String(base.id);
const related = await prisma.catalogItems.findMany({ const related = await db
where: { .select({
OR: [ id: CatalogItems.id,
{ itemIds: idStr }, catalogName: CatalogItems.catalogName,
{ itemIds: { startsWith: `${idStr};` } }, })
{ itemIds: { contains: `;${idStr};` } }, .from(CatalogItems)
{ itemIds: { endsWith: `;${idStr}` } }, .where(
], or(
}, eq(CatalogItems.itemIds, idStr),
select: { id: true, catalogName: true }, like(CatalogItems.itemIds, `${idStr};%`),
}); like(CatalogItems.itemIds, `%;${idStr};%`),
like(CatalogItems.itemIds, `%;${idStr}`),
),
);
for (const row of related) { for (const row of related) {
if (row.catalogName !== nextName) { if (row.catalogName !== nextName) {
await prisma.catalogItems.update({ await db
where: { id: row.id }, .update(CatalogItems)
data: { catalogName: nextName }, .set({ catalogName: nextName })
}); .where(eq(CatalogItems.id, row.id));
} }
} }
namesUpdated++; namesUpdated++;
+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
@@ -1,9 +1,17 @@
"use server"; "use server";
import { and, count, eq } from "drizzle-orm";
import { revalidatePath } from "next/cache"; import { revalidatePath } from "next/cache";
import { z } from "zod"; import { z } from "zod";
import {
db,
WebsiteEvent,
WebsiteEventPrize,
WebsiteEventRegistration,
WebsiteEventType,
WebsiteEventWinner,
} from "@/lib/db";
import { PERMS } from "@/lib/permissions"; import { PERMS } from "@/lib/permissions";
import { prisma } from "@/lib/prisma";
import { adminAction, authAction } from "@/lib/safe-action"; import { adminAction, authAction } from "@/lib/safe-action";
import { ActionError, actionError, actionOk } from "@/lib/safe-action-shared"; import { ActionError, actionError, actionOk } from "@/lib/safe-action-shared";
import { logAudit } from "@/lib/services/audit"; import { logAudit } from "@/lib/services/audit";
@@ -21,17 +29,16 @@ import {
export const createEventType = adminAction( export const createEventType = adminAction(
{ permission: PERMS.EVENTS_EDIT, schema: eventTypeSchema }, { permission: PERMS.EVENTS_EDIT, schema: eventTypeSchema },
async (ctx) => { async (ctx) => {
const eventType = await prisma.websiteEventType.create({ const [result] = await db.insert(WebsiteEventType).values(ctx.data);
data: ctx.data, const eventTypeId = Number(result.insertId);
});
logAudit({ logAudit({
userId: ctx.session.user.id, userId: ctx.session.user.id,
action: "event_type_create", action: "event_type_create",
target: "WebsiteEventType", target: "WebsiteEventType",
targetId: eventType.id, targetId: eventTypeId,
after: { name: eventType.name }, after: { name: ctx.data.name },
}); });
return actionOk({ id: eventType.id }); return actionOk({ id: eventTypeId });
}, },
); );
@@ -43,12 +50,17 @@ export const updateEventType = adminAction(
{ permission: PERMS.EVENTS_EDIT, schema: updateEventTypeInput }, { permission: PERMS.EVENTS_EDIT, schema: updateEventTypeInput },
async (ctx) => { async (ctx) => {
const { id, ...data } = ctx.data; const { id, ...data } = ctx.data;
const existing = await prisma.websiteEventType.findUnique({ const [existing] = await db
where: { id }, .select({ id: WebsiteEventType.id, name: WebsiteEventType.name })
}); .from(WebsiteEventType)
.where(eq(WebsiteEventType.id, id))
.limit(1);
if (!existing) throw new ActionError("Event type not found"); if (!existing) throw new ActionError("Event type not found");
await prisma.websiteEventType.update({ where: { id }, data }); await db
.update(WebsiteEventType)
.set(data)
.where(eq(WebsiteEventType.id, id));
logAudit({ logAudit({
userId: ctx.session.user.id, userId: ctx.session.user.id,
action: "event_type_update", action: "event_type_update",
@@ -68,12 +80,16 @@ const deleteEventTypeInput = z.object({
export const deleteEventType = adminAction( export const deleteEventType = adminAction(
{ permission: PERMS.EVENTS_EDIT, schema: deleteEventTypeInput }, { permission: PERMS.EVENTS_EDIT, schema: deleteEventTypeInput },
async (ctx) => { async (ctx) => {
const existing = await prisma.websiteEventType.findUnique({ const [existing] = await db
where: { id: ctx.data.id }, .select({ id: WebsiteEventType.id, name: WebsiteEventType.name })
}); .from(WebsiteEventType)
.where(eq(WebsiteEventType.id, ctx.data.id))
.limit(1);
if (!existing) throw new ActionError("Event type not found"); if (!existing) throw new ActionError("Event type not found");
await prisma.websiteEventType.delete({ where: { id: ctx.data.id } }); await db
.delete(WebsiteEventType)
.where(eq(WebsiteEventType.id, ctx.data.id));
logAudit({ logAudit({
userId: ctx.session.user.id, userId: ctx.session.user.id,
action: "event_type_delete", action: "event_type_delete",
@@ -90,20 +106,21 @@ export const deleteEventType = adminAction(
export const createEvent = adminAction( export const createEvent = adminAction(
{ permission: PERMS.EVENTS_EDIT, schema: createEventSchema }, { permission: PERMS.EVENTS_EDIT, schema: createEventSchema },
async (ctx) => { async (ctx) => {
const event = await prisma.websiteEvent.create({ const now = new Date();
data: { const [result] = await db.insert(WebsiteEvent).values({
...ctx.data, ...ctx.data,
hostUserId: Number(ctx.session.user.id), hostUserId: Number(ctx.session.user.id),
}, updatedAt: now,
}); });
const eventId = Number(result.insertId);
logAudit({ logAudit({
userId: ctx.session.user.id, userId: ctx.session.user.id,
action: "event_create", action: "event_create",
target: "WebsiteEvent", target: "WebsiteEvent",
targetId: event.id, targetId: eventId,
after: { title: event.title }, after: { title: ctx.data.title },
}); });
return actionOk({ id: event.id }); return actionOk({ id: eventId });
}, },
); );
@@ -115,10 +132,21 @@ export const updateEvent = adminAction(
{ permission: PERMS.EVENTS_EDIT, schema: updateEventInput }, { permission: PERMS.EVENTS_EDIT, schema: updateEventInput },
async (ctx) => { async (ctx) => {
const { id, ...data } = ctx.data; const { id, ...data } = ctx.data;
const existing = await prisma.websiteEvent.findUnique({ where: { id } }); const [existing] = await db
.select({
id: WebsiteEvent.id,
title: WebsiteEvent.title,
status: WebsiteEvent.status,
})
.from(WebsiteEvent)
.where(eq(WebsiteEvent.id, id))
.limit(1);
if (!existing) throw new ActionError("Event not found"); if (!existing) throw new ActionError("Event not found");
await prisma.websiteEvent.update({ where: { id }, data }); await db
.update(WebsiteEvent)
.set({ ...data, updatedAt: new Date() })
.where(eq(WebsiteEvent.id, id));
logAudit({ logAudit({
userId: ctx.session.user.id, userId: ctx.session.user.id,
action: "event_update", action: "event_update",
@@ -138,12 +166,14 @@ const deleteEventInput = z.object({
export const deleteEvent = adminAction( export const deleteEvent = adminAction(
{ permission: PERMS.EVENTS_EDIT, schema: deleteEventInput }, { permission: PERMS.EVENTS_EDIT, schema: deleteEventInput },
async (ctx) => { async (ctx) => {
const existing = await prisma.websiteEvent.findUnique({ const [existing] = await db
where: { id: ctx.data.id }, .select({ id: WebsiteEvent.id, title: WebsiteEvent.title })
}); .from(WebsiteEvent)
.where(eq(WebsiteEvent.id, ctx.data.id))
.limit(1);
if (!existing) throw new ActionError("Event not found"); if (!existing) throw new ActionError("Event not found");
await prisma.websiteEvent.delete({ where: { id: ctx.data.id } }); await db.delete(WebsiteEvent).where(eq(WebsiteEvent.id, ctx.data.id));
logAudit({ logAudit({
userId: ctx.session.user.id, userId: ctx.session.user.id,
action: "event_delete", action: "event_delete",
@@ -160,8 +190,8 @@ export const deleteEvent = adminAction(
export const addEventPrize = adminAction( export const addEventPrize = adminAction(
{ permission: PERMS.EVENTS_EDIT, schema: eventPrizeSchema }, { permission: PERMS.EVENTS_EDIT, schema: eventPrizeSchema },
async (ctx) => { async (ctx) => {
const prize = await prisma.websiteEventPrize.create({ data: ctx.data }); const [result] = await db.insert(WebsiteEventPrize).values(ctx.data);
return actionOk({ id: prize.id }); return actionOk({ id: Number(result.insertId) });
}, },
); );
@@ -170,7 +200,9 @@ const deletePrizeInput = z.object({ id: z.coerce.number().int().positive() });
export const deleteEventPrize = adminAction( export const deleteEventPrize = adminAction(
{ permission: PERMS.EVENTS_EDIT, schema: deletePrizeInput }, { permission: PERMS.EVENTS_EDIT, schema: deletePrizeInput },
async (ctx) => { async (ctx) => {
await prisma.websiteEventPrize.delete({ where: { id: ctx.data.id } }); await db
.delete(WebsiteEventPrize)
.where(eq(WebsiteEventPrize.id, ctx.data.id));
return actionOk(); return actionOk();
}, },
); );
@@ -180,19 +212,20 @@ export const deleteEventPrize = adminAction(
export const addEventWinner = adminAction( export const addEventWinner = adminAction(
{ permission: PERMS.EVENTS_EDIT, schema: eventWinnerSchema }, { permission: PERMS.EVENTS_EDIT, schema: eventWinnerSchema },
async (ctx) => { async (ctx) => {
const winner = await prisma.websiteEventWinner.create({ data: ctx.data }); const [result] = await db.insert(WebsiteEventWinner).values(ctx.data);
const winnerId = Number(result.insertId);
logAudit({ logAudit({
userId: ctx.session.user.id, userId: ctx.session.user.id,
action: "event_winner_add", action: "event_winner_add",
target: "WebsiteEventWinner", target: "WebsiteEventWinner",
targetId: winner.id, targetId: winnerId,
after: { after: {
eventId: ctx.data.eventId, eventId: ctx.data.eventId,
userId: ctx.data.userId, userId: ctx.data.userId,
position: ctx.data.position, position: ctx.data.position,
}, },
}); });
return actionOk({ id: winner.id }); return actionOk({ id: winnerId });
}, },
); );
@@ -211,13 +244,18 @@ export const registerForEvent = authAction(
return actionError("Unauthorized"); return actionError("Unauthorized");
} }
const event = await prisma.websiteEvent.findUnique({ const [event] = await db
where: { id: ctx.data.eventId }, .select({
include: { id: WebsiteEvent.id,
type: true, status: WebsiteEvent.status,
_count: { select: { registrations: true } }, endsAt: WebsiteEvent.endsAt,
}, maxPlayers: WebsiteEvent.maxPlayers,
}); minRank: WebsiteEventType.minRank,
})
.from(WebsiteEvent)
.innerJoin(WebsiteEventType, eq(WebsiteEvent.typeId, WebsiteEventType.id))
.where(eq(WebsiteEvent.id, ctx.data.eventId))
.limit(1);
if (!event) return actionError("Event not found"); if (!event) return actionError("Event not found");
if (event.status !== "published") { if (event.status !== "published") {
@@ -226,28 +264,37 @@ export const registerForEvent = authAction(
if (event.endsAt && event.endsAt.getTime() < Date.now()) { if (event.endsAt && event.endsAt.getTime() < Date.now()) {
return actionError("This event has already ended"); return actionError("This event has already ended");
} }
if (event.type.minRank > 0) { if (event.minRank > 0) {
const rank = Number(ctx.session.user.rank ?? 0); const rank = Number(ctx.session.user.rank ?? 0);
if (rank < event.type.minRank) { if (rank < event.minRank) {
return actionError("Your rank is too low to join this event"); return actionError("Your rank is too low to join this event");
} }
} }
if ( if (event.maxPlayers != null) {
event.maxPlayers != null && const [regCount] = await db
event._count.registrations >= event.maxPlayers .select({ value: count() })
) { .from(WebsiteEventRegistration)
.where(eq(WebsiteEventRegistration.eventId, event.id));
if ((regCount?.value ?? 0) >= event.maxPlayers) {
return actionError("This event is full"); return actionError("This event is full");
} }
}
const existing = await prisma.websiteEventRegistration.findUnique({ const [existing] = await db
where: { .select({ id: WebsiteEventRegistration.id })
eventId_userId: { eventId: event.id, userId }, .from(WebsiteEventRegistration)
}, .where(
}); and(
eq(WebsiteEventRegistration.eventId, event.id),
eq(WebsiteEventRegistration.userId, userId),
),
)
.limit(1);
if (existing) return actionError("You are already registered"); if (existing) return actionError("You are already registered");
await prisma.websiteEventRegistration.create({ await db.insert(WebsiteEventRegistration).values({
data: { eventId: event.id, userId }, eventId: event.id,
userId,
}); });
revalidatePath("/events"); revalidatePath("/events");
+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";
} }
+72 -55
View File
@@ -1,11 +1,17 @@
"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 { z } from "zod"; import { z } from "zod";
import { positiveBigInt } from "@/lib/api"; import { positiveBigInt } from "@/lib/api";
import { auth } from "@/lib/auth"; import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma"; import {
db,
WebsiteHelpCenterCategories,
WebsiteHelpCenterTicketReplies,
WebsiteHelpCenterTickets,
} from "@/lib/db";
import { clientIp, rateLimit } from "@/lib/rate-limit"; import { clientIp, rateLimit } from "@/lib/rate-limit";
import { moderateOrThrow } from "@/lib/services/moderation"; import { moderateOrThrow } from "@/lib/services/moderation";
import { createOwnedTicketReply } from "@/lib/services/ticket-replies"; import { createOwnedTicketReply } from "@/lib/services/ticket-replies";
@@ -109,34 +115,30 @@ export async function createTicket(formData: FormData): Promise<void> {
if (!/ban\s*appeal/i.test(ticketTitle)) { if (!/ban\s*appeal/i.test(ticketTitle)) {
ticketTitle = `[Ban appeal] ${ticketTitle}`.slice(0, 255); ticketTitle = `[Ban appeal] ${ticketTitle}`.slice(0, 255);
} }
const existing = await prisma.websiteHelpCenterCategories.findFirst( const [existing] = await db
{ .select({ id: WebsiteHelpCenterCategories.id })
where: { name: { equals: "Ban appeal" } }, .from(WebsiteHelpCenterCategories)
select: { id: true }, .where(eq(WebsiteHelpCenterCategories.name, "Ban appeal"))
}, .limit(1);
);
if (existing) { if (existing) {
categoryId = existing.id; categoryId = existing.id;
} else { } else {
try { try {
const created = await prisma.websiteHelpCenterCategories.create( const [created] = await db
{ .insert(WebsiteHelpCenterCategories)
data: { .values({
name: "Ban appeal", name: "Ban appeal",
content: content:
"Appeals for account bans. Staff can lift bans from the ticket.", "Appeals for account bans. Staff can lift bans from the ticket.",
position: 0, position: 0,
},
select: { id: true },
},
);
categoryId = created.id;
} catch {
const again =
await prisma.websiteHelpCenterCategories.findFirst({
where: { name: { equals: "Ban appeal" } },
select: { id: true },
}); });
categoryId = BigInt(created.insertId);
} catch {
const [again] = await db
.select({ id: WebsiteHelpCenterCategories.id })
.from(WebsiteHelpCenterCategories)
.where(eq(WebsiteHelpCenterCategories.name, "Ban appeal"))
.limit(1);
categoryId = again?.id ?? null; categoryId = again?.id ?? null;
} }
} }
@@ -148,8 +150,7 @@ export async function createTicket(formData: FormData): Promise<void> {
} }
const now = new Date(); const now = new Date();
await prisma.websiteHelpCenterTickets.create({ await db.insert(WebsiteHelpCenterTickets).values({
data: {
userId, userId,
title: ticketTitle, title: ticketTitle,
content, content,
@@ -157,7 +158,6 @@ export async function createTicket(formData: FormData): Promise<void> {
open: true, open: true,
createdAt: now, createdAt: now,
updatedAt: now, updatedAt: now,
},
}); });
outcome = "created"; outcome = "created";
} }
@@ -204,10 +204,15 @@ export async function replyHelpTicket(formData: FormData): Promise<void> {
if (!parsed.success) { if (!parsed.success) {
outcome = "invalid"; outcome = "invalid";
} else { } else {
const ticket = await prisma.websiteHelpCenterTickets.findUnique({ const [ticket] = await db
where: { id: ticketId }, .select({
select: { id: true, userId: true, open: true }, id: WebsiteHelpCenterTickets.id,
}); userId: WebsiteHelpCenterTickets.userId,
open: WebsiteHelpCenterTickets.open,
})
.from(WebsiteHelpCenterTickets)
.where(eq(WebsiteHelpCenterTickets.id, ticketId))
.limit(1);
if (!ticket || ticket.userId !== userId) { if (!ticket || ticket.userId !== userId) {
outcome = "not_found"; outcome = "not_found";
@@ -223,30 +228,37 @@ export async function replyHelpTicket(formData: FormData): Promise<void> {
} }
if (!moderated) { if (!moderated) {
const created = await prisma.$transaction((tx) => const created = await db.transaction(async (tx) =>
createOwnedTicketReply( createOwnedTicketReply(
{ {
findTicket: (id) => findTicket: async (id) => {
tx.websiteHelpCenterTickets.findUnique({ const [row] = await tx
where: { id }, .select({
select: { id: true, userId: true, open: true }, id: WebsiteHelpCenterTickets.id,
}), userId: WebsiteHelpCenterTickets.userId,
createReply: (data) => open: WebsiteHelpCenterTickets.open,
tx.websiteHelpCenterTicketReplies.create({ })
data, .from(WebsiteHelpCenterTickets)
select: { .where(eq(WebsiteHelpCenterTickets.id, id))
id: true, .limit(1);
userId: true, return row ?? null;
content: true, },
createdAt: true, createReply: async (data) => {
const [result] = await tx
.insert(WebsiteHelpCenterTicketReplies)
.values(data);
return {
id: BigInt(result.insertId),
userId: data.userId,
content: data.content,
createdAt: data.createdAt,
};
}, },
}),
touchTicket: (id, updatedAt) => touchTicket: (id, updatedAt) =>
tx.websiteHelpCenterTickets.update({ tx
where: { id }, .update(WebsiteHelpCenterTickets)
data: { updatedAt }, .set({ updatedAt })
select: { id: true }, .where(eq(WebsiteHelpCenterTickets.id, id)),
}),
}, },
{ {
ticketId, ticketId,
@@ -289,10 +301,15 @@ export async function closeHelpTicket(formData: FormData): Promise<void> {
if (!(await rateLimit(`ticket-close:${userId}`, 10, 60_000)).ok) { if (!(await rateLimit(`ticket-close:${userId}`, 10, 60_000)).ok) {
outcome = "ratelimit"; outcome = "ratelimit";
} else { } else {
const ticket = await prisma.websiteHelpCenterTickets.findUnique({ const [ticket] = await db
where: { id: ticketId }, .select({
select: { id: true, userId: true, open: true }, id: WebsiteHelpCenterTickets.id,
}); userId: WebsiteHelpCenterTickets.userId,
open: WebsiteHelpCenterTickets.open,
})
.from(WebsiteHelpCenterTickets)
.where(eq(WebsiteHelpCenterTickets.id, ticketId))
.limit(1);
if (!ticket || ticket.userId !== userId) { if (!ticket || ticket.userId !== userId) {
outcome = "not_found"; outcome = "not_found";
@@ -300,10 +317,10 @@ export async function closeHelpTicket(formData: FormData): Promise<void> {
outcome = "closed_ticket"; outcome = "closed_ticket";
} else { } else {
const now = new Date(); const now = new Date();
await prisma.websiteHelpCenterTickets.update({ await db
where: { id: ticketId }, .update(WebsiteHelpCenterTickets)
data: { open: false, updatedAt: now }, .set({ open: false, updatedAt: now })
}); .where(eq(WebsiteHelpCenterTickets.id, ticketId));
outcome = "closed"; outcome = "closed";
} }
} }
+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
@@ -1,9 +1,16 @@
"use server"; "use server";
import { and, eq, or } 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,
MessengerFriendrequests,
MessengerFriendships,
MessengerOffline,
User,
} from "@/lib/db";
import { clientIp, rateLimit } from "@/lib/rate-limit"; import { clientIp, rateLimit } from "@/lib/rate-limit";
type FriendOutcome = type FriendOutcome =
@@ -76,10 +83,15 @@ export async function acceptFriend(formData: FormData): Promise<void> {
outcome = "invalid"; outcome = "invalid";
} else { } else {
// The request must exist AND be addressed to the session user. // The request must exist AND be addressed to the session user.
const request = await prisma.messengerFriendrequests.findUnique({ const [request] = await db
where: { id: requestId }, .select({
select: { id: true, userFromId: true, userToId: true }, id: MessengerFriendrequests.id,
}); userFromId: MessengerFriendrequests.userFromId,
userToId: MessengerFriendrequests.userToId,
})
.from(MessengerFriendrequests)
.where(eq(MessengerFriendrequests.id, requestId))
.limit(1);
if (!request) { if (!request) {
outcome = "not_found"; outcome = "not_found";
} else if (request.userToId !== meId) { } else if (request.userToId !== meId) {
@@ -92,44 +104,55 @@ export async function acceptFriend(formData: FormData): Promise<void> {
friendId === meId friendId === meId
) { ) {
// Malformed/self request — clear it and treat as not found. // Malformed/self request — clear it and treat as not found.
await prisma.messengerFriendrequests.delete({ await db
where: { id: requestId }, .delete(MessengerFriendrequests)
}); .where(eq(MessengerFriendrequests.id, requestId));
outcome = "not_found"; outcome = "not_found";
} else { } else {
const friendsSince = Math.floor(Date.now() / 1000); const friendsSince = Math.floor(Date.now() / 1000);
await prisma.$transaction(async (tx) => { await db.transaction(async (tx) => {
// Don't double-insert if a friendship already exists in either direction. // Don't double-insert if a friendship already exists in either direction.
const existing = await tx.messengerFriendships.findFirst({ const [existing] = await tx
where: { .select({ id: MessengerFriendships.id })
OR: [ .from(MessengerFriendships)
{ userOneId: meId, userTwoId: friendId }, .where(
{ userOneId: friendId, userTwoId: meId }, or(
], and(
}, eq(MessengerFriendships.userOneId, meId),
select: { id: true }, eq(MessengerFriendships.userTwoId, friendId),
}); ),
and(
eq(MessengerFriendships.userOneId, friendId),
eq(MessengerFriendships.userTwoId, meId),
),
),
)
.limit(1);
if (!existing) { if (!existing) {
await tx.messengerFriendships.createMany({ await tx.insert(MessengerFriendships).values([
data: [
{ userOneId: meId, userTwoId: friendId, friendsSince }, { userOneId: meId, userTwoId: friendId, friendsSince },
{ userOneId: friendId, userTwoId: meId, friendsSince }, { userOneId: friendId, userTwoId: meId, friendsSince },
], ]);
});
} }
// Clear this request and any reverse pending request between the pair. // Clear this request and any reverse pending request between the pair.
await tx.messengerFriendrequests.deleteMany({ await tx
where: { .delete(MessengerFriendrequests)
OR: [ .where(
{ id: requestId }, or(
{ userFromId: meId, userToId: friendId }, eq(MessengerFriendrequests.id, requestId),
{ userFromId: friendId, userToId: meId }, and(
], eq(MessengerFriendrequests.userFromId, meId),
}, eq(MessengerFriendrequests.userToId, friendId),
}); ),
and(
eq(MessengerFriendrequests.userFromId, friendId),
eq(MessengerFriendrequests.userToId, meId),
),
),
);
}); });
outcome = "accepted"; outcome = "accepted";
@@ -180,18 +203,22 @@ export async function declineFriendRequest(formData: FormData): Promise<void> {
if (!Number.isInteger(requestId) || requestId <= 0) { if (!Number.isInteger(requestId) || requestId <= 0) {
outcome = "invalid"; outcome = "invalid";
} else { } else {
const request = await prisma.messengerFriendrequests.findUnique({ const [request] = await db
where: { id: requestId }, .select({
select: { id: true, userToId: true }, id: MessengerFriendrequests.id,
}); userToId: MessengerFriendrequests.userToId,
})
.from(MessengerFriendrequests)
.where(eq(MessengerFriendrequests.id, requestId))
.limit(1);
if (!request) { if (!request) {
outcome = "not_found"; outcome = "not_found";
} else if (request.userToId !== meId) { } else if (request.userToId !== meId) {
outcome = "unauthorized"; outcome = "unauthorized";
} else { } else {
await prisma.messengerFriendrequests.delete({ await db
where: { id: requestId }, .delete(MessengerFriendrequests)
}); .where(eq(MessengerFriendrequests.id, requestId));
outcome = "declined"; outcome = "declined";
} }
} }
@@ -239,26 +266,41 @@ export async function removeFriendship(formData: FormData): Promise<void> {
if (!Number.isInteger(friendId) || friendId <= 0 || friendId === meId) { if (!Number.isInteger(friendId) || friendId <= 0 || friendId === meId) {
outcome = "invalid"; outcome = "invalid";
} else { } else {
const deleted = await prisma.$transaction(async (tx) => { const deleted = await db.transaction(async (tx) => {
const result = await tx.messengerFriendships.deleteMany({ const result = await tx
where: { .delete(MessengerFriendships)
OR: [ .where(
{ userOneId: meId, userTwoId: friendId }, or(
{ userOneId: friendId, userTwoId: meId }, and(
], eq(MessengerFriendships.userOneId, meId),
}, eq(MessengerFriendships.userTwoId, friendId),
}); ),
and(
eq(MessengerFriendships.userOneId, friendId),
eq(MessengerFriendships.userTwoId, meId),
),
),
);
await tx.messengerFriendrequests.deleteMany({ await tx
where: { .delete(MessengerFriendrequests)
OR: [ .where(
{ userFromId: meId, userToId: friendId }, or(
{ userFromId: friendId, userToId: meId }, and(
], eq(MessengerFriendrequests.userFromId, meId),
}, eq(MessengerFriendrequests.userToId, friendId),
}); ),
and(
eq(MessengerFriendrequests.userFromId, friendId),
eq(MessengerFriendrequests.userToId, meId),
),
),
);
return result.count; return Number(
(result as unknown as [{ affectedRows: number }])[0]
?.affectedRows ?? 0,
);
}); });
outcome = deleted > 0 ? "removed" : "not_found"; outcome = deleted > 0 ? "removed" : "not_found";
@@ -310,33 +352,39 @@ export async function sendOfflineMessage(formData: FormData): Promise<void> {
} else if (!message) { } else if (!message) {
outcome = "empty"; outcome = "empty";
} else { } else {
const friendship = await prisma.messengerFriendships.findFirst({ const [friendship] = await db
where: { .select({ id: MessengerFriendships.id })
OR: [ .from(MessengerFriendships)
{ userOneId: meId, userTwoId: friendId }, .where(
{ userOneId: friendId, userTwoId: meId }, or(
], and(
}, eq(MessengerFriendships.userOneId, meId),
select: { id: true }, eq(MessengerFriendships.userTwoId, friendId),
}); ),
and(
eq(MessengerFriendships.userOneId, friendId),
eq(MessengerFriendships.userTwoId, meId),
),
),
)
.limit(1);
if (!friendship) { if (!friendship) {
outcome = "not_friend"; outcome = "not_friend";
} else { } else {
const recipient = await prisma.user.findUnique({ const [recipient] = await db
where: { id: friendId }, .select({ id: User.id })
select: { id: true }, .from(User)
}); .where(eq(User.id, friendId))
.limit(1);
if (!recipient) { if (!recipient) {
outcome = "invalid"; outcome = "invalid";
} else { } else {
await prisma.messengerOffline.create({ await db.insert(MessengerOffline).values({
data: {
userId: friendId, userId: friendId,
userFromId: meId, userFromId: meId,
message, message,
sendedOn: Math.floor(Date.now() / 1000), sendedOn: Math.floor(Date.now() / 1000),
},
}); });
outcome = "sent"; outcome = "sent";
} }
+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
@@ -1,9 +1,15 @@
"use server"; "use server";
import { and, eq } from "drizzle-orm";
import { revalidatePath } from "next/cache"; import { revalidatePath } from "next/cache";
import { z } from "zod"; import { z } from "zod";
import {
db,
WebsitePoll,
WebsitePollQuestion,
WebsitePollVote,
} from "@/lib/db";
import { PERMS } from "@/lib/permissions"; import { PERMS } from "@/lib/permissions";
import { prisma } from "@/lib/prisma";
import { adminAction, authAction } from "@/lib/safe-action"; import { adminAction, authAction } from "@/lib/safe-action";
import { ActionError, actionError, actionOk } from "@/lib/safe-action-shared"; import { ActionError, actionError, actionOk } from "@/lib/safe-action-shared";
import { logAudit } from "@/lib/services/audit"; import { logAudit } from "@/lib/services/audit";
@@ -19,15 +25,20 @@ import {
export const createPoll = adminAction( export const createPoll = adminAction(
{ permission: PERMS.POLLS_EDIT, schema: createPollSchema }, { permission: PERMS.POLLS_EDIT, schema: createPollSchema },
async (ctx) => { async (ctx) => {
const poll = await prisma.websitePoll.create({ data: ctx.data }); const now = new Date();
const [result] = await db.insert(WebsitePoll).values({
...ctx.data,
updatedAt: now,
});
const pollId = Number(result.insertId);
logAudit({ logAudit({
userId: ctx.session.user.id, userId: ctx.session.user.id,
action: "poll_create", action: "poll_create",
target: "WebsitePoll", target: "WebsitePoll",
targetId: poll.id, targetId: pollId,
after: { title: poll.title }, after: { title: ctx.data.title },
}); });
return actionOk({ id: poll.id }); return actionOk({ id: pollId });
}, },
); );
@@ -39,10 +50,21 @@ export const updatePoll = adminAction(
{ permission: PERMS.POLLS_EDIT, schema: updatePollInput }, { permission: PERMS.POLLS_EDIT, schema: updatePollInput },
async (ctx) => { async (ctx) => {
const { id, ...data } = ctx.data; const { id, ...data } = ctx.data;
const existing = await prisma.websitePoll.findUnique({ where: { id } }); const [existing] = await db
.select({
id: WebsitePoll.id,
title: WebsitePoll.title,
status: WebsitePoll.status,
})
.from(WebsitePoll)
.where(eq(WebsitePoll.id, id))
.limit(1);
if (!existing) throw new ActionError("Poll not found"); if (!existing) throw new ActionError("Poll not found");
await prisma.websitePoll.update({ where: { id }, data }); await db
.update(WebsitePoll)
.set({ ...data, updatedAt: new Date() })
.where(eq(WebsitePoll.id, id));
logAudit({ logAudit({
userId: ctx.session.user.id, userId: ctx.session.user.id,
action: "poll_update", action: "poll_update",
@@ -62,12 +84,14 @@ const deletePollInput = z.object({
export const deletePoll = adminAction( export const deletePoll = adminAction(
{ permission: PERMS.POLLS_EDIT, schema: deletePollInput }, { permission: PERMS.POLLS_EDIT, schema: deletePollInput },
async (ctx) => { async (ctx) => {
const existing = await prisma.websitePoll.findUnique({ const [existing] = await db
where: { id: ctx.data.id }, .select({ id: WebsitePoll.id, title: WebsitePoll.title })
}); .from(WebsitePoll)
.where(eq(WebsitePoll.id, ctx.data.id))
.limit(1);
if (!existing) throw new ActionError("Poll not found"); if (!existing) throw new ActionError("Poll not found");
await prisma.websitePoll.delete({ where: { id: ctx.data.id } }); await db.delete(WebsitePoll).where(eq(WebsitePoll.id, ctx.data.id));
logAudit({ logAudit({
userId: ctx.session.user.id, userId: ctx.session.user.id,
action: "poll_delete", action: "poll_delete",
@@ -84,10 +108,8 @@ export const deletePoll = adminAction(
export const addPollQuestion = adminAction( export const addPollQuestion = adminAction(
{ permission: PERMS.POLLS_EDIT, schema: pollQuestionSchema }, { permission: PERMS.POLLS_EDIT, schema: pollQuestionSchema },
async (ctx) => { async (ctx) => {
const question = await prisma.websitePollQuestion.create({ const [result] = await db.insert(WebsitePollQuestion).values(ctx.data);
data: ctx.data, return actionOk({ id: Number(result.insertId) });
});
return actionOk({ id: question.id });
}, },
); );
@@ -99,7 +121,10 @@ export const updatePollQuestion = adminAction(
{ permission: PERMS.POLLS_EDIT, schema: updateQuestionInput }, { permission: PERMS.POLLS_EDIT, schema: updateQuestionInput },
async (ctx) => { async (ctx) => {
const { id, ...data } = ctx.data; const { id, ...data } = ctx.data;
await prisma.websitePollQuestion.update({ where: { id }, data }); await db
.update(WebsitePollQuestion)
.set(data)
.where(eq(WebsitePollQuestion.id, id));
return actionOk({ id }); return actionOk({ id });
}, },
); );
@@ -111,7 +136,9 @@ const deleteQuestionInput = z.object({
export const deletePollQuestion = adminAction( export const deletePollQuestion = adminAction(
{ permission: PERMS.POLLS_EDIT, schema: deleteQuestionInput }, { permission: PERMS.POLLS_EDIT, schema: deleteQuestionInput },
async (ctx) => { async (ctx) => {
await prisma.websitePollQuestion.delete({ where: { id: ctx.data.id } }); await db
.delete(WebsitePollQuestion)
.where(eq(WebsitePollQuestion.id, ctx.data.id));
return actionOk(); return actionOk();
}, },
); );
@@ -138,10 +165,11 @@ export const voteOnPoll = authAction(
return actionError("Unauthorized"); return actionError("Unauthorized");
} }
const poll = await prisma.websitePoll.findUnique({ const [poll] = await db
where: { id: ctx.data.pollId }, .select()
include: { questions: true }, .from(WebsitePoll)
}); .where(eq(WebsitePoll.id, ctx.data.pollId))
.limit(1);
if (!poll) return actionError("Poll not found"); if (!poll) return actionError("Poll not found");
if (poll.status !== "active") { if (poll.status !== "active") {
@@ -155,7 +183,12 @@ export const voteOnPoll = authAction(
return actionError("This poll has ended"); return actionError("This poll has ended");
} }
const questionById = new Map(poll.questions.map((q) => [q.id, q])); const questions = await db
.select()
.from(WebsitePollQuestion)
.where(eq(WebsitePollQuestion.pollId, poll.id));
const questionById = new Map(questions.map((q) => [q.id, q]));
const seen = new Set<number>(); const seen = new Set<number>();
for (const vote of ctx.data.votes) { for (const vote of ctx.data.votes) {
@@ -194,30 +227,30 @@ export const voteOnPoll = authAction(
} }
} }
const existing = await prisma.websitePollVote.findUnique({ const [existing] = await db
where: { .select({ id: WebsitePollVote.id })
questionId_userId: { .from(WebsitePollVote)
questionId: vote.questionId, .where(
userId, and(
}, eq(WebsitePollVote.questionId, vote.questionId),
}, eq(WebsitePollVote.userId, userId),
}); ),
)
.limit(1);
if (existing) { if (existing) {
return actionError("You have already voted on this poll"); return actionError("You have already voted on this poll");
} }
} }
await prisma.$transaction( await db.transaction(async (tx) => {
ctx.data.votes.map((vote) => for (const vote of ctx.data.votes) {
prisma.websitePollVote.create({ await tx.insert(WebsitePollVote).values({
data: {
questionId: vote.questionId, questionId: vote.questionId,
userId, userId,
answer: vote.answer.trim(), answer: vote.answer.trim(),
}, });
}), }
), });
);
revalidatePath("/polls"); revalidatePath("/polls");
revalidatePath(`/polls/${poll.id}`); revalidatePath(`/polls/${poll.id}`);
+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
@@ -1,13 +1,14 @@
"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, WebsiteShopArticles } from "@/lib/db";
import { clientIp, rateLimit } from "@/lib/rate-limit"; import { clientIp, rateLimit } from "@/lib/rate-limit";
import { creditsPerUnit } from "@/lib/services/paypal"; import { creditsPerUnit } from "@/lib/services/paypal";
import { rcon } from "@/lib/services/rcon"; import { rcon } from "@/lib/services/rcon";
import { sendCurrency } from "@/lib/services/send-currency"; import { currencyDb, sendCurrency } from "@/lib/services/send-currency";
/** /**
* Credits charged for a package row. AtomCMS stores `costs` in cents (USD display); * Credits charged for a package row. AtomCMS stores `costs` in cents (USD display);
@@ -86,19 +87,20 @@ export async function buyShopArticle(formData: FormData): Promise<void> {
if (!/^\d+$/.test(rawId)) { if (!/^\d+$/.test(rawId)) {
outcome = "invalid"; outcome = "invalid";
} else { } else {
const article = await prisma.websiteShopArticles.findUnique({ const [article] = await db
where: { id: BigInt(rawId) }, .select({
select: { id: WebsiteShopArticles.id,
id: true, name: WebsiteShopArticles.name,
name: true, costs: WebsiteShopArticles.costs,
costs: true, credits: WebsiteShopArticles.credits,
credits: true, duckets: WebsiteShopArticles.duckets,
duckets: true, diamonds: WebsiteShopArticles.diamonds,
diamonds: true, badges: WebsiteShopArticles.badges,
badges: true, giveRank: WebsiteShopArticles.giveRank,
giveRank: true, })
}, .from(WebsiteShopArticles)
}); .where(eq(WebsiteShopArticles.id, BigInt(rawId)))
.limit(1);
if (!article) { if (!article) {
outcome = "invalid"; outcome = "invalid";
@@ -106,21 +108,22 @@ export async function buyShopArticle(formData: FormData): Promise<void> {
packageName = article.name; packageName = article.name;
const price = creditPriceFromCosts(article.costs); const price = creditPriceFromCosts(article.costs);
const buyer = await prisma.user.findUnique({ const [buyer] = await db
where: { id: userId }, .select({ credits: User.credits, rank: User.rank })
select: { credits: true, rank: 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 {
const badgeCodes = parseBadgeCodes(article.badges); const badgeCodes = parseBadgeCodes(article.badges);
await prisma.$transaction(async (tx) => { await db.transaction(async (tx) => {
if (price > 0) { if (price > 0) {
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));
} }
if ( if (
@@ -128,44 +131,52 @@ export async function buyShopArticle(formData: FormData): Promise<void> {
article.giveRank > 0 && article.giveRank > 0 &&
article.giveRank > buyer.rank article.giveRank > buyer.rank
) { ) {
await tx.user.update({ await tx
where: { id: userId }, .update(User)
data: { rank: article.giveRank }, .set({ rank: article.giveRank })
}); .where(eq(User.id, userId));
} }
for (const code of badgeCodes) { for (const code of badgeCodes) {
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,
}); });
} }
} }
}); });
await sendCurrency( await sendCurrency(
{ rcon, db: prisma }, { rcon, db: currencyDb },
userId, userId,
"credits", "credits",
article.credits, article.credits,
); );
await sendCurrency( await sendCurrency(
{ rcon, db: prisma }, { rcon, db: currencyDb },
userId, userId,
"duckets", "duckets",
article.duckets, article.duckets,
); );
await sendCurrency( await sendCurrency(
{ rcon, db: prisma }, { rcon, db: currencyDb },
userId, userId,
"diamonds", "diamonds",
article.diamonds, article.diamonds,
+81 -51
View File
@@ -1,9 +1,17 @@
"use server"; "use server";
import { and, eq, or } 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,
Guilds,
GuildsForumsComments,
GuildsForumsThreads,
MessengerFriendrequests,
MessengerFriendships,
} from "@/lib/db";
import { clientIp, rateLimit } from "@/lib/rate-limit"; import { clientIp, rateLimit } from "@/lib/rate-limit";
// Guild forum subjects are VARCHAR(255); the comment/message body lives in // Guild forum subjects are VARCHAR(255); the comment/message body lives in
@@ -117,25 +125,44 @@ export async function sendFriendRequest(formData: FormData): Promise<void> {
outcome = "self"; outcome = "self";
} else { } else {
// Guard against duplicate pending requests and already-existing friendships. // Guard against duplicate pending requests and already-existing friendships.
const [outgoingRequest, incomingRequest, existingFriendship] = const [[outgoingRequest], [incomingRequest], [existingFriendship]] =
await Promise.all([ await Promise.all([
prisma.messengerFriendrequests.findFirst({ db
where: { userFromId: fromId, userToId: toId }, .select({ id: MessengerFriendrequests.id })
select: { id: true }, .from(MessengerFriendrequests)
}), .where(
prisma.messengerFriendrequests.findFirst({ and(
where: { userFromId: toId, userToId: fromId }, eq(MessengerFriendrequests.userFromId, fromId),
select: { id: true }, eq(MessengerFriendrequests.userToId, toId),
}), ),
prisma.messengerFriendships.findFirst({ )
where: { .limit(1),
OR: [ db
{ userOneId: fromId, userTwoId: toId }, .select({ id: MessengerFriendrequests.id })
{ userOneId: toId, userTwoId: fromId }, .from(MessengerFriendrequests)
], .where(
}, and(
select: { id: true }, eq(MessengerFriendrequests.userFromId, toId),
}), eq(MessengerFriendrequests.userToId, fromId),
),
)
.limit(1),
db
.select({ id: MessengerFriendships.id })
.from(MessengerFriendships)
.where(
or(
and(
eq(MessengerFriendships.userOneId, fromId),
eq(MessengerFriendships.userTwoId, toId),
),
and(
eq(MessengerFriendships.userOneId, toId),
eq(MessengerFriendships.userTwoId, fromId),
),
),
)
.limit(1),
]); ]);
if (existingFriendship) { if (existingFriendship) {
@@ -147,8 +174,9 @@ export async function sendFriendRequest(formData: FormData): Promise<void> {
// creating a duplicate reverse row. // creating a duplicate reverse row.
outcome = "incoming_pending"; outcome = "incoming_pending";
} else { } else {
await prisma.messengerFriendrequests.create({ await db.insert(MessengerFriendrequests).values({
data: { userFromId: fromId, userToId: toId }, userFromId: fromId,
userToId: toId,
}); });
outcome = "sent"; outcome = "sent";
} }
@@ -210,16 +238,16 @@ export async function postThread(formData: FormData): Promise<void> {
} else { } else {
const now = Math.floor(Date.now() / 1000); const now = Math.floor(Date.now() / 1000);
const guild = await prisma.guilds.findUnique({ const [guild] = await db
where: { id: guildId }, .select({ id: Guilds.id })
select: { id: true }, .from(Guilds)
}); .where(eq(Guilds.id, guildId))
.limit(1);
if (!guild) { if (!guild) {
outcome = "not_found"; outcome = "not_found";
} else { } else {
await prisma.$transaction(async (tx) => { await db.transaction(async (tx) => {
const thread = await tx.guildsForumsThreads.create({ const [result] = await tx.insert(GuildsForumsThreads).values({
data: {
guildId, guildId,
openerId, openerId,
subject, subject,
@@ -230,19 +258,16 @@ export async function postThread(formData: FormData): Promise<void> {
pinned: 0, pinned: 0,
locked: 0, locked: 0,
adminId: 0, adminId: 0,
},
select: { id: true },
}); });
const threadId = Number(result.insertId);
await tx.guildsForumsComments.create({ await tx.insert(GuildsForumsComments).values({
data: { threadId,
threadId: thread.id,
userId: openerId, userId: openerId,
message, message,
createdAt: now, createdAt: now,
state: 0, state: 0,
adminId: 0, adminId: 0,
},
}); });
}); });
outcome = "posted"; outcome = "posted";
@@ -304,39 +329,44 @@ export async function replyToThread(formData: FormData): Promise<void> {
} else { } else {
const now = Math.floor(Date.now() / 1000); const now = Math.floor(Date.now() / 1000);
const thread = await prisma.guildsForumsThreads.findFirst({ const [thread] = await db
where: { id: threadId, guildId, state: 0 }, .select({
select: { id: GuildsForumsThreads.id,
id: true, locked: GuildsForumsThreads.locked,
locked: true, postsCount: GuildsForumsThreads.postsCount,
postsCount: true, })
}, .from(GuildsForumsThreads)
}); .where(
and(
eq(GuildsForumsThreads.id, threadId),
eq(GuildsForumsThreads.guildId, guildId),
eq(GuildsForumsThreads.state, 0),
),
)
.limit(1);
if (!thread) { if (!thread) {
outcome = "not_found"; outcome = "not_found";
} else if (thread.locked) { } else if (thread.locked) {
outcome = "locked"; outcome = "locked";
} else { } else {
await prisma.$transaction(async (tx) => { await db.transaction(async (tx) => {
await tx.guildsForumsComments.create({ await tx.insert(GuildsForumsComments).values({
data: {
threadId: thread.id, threadId: thread.id,
userId, userId,
message, message,
createdAt: now, createdAt: now,
state: 0, state: 0,
adminId: 0, adminId: 0,
},
}); });
await tx.guildsForumsThreads.update({ await tx
where: { id: thread.id }, .update(GuildsForumsThreads)
data: { .set({
postsCount: (thread.postsCount ?? 0) + 1, postsCount: (thread.postsCount ?? 0) + 1,
updatedAt: now, updatedAt: now,
}, })
}); .where(eq(GuildsForumsThreads.id, thread.id));
}); });
outcome = "replied"; outcome = "replied";
} }
+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
@@ -1,7 +1,8 @@
"use server"; "use server";
import { eq } from "drizzle-orm";
import { db, WebsiteTicket, WebsiteTicketMessage } from "@/lib/db";
import { PERMS } from "@/lib/permissions"; import { PERMS } from "@/lib/permissions";
import { prisma } from "@/lib/prisma";
import { adminAction, authAction } from "@/lib/safe-action"; import { adminAction, authAction } 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";
@@ -24,32 +25,31 @@ export const createTicket = authAction(
rateLimitWindowMs: 60_000, rateLimitWindowMs: 60_000,
}, },
async (ctx) => { async (ctx) => {
const ticket = await prisma.websiteTicket.create({ const now = new Date();
data: { const [result] = await db.insert(WebsiteTicket).values({
subject: ctx.data.subject, subject: ctx.data.subject,
category: ctx.data.category, category: ctx.data.category,
creatorId: ctx.session.user.id, creatorId: ctx.session.user.id,
}, updatedAt: now,
}); });
const ticketId = Number(result.insertId);
// Create the first message // Create the first message
await prisma.websiteTicketMessage.create({ await db.insert(WebsiteTicketMessage).values({
data: { ticketId,
ticketId: ticket.id,
userId: ctx.session.user.id, userId: ctx.session.user.id,
message: ctx.data.message, message: ctx.data.message,
isStaff: 0, isStaff: 0,
},
}); });
notify({ notify({
action: "ticket_create", action: "ticket_create",
actor: ctx.session.user.username, actor: ctx.session.user.username,
target: `#${ticket.id} - ${ticket.subject}`, target: `#${ticketId} - ${ctx.data.subject}`,
details: `Category: ${ticket.category}`, details: `Category: ${ctx.data.category}`,
}); });
return actionOk({ id: ticket.id }); return actionOk({ id: ticketId });
}, },
); );
@@ -61,30 +61,30 @@ export const userReplyTicket = authAction(
rateLimitWindowMs: 60_000, rateLimitWindowMs: 60_000,
}, },
async (ctx) => { async (ctx) => {
const ticket = await prisma.websiteTicket.findUnique({ const [ticket] = await db
where: { id: ctx.data.ticketId }, .select()
}); .from(WebsiteTicket)
.where(eq(WebsiteTicket.id, ctx.data.ticketId))
.limit(1);
if (!ticket) throw new ActionError("Ticket not found"); if (!ticket) throw new ActionError("Ticket not found");
if (ticket.creatorId !== ctx.session.user.id) if (ticket.creatorId !== ctx.session.user.id)
throw new ActionError("Unauthorized"); throw new ActionError("Unauthorized");
if (ticket.status === "closed") throw new ActionError("Ticket is closed"); if (ticket.status === "closed") throw new ActionError("Ticket is closed");
await prisma.websiteTicketMessage.create({ await db.insert(WebsiteTicketMessage).values({
data: {
ticketId: ctx.data.ticketId, ticketId: ctx.data.ticketId,
userId: ctx.session.user.id, userId: ctx.session.user.id,
message: ctx.data.message, message: ctx.data.message,
isStaff: 0, isStaff: 0,
},
}); });
// If ticket was in "waiting" (waiting for user), move back to open // If ticket was in "waiting" (waiting for user), move back to open
if (ticket.status === "waiting") { if (ticket.status === "waiting") {
await prisma.websiteTicket.update({ await db
where: { id: ctx.data.ticketId }, .update(WebsiteTicket)
data: { status: "open" }, .set({ status: "open", updatedAt: new Date() })
}); .where(eq(WebsiteTicket.id, ctx.data.ticketId));
} }
return actionOk(); return actionOk();
@@ -99,9 +99,11 @@ export const closeTicketByUser = authAction(
rateLimitWindowMs: 60_000, rateLimitWindowMs: 60_000,
}, },
async (ctx) => { async (ctx) => {
const ticket = await prisma.websiteTicket.findUnique({ const [ticket] = await db
where: { id: ctx.data.ticketId }, .select()
}); .from(WebsiteTicket)
.where(eq(WebsiteTicket.id, ctx.data.ticketId))
.limit(1);
if (!ticket) throw new ActionError("Ticket not found"); if (!ticket) throw new ActionError("Ticket not found");
if (ticket.creatorId !== ctx.session.user.id) if (ticket.creatorId !== ctx.session.user.id)
@@ -109,10 +111,10 @@ export const closeTicketByUser = authAction(
if (ticket.status === "closed") if (ticket.status === "closed")
throw new ActionError("Ticket is already closed"); throw new ActionError("Ticket is already closed");
await prisma.websiteTicket.update({ await db
where: { id: ctx.data.ticketId }, .update(WebsiteTicket)
data: { status: "closed", closedAt: new Date() }, .set({ status: "closed", closedAt: new Date(), updatedAt: new Date() })
}); .where(eq(WebsiteTicket.id, ctx.data.ticketId));
return actionOk(); return actionOk();
}, },
@@ -126,31 +128,34 @@ export const adminReplyTicket = adminAction(
schema: replyTicketSchema, schema: replyTicketSchema,
}, },
async (ctx) => { async (ctx) => {
const ticket = await prisma.websiteTicket.findUnique({ const [ticket] = await db
where: { id: ctx.data.ticketId }, .select()
}); .from(WebsiteTicket)
.where(eq(WebsiteTicket.id, ctx.data.ticketId))
.limit(1);
if (!ticket) throw new ActionError("Ticket not found"); if (!ticket) throw new ActionError("Ticket not found");
await prisma.websiteTicketMessage.create({ await db.insert(WebsiteTicketMessage).values({
data: {
ticketId: ctx.data.ticketId, ticketId: ctx.data.ticketId,
userId: ctx.session.user.id, userId: ctx.session.user.id,
message: ctx.data.message, message: ctx.data.message,
isStaff: 1, isStaff: 1,
},
}); });
// Auto-assign if not assigned yet // Auto-assign if not assigned yet
const updates: Record<string, unknown> = { status: "waiting" }; const updates: Partial<typeof WebsiteTicket.$inferInsert> = {
status: "waiting",
updatedAt: new Date(),
};
if (!ticket.assigneeId) { if (!ticket.assigneeId) {
updates.assigneeId = ctx.session.user.id; updates.assigneeId = ctx.session.user.id;
} }
await prisma.websiteTicket.update({ await db
where: { id: ctx.data.ticketId }, .update(WebsiteTicket)
data: updates, .set(updates)
}); .where(eq(WebsiteTicket.id, ctx.data.ticketId));
logAudit({ logAudit({
userId: ctx.session.user.id, userId: ctx.session.user.id,
@@ -169,13 +174,18 @@ export const updateTicketStatus = adminAction(
schema: updateTicketStatusSchema, schema: updateTicketStatusSchema,
}, },
async (ctx) => { async (ctx) => {
const ticket = await prisma.websiteTicket.findUnique({ const [ticket] = await db
where: { id: ctx.data.ticketId }, .select()
}); .from(WebsiteTicket)
.where(eq(WebsiteTicket.id, ctx.data.ticketId))
.limit(1);
if (!ticket) throw new ActionError("Ticket not found"); if (!ticket) throw new ActionError("Ticket not found");
const data: Record<string, unknown> = { status: ctx.data.status }; const data: Partial<typeof WebsiteTicket.$inferInsert> = {
status: ctx.data.status,
updatedAt: new Date(),
};
if (ctx.data.status === "closed") { if (ctx.data.status === "closed") {
data.closedAt = new Date(); data.closedAt = new Date();
} }
@@ -183,10 +193,10 @@ export const updateTicketStatus = adminAction(
data.assigneeId = ctx.session.user.id; data.assigneeId = ctx.session.user.id;
} }
await prisma.websiteTicket.update({ await db
where: { id: ctx.data.ticketId }, .update(WebsiteTicket)
data, .set(data)
}); .where(eq(WebsiteTicket.id, ctx.data.ticketId));
logAudit({ logAudit({
userId: ctx.session.user.id, userId: ctx.session.user.id,
@@ -207,19 +217,22 @@ export const assignTicket = adminAction(
schema: assignTicketSchema, schema: assignTicketSchema,
}, },
async (ctx) => { async (ctx) => {
const ticket = await prisma.websiteTicket.findUnique({ const [ticket] = await db
where: { id: ctx.data.ticketId }, .select()
}); .from(WebsiteTicket)
.where(eq(WebsiteTicket.id, ctx.data.ticketId))
.limit(1);
if (!ticket) throw new ActionError("Ticket not found"); if (!ticket) throw new ActionError("Ticket not found");
await prisma.websiteTicket.update({ await db
where: { id: ctx.data.ticketId }, .update(WebsiteTicket)
data: { .set({
assigneeId: ctx.data.assigneeId, assigneeId: ctx.data.assigneeId,
status: ctx.data.assigneeId ? "in_progress" : "open", status: ctx.data.assigneeId ? "in_progress" : "open",
}, updatedAt: new Date(),
}); })
.where(eq(WebsiteTicket.id, ctx.data.ticketId));
logAudit({ logAudit({
userId: ctx.session.user.id, userId: ctx.session.user.id,
@@ -240,16 +253,18 @@ export const updateTicketPriority = adminAction(
schema: updateTicketPrioritySchema, schema: updateTicketPrioritySchema,
}, },
async (ctx) => { async (ctx) => {
const ticket = await prisma.websiteTicket.findUnique({ const [ticket] = await db
where: { id: ctx.data.ticketId }, .select()
}); .from(WebsiteTicket)
.where(eq(WebsiteTicket.id, ctx.data.ticketId))
.limit(1);
if (!ticket) throw new ActionError("Ticket not found"); if (!ticket) throw new ActionError("Ticket not found");
await prisma.websiteTicket.update({ await db
where: { id: ctx.data.ticketId }, .update(WebsiteTicket)
data: { priority: ctx.data.priority }, .set({ priority: ctx.data.priority, updatedAt: new Date() })
}); .where(eq(WebsiteTicket.id, ctx.data.ticketId));
logAudit({ logAudit({
userId: ctx.session.user.id, userId: ctx.session.user.id,
+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
@@ -1,11 +1,19 @@
"use server"; "use server";
import crypto from "node:crypto"; import crypto from "node:crypto";
import { and, eq } from "drizzle-orm";
import { z } from "zod"; import { z } from "zod";
import { invalidateLoginCache } from "@/lib/auth"; import { invalidateLoginCache } from "@/lib/auth";
import { hashPassword } from "@/lib/auth/password"; import { hashPassword } from "@/lib/auth/password";
import {
Ban,
db,
User,
UsersBadges,
UsersCurrency,
UsersSettings,
} 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";
@@ -21,6 +29,27 @@ import {
const DEFAULT_LOOK = const DEFAULT_LOOK =
"hr-115-42.hd-195-19.ch-3030-82.lg-275-1408.fa-1201.ca-1804-64"; "hr-115-42.hd-195-19.ch-3030-82.lg-275-1408.fa-1201.ca-1804-64";
function isDuplicateKey(err: unknown): boolean {
if (!err || typeof err !== "object") return false;
const e = err as { code?: string | number; errno?: number };
return e.code === "P2002" || e.code === "ER_DUP_ENTRY" || e.errno === 1062;
}
function duplicateField(err: unknown): "username" | "mail" | null {
if (!isDuplicateKey(err)) return null;
const e = err as {
message?: string;
meta?: { target?: string[] };
};
const target = e.meta?.target ?? [];
if (target.includes("username")) return "username";
if (target.includes("mail")) return "mail";
const msg = e.message ?? "";
if (msg.includes("username")) return "username";
if (msg.includes("mail")) return "mail";
return null;
}
export const createUser = adminAction( export const createUser = adminAction(
{ permission: PERMS.USERS_EDIT, schema: createUserSchema }, { permission: PERMS.USERS_EDIT, schema: createUserSchema },
async (ctx) => { async (ctx) => {
@@ -34,9 +63,8 @@ export const createUser = adminAction(
const now = Math.floor(Date.now() / 1000); const now = Math.floor(Date.now() / 1000);
try { try {
const user = await prisma.$transaction(async (tx) => { const user = await db.transaction(async (tx) => {
const created = await tx.user.create({ const [result] = await tx.insert(User).values({
data: {
username, username,
mail, mail,
password: hashedPassword, password: hashedPassword,
@@ -48,18 +76,16 @@ export const createUser = adminAction(
accountCreated: now, accountCreated: now,
ipRegister: "0.0.0.0", ipRegister: "0.0.0.0",
ipCurrent: "0.0.0.0", ipCurrent: "0.0.0.0",
},
}); });
const id = Number(result.insertId);
await tx.usersSettings.create({ data: { userId: created.id } }); await tx.insert(UsersSettings).values({ userId: id });
await tx.usersCurrency.createMany({ await tx.insert(UsersCurrency).values([
data: [ { userId: id, type: 0, amount: 5000 },
{ userId: created.id, type: 0, amount: 5000 }, { userId: id, type: 5, amount: 5000 },
{ userId: created.id, type: 5, amount: 5000 }, ]);
],
});
return created; return { id, username };
}); });
logAudit({ logAudit({
@@ -80,21 +106,11 @@ export const createUser = adminAction(
return actionOk({ id: user.id, username: user.username }); return actionOk({ id: user.id, username: user.username });
} catch (err) { } catch (err) {
if ( const field = duplicateField(err);
err && if (field === "username") throw new ActionError("Username already taken");
typeof err === "object" && if (field === "mail") throw new ActionError("Email already registered");
"code" in err && if (isDuplicateKey(err))
err.code === "P2002"
) {
const target =
((err as { meta?: { target?: string[] } }).meta
?.target as string[]) ?? [];
if (target.includes("username"))
throw new ActionError("Username already taken");
if (target.includes("mail"))
throw new ActionError("Email already registered");
throw new ActionError("Username or email already in use"); throw new ActionError("Username or email already in use");
}
throw err; throw err;
} }
}, },
@@ -119,22 +135,32 @@ export const updateUser = adminAction(
throw new ActionError("Cannot assign rank equal or higher than your own"); throw new ActionError("Cannot assign rank equal or higher than your own");
} }
await prisma.user.update({ where: { id }, data: userData }); const patch = Object.fromEntries(
Object.entries(userData).filter(([, v]) => v !== undefined),
) as Partial<{
username: string;
mail: string;
rank: number;
motto: string;
credits: number;
pixels: number;
}>;
if (Object.keys(patch).length > 0) {
await db.update(User).set(patch).where(eq(User.id, id));
}
invalidateLoginCache(targetUser.username); invalidateLoginCache(targetUser.username);
if (diamonds !== undefined) { if (diamonds !== undefined) {
await prisma.usersCurrency.upsert({ await db
where: { userId_type: { userId: id, type: 5 } }, .insert(UsersCurrency)
update: { amount: diamonds }, .values({ userId: id, type: 5, amount: diamonds })
create: { userId: id, type: 5, amount: diamonds }, .onDuplicateKeyUpdate({ set: { amount: diamonds } });
});
} }
if (duckets !== undefined) { if (duckets !== undefined) {
await prisma.usersCurrency.upsert({ await db
where: { userId_type: { userId: id, type: 0 } }, .insert(UsersCurrency)
update: { amount: duckets }, .values({ userId: id, type: 0, amount: duckets })
create: { userId: id, type: 0, amount: duckets }, .onDuplicateKeyUpdate({ set: { amount: duckets } });
});
} }
logAudit({ logAudit({
@@ -173,8 +199,7 @@ export const banUser = adminAction(
const now = Math.floor(Date.now() / 1000); const now = Math.floor(Date.now() / 1000);
const banExpire = duration > 0 ? now + duration * 3600 : 0; const banExpire = duration > 0 ? now + duration * 3600 : 0;
await prisma.ban.create({ await db.insert(Ban).values({
data: {
userId, userId,
userStaffId: ctx.session.user.id, userStaffId: ctx.session.user.id,
timestamp: now, timestamp: now,
@@ -183,7 +208,6 @@ export const banUser = adminAction(
type: type || "account", type: type || "account",
ip: ip || "", ip: ip || "",
machineId: "", machineId: "",
},
}); });
await rcon.disconnectUser(userId); await rcon.disconnectUser(userId);
@@ -216,7 +240,7 @@ export const unbanUser = adminAction(
const targetUser = await guardRank(userId, ctx.session.user.rank); const targetUser = await guardRank(userId, ctx.session.user.rank);
await prisma.ban.deleteMany({ where: { userId } }); await db.delete(Ban).where(eq(Ban.userId, userId));
logAudit({ logAudit({
userId: ctx.session.user.id, userId: ctx.session.user.id,
@@ -242,12 +266,19 @@ export const giveBadge = adminAction(
await guardRank(userId, ctx.session.user.rank); await guardRank(userId, ctx.session.user.rank);
const existing = await prisma.usersBadges.findFirst({ const [existing] = await db
where: { userId, badgeCode }, .select({ id: UsersBadges.id })
}); .from(UsersBadges)
.where(
and(
eq(UsersBadges.userId, userId),
eq(UsersBadges.badgeCode, badgeCode),
),
)
.limit(1);
if (existing) throw new ActionError("Badge already assigned"); if (existing) throw new ActionError("Badge already assigned");
await prisma.usersBadges.create({ data: { userId, badgeCode } }); await db.insert(UsersBadges).values({ userId, badgeCode });
await rcon.giveBadge(userId, badgeCode); await rcon.giveBadge(userId, badgeCode);
return actionOk(); return actionOk();
@@ -268,12 +299,19 @@ export const removeBadge = adminAction(
await guardRank(userId, ctx.session.user.rank); await guardRank(userId, ctx.session.user.rank);
const existing = await prisma.usersBadges.findFirst({ const [existing] = await db
where: { userId, badgeCode }, .select({ id: UsersBadges.id })
}); .from(UsersBadges)
.where(
and(
eq(UsersBadges.userId, userId),
eq(UsersBadges.badgeCode, badgeCode),
),
)
.limit(1);
if (!existing) throw new ActionError("Badge not found"); if (!existing) throw new ActionError("Badge not found");
await prisma.usersBadges.delete({ where: { id: existing.id } }); await db.delete(UsersBadges).where(eq(UsersBadges.id, existing.id));
await rcon.removeBadge(userId, badgeCode); await rcon.removeBadge(userId, badgeCode);
return actionOk(); return actionOk();
@@ -283,10 +321,15 @@ export const removeBadge = adminAction(
// ── Rank guard helper ─────────────────────────────────────────────── // ── Rank guard helper ───────────────────────────────────────────────
async function guardRank(targetUserId: number, sessionRank: number) { async function guardRank(targetUserId: number, sessionRank: number) {
const target = await prisma.user.findUnique({ const [target] = await db
where: { id: targetUserId }, .select({
select: { username: true, rank: true, mail: true }, username: User.username,
}); rank: User.rank,
mail: User.mail,
})
.from(User)
.where(eq(User.id, targetUserId))
.limit(1);
if (!target) throw new ActionError("User not found"); if (!target) throw new ActionError("User not found");
if (target.rank >= sessionRank && sessionRank < 7) { if (target.rank >= sessionRank && sessionRank < 7) {
throw new ActionError("Cannot modify user with equal or higher rank"); throw new ActionError("Cannot modify user with equal or higher rank");
@@ -311,10 +354,10 @@ export const resetPassword = adminAction(
.slice(0, 16); .slice(0, 16);
const hashed = await hashPassword(newPassword); const hashed = await hashPassword(newPassword);
await prisma.user.update({ await db
where: { id: ctx.data.userId }, .update(User)
data: { password: hashed }, .set({ password: hashed })
}); .where(eq(User.id, ctx.data.userId));
invalidateLoginCache(target.username); invalidateLoginCache(target.username);
logAudit({ logAudit({
+52 -30
View File
@@ -1,11 +1,12 @@
"use server"; "use server";
import { and, eq, sql } from "drizzle-orm";
import { revalidatePath } from "next/cache"; import { revalidatePath } from "next/cache";
import { auth } from "@/lib/auth"; import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma"; import { db, WebsiteShopVouchers, WebsiteUsedShopVouchers } 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 { sendCurrency } from "@/lib/services/send-currency"; import { currencyDb, sendCurrency } from "@/lib/services/send-currency";
/** Feedback returned to the <RedeemForm/> client component via useActionState. */ /** Feedback returned to the <RedeemForm/> client component via useActionState. */
export type RedeemState = { ok: boolean; message: string } | null; export type RedeemState = { ok: boolean; message: string } | null;
@@ -20,7 +21,7 @@ export type RedeemState = { ok: boolean; message: string } | null;
* - on success the reward `amount` is granted, the used-row is inserted, * - on success the reward `amount` is granted, the used-row is inserted,
* use_count is incremented, and the voucher is expired once max_uses is hit. * use_count is incremented, and the voucher is expired once max_uses is hit.
* *
* The reward is delivered through sendCurrency({ rcon, db: prisma }); the * The reward is delivered through sendCurrency({ rcon, db: currencyDb }); the
* voucher schema carries a single `amount`, granted as the website credits * voucher schema carries a single `amount`, granted as the website credits
* wallet currency. * wallet currency.
*/ */
@@ -65,16 +66,18 @@ export async function redeem(
expiresAt: Date | null; expiresAt: Date | null;
} | null; } | null;
try { try {
voucher = await prisma.websiteShopVouchers.findUnique({ const [row] = await db
where: { code }, .select({
select: { id: WebsiteShopVouchers.id,
id: true, amount: WebsiteShopVouchers.amount,
amount: true, maxUses: WebsiteShopVouchers.maxUses,
maxUses: true, useCount: WebsiteShopVouchers.useCount,
useCount: true, expiresAt: WebsiteShopVouchers.expiresAt,
expiresAt: true, })
}, .from(WebsiteShopVouchers)
}); .where(eq(WebsiteShopVouchers.code, code))
.limit(1);
voucher = row ?? null;
} catch { } catch {
return { return {
ok: false, ok: false,
@@ -95,10 +98,16 @@ export async function redeem(
// One redemption per user. // One redemption per user.
try { try {
const already = await prisma.websiteUsedShopVouchers.findFirst({ const [already] = await db
where: { userId, voucherId: voucher.id }, .select({ id: WebsiteUsedShopVouchers.id })
select: { id: true }, .from(WebsiteUsedShopVouchers)
}); .where(
and(
eq(WebsiteUsedShopVouchers.userId, userId),
eq(WebsiteUsedShopVouchers.voucherId, voucher.id),
),
)
.limit(1);
if (already) { if (already) {
return { ok: false, message: "You can only use each shop voucher once." }; return { ok: false, message: "You can only use each shop voucher once." };
} }
@@ -111,8 +120,9 @@ export async function redeem(
// Record the redemption first so a successful grant can never be double-claimed. // Record the redemption first so a successful grant can never be double-claimed.
try { try {
await prisma.websiteUsedShopVouchers.create({ await db.insert(WebsiteUsedShopVouchers).values({
data: { userId, voucherId: voucher.id }, userId,
voucherId: voucher.id,
}); });
} catch { } catch {
// Most likely a race (another tab redeemed it) — treat as already used. // Most likely a race (another tab redeemed it) — treat as already used.
@@ -121,7 +131,12 @@ export async function redeem(
// Grant the reward. The voucher carries a single amount, delivered as credits. // Grant the reward. The voucher carries a single amount, delivered as credits.
try { try {
await sendCurrency({ rcon, db: prisma }, userId, "credits", voucher.amount); await sendCurrency(
{ rcon, db: currencyDb },
userId,
"credits",
voucher.amount,
);
} catch { } catch {
// sendCurrency already falls back to a direct DB write; if it still throws, // sendCurrency already falls back to a direct DB write; if it still throws,
// the used-row stands and the balance simply wasn't credited — surface that. // the used-row stands and the balance simply wasn't credited — surface that.
@@ -134,16 +149,23 @@ export async function redeem(
// Bump use_count and expire the voucher once the cap is reached. // Bump use_count and expire the voucher once the cap is reached.
try { try {
const updated = await prisma.websiteShopVouchers.update({ await db
where: { id: voucher.id }, .update(WebsiteShopVouchers)
data: { useCount: { increment: 1 } }, .set({ useCount: sql`${WebsiteShopVouchers.useCount} + 1` })
select: { maxUses: true, useCount: true }, .where(eq(WebsiteShopVouchers.id, voucher.id));
}); const [updated] = await db
if (updated.maxUses && updated.useCount >= updated.maxUses) { .select({
await prisma.websiteShopVouchers.update({ maxUses: WebsiteShopVouchers.maxUses,
where: { id: voucher.id }, useCount: WebsiteShopVouchers.useCount,
data: { expiresAt: new Date() }, })
}); .from(WebsiteShopVouchers)
.where(eq(WebsiteShopVouchers.id, voucher.id))
.limit(1);
if (updated?.maxUses && updated.useCount >= updated.maxUses) {
await db
.update(WebsiteShopVouchers)
.set({ expiresAt: new Date() })
.where(eq(WebsiteShopVouchers.id, voucher.id));
} }
} catch { } catch {
// Reward already delivered; the counter bump is best-effort. // Reward already delivered; the counter bump is best-effort.
+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