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
+2612 -1832

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 { requirePermission } from "@/lib/admin/guard";
import { logger } from "@/lib/logger";
import { prisma } from "@/lib/prisma";
import { ActionError } from "@/lib/safe-action-shared";
import { logStaffActivity } from "@/lib/services/staff-activity";
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/permissions", () => ({ PERMS: { PAGES_EDIT: "pages.edit" } }));
vi.mock("@/lib/prisma", () => ({
prisma: { websiteAds: { create: vi.fn(), update: vi.fn(), delete: vi.fn() } },
vi.mock("@/lib/db", () => ({
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/safe-action", () => ({
@@ -34,28 +48,27 @@ const fakeForm = (data: Record<string, string>) => ({
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(requirePermission).mockResolvedValue(staff as never);
insertValues.mockResolvedValue([{ insertId: 1 }]);
deleteWhere.mockResolvedValue([{ affectedRows: 1 }]);
});
describe("createAd", () => {
it("creates ad and redirects", async () => {
vi.mocked(prisma.websiteAds.create).mockResolvedValue({
id: BigInt(1),
} as never);
await createAd(
fakeForm({ image: "https://example.com/ad.png" }) as unknown as FormData,
);
expect(prisma.websiteAds.create).toHaveBeenCalled();
expect(insertValues).toHaveBeenCalled();
expect(logStaffActivity).toHaveBeenCalled();
expect(redirect).toHaveBeenCalledWith("/admin/ads");
});
it("returns early when image empty", async () => {
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 () => {
vi.mocked(prisma.websiteAds.create).mockRejectedValue(new Error("db"));
insertValues.mockRejectedValue(new Error("db"));
await createAd(fakeForm({ image: "x" }) as unknown as FormData);
expect(logger.error).toHaveBeenCalled();
});
@@ -63,7 +76,6 @@ describe("createAd", () => {
describe("deleteAd", () => {
it("deletes ad and returns ok", async () => {
vi.mocked(prisma.websiteAds.delete).mockResolvedValue({} as never);
const h = deleteAd as unknown as (ctx: {
data: { id: bigint };
session: { user: { id: string } };
@@ -74,7 +86,7 @@ describe("deleteAd", () => {
});
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: {
data: { id: bigint };
session: { user: { id: string } };
+23 -13
View File
@@ -1,13 +1,15 @@
"use server";
import { eq } from "drizzle-orm";
import type { ResultSetHeader } from "mysql2";
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import { z } from "zod";
import { requirePermission } from "@/lib/admin/guard";
import { db, WebsiteAds } from "@/lib/db";
import { formPositiveBigInt } from "@/lib/form-data";
import { logger } from "@/lib/logger";
import { PERMS } from "@/lib/permissions";
import { prisma } from "@/lib/prisma";
import { adminAction } from "@/lib/safe-action";
import { ActionError, actionOk } from "@/lib/safe-action-shared";
import { logStaffActivity } from "@/lib/services/staff-activity";
@@ -25,15 +27,17 @@ export async function createAd(formData: FormData): Promise<void> {
const now = new Date();
try {
const ad = await prisma.websiteAds.create({
data: { image, createdAt: now, updatedAt: now },
});
const [result] = (await db.insert(WebsiteAds).values({
image,
createdAt: now,
updatedAt: now,
})) as unknown as [ResultSetHeader];
await logStaffActivity({
staffId: staff.id,
action: "ad_create",
description: `Created advertisement #${ad.id} (${image})`,
description: `Created advertisement #${result.insertId} (${image})`,
targetType: "website_ad",
targetId: Number(ad.id),
targetId: Number(result.insertId),
});
} catch (err) {
logger.error("Action failed: createAd", {
@@ -58,10 +62,10 @@ export async function updateAd(formData: FormData): Promise<void> {
if (!image) return;
try {
await prisma.websiteAds.update({
where: { id },
data: { image, updatedAt: new Date() },
});
await db
.update(WebsiteAds)
.set({ image, updatedAt: new Date() })
.where(eq(WebsiteAds.id, id));
await logStaffActivity({
staffId: staff.id,
action: "ad_update",
@@ -92,8 +96,14 @@ export const deleteAd = adminAction(
async (ctx) => {
const id = ctx.data.id;
try {
await prisma.websiteAds.delete({ where: { id } });
} catch {
const [result] = (await db
.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");
}
await logStaffActivity({
@@ -115,7 +125,7 @@ export async function deleteAdForm(formData: FormData): Promise<void> {
if (!id) return;
try {
await prisma.websiteAds.delete({ where: { id } });
await db.delete(WebsiteAds).where(eq(WebsiteAds.id, id));
await logStaffActivity({
staffId: staff.id,
action: "ad_delete",
+5 -2
View File
@@ -1,10 +1,11 @@
"use server";
import { eq } from "drizzle-orm";
import { revalidatePath } from "next/cache";
import { requirePermission } from "@/lib/admin/guard";
import { db, WebsiteStaffApplications } from "@/lib/db";
import { formPositiveBigInt } from "@/lib/form-data";
import { PERMS } from "@/lib/permissions";
import { prisma } from "@/lib/prisma";
export async function dismissApplication(formData: FormData): Promise<void> {
await requirePermission(PERMS.USERS_EDIT);
@@ -12,7 +13,9 @@ export async function dismissApplication(formData: FormData): Promise<void> {
if (!id) return;
try {
await prisma.websiteStaffApplications.delete({ where: { id } });
await db
.delete(WebsiteStaffApplications)
.where(eq(WebsiteStaffApplications.id, id));
} catch {
// already gone / no DB — nothing to do
}
+37 -29
View File
@@ -1,25 +1,31 @@
"use server";
import { eq } from "drizzle-orm";
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import { requirePermission } from "@/lib/admin/guard";
import {
db,
WebsiteArticleComments,
WebsiteArticleReactions,
WebsiteArticles,
} from "@/lib/db";
import { slugify } from "@/lib/format";
import { PERMS } from "@/lib/permissions";
import { prisma } from "@/lib/prisma";
async function uniqueSlug(title: string): Promise<string> {
const base = slugify(title);
let slug = base;
let n = 2;
while (
await prisma.websiteArticles.findUnique({
where: { slug },
select: { id: true },
})
) {
for (;;) {
const [existing] = await db
.select({ id: WebsiteArticles.id })
.from(WebsiteArticles)
.where(eq(WebsiteArticles.slug, slug))
.limit(1);
if (!existing) return slug;
slug = `${base}-${n++}`;
}
return slug;
}
export async function createArticle(formData: FormData): Promise<void> {
@@ -41,17 +47,15 @@ export async function createArticle(formData: FormData): Promise<void> {
try {
const now = new Date();
await prisma.websiteArticles.create({
data: {
slug: rawSlug ? await uniqueSlug(rawSlug) : await uniqueSlug(title),
title: title.slice(0, 255),
shortStory: shortStory.slice(0, 255),
fullStory,
image: image.slice(0, 255),
userId: staff.id,
createdAt: now,
updatedAt: now,
},
await db.insert(WebsiteArticles).values({
slug: rawSlug ? await uniqueSlug(rawSlug) : await uniqueSlug(title),
title: title.slice(0, 255),
shortStory: shortStory.slice(0, 255),
fullStory,
image: image.slice(0, 255),
userId: staff.id,
createdAt: now,
updatedAt: now,
});
} catch {
// Database error — re-render unchanged with error.
@@ -67,9 +71,9 @@ export async function updateArticle(formData: FormData): Promise<void> {
const id = BigInt(String(formData.get("id")));
const rawSlug = String(formData.get("slug") ?? "").trim();
try {
await prisma.websiteArticles.update({
where: { id },
data: {
await db
.update(WebsiteArticles)
.set({
title: String(formData.get("title") ?? "")
.normalize("NFC")
.trim()
@@ -87,8 +91,8 @@ export async function updateArticle(formData: FormData): Promise<void> {
.trim()
.slice(0, 255),
updatedAt: new Date(),
},
});
})
.where(eq(WebsiteArticles.id, id));
} catch {
redirect("/admin/articles?error=Update failed");
}
@@ -100,11 +104,15 @@ export async function deleteArticle(formData: FormData): Promise<void> {
await requirePermission(PERMS.NEWS_EDIT);
const id = BigInt(String(formData.get("id")));
try {
await prisma.$transaction([
prisma.websiteArticleReactions.deleteMany({ where: { articleId: id } }),
prisma.websiteArticleComments.deleteMany({ where: { articleId: id } }),
prisma.websiteArticles.delete({ where: { id } }),
]);
await db.transaction(async (tx) => {
await tx
.delete(WebsiteArticleReactions)
.where(eq(WebsiteArticleReactions.articleId, id));
await tx
.delete(WebsiteArticleComments)
.where(eq(WebsiteArticleComments.articleId, id));
await tx.delete(WebsiteArticles).where(eq(WebsiteArticles.id, id));
});
} catch {
redirect("/admin/articles?error=Delete failed");
}
+15 -13
View File
@@ -1,9 +1,10 @@
"use server";
import { and, eq, max } from "drizzle-orm";
import { revalidatePath } from "next/cache";
import { requirePermission } from "@/lib/admin/guard";
import { db, UsersBadges } from "@/lib/db";
import { PERMS } from "@/lib/permissions";
import { prisma } from "@/lib/prisma";
import { rcon } from "@/lib/services/rcon";
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
// against duplicates and compute the next free slot ourselves.
try {
const existing = await prisma.usersBadges.findFirst({
where: { userId, badgeCode: code },
select: { id: true },
});
const [existing] = await db
.select({ id: UsersBadges.id })
.from(UsersBadges)
.where(
and(eq(UsersBadges.userId, userId), eq(UsersBadges.badgeCode, code)),
)
.limit(1);
if (!existing) {
const max = await prisma.usersBadges.aggregate({
where: { userId },
_max: { slotId: true },
});
const slotId = (max._max.slotId ?? 0) + 1;
await prisma.usersBadges.create({
data: { userId, slotId, badgeCode: code },
});
const [agg] = await db
.select({ maxSlot: max(UsersBadges.slotId) })
.from(UsersBadges)
.where(eq(UsersBadges.userId, userId));
const slotId = (agg?.maxSlot ?? 0) + 1;
await db.insert(UsersBadges).values({ userId, slotId, badgeCode: code });
}
} catch {
// Best-effort: the RCON grant already succeeded for online users.
+28 -13
View File
@@ -2,17 +2,32 @@
import { revalidatePath } from "next/cache";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { requirePermissionRateLimited } from "@/lib/admin/guard";
import { prisma } from "@/lib/prisma";
import { rcon } from "@/lib/services/rcon";
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/permissions", () => ({ PERMS: { USERS_BAN: "users.ban" } }));
vi.mock("@/lib/prisma", () => ({
prisma: {
user: { findUnique: vi.fn() },
ban: { create: vi.fn(), delete: vi.fn() },
vi.mock("@/lib/db", () => ({
db: {
select: 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/staff-activity", () => ({ logStaffActivity: vi.fn() }));
@@ -26,13 +41,13 @@ const fakeForm = (data: Record<string, string>) => ({
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(requirePermissionRateLimited).mockResolvedValue(staff as never);
selectLimit.mockResolvedValue([{ username: "baduser" }]);
insertValues.mockResolvedValue([{ insertId: 1 }]);
deleteWhere.mockResolvedValue([{ affectedRows: 1 }]);
});
describe("createBan", () => {
it("creates a ban for valid inputs", async () => {
vi.mocked(prisma.user.findUnique).mockResolvedValue({
username: "baduser",
} as never);
await createBan(
fakeForm({
userId: "42",
@@ -41,9 +56,9 @@ describe("createBan", () => {
type: "account",
}) as unknown as FormData,
);
expect(prisma.ban.create).toHaveBeenCalledWith({
data: expect.objectContaining({ userId: 42, type: "account" }),
});
expect(insertValues).toHaveBeenCalledWith(
expect.objectContaining({ userId: 42, type: "account" }),
);
expect(rcon.disconnectUser).toHaveBeenCalledWith(42, "baduser");
expect(revalidatePath).toHaveBeenCalledWith("/admin/bans");
});
@@ -56,14 +71,14 @@ describe("createBan", () => {
type: "account",
}) as unknown as FormData,
);
expect(prisma.ban.create).not.toHaveBeenCalled();
expect(insertValues).not.toHaveBeenCalled();
});
});
describe("liftBan", () => {
it("deletes ban and revalidates", async () => {
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");
});
});
+18 -18
View File
@@ -1,9 +1,10 @@
"use server";
import { eq } from "drizzle-orm";
import { revalidatePath } from "next/cache";
import { requirePermissionRateLimited } from "@/lib/admin/guard";
import { Ban, db, User } from "@/lib/db";
import { PERMS } from "@/lib/permissions";
import { prisma } from "@/lib/prisma";
import { rcon } from "@/lib/services/rcon";
import { logStaffActivity } from "@/lib/services/staff-activity";
@@ -29,23 +30,22 @@ export async function createBan(formData: FormData): Promise<void> {
// Emulator convention: banExpire 0 = permanent (not a far-future timestamp).
const banExpire = hours > 0 ? now + Math.floor(hours) * 3600 : 0;
const user = await prisma.user.findUnique({
where: { id: userId },
select: { username: true },
});
const [user] = await db
.select({ username: User.username })
.from(User)
.where(eq(User.id, userId))
.limit(1);
await prisma.ban.create({
data: {
userId,
ip: "",
machineId: "",
userStaffId: staff.id,
timestamp: now,
banExpire,
banReason: reason,
type: type as any,
cfhTopic: -1,
},
await db.insert(Ban).values({
userId,
ip: "",
machineId: "",
userStaffId: staff.id,
timestamp: now,
banExpire,
banReason: reason,
type: type as "account" | "ip" | "machine" | "super",
cfhTopic: -1,
});
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 id = Number(formData.get("id"));
if (id > 0) {
await prisma.ban.delete({ where: { id } });
await db.delete(Ban).where(eq(Ban.id, id));
await logStaffActivity({
staffId: staff.id,
action: "ban_lift",
+14 -15
View File
@@ -1,10 +1,11 @@
"use server";
import { eq } from "drizzle-orm";
import { revalidatePath } from "next/cache";
import { requirePermission } from "@/lib/admin/guard";
import { db, EmailTemplates } from "@/lib/db";
import { formPositiveBigInt } from "@/lib/form-data";
import { PERMS } from "@/lib/permissions";
import { prisma } from "@/lib/prisma";
export async function createEmailTemplate(formData: FormData): Promise<void> {
await requirePermission(PERMS.PAGES_EDIT);
@@ -23,14 +24,12 @@ export async function createEmailTemplate(formData: FormData): Promise<void> {
const isActive = formData.get("isActive") != null;
if (!name || !subject || !body) return;
await prisma.emailTemplates.create({
data: {
name,
subject,
body,
variables: variablesRaw || null,
isActive,
},
await db.insert(EmailTemplates).values({
name,
subject,
body,
variables: variablesRaw || null,
isActive,
});
revalidatePath("/admin/email-templates");
}
@@ -56,15 +55,15 @@ export async function updateEmailTemplate(formData: FormData): Promise<void> {
const isActive = formData.get("isActive") != null;
if (!subject || !body) return;
await prisma.emailTemplates.update({
where: { id },
data: {
await db
.update(EmailTemplates)
.set({
subject,
body,
variables: variablesRaw || null,
isActive,
},
});
})
.where(eq(EmailTemplates.id, id));
revalidatePath("/admin/email-templates");
}
@@ -72,6 +71,6 @@ export async function deleteEmailTemplate(formData: FormData): Promise<void> {
await requirePermission(PERMS.PAGES_EDIT);
const id = formPositiveBigInt(formData, "id");
if (!id) return;
await prisma.emailTemplates.delete({ where: { id } });
await db.delete(EmailTemplates).where(eq(EmailTemplates.id, id));
revalidatePath("/admin/email-templates");
}
+9 -11
View File
@@ -2,8 +2,8 @@
import { revalidatePath } from "next/cache";
import { requirePermission } from "@/lib/admin/guard";
import { db, EmulatorSettings, EmulatorTexts } from "@/lib/db";
import { PERMS } from "@/lib/permissions";
import { prisma } from "@/lib/prisma";
// emulator_settings: PK is the string column `key`, payload is `value` (VarChar 512).
// emulator_texts: PK is the string column `key`, payload is `value` (VarChar 4096).
@@ -20,11 +20,10 @@ export async function updateEmulatorSetting(formData: FormData): Promise<void> {
.normalize("NFC")
.slice(0, 512);
if (!key) return;
await prisma.emulatorSettings.upsert({
where: { key },
update: { value },
create: { key, value },
});
await db
.insert(EmulatorSettings)
.values({ key, value })
.onDuplicateKeyUpdate({ set: { value } });
revalidatePath("/admin/emulator");
}
@@ -38,10 +37,9 @@ export async function updateEmulatorText(formData: FormData): Promise<void> {
.normalize("NFC")
.slice(0, 4096);
if (!key) return;
await prisma.emulatorTexts.upsert({
where: { key },
update: { value },
create: { key, value },
});
await db
.insert(EmulatorTexts)
.values({ key, value })
.onDuplicateKeyUpdate({ set: { value } });
revalidatePath("/admin/emulator");
}
+56 -35
View File
@@ -2,23 +2,41 @@
import { revalidatePath } from "next/cache";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { requirePermissionRateLimited } from "@/lib/admin/guard";
import { prisma } from "@/lib/prisma";
import { logStaffActivity } from "@/lib/services/staff-activity";
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/permissions", () => ({ PERMS: { USERS_EDIT: "users.edit" } }));
vi.mock("@/lib/prisma", () => ({
prisma: {
guilds: { findUnique: vi.fn(), delete: vi.fn() },
guildsForumsThreads: { findMany: vi.fn(), deleteMany: vi.fn() },
guildsForumsComments: { deleteMany: vi.fn() },
guildForumViews: { deleteMany: vi.fn() },
guildsMembers: { deleteMany: vi.fn() },
rooms: { updateMany: vi.fn() },
items: { updateMany: vi.fn() },
$transaction: vi.fn(),
vi.mock("@/lib/db", () => ({
db: {
select: vi.fn(() => ({
from: vi.fn(() => ({
where: vi.fn(() => ({
limit: selectLimit,
})),
})),
})),
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("next/cache", () => ({ revalidatePath: vi.fn() }));
@@ -35,29 +53,32 @@ beforeEach(() => {
describe("disbandGuild", () => {
it("disbands guild and cleans related data", async () => {
vi.mocked(prisma.guilds.findUnique).mockResolvedValue({
id: 1,
name: "TestGuild",
userId: 42,
} as never);
vi.mocked(prisma.guildsForumsThreads.findMany).mockResolvedValue([
{ id: 10 },
] as never);
vi.mocked(prisma.$transaction).mockImplementation(async (fn: unknown) => {
const tx = {
guildsForumsComments: { deleteMany: vi.fn().mockResolvedValue({}) },
guildsForumsThreads: {
findMany: vi.fn().mockResolvedValue([{ id: 10 }]),
deleteMany: vi.fn().mockResolvedValue({}),
},
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);
});
selectLimit.mockResolvedValue([{ id: 1, name: "TestGuild", userId: 42 }]);
transactionFn.mockImplementation(
async (fn: (tx: unknown) => Promise<void>) => {
const txSelectLimit = vi.fn().mockResolvedValue([{ id: 10 }]);
const tx = {
select: vi.fn(() => ({
from: vi.fn(() => ({
where: vi.fn(() => ({
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);
},
);
await disbandGuild(fakeForm({ id: "1" }) as unknown as FormData);
expect(logStaffActivity).toHaveBeenCalled();
expect(revalidatePath).toHaveBeenCalledWith("/admin/guilds");
@@ -65,6 +86,6 @@ describe("disbandGuild", () => {
it("returns early when id is not positive", async () => {
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";
import { eq, inArray } from "drizzle-orm";
import { revalidatePath } from "next/cache";
import { requirePermissionRateLimited } from "@/lib/admin/guard";
import {
db,
GuildForumViews,
Guilds,
GuildsForumsComments,
GuildsForumsThreads,
GuildsMembers,
Items,
Rooms,
} from "@/lib/db";
import { PERMS } from "@/lib/permissions";
import { prisma } from "@/lib/prisma";
import { logStaffActivity } from "@/lib/services/staff-activity";
/** 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"));
if (!(id > 0)) return;
const guild = await prisma.guilds.findUnique({
where: { id },
select: { id: true, name: true, userId: true },
});
const [guild] = await db
.select({
id: Guilds.id,
name: Guilds.name,
userId: Guilds.userId,
})
.from(Guilds)
.where(eq(Guilds.id, id))
.limit(1);
if (!guild) return;
await prisma.$transaction(async (tx) => {
const threads = await tx.guildsForumsThreads.findMany({
where: { guildId: id },
select: { id: true },
});
await db.transaction(async (tx) => {
const threads = await tx
.select({ id: GuildsForumsThreads.id })
.from(GuildsForumsThreads)
.where(eq(GuildsForumsThreads.guildId, id));
const threadIds = threads.map((t) => t.id);
if (threadIds.length > 0) {
await tx.guildsForumsComments.deleteMany({
where: { threadId: { in: threadIds } },
});
await tx.guildsForumsThreads.deleteMany({ where: { guildId: id } });
await tx
.delete(GuildsForumsComments)
.where(inArray(GuildsForumsComments.threadId, threadIds));
await tx
.delete(GuildsForumsThreads)
.where(eq(GuildsForumsThreads.guildId, id));
}
await tx.guildForumViews.deleteMany({ where: { guildId: id } });
await tx.guildsMembers.deleteMany({ where: { guildId: id } });
await tx.rooms.updateMany({ where: { guildId: id }, data: { guildId: 0 } });
await tx.items.updateMany({ where: { guildId: id }, data: { guildId: 0 } });
await tx.guilds.delete({ where: { id } });
await tx.delete(GuildForumViews).where(eq(GuildForumViews.guildId, id));
await tx.delete(GuildsMembers).where(eq(GuildsMembers.guildId, id));
await tx.update(Rooms).set({ guildId: 0 }).where(eq(Rooms.guildId, id));
await tx.update(Items).set({ guildId: 0 }).where(eq(Items.guildId, id));
await tx.delete(Guilds).where(eq(Guilds.id, id));
});
await logStaffActivity({
+72 -49
View File
@@ -1,9 +1,15 @@
"use server";
import { eq } from "drizzle-orm";
import { revalidatePath } from "next/cache";
import { z } from "zod";
import {
Ban,
db,
WebsiteHelpCenterTicketReplies,
WebsiteHelpCenterTickets,
} from "@/lib/db";
import { PERMS } from "@/lib/permissions";
import { prisma } from "@/lib/prisma";
import { adminAction } from "@/lib/safe-action";
import { ActionError, actionOk } from "@/lib/safe-action-shared";
import { logAudit } from "@/lib/services/audit";
@@ -38,25 +44,32 @@ export const liftBanFromHelpTicket = adminAction(
},
async (ctx) => {
const ticketId = ctx.data.ticketId;
const ticket = await prisma.websiteHelpCenterTickets.findUnique({
where: { id: ticketId },
select: { id: true, userId: true, open: true, title: true },
});
const [ticket] = await db
.select({
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.userId == null) {
throw new ActionError("Ticket has no requester to unban");
}
const removed = await prisma.ban.deleteMany({
where: { userId: ticket.userId },
});
const result = await db.delete(Ban).where(eq(Ban.userId, ticket.userId));
const removed = Number(
(result as unknown as [{ affectedRows: number }])[0]?.affectedRows ?? 0,
);
const now = new Date();
if (ticket.open) {
await prisma.websiteHelpCenterTickets.update({
where: { id: ticketId },
data: { open: false, updatedAt: now },
});
await db
.update(WebsiteHelpCenterTickets)
.set({ open: false, updatedAt: now })
.where(eq(WebsiteHelpCenterTickets.id, ticketId));
}
logAudit({
@@ -66,7 +79,7 @@ export const liftBanFromHelpTicket = adminAction(
targetId: ticket.userId,
after: {
ticketId: String(ticketId),
removedBans: removed.count,
removedBans: removed,
title: ticket.title,
},
});
@@ -74,7 +87,7 @@ export const liftBanFromHelpTicket = adminAction(
revalidateHelpCenterTicketPaths(ticketId);
revalidatePath("/admin/bans");
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 },
async (ctx) => {
const ticketId = ctx.data.ticketId;
const ticket = await prisma.websiteHelpCenterTickets.findUnique({
where: { id: ticketId },
select: { id: true, open: true },
});
const [ticket] = await db
.select({
id: WebsiteHelpCenterTickets.id,
open: WebsiteHelpCenterTickets.open,
})
.from(WebsiteHelpCenterTickets)
.where(eq(WebsiteHelpCenterTickets.id, ticketId))
.limit(1);
if (!ticket) throw new ActionError("Ticket not found");
const now = new Date();
const staffId = Number(ctx.session.user.id);
await prisma.$transaction([
prisma.websiteHelpCenterTicketReplies.create({
data: {
ticketId,
userId: staffId,
content: ctx.data.content.trim(),
createdAt: now,
updatedAt: now,
},
}),
prisma.websiteHelpCenterTickets.update({
where: { id: ticketId },
data: { updatedAt: now },
}),
]);
await db.transaction(async (tx) => {
await tx.insert(WebsiteHelpCenterTicketReplies).values({
ticketId,
userId: staffId,
content: ctx.data.content.trim(),
createdAt: now,
updatedAt: now,
});
await tx
.update(WebsiteHelpCenterTickets)
.set({ updatedAt: now })
.where(eq(WebsiteHelpCenterTickets.id, ticketId));
});
logAudit({
userId: staffId,
@@ -126,19 +141,23 @@ export const closeHelpCenterTicket = adminAction(
{ permission: HELP_TICKET_EDIT, schema: helpCenterTicketIdSchema },
async (ctx) => {
const ticketId = ctx.data.ticketId;
const ticket = await prisma.websiteHelpCenterTickets.findUnique({
where: { id: ticketId },
select: { id: true, open: true },
});
const [ticket] = await db
.select({
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.open) throw new ActionError("Ticket is already closed");
const now = new Date();
await prisma.websiteHelpCenterTickets.update({
where: { id: ticketId },
data: { open: false, updatedAt: now },
});
await db
.update(WebsiteHelpCenterTickets)
.set({ open: false, updatedAt: now })
.where(eq(WebsiteHelpCenterTickets.id, ticketId));
logAudit({
userId: Number(ctx.session.user.id),
@@ -158,19 +177,23 @@ export const reopenHelpCenterTicket = adminAction(
{ permission: HELP_TICKET_EDIT, schema: helpCenterTicketIdSchema },
async (ctx) => {
const ticketId = ctx.data.ticketId;
const ticket = await prisma.websiteHelpCenterTickets.findUnique({
where: { id: ticketId },
select: { id: true, open: true },
});
const [ticket] = await db
.select({
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.open) throw new ActionError("Ticket is already open");
const now = new Date();
await prisma.websiteHelpCenterTickets.update({
where: { id: ticketId },
data: { open: true, updatedAt: now },
});
await db
.update(WebsiteHelpCenterTickets)
.set({ open: true, updatedAt: now })
.where(eq(WebsiteHelpCenterTickets.id, ticketId));
logAudit({
userId: Number(ctx.session.user.id),
+19 -20
View File
@@ -1,23 +1,28 @@
import { redirect } from "next/navigation";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { requirePermission } from "@/lib/admin/guard";
import { prisma } from "@/lib/prisma";
import {
createHelpQuestion,
deleteHelpQuestion,
updateHelpQuestion,
} 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/permissions", () => ({ PERMS: { PAGES_EDIT: "pages.edit" } }));
vi.mock("@/lib/prisma", () => ({
prisma: {
websiteHelpCenterCategories: {
create: vi.fn(),
update: vi.fn(),
delete: vi.fn(),
},
vi.mock("@/lib/db", () => ({
db: {
insert: vi.fn(() => ({ values: insertValues })),
update: vi.fn(() => ({ set: vi.fn(() => ({ where: updateWhere })) })),
delete: vi.fn(() => ({ where: deleteWhere })),
},
WebsiteHelpCenterCategories: { id: "id" },
}));
vi.mock("@/lib/services/staff-activity", () => ({ logStaffActivity: vi.fn() }));
vi.mock("next/cache", () => ({ revalidatePath: vi.fn() }));
@@ -31,29 +36,26 @@ const fakeForm = (data: Record<string, string | null>) => ({
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(requirePermission).mockResolvedValue(staff as never);
insertValues.mockResolvedValue([{ insertId: 5 }]);
updateWhere.mockResolvedValue([{ affectedRows: 1 }]);
deleteWhere.mockResolvedValue([{ affectedRows: 1 }]);
});
describe("createHelpQuestion", () => {
it("creates a help question and redirects", async () => {
vi.mocked(prisma.websiteHelpCenterCategories.create).mockResolvedValue({
id: BigInt(5),
} as never);
await createHelpQuestion(
fakeForm({
name: "FAQ",
content: "<p>Answer</p>",
}) as unknown as FormData,
);
expect(prisma.websiteHelpCenterCategories.create).toHaveBeenCalled();
expect(insertValues).toHaveBeenCalled();
expect(redirect).toHaveBeenCalledWith("/admin/help-questions");
});
});
describe("updateHelpQuestion", () => {
it("updates and redirects", async () => {
vi.mocked(prisma.websiteHelpCenterCategories.update).mockResolvedValue(
{} as never,
);
await updateHelpQuestion(
fakeForm({
id: "42",
@@ -61,18 +63,15 @@ describe("updateHelpQuestion", () => {
content: "New",
}) as unknown as FormData,
);
expect(prisma.websiteHelpCenterCategories.update).toHaveBeenCalled();
expect(updateWhere).toHaveBeenCalled();
expect(redirect).toHaveBeenCalledWith("/admin/help-questions");
});
});
describe("deleteHelpQuestion", () => {
it("deletes and redirects", async () => {
vi.mocked(prisma.websiteHelpCenterCategories.delete).mockResolvedValue(
{} as never,
);
await deleteHelpQuestion(fakeForm({ id: "42" }) as unknown as FormData);
expect(prisma.websiteHelpCenterCategories.delete).toHaveBeenCalled();
expect(deleteWhere).toHaveBeenCalled();
expect(redirect).toHaveBeenCalledWith("/admin/help-questions");
});
});
+24 -22
View File
@@ -1,12 +1,14 @@
"use server";
import { eq } from "drizzle-orm";
import type { ResultSetHeader } from "mysql2";
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import { requirePermission } from "@/lib/admin/guard";
import { db, WebsiteHelpCenterCategories } from "@/lib/db";
import { formPositiveBigInt } from "@/lib/form-data";
import { canonicalize, sanitizeField } from "@/lib/foundation/security";
import { PERMS } from "@/lib/permissions";
import { prisma } from "@/lib/prisma";
import { logStaffActivity } from "@/lib/services/staff-activity";
// CRUD for help-center FAQ entries (website_help_center_categories). Each entry
@@ -32,25 +34,23 @@ export async function createHelpQuestion(formData: FormData): Promise<void> {
sanitizeField(formData.get("buttonBorderColor"), 16) || "#facc15";
try {
const entry = await prisma.websiteHelpCenterCategories.create({
data: {
name,
content,
position: parsePosition(formData.get("position")),
imageUrl: imageUrl || null,
buttonText: buttonText || null,
buttonUrl: buttonUrl || null,
buttonColor,
buttonBorderColor,
smallBox: formData.get("smallBox") != null,
},
});
const [result] = (await db.insert(WebsiteHelpCenterCategories).values({
name,
content,
position: parsePosition(formData.get("position")),
imageUrl: imageUrl || null,
buttonText: buttonText || null,
buttonUrl: buttonUrl || null,
buttonColor,
buttonBorderColor,
smallBox: formData.get("smallBox") != null,
})) as unknown as [ResultSetHeader];
await logStaffActivity({
staffId: staff.id,
action: "help_create",
description: `Created help-center entry #${entry.id} (${name})`,
description: `Created help-center entry #${result.insertId} (${name})`,
targetType: "help_center_category",
targetId: Number(entry.id),
targetId: Number(result.insertId),
});
} catch {
// 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";
try {
await prisma.websiteHelpCenterCategories.update({
where: { id },
data: {
await db
.update(WebsiteHelpCenterCategories)
.set({
name,
content,
position: parsePosition(formData.get("position")),
@@ -93,8 +93,8 @@ export async function updateHelpQuestion(formData: FormData): Promise<void> {
buttonColor,
buttonBorderColor,
smallBox: formData.get("smallBox") != null,
},
});
})
.where(eq(WebsiteHelpCenterCategories.id, id));
await logStaffActivity({
staffId: staff.id,
action: "help_update",
@@ -116,7 +116,9 @@ export async function deleteHelpQuestion(formData: FormData): Promise<void> {
if (!id) return;
try {
await prisma.websiteHelpCenterCategories.delete({ where: { id } });
await db
.delete(WebsiteHelpCenterCategories)
.where(eq(WebsiteHelpCenterCategories.id, id));
await logStaffActivity({
staffId: staff.id,
action: "help_delete",
+25 -16
View File
@@ -2,7 +2,6 @@
import { revalidatePath } from "next/cache";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { requirePermission } from "@/lib/admin/guard";
import { prisma } from "@/lib/prisma";
import {
addBlacklist,
addWhitelist,
@@ -10,15 +9,23 @@ import {
deleteWhitelist,
} 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/permissions", () => ({
PERMS: { SETTINGS_EDIT: "settings.edit" },
}));
vi.mock("@/lib/prisma", () => ({
prisma: {
websiteIpWhitelist: { create: vi.fn(), delete: vi.fn() },
websiteIpBlacklist: { create: vi.fn(), delete: vi.fn() },
vi.mock("@/lib/db", () => ({
db: {
insert: vi.fn(() => ({ values: insertValues })),
delete: vi.fn(() => ({ where: deleteWhere })),
},
WebsiteIpWhitelist: { id: "id" },
WebsiteIpBlacklist: { id: "id" },
}));
vi.mock("next/cache", () => ({ revalidatePath: vi.fn() }));
@@ -30,6 +37,8 @@ const fakeForm = (data: Record<string, string>) => ({
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(requirePermission).mockResolvedValue(staff as never);
insertValues.mockResolvedValue([{ insertId: 1 }]);
deleteWhere.mockResolvedValue([{ affectedRows: 1 }]);
});
describe("addWhitelist", () => {
@@ -37,24 +46,24 @@ describe("addWhitelist", () => {
await addWhitelist(
fakeForm({ ipAddress: "192.168.1.1" }) as unknown as FormData,
);
expect(prisma.websiteIpWhitelist.create).toHaveBeenCalledWith({
data: { ipAddress: "192.168.1.1", asn: null, whitelistAsn: false },
expect(insertValues).toHaveBeenCalledWith({
ipAddress: "192.168.1.1",
asn: null,
whitelistAsn: false,
});
expect(revalidatePath).toHaveBeenCalledWith("/admin/ip");
});
it("returns early when ip is empty", async () => {
await addWhitelist(fakeForm({ ipAddress: "" }) as unknown as FormData);
expect(prisma.websiteIpWhitelist.create).not.toHaveBeenCalled();
expect(insertValues).not.toHaveBeenCalled();
});
});
describe("deleteWhitelist", () => {
it("deletes whitelist entry", async () => {
await deleteWhitelist(fakeForm({ id: "42" }) as unknown as FormData);
expect(prisma.websiteIpWhitelist.delete).toHaveBeenCalledWith({
where: { id: BigInt(42) },
});
expect(deleteWhere).toHaveBeenCalled();
});
});
@@ -63,8 +72,10 @@ describe("addBlacklist", () => {
await addBlacklist(
fakeForm({ ipAddress: "203.0.113.1" }) as unknown as FormData,
);
expect(prisma.websiteIpBlacklist.create).toHaveBeenCalledWith({
data: { ipAddress: "203.0.113.1", asn: null, blacklistAsn: false },
expect(insertValues).toHaveBeenCalledWith({
ipAddress: "203.0.113.1",
asn: null,
blacklistAsn: false,
});
});
});
@@ -72,8 +83,6 @@ describe("addBlacklist", () => {
describe("deleteBlacklist", () => {
it("deletes blacklist entry", async () => {
await deleteBlacklist(fakeForm({ id: "99" }) as unknown as FormData);
expect(prisma.websiteIpBlacklist.delete).toHaveBeenCalledWith({
where: { id: BigInt(99) },
});
expect(deleteWhere).toHaveBeenCalled();
});
});
+16 -7
View File
@@ -1,9 +1,10 @@
"use server";
import { eq } from "drizzle-orm";
import { revalidatePath } from "next/cache";
import { requirePermission } from "@/lib/admin/guard";
import { db, WebsiteIpBlacklist, WebsiteIpWhitelist } from "@/lib/db";
import { PERMS } from "@/lib/permissions";
import { prisma } from "@/lib/prisma";
function parseIp(formData: FormData): string {
return String(formData.get("ipAddress") ?? "")
@@ -25,8 +26,10 @@ export async function addWhitelist(formData: FormData): Promise<void> {
const ipAddress = parseIp(formData);
if (!ipAddress) return;
const asn = parseAsn(formData);
await prisma.websiteIpWhitelist.create({
data: { ipAddress, asn, whitelistAsn: asn != null },
await db.insert(WebsiteIpWhitelist).values({
ipAddress,
asn,
whitelistAsn: asn != null,
});
revalidatePath("/admin/ip");
}
@@ -37,7 +40,9 @@ export async function deleteWhitelist(formData: FormData): Promise<void> {
.normalize("NFC")
.trim();
if (!raw) return;
await prisma.websiteIpWhitelist.delete({ where: { id: BigInt(raw) } });
await db
.delete(WebsiteIpWhitelist)
.where(eq(WebsiteIpWhitelist.id, BigInt(raw)));
revalidatePath("/admin/ip");
}
@@ -46,8 +51,10 @@ export async function addBlacklist(formData: FormData): Promise<void> {
const ipAddress = parseIp(formData);
if (!ipAddress) return;
const asn = parseAsn(formData);
await prisma.websiteIpBlacklist.create({
data: { ipAddress, asn, blacklistAsn: asn != null },
await db.insert(WebsiteIpBlacklist).values({
ipAddress,
asn,
blacklistAsn: asn != null,
});
revalidatePath("/admin/ip");
}
@@ -58,6 +65,8 @@ export async function deleteBlacklist(formData: FormData): Promise<void> {
.normalize("NFC")
.trim();
if (!raw) return;
await prisma.websiteIpBlacklist.delete({ where: { id: BigInt(raw) } });
await db
.delete(WebsiteIpBlacklist)
.where(eq(WebsiteIpBlacklist.id, BigInt(raw)));
revalidatePath("/admin/ip");
}
+47 -41
View File
@@ -1,12 +1,24 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const { mockUpsert, mockRequirePermission, mockReload, mockRevalidatePath } =
vi.hoisted(() => ({
mockUpsert: vi.fn(),
const {
mockValues,
mockOnDuplicateKeyUpdate,
mockRequirePermission,
mockReload,
mockRevalidatePath,
} = vi.hoisted(() => {
const mockOnDuplicateKeyUpdate = vi.fn().mockResolvedValue(undefined);
const mockValues = vi.fn(() => ({
onDuplicateKeyUpdate: mockOnDuplicateKeyUpdate,
}));
return {
mockValues,
mockOnDuplicateKeyUpdate,
mockRequirePermission: vi.fn(),
mockReload: vi.fn(),
mockRevalidatePath: vi.fn(),
}));
};
});
vi.mock("@/lib/permissions", () => ({
PERMS: {
@@ -16,10 +28,11 @@ vi.mock("@/lib/permissions", () => ({
},
}));
vi.mock("@/lib/prisma", () => ({
prisma: {
websiteSetting: { upsert: mockUpsert },
vi.mock("@/lib/db", () => ({
db: {
insert: vi.fn(() => ({ values: mockValues })),
},
WebsiteSetting: { key: "key", value: "value" },
}));
vi.mock("@/lib/admin/guard", () => ({
@@ -38,6 +51,10 @@ import { saveMaintenance } from "./admin-maintenance";
beforeEach(() => {
vi.clearAllMocks();
mockValues.mockReturnValue({
onDuplicateKeyUpdate: mockOnDuplicateKeyUpdate,
});
mockOnDuplicateKeyUpdate.mockResolvedValue(undefined);
});
describe("saveMaintenance", () => {
@@ -57,37 +74,26 @@ describe("saveMaintenance", () => {
expect(mockRequirePermission).toHaveBeenCalled();
expect(mockUpsert).toHaveBeenCalledTimes(3);
expect(mockUpsert).toHaveBeenCalledWith(
expect(mockValues).toHaveBeenCalledTimes(3);
expect(mockValues).toHaveBeenCalledWith(
expect.objectContaining({
where: { key: "maintenance_enabled" },
update: { value: "1" },
create: expect.objectContaining({
key: "maintenance_enabled",
value: "1",
}),
key: "maintenance_enabled",
value: "1",
}),
);
expect(mockUpsert).toHaveBeenCalledWith(
expect(mockValues).toHaveBeenCalledWith(
expect.objectContaining({
where: { key: "maintenance_message" },
update: { value: "We will be back soon!" },
create: expect.objectContaining({
key: "maintenance_message",
value: "We will be back soon!",
}),
key: "maintenance_message",
value: "We will be back soon!",
}),
);
expect(mockUpsert).toHaveBeenCalledWith(
expect(mockValues).toHaveBeenCalledWith(
expect.objectContaining({
where: { key: "min_maintenance_login_rank" },
update: { value: "3" },
create: expect.objectContaining({
key: "min_maintenance_login_rank",
value: "3",
}),
key: "min_maintenance_login_rank",
value: "3",
}),
);
expect(mockOnDuplicateKeyUpdate).toHaveBeenCalledTimes(3);
expect(mockReload).toHaveBeenCalledOnce();
expect(mockRevalidatePath).toHaveBeenCalledWith("/admin/maintenance");
@@ -106,16 +112,16 @@ describe("saveMaintenance", () => {
await saveMaintenance(fd);
expect(mockUpsert).toHaveBeenCalledWith(
expect(mockValues).toHaveBeenCalledWith(
expect.objectContaining({
where: { key: "maintenance_enabled" },
update: { value: "0" },
key: "maintenance_enabled",
value: "0",
}),
);
expect(mockUpsert).toHaveBeenCalledWith(
expect(mockValues).toHaveBeenCalledWith(
expect.objectContaining({
where: { key: "min_maintenance_login_rank" },
update: { value: "5" },
key: "min_maintenance_login_rank",
value: "5",
}),
);
});
@@ -134,10 +140,10 @@ describe("saveMaintenance", () => {
await saveMaintenance(fd);
expect(mockUpsert).toHaveBeenCalledWith(
expect(mockValues).toHaveBeenCalledWith(
expect.objectContaining({
where: { key: "min_maintenance_login_rank" },
update: { value: "5" },
key: "min_maintenance_login_rank",
value: "5",
}),
);
});
@@ -156,10 +162,10 @@ describe("saveMaintenance", () => {
await saveMaintenance(fd);
expect(mockUpsert).toHaveBeenCalledWith(
expect(mockValues).toHaveBeenCalledWith(
expect.objectContaining({
where: { key: "min_maintenance_login_rank" },
update: { value: "5" },
key: "min_maintenance_login_rank",
value: "5",
}),
);
});
+10 -7
View File
@@ -2,8 +2,8 @@
import { revalidatePath } from "next/cache";
import { requirePermission } from "@/lib/admin/guard";
import { db, WebsiteSetting } from "@/lib/db";
import { PERMS } from "@/lib/permissions";
import { prisma } from "@/lib/prisma";
import { siteSettings } from "@/lib/services/site-settings";
// 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> {
await prisma.websiteSetting.upsert({
where: { key },
update: { value },
// eslint-disable-next-line security/detect-object-injection -- key is one of 3 known const values
create: { key, value, comment: COMMENTS[key] ?? null },
});
await db
.insert(WebsiteSetting)
.values({
key,
value,
// eslint-disable-next-line security/detect-object-injection -- key is one of 3 known const values
comment: COMMENTS[key] ?? null,
})
.onDuplicateKeyUpdate({ set: { value } });
}
export async function saveMaintenance(formData: FormData): Promise<void> {
+17 -9
View File
@@ -1,9 +1,10 @@
"use server";
import { eq } from "drizzle-orm";
import { revalidatePath } from "next/cache";
import { requirePermissionRateLimited } from "@/lib/admin/guard";
import { db, MarketplaceItems } from "@/lib/db";
import { PERMS } from "@/lib/permissions";
import { prisma } from "@/lib/prisma";
import { logStaffActivity } from "@/lib/services/staff-activity";
/** Cancel an active marketplace listing (state 1 → 0). */
@@ -14,16 +15,23 @@ export async function cancelMarketplaceListing(
const id = Number(formData.get("id"));
if (!(id > 0)) return;
const listing = await prisma.marketplaceItems.findUnique({
where: { id },
select: { id: true, state: true, userId: true, itemId: true, price: true },
});
const [listing] = await db
.select({
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;
await prisma.marketplaceItems.update({
where: { id },
data: { state: 0 },
});
await db
.update(MarketplaceItems)
.set({ state: 0 })
.where(eq(MarketplaceItems.id, id));
await logStaffActivity({
staffId: staff.id,
+26 -22
View File
@@ -1,11 +1,12 @@
"use server";
import { randomBytes } from "node:crypto";
import { eq } from "drizzle-orm";
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import { requirePermission } from "@/lib/admin/guard";
import { db, RadioApiKeys } from "@/lib/db";
import { PERMS } from "@/lib/permissions";
import { prisma } from "@/lib/prisma";
import { logStaffActivity } from "@/lib/services/staff-activity";
// Radio API keys (radio_api_keys). External integrations (AzureCast bridges,
@@ -50,23 +51,22 @@ export async function createApiKey(formData: FormData): Promise<void> {
const now = new Date();
try {
const created = await prisma.radioApiKeys.create({
data: {
name,
key,
allowedIps,
rateLimit,
isActive: true,
createdAt: now,
updatedAt: now,
},
const [result] = await db.insert(RadioApiKeys).values({
name,
key,
allowedIps,
rateLimit,
isActive: true,
createdAt: now,
updatedAt: now,
});
const createdId = BigInt(result.insertId);
await logStaffActivity({
staffId: staff.id,
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",
targetId: Number(created.id),
targetId: Number(createdId),
});
} catch {
// 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;
try {
const existing = await prisma.radioApiKeys.findUnique({
where: { id },
select: { name: true, isActive: true },
});
const [existing] = await db
.select({
name: RadioApiKeys.name,
isActive: RadioApiKeys.isActive,
})
.from(RadioApiKeys)
.where(eq(RadioApiKeys.id, id))
.limit(1);
if (!existing) return;
const next = !existing.isActive;
await prisma.radioApiKeys.update({
where: { id },
data: { isActive: next, updatedAt: new Date() },
});
await db
.update(RadioApiKeys)
.set({ isActive: next, updatedAt: new Date() })
.where(eq(RadioApiKeys.id, id));
await logStaffActivity({
staffId: staff.id,
action: "radio_api_key_toggle",
@@ -116,7 +120,7 @@ export async function deleteApiKey(formData: FormData): Promise<void> {
if (id == null) return;
try {
await prisma.radioApiKeys.delete({ where: { id } });
await db.delete(RadioApiKeys).where(eq(RadioApiKeys.id, id));
await logStaffActivity({
staffId: staff.id,
action: "radio_api_key_delete",
+19 -19
View File
@@ -1,9 +1,10 @@
"use server";
import { eq } from "drizzle-orm";
import { revalidatePath } from "next/cache";
import { requirePermission } from "@/lib/admin/guard";
import { db, RadioAutoDjPlaylist } from "@/lib/db";
import { PERMS } from "@/lib/permissions";
import { prisma } from "@/lib/prisma";
import { logStaffActivity } from "@/lib/services/staff-activity";
// AutoDJ playlist CRUD (radio_auto_dj_playlist). CMS-owned table backing the
@@ -65,25 +66,24 @@ export async function createTrack(formData: FormData): Promise<void> {
const now = new Date();
try {
const created = await prisma.radioAutoDjPlaylist.create({
data: {
title,
artist: artist || null,
album: album || null,
artworkUrl: artworkUrl || null,
duration,
sortOrder,
isActive,
createdAt: now,
updatedAt: now,
},
const [result] = await db.insert(RadioAutoDjPlaylist).values({
title,
artist: artist || null,
album: album || null,
artworkUrl: artworkUrl || null,
duration,
sortOrder,
isActive,
createdAt: now,
updatedAt: now,
});
const createdId = Number(result.insertId);
await logStaffActivity({
staffId: staff.id,
action: "radio_autodj_create",
description: `Created AutoDJ track "${title}"${artist ? ` by ${artist}` : ""}`,
targetType: "radio_auto_dj_track",
targetId: Number(created.id),
targetId: createdId,
});
} catch {
// 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"));
try {
await prisma.radioAutoDjPlaylist.update({
where: { id },
data: { isActive, updatedAt: new Date() },
});
await db
.update(RadioAutoDjPlaylist)
.set({ isActive, updatedAt: new Date() })
.where(eq(RadioAutoDjPlaylist.id, id));
await logStaffActivity({
staffId: staff.id,
action: "radio_autodj_toggle",
@@ -123,7 +123,7 @@ export async function deleteTrack(formData: FormData): Promise<void> {
if (id === null) return;
try {
await prisma.radioAutoDjPlaylist.delete({ where: { id } });
await db.delete(RadioAutoDjPlaylist).where(eq(RadioAutoDjPlaylist.id, id));
await logStaffActivity({
staffId: staff.id,
action: "radio_autodj_delete",
+39 -44
View File
@@ -1,9 +1,10 @@
"use server";
import { eq } from "drizzle-orm";
import { revalidatePath } from "next/cache";
import { requirePermission } from "@/lib/admin/guard";
import { db, RadioBanners, RadioRanks, WebsiteSetting } from "@/lib/db";
import { PERMS } from "@/lib/permissions";
import { prisma } from "@/lib/prisma";
import { siteSettings } from "@/lib/services/site-settings";
// ── Helpers ────────────────────────────────────────────────────────────────
@@ -44,11 +45,10 @@ export async function saveRadioSetting(formData: FormData): Promise<void> {
if (!key) return;
try {
await prisma.websiteSetting.upsert({
where: { key },
update: { value },
create: { key, value, comment: comment || null },
});
await db
.insert(WebsiteSetting)
.values({ key, value, comment: comment || null })
.onDuplicateKeyUpdate({ set: { value } });
siteSettings.reload();
} catch {
// 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;
try {
await prisma.$transaction(
await Promise.all(
keys.map((key) => {
const value = str(formData.get(key));
return prisma.websiteSetting.upsert({
where: { key },
update: { value },
create: { key, value, comment: null },
});
return db
.insert(WebsiteSetting)
.values({ key, value, comment: null })
.onDuplicateKeyUpdate({ set: { value } });
}),
);
siteSettings.reload();
@@ -105,17 +104,15 @@ export async function createRadioBanner(formData: FormData): Promise<void> {
const now = new Date();
try {
await prisma.radioBanners.create({
data: {
userId: BigInt(staff.id),
imagePath,
title: title || null,
description: description || null,
sortOrder,
isActive,
createdAt: now,
updatedAt: now,
},
await db.insert(RadioBanners).values({
userId: BigInt(staff.id),
imagePath,
title: title || null,
description: description || null,
sortOrder,
isActive,
createdAt: now,
updatedAt: now,
});
} catch {
// Fail soft.
@@ -139,17 +136,17 @@ export async function updateRadioBanner(formData: FormData): Promise<void> {
if (!imagePath) return;
try {
await prisma.radioBanners.update({
where: { id },
data: {
await db
.update(RadioBanners)
.set({
imagePath,
title: title || null,
description: description || null,
sortOrder,
isActive,
updatedAt: new Date(),
},
});
})
.where(eq(RadioBanners.id, id));
} catch {
// Row may be gone; ignore.
}
@@ -161,7 +158,7 @@ export async function deleteRadioBanner(formData: FormData): Promise<void> {
const id = parseId(formData.get("id"));
if (id === null) return;
try {
await prisma.radioBanners.delete({ where: { id } });
await db.delete(RadioBanners).where(eq(RadioBanners.id, id));
} catch {
// Already deleted; ignore.
}
@@ -181,15 +178,13 @@ export async function createRadioRank(formData: FormData): Promise<void> {
const now = new Date();
try {
await prisma.radioRanks.create({
data: {
name,
description: description || null,
badgeCode: badgeCode || null,
isActive,
createdAt: now,
updatedAt: now,
},
await db.insert(RadioRanks).values({
name,
description: description || null,
badgeCode: badgeCode || null,
isActive,
createdAt: now,
updatedAt: now,
});
} catch {
// Fail soft.
@@ -209,16 +204,16 @@ export async function updateRadioRank(formData: FormData): Promise<void> {
if (!name) return;
try {
await prisma.radioRanks.update({
where: { id },
data: {
await db
.update(RadioRanks)
.set({
name,
description: description || null,
badgeCode: badgeCode || null,
isActive,
updatedAt: new Date(),
},
});
})
.where(eq(RadioRanks.id, id));
} catch {
// Row may be gone; ignore.
}
@@ -230,7 +225,7 @@ export async function deleteRadioRank(formData: FormData): Promise<void> {
const id = parseId(formData.get("id"));
if (id === null) return;
try {
await prisma.radioRanks.delete({ where: { id } });
await db.delete(RadioRanks).where(eq(RadioRanks.id, id));
} catch {
// Already deleted; ignore.
}
+3 -2
View File
@@ -1,9 +1,10 @@
"use server";
import { eq } from "drizzle-orm";
import { revalidatePath } from "next/cache";
import { requirePermission } from "@/lib/admin/guard";
import { db, RadioShouts } from "@/lib/db";
import { PERMS } from "@/lib/permissions";
import { prisma } from "@/lib/prisma";
import { logStaffActivity } from "@/lib/services/staff-activity";
/** Parse a FormData field into a positive BigInt id, or null when invalid. */
@@ -28,7 +29,7 @@ export async function deleteShout(formData: FormData): Promise<void> {
if (id === null) return;
try {
await prisma.radioShouts.delete({ where: { id } });
await db.delete(RadioShouts).where(eq(RadioShouts.id, id));
await logStaffActivity({
staffId: staff.id,
action: "radio.shout.delete",
+9 -8
View File
@@ -3,8 +3,8 @@
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import { requirePermission } from "@/lib/admin/guard";
import { db, WebsiteSetting } from "@/lib/db";
import { PERMS } from "@/lib/permissions";
import { prisma } from "@/lib/prisma";
import { siteSettings } from "@/lib/services/site-settings";
import { logStaffActivity } from "@/lib/services/staff-activity";
@@ -67,15 +67,16 @@ export async function savePoints(formData: FormData): Promise<void> {
};
try {
await prisma.$transaction(
await Promise.all(
POINTS_KEYS.map((key) =>
prisma.websiteSetting.upsert({
where: { key },
db
.insert(WebsiteSetting)
// eslint-disable-next-line security/detect-object-injection -- key from POINTS_KEYS const
update: { value: values[key] },
// eslint-disable-next-line security/detect-object-injection -- key from POINTS_KEYS const
create: { key, value: values[key], comment: "Radio points" },
}),
.values({ key, value: values[key], comment: "Radio points" })
.onDuplicateKeyUpdate({
// eslint-disable-next-line security/detect-object-injection -- key from POINTS_KEYS const
set: { value: values[key] },
}),
),
);
siteSettings.reload();
+21 -16
View File
@@ -1,10 +1,11 @@
"use server";
import { eq } from "drizzle-orm";
import { revalidatePath } from "next/cache";
import { requirePermission } from "@/lib/admin/guard";
import { db, WebsiteRareValueCategories, WebsiteRareValues } from "@/lib/db";
import { formPositiveBigInt } from "@/lib/form-data";
import { PERMS } from "@/lib/permissions";
import { prisma } from "@/lib/prisma";
export async function createCategory(formData: FormData): Promise<void> {
await requirePermission(PERMS.SHOP_EDIT);
@@ -24,8 +25,10 @@ export async function createCategory(formData: FormData): Promise<void> {
if (!name || !badge) return;
try {
await prisma.websiteRareValueCategories.create({
data: { name, badge, priority },
await db.insert(WebsiteRareValueCategories).values({
name,
badge,
priority,
});
} catch {
// 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 {
// Remove the category's values first to avoid orphaned rows.
await prisma.websiteRareValues.deleteMany({ where: { categoryId: id } });
await prisma.websiteRareValueCategories.delete({ where: { id } });
await db
.delete(WebsiteRareValues)
.where(eq(WebsiteRareValues.categoryId, id));
await db
.delete(WebsiteRareValueCategories)
.where(eq(WebsiteRareValueCategories.id, id));
} catch {
// Not found or DB error — ignore.
}
@@ -81,16 +88,14 @@ export async function createValue(formData: FormData): Promise<void> {
.slice(0, 255) || "diamonds";
try {
await prisma.websiteRareValues.create({
data: {
categoryId,
itemId,
name,
creditValue: creditValueRaw || null,
currencyValue: currencyValueRaw || null,
currencyType,
furnitureIcon,
},
await db.insert(WebsiteRareValues).values({
categoryId,
itemId,
name,
creditValue: creditValueRaw || null,
currencyValue: currencyValueRaw || null,
currencyType,
furnitureIcon,
});
} catch {
// DB error — ignore.
@@ -104,7 +109,7 @@ export async function deleteValue(formData: FormData): Promise<void> {
if (!id) return;
try {
await prisma.websiteRareValues.delete({ where: { id } });
await db.delete(WebsiteRareValues).where(eq(WebsiteRareValues.id, id));
} catch {
// Not found or DB error — ignore.
}
+15 -13
View File
@@ -1,16 +1,17 @@
"use server";
import { eq } from "drizzle-orm";
import { revalidatePath } from "next/cache";
import { z } from "zod";
import { MANAGED_SETTING_KEYS } from "@/app/admin/settings/cms-settings-config";
import { requirePermissionRateLimited } from "@/lib/admin/guard";
import { db, WebsiteSetting } from "@/lib/db";
import { actionOk, adminAction } from "@/lib/foundation/action";
import {
HABBO_GAMEDATA_HOTEL_SETTING_KEY,
normalizeHabboGamedataHotel,
} from "@/lib/habbo-gamedata-hotel";
import { PERMS } from "@/lib/permissions";
import { prisma } from "@/lib/prisma";
import { clearHabboItCache } from "@/lib/services/habbo-furnidata-cache";
import { clearBadgeCache } from "@/lib/services/habboassets";
import { siteSettings } from "@/lib/services/site-settings";
@@ -48,11 +49,10 @@ export const saveManagedSettings = adminAction(
.map(([key, value]) => [key, normalizeSettingValue(key, value)] as const);
await Promise.all(
entries.map(([key, value]) =>
prisma.websiteSetting.upsert({
where: { key },
update: { value },
create: { key, value },
}),
db
.insert(WebsiteSetting)
.values({ key, value })
.onDuplicateKeyUpdate({ set: { value } }),
),
);
await siteSettings.reload();
@@ -76,7 +76,10 @@ export async function updateSetting(formData: FormData): Promise<void> {
String(formData.get("value") ?? "").normalize("NFC"),
);
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();
bustGamedataCachesIfNeeded(key);
revalidatePath("/admin/settings");
@@ -97,11 +100,10 @@ export async function createSetting(formData: FormData): Promise<void> {
.trim()
.slice(0, 255);
if (!key) return;
await prisma.websiteSetting.upsert({
where: { key },
update: { value },
create: { key, value, comment: comment || null },
});
await db
.insert(WebsiteSetting)
.values({ key, value, comment: comment || null })
.onDuplicateKeyUpdate({ set: { value } });
await siteSettings.reload();
bustGamedataCachesIfNeeded(key);
revalidatePath("/admin/settings");
@@ -113,7 +115,7 @@ export async function deleteSetting(formData: FormData): Promise<void> {
.normalize("NFC")
.trim();
if (!key) return;
await prisma.websiteSetting.delete({ where: { key } });
await db.delete(WebsiteSetting).where(eq(WebsiteSetting.key, key));
await siteSettings.reload();
bustGamedataCachesIfNeeded(key);
revalidatePath("/admin/settings");
+38 -37
View File
@@ -1,11 +1,13 @@
"use server";
import { eq } from "drizzle-orm";
import type { ResultSetHeader } from "mysql2";
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import { requirePermission } from "@/lib/admin/guard";
import { db, WebsiteShopArticles } from "@/lib/db";
import { formPositiveBigInt } from "@/lib/form-data";
import { PERMS } from "@/lib/permissions";
import { prisma } from "@/lib/prisma";
import { logServerError } from "@/lib/server-log";
import { logStaffActivity } from "@/lib/services/staff-activity";
@@ -40,43 +42,42 @@ export async function createShopArticle(formData: FormData): Promise<void> {
if (!name) return;
const now = new Date();
const costs = reqUInt(formData, "costs");
try {
const created = await prisma.websiteShopArticles.create({
data: {
name,
info: String(formData.get("info") ?? "")
const [result] = (await db.insert(WebsiteShopArticles).values({
name,
info: String(formData.get("info") ?? "")
.normalize("NFC")
.trim()
.slice(0, 255),
iconUrl: String(formData.get("icon") ?? "")
.normalize("NFC")
.trim()
.slice(0, 255),
color: String(formData.get("color") ?? "")
.normalize("NFC")
.trim()
.slice(0, 255),
costs,
giveRank: optUInt(formData, "giveRank"),
credits: optUInt(formData, "credits"),
duckets: optUInt(formData, "duckets"),
diamonds: optUInt(formData, "diamonds"),
badges:
String(formData.get("badges") ?? "")
.normalize("NFC")
.trim()
.slice(0, 255),
iconUrl: String(formData.get("icon") ?? "")
.normalize("NFC")
.trim()
.slice(0, 255),
color: String(formData.get("color") ?? "")
.normalize("NFC")
.trim()
.slice(0, 255),
costs: reqUInt(formData, "costs"),
giveRank: optUInt(formData, "giveRank"),
credits: optUInt(formData, "credits"),
duckets: optUInt(formData, "duckets"),
diamonds: optUInt(formData, "diamonds"),
badges:
String(formData.get("badges") ?? "")
.normalize("NFC")
.trim()
.slice(0, 255) || null,
position: reqUInt(formData, "position"),
createdAt: now,
updatedAt: now,
},
});
.slice(0, 255) || null,
position: reqUInt(formData, "position"),
createdAt: now,
updatedAt: now,
})) as unknown as [ResultSetHeader];
await logStaffActivity({
staffId: staff.id,
action: "shop_create",
description: `Created shop package "${name}" (${created.costs} costs)`,
description: `Created shop package "${name}" (${costs} costs)`,
targetType: "shop_article",
targetId: Number(created.id),
targetId: Number(result.insertId),
});
} catch (error) {
logServerError("admin.shop_create_failed", error, {
@@ -103,9 +104,9 @@ export async function updateShopArticle(formData: FormData): Promise<void> {
if (!name) return;
try {
await prisma.websiteShopArticles.update({
where: { id },
data: {
await db
.update(WebsiteShopArticles)
.set({
name,
info: String(formData.get("info") ?? "")
.normalize("NFC")
@@ -131,8 +132,8 @@ export async function updateShopArticle(formData: FormData): Promise<void> {
.slice(0, 255) || null,
position: reqUInt(formData, "position"),
updatedAt: new Date(),
},
});
})
.where(eq(WebsiteShopArticles.id, id));
await logStaffActivity({
staffId: staff.id,
action: "shop_update",
@@ -159,7 +160,7 @@ export async function deleteShopArticle(formData: FormData): Promise<void> {
if (!id) return;
try {
await prisma.websiteShopArticles.delete({ where: { id } });
await db.delete(WebsiteShopArticles).where(eq(WebsiteShopArticles.id, id));
await logStaffActivity({
staffId: staff.id,
action: "shop_delete",
+42 -48
View File
@@ -2,18 +2,34 @@
import { revalidatePath } from "next/cache";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { requirePermission } from "@/lib/admin/guard";
import { prisma } from "@/lib/prisma";
import { logStaffActivity } from "@/lib/services/staff-activity";
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/permissions", () => ({ PERMS: { PAGES_EDIT: "pages.edit" } }));
vi.mock("@/lib/prisma", () => ({
prisma: {
tags: { create: vi.fn(), update: vi.fn(), delete: vi.fn() },
taggables: { deleteMany: vi.fn() },
$transaction: vi.fn(),
vi.mock("@/lib/db", () => ({
db: {
insert: vi.fn(() => ({ values: insertValues })),
update: vi.fn(() => ({ set: vi.fn(() => ({ where: updateWhere })) })),
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("next/cache", () => ({ revalidatePath: vi.fn() }));
@@ -26,12 +42,18 @@ const fakeForm = (data: Record<string, string>) => ({
beforeEach(() => {
vi.clearAllMocks();
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", () => {
it("creates a tag and revalidates", async () => {
vi.mocked(prisma.tags.create).mockResolvedValue({ id: BigInt(1) } as never);
await createTag(
fakeForm({
name: "News",
@@ -39,10 +61,8 @@ describe("createTag", () => {
}) as unknown as FormData,
);
expect(prisma.tags.create).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({ name: "News" }),
}),
expect(insertValues).toHaveBeenCalledWith(
expect.objectContaining({ name: "News" }),
);
expect(logStaffActivity).toHaveBeenCalled();
expect(revalidatePath).toHaveBeenCalledWith("/admin/tags");
@@ -50,23 +70,19 @@ describe("createTag", () => {
it("returns early when name is empty", async () => {
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 () => {
vi.mocked(prisma.tags.create).mockResolvedValue({ id: BigInt(1) } as never);
await createTag(fakeForm({ name: "Test" }) as unknown as FormData);
expect(prisma.tags.create).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({ backgroundColor: "#888888" }),
}),
expect(insertValues).toHaveBeenCalledWith(
expect.objectContaining({ backgroundColor: "#888888" }),
);
});
it("handles db error gracefully", async () => {
vi.mocked(prisma.tags.create).mockRejectedValue(new Error("DB error"));
insertValues.mockRejectedValue(new Error("DB error"));
await expect(
createTag(fakeForm({ name: "News" }) as unknown as FormData),
@@ -77,8 +93,6 @@ describe("createTag", () => {
describe("updateTag", () => {
it("updates a tag and revalidates", async () => {
vi.mocked(prisma.tags.update).mockResolvedValue({} as never);
await updateTag(
fakeForm({
id: "42",
@@ -87,54 +101,34 @@ describe("updateTag", () => {
}) as unknown as FormData,
);
expect(prisma.tags.update).toHaveBeenCalledWith({
where: { id: BigInt(42) },
data: expect.objectContaining({ name: "Updated" }),
});
expect(updateWhere).toHaveBeenCalled();
expect(logStaffActivity).toHaveBeenCalled();
expect(revalidatePath).toHaveBeenCalledWith("/admin/tags");
});
it("returns early when id is invalid", async () => {
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 () => {
await updateTag(fakeForm({ id: "42", name: "" }) as unknown as FormData);
expect(prisma.tags.update).not.toHaveBeenCalled();
expect(updateWhere).not.toHaveBeenCalled();
});
});
describe("deleteTag", () => {
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);
expect(prisma.taggables.deleteMany).toHaveBeenCalledWith({
where: { tagId: BigInt(42) },
});
expect(prisma.tags.delete).toHaveBeenCalledWith({
where: { id: BigInt(42) },
});
expect(transaction).toHaveBeenCalled();
expect(deleteWhere).toHaveBeenCalled();
expect(logStaffActivity).toHaveBeenCalled();
expect(revalidatePath).toHaveBeenCalledWith("/admin/tags");
});
it("returns early when id is invalid", async () => {
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";
import { eq } from "drizzle-orm";
import type { ResultSetHeader } from "mysql2";
import { revalidatePath } from "next/cache";
import { requirePermission } from "@/lib/admin/guard";
import { db, Taggables, Tags } from "@/lib/db";
import { PERMS } from "@/lib/permissions";
import { prisma } from "@/lib/prisma";
import { logStaffActivity } from "@/lib/services/staff-activity";
// ── Helpers ────────────────────────────────────────────────────────────────
@@ -40,15 +42,18 @@ export async function createTag(formData: FormData): Promise<void> {
const now = new Date();
try {
const created = await prisma.tags.create({
data: { name, backgroundColor, createdAt: now, updatedAt: now },
});
const [result] = (await db.insert(Tags).values({
name,
backgroundColor,
createdAt: now,
updatedAt: now,
})) as unknown as [ResultSetHeader];
await logStaffActivity({
staffId: staff.id,
action: "tag_create",
description: `Created tag "${name}" (#${created.id})`,
description: `Created tag "${name}" (#${result.insertId})`,
targetType: "tag",
targetId: Number(created.id),
targetId: Number(result.insertId),
});
} catch {
// Fail soft — DB unavailable or duplicate.
@@ -66,10 +71,10 @@ export async function updateTag(formData: FormData): Promise<void> {
if (!name) return;
try {
await prisma.tags.update({
where: { id },
data: { name, backgroundColor, updatedAt: new Date() },
});
await db
.update(Tags)
.set({ name, backgroundColor, updatedAt: new Date() })
.where(eq(Tags.id, id));
await logStaffActivity({
staffId: staff.id,
action: "tag_update",
@@ -90,10 +95,10 @@ export async function deleteTag(formData: FormData): Promise<void> {
try {
// Remove the tag and any taggable links pointing at it.
await prisma.$transaction([
prisma.taggables.deleteMany({ where: { tagId: id } }),
prisma.tags.delete({ where: { id } }),
]);
await db.transaction(async (tx) => {
await tx.delete(Taggables).where(eq(Taggables.tagId, id));
await tx.delete(Tags).where(eq(Tags.id, id));
});
await logStaffActivity({
staffId: staff.id,
action: "tag_delete",
+19 -11
View File
@@ -2,13 +2,22 @@
import { revalidatePath } from "next/cache";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { requirePermission } from "@/lib/admin/guard";
import { prisma } from "@/lib/prisma";
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/permissions", () => ({ PERMS: { USERS_EDIT: "users.edit" } }));
vi.mock("@/lib/prisma", () => ({
prisma: { websiteTeams: { create: vi.fn(), delete: vi.fn() } },
vi.mock("@/lib/db", () => ({
db: {
insert: vi.fn(() => ({ values: insertValues })),
delete: vi.fn(() => ({ where: deleteWhere })),
},
WebsiteTeams: { id: "id" },
}));
vi.mock("next/cache", () => ({ revalidatePath: vi.fn() }));
@@ -20,6 +29,8 @@ const fakeForm = (data: Record<string, string | null>) => ({
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(requirePermission).mockResolvedValue(staff as never);
insertValues.mockResolvedValue([{ insertId: 1 }]);
deleteWhere.mockResolvedValue([{ affectedRows: 1 }]);
});
describe("createTeam", () => {
@@ -27,25 +38,22 @@ describe("createTeam", () => {
await createTeam(
fakeForm({ rankName: "Moderator" }) as unknown as FormData,
);
expect(prisma.websiteTeams.create).toHaveBeenCalledWith({
data: expect.objectContaining({ rankName: "Moderator" }),
});
expect(insertValues).toHaveBeenCalledWith(
expect.objectContaining({ rankName: "Moderator" }),
);
expect(revalidatePath).toHaveBeenCalledWith("/admin/teams");
});
it("returns early when rankName is empty", async () => {
await createTeam(fakeForm({ rankName: "" }) as unknown as FormData);
expect(prisma.websiteTeams.create).not.toHaveBeenCalled();
expect(insertValues).not.toHaveBeenCalled();
});
});
describe("deleteTeam", () => {
it("deletes a team entry", async () => {
vi.mocked(prisma.websiteTeams.delete).mockResolvedValue({} as never);
await deleteTeam(fakeForm({ id: "42" }) as unknown as FormData);
expect(prisma.websiteTeams.delete).toHaveBeenCalledWith({
where: { id: BigInt(42) },
});
expect(deleteWhere).toHaveBeenCalled();
expect(revalidatePath).toHaveBeenCalledWith("/admin/teams");
});
});
+11 -12
View File
@@ -1,9 +1,10 @@
"use server";
import { eq } from "drizzle-orm";
import { revalidatePath } from "next/cache";
import { requirePermission } from "@/lib/admin/guard";
import { db, WebsiteTeams } from "@/lib/db";
import { PERMS } from "@/lib/permissions";
import { prisma } from "@/lib/prisma";
export async function createTeam(formData: FormData): Promise<void> {
await requirePermission(PERMS.USERS_EDIT);
@@ -26,16 +27,14 @@ export async function createTeam(formData: FormData): Promise<void> {
const hiddenRank = formData.get("hiddenRank") === "on";
const now = new Date();
await prisma.websiteTeams.create({
data: {
rankName: rankName.slice(0, 255),
badge: badge ? badge.slice(0, 255) : null,
jobDescription: jobDescription ? jobDescription.slice(0, 255) : null,
staffColor: staffColor.slice(0, 255),
hiddenRank,
createdAt: now,
updatedAt: now,
},
await db.insert(WebsiteTeams).values({
rankName: rankName.slice(0, 255),
badge: badge ? badge.slice(0, 255) : null,
jobDescription: jobDescription ? jobDescription.slice(0, 255) : null,
staffColor: staffColor.slice(0, 255),
hiddenRank,
createdAt: now,
updatedAt: now,
});
revalidatePath("/admin/teams");
@@ -45,7 +44,7 @@ export async function deleteTeam(formData: FormData): Promise<void> {
await requirePermission(PERMS.USERS_EDIT);
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");
}
+5 -6
View File
@@ -3,8 +3,8 @@
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import { requirePermission } from "@/lib/admin/guard";
import { db, WebsiteSetting } from "@/lib/db";
import { PERMS } from "@/lib/permissions";
import { prisma } from "@/lib/prisma";
import { siteSettings } from "@/lib/services/site-settings";
import { logStaffActivity } from "@/lib/services/staff-activity";
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;
async function writeSetting(key: string, value: string): Promise<void> {
await prisma.websiteSetting.upsert({
where: { key },
update: { value },
create: { key, value, comment: "Theme (housekeeping)" },
});
await db
.insert(WebsiteSetting)
.values({ key, value, comment: "Theme (housekeeping)" })
.onDuplicateKeyUpdate({ set: { value } });
}
export async function saveTheme(formData: FormData): Promise<void> {
+14 -14
View File
@@ -1,10 +1,12 @@
"use server";
import { eq } from "drizzle-orm";
import type { ResultSetHeader } from "mysql2";
import { revalidatePath } from "next/cache";
import { requirePermission } from "@/lib/admin/guard";
import { positiveBigInt } from "@/lib/api";
import { db, WebsiteShopVouchers } from "@/lib/db";
import { PERMS } from "@/lib/permissions";
import { prisma } from "@/lib/prisma";
import {
type ActionResult,
actionError,
@@ -45,19 +47,17 @@ export async function createVoucher(input: {
const now = new Date();
try {
const created = await prisma.websiteShopVouchers.create({
data: {
code,
amount: Math.floor(amount),
maxUses,
useCount: 0,
expiresAt,
createdAt: now,
updatedAt: now,
},
});
const [result] = (await db.insert(WebsiteShopVouchers).values({
code,
amount: Math.floor(amount),
maxUses,
useCount: 0,
expiresAt,
createdAt: now,
updatedAt: now,
})) as unknown as [ResultSetHeader];
revalidatePath("/admin/vouchers");
return actionOk({ id: String(created.id) });
return actionOk({ id: String(result.insertId) });
} catch (error) {
logServerError("admin.voucher_create_failed", error);
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");
try {
await prisma.websiteShopVouchers.delete({ where: { id } });
await db.delete(WebsiteShopVouchers).where(eq(WebsiteShopVouchers.id, id));
revalidatePath("/admin/vouchers");
return actionOk();
} catch (error) {
+19 -5
View File
@@ -1,16 +1,26 @@
import { redirect } from "next/navigation";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { requirePermission } from "@/lib/admin/guard";
import { prisma } from "@/lib/prisma";
import { siteSettings } from "@/lib/services/site-settings";
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/permissions", () => ({
PERMS: { SETTINGS_EDIT: "settings.edit" },
}));
vi.mock("@/lib/prisma", () => ({
prisma: { websiteSetting: { upsert: vi.fn() } },
vi.mock("@/lib/db", () => ({
db: {
insert: vi.fn(() => ({ values: mockValues })),
},
WebsiteSetting: { key: "key", value: "value" },
}));
vi.mock("@/lib/services/site-settings", () => ({
siteSettings: { reload: vi.fn() },
@@ -27,7 +37,10 @@ const fakeForm = (data: Record<string, string | null>) => ({
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(requirePermission).mockResolvedValue(staff as never);
vi.mocked(prisma.websiteSetting.upsert).mockResolvedValue({} as never);
mockValues.mockReturnValue({
onDuplicateKeyUpdate: mockOnDuplicateKeyUpdate,
});
mockOnDuplicateKeyUpdate.mockResolvedValue(undefined);
});
describe("saveVpn", () => {
@@ -39,7 +52,8 @@ describe("saveVpn", () => {
vpn_api_key: "abc123",
}) as unknown as FormData,
);
expect(prisma.websiteSetting.upsert).toHaveBeenCalledTimes(4);
expect(mockValues).toHaveBeenCalledTimes(4);
expect(mockOnDuplicateKeyUpdate).toHaveBeenCalledTimes(4);
expect(siteSettings.reload).toHaveBeenCalled();
expect(redirect).toHaveBeenCalledWith("/admin/vpn?saved=1");
});
+5 -6
View File
@@ -3,8 +3,8 @@
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import { requirePermission } from "@/lib/admin/guard";
import { db, WebsiteSetting } from "@/lib/db";
import { PERMS } from "@/lib/permissions";
import { prisma } from "@/lib/prisma";
import { siteSettings } from "@/lib/services/site-settings";
import { logStaffActivity } from "@/lib/services/staff-activity";
@@ -21,11 +21,10 @@ async function writeSetting(
value: string,
comment: string,
): Promise<void> {
await prisma.websiteSetting.upsert({
where: { key },
update: { value },
create: { key, value, comment },
});
await db
.insert(WebsiteSetting)
.values({ key, value, comment })
.onDuplicateKeyUpdate({ set: { value } });
}
export async function saveVpn(formData: FormData): Promise<void> {
+10 -4
View File
@@ -1,9 +1,11 @@
"use server";
import { eq } from "drizzle-orm";
import type { ResultSetHeader } from "mysql2";
import { revalidatePath } from "next/cache";
import { requirePermission } from "@/lib/admin/guard";
import { db, WebsiteWordfilter } from "@/lib/db";
import { PERMS } from "@/lib/permissions";
import { prisma } from "@/lib/prisma";
import {
type ActionResult,
actionError,
@@ -23,11 +25,13 @@ export async function addWord(input: {
if (!word) return actionError("Word is required");
try {
const created = await prisma.websiteWordfilter.create({ data: { word } });
const [result] = (await db
.insert(WebsiteWordfilter)
.values({ word })) as unknown as [ResultSetHeader];
reloadWordFilter();
await rcon.updateWordFilter();
revalidatePath("/admin/wordfilter");
return actionOk({ id: String(created.id) });
return actionOk({ id: String(result.insertId) });
} catch {
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");
try {
await prisma.websiteWordfilter.delete({ where: { id: BigInt(raw) } });
await db
.delete(WebsiteWordfilter)
.where(eq(WebsiteWordfilter.id, BigInt(raw)));
reloadWordFilter();
await rcon.updateWordFilter();
revalidatePath("/admin/wordfilter");
+30 -29
View File
@@ -1,9 +1,11 @@
"use server";
import { eq } from "drizzle-orm";
import type { ResultSetHeader } from "mysql2";
import { revalidatePath } from "next/cache";
import { requirePermission } from "@/lib/admin/guard";
import { db, WebsiteWriteableBoxes } from "@/lib/db";
import { PERMS } from "@/lib/permissions";
import { prisma } from "@/lib/prisma";
import { logStaffActivity } from "@/lib/services/staff-activity";
// Writeable boxes (website_writeable_boxes). CMS-owned table backing the
@@ -51,28 +53,25 @@ export async function createBox(formData: FormData): Promise<void> {
const now = new Date();
try {
const created = await prisma.websiteWriteableBoxes.create({
data: {
title,
icon:
String(formData.get("icon") ?? "")
.normalize("NFC")
.trim()
.slice(0, 255) || null,
content: String(formData.get("content") ?? "").normalize("NFC"),
position: reqInt(formData, "position"),
isActive:
String(formData.get("isActive") ?? "").normalize("NFC") === "1",
createdAt: now,
updatedAt: now,
},
});
const [result] = (await db.insert(WebsiteWriteableBoxes).values({
title,
icon:
String(formData.get("icon") ?? "")
.normalize("NFC")
.trim()
.slice(0, 255) || null,
content: String(formData.get("content") ?? "").normalize("NFC"),
position: reqInt(formData, "position"),
isActive: String(formData.get("isActive") ?? "").normalize("NFC") === "1",
createdAt: now,
updatedAt: now,
})) as unknown as [ResultSetHeader];
await logStaffActivity({
staffId: staff.id,
action: "writeable_box_create",
description: `Created writeable box "${title}" (#${created.id})`,
description: `Created writeable box "${title}" (#${result.insertId})`,
targetType: "writeable_box",
targetId: Number(created.id),
targetId: Number(result.insertId),
});
} catch {
// DB unavailable — swallow and re-render.
@@ -95,9 +94,9 @@ export async function updateBox(formData: FormData): Promise<void> {
if (!title) return;
try {
await prisma.websiteWriteableBoxes.update({
where: { id },
data: {
await db
.update(WebsiteWriteableBoxes)
.set({
title,
icon:
String(formData.get("icon") ?? "")
@@ -109,8 +108,8 @@ export async function updateBox(formData: FormData): Promise<void> {
isActive:
String(formData.get("isActive") ?? "").normalize("NFC") === "1",
updatedAt: new Date(),
},
});
})
.where(eq(WebsiteWriteableBoxes.id, id));
await logStaffActivity({
staffId: staff.id,
action: "writeable_box_update",
@@ -132,7 +131,9 @@ export async function deleteBox(formData: FormData): Promise<void> {
if (id == null) return;
try {
await prisma.websiteWriteableBoxes.delete({ where: { id } });
await db
.delete(WebsiteWriteableBoxes)
.where(eq(WebsiteWriteableBoxes.id, id));
await logStaffActivity({
staffId: staff.id,
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";
try {
await prisma.websiteWriteableBoxes.update({
where: { id },
data: { isActive: next, updatedAt: new Date() },
});
await db
.update(WebsiteWriteableBoxes)
.set({ isActive: next, updatedAt: new Date() })
.where(eq(WebsiteWriteableBoxes.id, id));
await logStaffActivity({
staffId: staff.id,
action: "writeable_box_toggle",
+34 -13
View File
@@ -1,9 +1,10 @@
"use server";
import { and, eq } from "drizzle-orm";
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
import { db, WebsiteStaffApplications } from "@/lib/db";
import { clientIp, rateLimit } from "@/lib/rate-limit";
// 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) {
outcome = "empty";
} else {
const existing = await prisma.websiteStaffApplications.findFirst({
where: { userId, rankId },
select: { id: true },
});
const [existing] = await db
.select({ id: WebsiteStaffApplications.id })
.from(WebsiteStaffApplications)
.where(
and(
eq(WebsiteStaffApplications.userId, userId),
eq(WebsiteStaffApplications.rankId, rankId),
),
)
.limit(1);
if (existing) {
outcome = "duplicate";
} else {
const now = new Date();
await prisma.websiteStaffApplications.create({
data: { userId, rankId, content, createdAt: now, updatedAt: now },
await db.insert(WebsiteStaffApplications).values({
userId,
rankId,
content,
createdAt: now,
updatedAt: now,
});
outcome = "submitted";
}
@@ -120,16 +131,26 @@ export async function applyTeam(formData: FormData): Promise<void> {
if (content.length < CONTENT_MIN) {
outcome = "empty";
} else {
const existing = await prisma.websiteStaffApplications.findFirst({
where: { userId, rankId },
select: { id: true },
});
const [existing] = await db
.select({ id: WebsiteStaffApplications.id })
.from(WebsiteStaffApplications)
.where(
and(
eq(WebsiteStaffApplications.userId, userId),
eq(WebsiteStaffApplications.rankId, rankId),
),
)
.limit(1);
if (existing) {
outcome = "duplicate";
} else {
const now = new Date();
await prisma.websiteStaffApplications.create({
data: { userId, rankId, content, createdAt: now, updatedAt: now },
await db.insert(WebsiteStaffApplications).values({
userId,
rankId,
content,
createdAt: now,
updatedAt: now,
});
outcome = "submitted";
}
+13 -13
View File
@@ -1,9 +1,10 @@
"use server";
import { eq } from "drizzle-orm";
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
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 { isAllowed } from "@/lib/services/moderation";
@@ -83,23 +84,22 @@ export async function postComment(formData: FormData): Promise<void> {
outcome = "invalid";
} else {
const articleId = BigInt(articleIdRaw);
const article = await prisma.websiteArticles.findUnique({
where: { id: articleId },
select: { slug: true },
});
const [article] = await db
.select({ slug: WebsiteArticles.slug })
.from(WebsiteArticles)
.where(eq(WebsiteArticles.id, articleId))
.limit(1);
if (!article) {
outcome = "not_found";
} else {
slug = article.slug;
const now = new Date();
await prisma.websiteArticleComments.create({
data: {
articleId,
userId,
comment,
createdAt: now,
updatedAt: now,
},
await db.insert(WebsiteArticleComments).values({
articleId,
userId,
comment,
createdAt: now,
updatedAt: now,
});
outcome = "posted";
}
+44 -23
View File
@@ -1,9 +1,10 @@
"use server";
import { and, eq } from "drizzle-orm";
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
import { db, WebsiteArticleReactions, WebsiteArticles } from "@/lib/db";
import { clientIp, rateLimit } from "@/lib/rate-limit";
// 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 {
const articleId = BigInt(articleIdRaw);
const article = await prisma.websiteArticles.findUnique({
where: { id: articleId },
select: { slug: true },
});
const [article] = await db
.select({ slug: WebsiteArticles.slug })
.from(WebsiteArticles)
.where(eq(WebsiteArticles.id, articleId))
.limit(1);
if (!article) {
outcome = "not_found";
} else {
slug = article.slug;
const existing = await prisma.websiteArticleReactions.findFirst({
where: { userId, articleId, reaction },
select: { id: true, active: true },
});
const [existing] = await db
.select({
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) {
await prisma.websiteArticleReactions.update({
where: { id: existing.id },
data: { active: false },
});
await db
.update(WebsiteArticleReactions)
.set({ active: false })
.where(eq(WebsiteArticleReactions.id, existing.id));
} else {
await prisma.websiteArticleReactions.updateMany({
where: { userId, articleId, active: true },
data: { active: false },
});
await db
.update(WebsiteArticleReactions)
.set({ active: false })
.where(
and(
eq(WebsiteArticleReactions.userId, userId),
eq(WebsiteArticleReactions.articleId, articleId),
eq(WebsiteArticleReactions.active, true),
),
);
if (existing) {
await prisma.websiteArticleReactions.update({
where: { id: existing.id },
data: { active: true },
});
await db
.update(WebsiteArticleReactions)
.set({ active: true })
.where(eq(WebsiteArticleReactions.id, existing.id));
} else {
await prisma.websiteArticleReactions.create({
data: { userId, articleId, reaction, active: true },
await db.insert(WebsiteArticleReactions).values({
userId,
articleId,
reaction,
active: true,
});
}
}
+13 -10
View File
@@ -1,8 +1,9 @@
"use server";
import { eq } from "drizzle-orm";
import { env } from "@/env";
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 { captchaConfig, verifyCaptcha } from "@/lib/services/captcha";
import { siteSettings } from "@/lib/services/site-settings";
@@ -45,15 +46,17 @@ export async function precheckLogin(
mailVerified: string;
} | null;
try {
user = await prisma.user.findUnique({
where: { username: u },
select: {
password: true,
twoFactorConfirmedAt: true,
mail: true,
mailVerified: true,
},
});
const [row] = await db
.select({
password: User.password,
twoFactorConfirmedAt: User.twoFactorConfirmedAt,
mail: User.mail,
mailVerified: User.mailVerified,
})
.from(User)
.where(eq(User.username, u))
.limit(1);
user = row ?? null;
} catch {
return "invalid";
}
+24 -13
View File
@@ -1,16 +1,21 @@
"use server";
import { eq } from "drizzle-orm";
import { revalidatePath } from "next/cache";
import { requirePermission } from "@/lib/admin/guard";
import { db, WebsiteBadges } from "@/lib/db";
import { PERMS } from "@/lib/permissions";
import { prisma } from "@/lib/prisma";
export async function getBadgeData({ code }: { code: string }) {
await requirePermission(PERMS.CATALOG_EDIT);
const badge = await prisma.websiteBadges.findUnique({
where: { badgeKey: code },
select: { badgeName: true, badgeDescription: true },
});
const [badge] = await db
.select({
badgeName: WebsiteBadges.badgeName,
badgeDescription: WebsiteBadges.badgeDescription,
})
.from(WebsiteBadges)
.where(eq(WebsiteBadges.badgeKey, code))
.limit(1);
if (!badge) return { ok: false as const, data: null };
return {
ok: true as const,
@@ -28,16 +33,22 @@ export async function updateBadge({
desc: string;
}) {
await requirePermission(PERMS.CATALOG_EDIT);
await prisma.websiteBadges.upsert({
where: { badgeKey: code },
update: { badgeName: name, badgeDescription: desc, updatedAt: new Date() },
create: {
const now = new Date();
await db
.insert(WebsiteBadges)
.values({
badgeKey: code,
badgeName: name,
badgeDescription: desc,
createdAt: new Date(),
updatedAt: new Date(),
},
});
createdAt: now,
updatedAt: now,
})
.onDuplicateKeyUpdate({
set: {
badgeName: name,
badgeDescription: desc,
updatedAt: now,
},
});
revalidatePath("/admin/import/badges");
}
+17 -8
View File
@@ -1,8 +1,10 @@
"use server";
import { eq } from "drizzle-orm";
import type { ResultSetHeader } from "mysql2";
import { z } from "zod";
import { db, WebsiteBanner } from "@/lib/db";
import { PERMS } from "@/lib/permissions";
import { prisma } from "@/lib/prisma";
import { adminAction } from "@/lib/safe-action";
import { ActionError, actionOk } from "@/lib/safe-action-shared";
import { logAudit } from "@/lib/services/audit";
@@ -22,15 +24,18 @@ const bannerSchema = z.object({
export const createBanner = adminAction(
{ permission: PERMS.BANNERS_EDIT, schema: bannerSchema },
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({
userId: ctx.session.user.id,
action: "banner_create",
target: "WebsiteBanner",
targetId: banner.id,
after: { title: banner.title },
targetId: id,
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 },
async (ctx) => {
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");
await prisma.websiteBanner.update({ where: { id }, data });
await db.update(WebsiteBanner).set(data).where(eq(WebsiteBanner.id, id));
logAudit({
userId: ctx.session.user.id,
action: "banner_update",
@@ -60,7 +69,7 @@ const deleteBannerInput = z.object({ id: z.coerce.number().int().positive() });
export const deleteBanner = adminAction(
{ permission: PERMS.BANNERS_EDIT, schema: deleteBannerInput },
async (ctx) => {
await prisma.websiteBanner.delete({ where: { id: ctx.data.id } });
await db.delete(WebsiteBanner).where(eq(WebsiteBanner.id, ctx.data.id));
logAudit({
userId: ctx.session.user.id,
action: "banner_delete",
+90 -17
View File
@@ -1,7 +1,6 @@
// @ts-nocheck
import { beforeEach, describe, expect, it, vi } from "vitest";
import { requirePermission } from "@/lib/admin/guard";
import { prisma } from "@/lib/prisma";
import { rcon } from "@/lib/services/rcon";
import {
bulkBan,
@@ -10,14 +9,78 @@ import {
bulkUnban,
} 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/permissions", () => ({ PERMS: { USERS_EDIT: "users.edit" } }));
vi.mock("@/lib/prisma", () => ({
prisma: {
ban: { deleteMany: vi.fn(), create: vi.fn() },
user: { update: vi.fn() },
usersCurrency: { upsert: vi.fn() },
usersBadges: { findFirst: vi.fn(), aggregate: vi.fn(), create: vi.fn() },
vi.mock("@/lib/db", () => ({
db: {
delete: vi.fn(() => ({ where: deleteWhere })),
insert: vi.fn(() => ({ values: insertValues })),
update: 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", () => ({
@@ -35,11 +98,22 @@ const staff = { id: 1, rank: 7, username: "admin" };
beforeEach(() => {
vi.clearAllMocks();
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", () => {
it("unbans users", async () => {
vi.mocked(prisma.ban.deleteMany).mockResolvedValue({ count: 3 } as never);
const r = await bulkUnban({ userIds: [1, 2, 3] });
expect(r.ok).toBe(true);
expect(r.data).toEqual({ unbanned: 3, total: 3 });
@@ -48,7 +122,6 @@ describe("bulkUnban", () => {
describe("bulkBan", () => {
it("bans users", async () => {
vi.mocked(prisma.ban.create).mockResolvedValue({} as never);
const r = await bulkBan({
userIds: [1, 2],
reason: "Spam",
@@ -56,12 +129,12 @@ describe("bulkBan", () => {
});
expect(r.ok).toBe(true);
expect(r.data.banned).toBe(2);
expect(insertValues).toHaveBeenCalledTimes(2);
});
});
describe("bulkGiveCurrency", () => {
it("gives credits", async () => {
vi.mocked(prisma.user.update).mockResolvedValue({} as never);
const r = await bulkGiveCurrency({
userIds: [1],
amount: 100,
@@ -69,10 +142,10 @@ describe("bulkGiveCurrency", () => {
});
expect(r.data.given).toBe(1);
expect(rcon.giveCredits).toHaveBeenCalledWith(1, 100);
expect(updateWhere).toHaveBeenCalled();
});
it("gives pixels", async () => {
vi.mocked(prisma.usersCurrency.upsert).mockResolvedValue({} as never);
const r = await bulkGiveCurrency({
userIds: [2],
amount: 50,
@@ -80,10 +153,10 @@ describe("bulkGiveCurrency", () => {
});
expect(r.data.given).toBe(1);
expect(rcon.giveDuckets).toHaveBeenCalledWith(2, 50);
expect(onDuplicateKeyUpdate).toHaveBeenCalled();
});
it("gives points", async () => {
vi.mocked(prisma.usersCurrency.upsert).mockResolvedValue({} as never);
const r = await bulkGiveCurrency({
userIds: [3],
amount: 25,
@@ -91,17 +164,17 @@ describe("bulkGiveCurrency", () => {
});
expect(r.data.given).toBe(1);
expect(rcon.givePointsGotw).toHaveBeenCalledWith(3, 25);
expect(onDuplicateKeyUpdate).toHaveBeenCalled();
});
});
describe("bulkGiveBadge", () => {
it("gives badge to user", async () => {
vi.mocked(prisma.usersBadges.findFirst).mockResolvedValue(null);
vi.mocked(prisma.usersBadges.aggregate).mockResolvedValue({
_max: { slotId: 5 },
} as never);
vi.mocked(prisma.usersBadges.create).mockResolvedValue({} as never);
selectLimit.mockResolvedValueOnce([]);
selectWhereResolved.mockResolvedValueOnce([{ maxSlot: 5 }]);
const r = await bulkGiveBadge({ userIds: [1], badgeCode: "ADM" });
expect(r.data.given).toBe(1);
expect(insertValues).toHaveBeenCalled();
expect(rcon.giveBadge).toHaveBeenCalledWith(1, "ADM");
});
});
+77 -61
View File
@@ -1,10 +1,17 @@
"use server";
import { eq, sql } from "drizzle-orm";
import { and, eq, inArray, max, sql } from "drizzle-orm";
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 { prisma } from "@/lib/prisma";
import type { ActionResult } from "@/lib/safe-action-shared";
import { rcon } from "@/lib/services/rcon";
import { logStaffActivity } from "@/lib/services/staff-activity";
@@ -15,18 +22,19 @@ export async function bulkUnban({
userIds: number[];
}): Promise<ActionResult<{ unbanned: number; total: number }>> {
const staff = await requirePermission(PERMS.USERS_EDIT);
const result = await prisma.ban.deleteMany({
where: { userId: { in: userIds } },
});
const result = await db.delete(Ban).where(inArray(Ban.userId, userIds));
const unbanned = Number(
(result as unknown as [{ affectedRows: number }])[0]?.affectedRows ?? 0,
);
await logStaffActivity({
staffId: staff.id,
action: "bulk_unban",
description: `Unbanned ${result.count} user(s)`,
description: `Unbanned ${unbanned} user(s)`,
targetType: "user",
});
return {
ok: true as const,
data: { unbanned: result.count, total: userIds.length },
data: { unbanned, total: userIds.length },
};
}
@@ -45,17 +53,15 @@ export async function bulkBan({
for (const userId of userIds) {
try {
await prisma.ban.create({
data: {
userId,
ip: "",
machineId: "",
userStaffId: staff.id,
timestamp: now,
banExpire: duration > 0 ? now + duration : 0,
banReason: reason,
type: "account",
},
await db.insert(Ban).values({
userId,
ip: "",
machineId: "",
userStaffId: staff.id,
timestamp: now,
banExpire: duration > 0 ? now + duration : 0,
banReason: reason,
type: "account",
});
banned++;
} catch {
@@ -94,24 +100,26 @@ export async function bulkGiveCurrency({
for (const userId of userIds) {
try {
if (type === "credits") {
await prisma.user.update({
where: { id: userId },
data: { credits: { increment: amount } },
});
await db
.update(User)
.set({ credits: sql`${User.credits} + ${amount}` })
.where(eq(User.id, userId));
await rcon.giveCredits(userId, amount);
} else if (type === "pixels") {
await prisma.usersCurrency.upsert({
where: { userId_type: { userId, type: 0 } },
update: { amount: { increment: amount } },
create: { userId, type: 0, amount },
});
await db
.insert(UsersCurrency)
.values({ userId, type: 0, amount })
.onDuplicateKeyUpdate({
set: { amount: sql`${UsersCurrency.amount} + ${amount}` },
});
await rcon.giveDuckets(userId, amount);
} else if (type === "points") {
await prisma.usersCurrency.upsert({
where: { userId_type: { userId, type: 101 } },
update: { amount: { increment: amount } },
create: { userId, type: 101, amount },
});
await db
.insert(UsersCurrency)
.values({ userId, type: 101, amount })
.onDuplicateKeyUpdate({
set: { amount: sql`${UsersCurrency.amount} + ${amount}` },
});
await rcon.givePointsGotw(userId, amount);
}
given++;
@@ -151,19 +159,23 @@ export async function bulkGiveBadge({
for (const userId of userIds) {
try {
const existing = await prisma.usersBadges.findFirst({
where: { userId, badgeCode },
select: { id: true },
});
const [existing] = await db
.select({ id: UsersBadges.id })
.from(UsersBadges)
.where(
and(
eq(UsersBadges.userId, userId),
eq(UsersBadges.badgeCode, badgeCode),
),
)
.limit(1);
if (!existing) {
const max = await prisma.usersBadges.aggregate({
where: { userId },
_max: { slotId: true },
});
const slotId = (max._max.slotId ?? 0) + 1;
await prisma.usersBadges.create({
data: { userId, slotId, badgeCode },
});
const [agg] = await db
.select({ maxSlot: max(UsersBadges.slotId) })
.from(UsersBadges)
.where(eq(UsersBadges.userId, userId));
const slotId = (agg?.maxSlot ?? 0) + 1;
await db.insert(UsersBadges).values({ userId, slotId, badgeCode });
await rcon.giveBadge(userId, badgeCode);
}
given++;
@@ -228,31 +240,35 @@ export async function bulkAdjustCurrency({
for (const userId of userIds) {
try {
if (type === "credits") {
const user = await prisma.user.findUnique({
where: { id: userId },
select: { credits: true },
});
const [user] = await db
.select({ credits: User.credits })
.from(User)
.where(eq(User.id, userId))
.limit(1);
if (!user) {
failedIds.push({ userId, reason: "Not found" });
continue;
}
const next = Math.max(0, user.credits - take);
await prisma.user.update({
where: { id: userId },
data: { credits: next },
});
await db.update(User).set({ credits: next }).where(eq(User.id, userId));
} else {
const currencyType = type === "pixels" ? 0 : 101;
const row = await prisma.usersCurrency.findUnique({
where: { userId_type: { userId, type: currencyType } },
});
const [row] = await db
.select({ amount: UsersCurrency.amount })
.from(UsersCurrency)
.where(
and(
eq(UsersCurrency.userId, userId),
eq(UsersCurrency.type, currencyType),
),
)
.limit(1);
const current = row?.amount ?? 0;
const next = Math.max(0, current - take);
await prisma.usersCurrency.upsert({
where: { userId_type: { userId, type: currencyType } },
update: { amount: next },
create: { userId, type: currencyType, amount: next },
});
await db
.insert(UsersCurrency)
.values({ userId, type: currencyType, amount: next })
.onDuplicateKeyUpdate({ set: { amount: next } });
}
adjusted++;
} catch {
+84 -75
View File
@@ -1,9 +1,10 @@
"use server";
import { eq, inArray } from "drizzle-orm";
import { revalidatePath } from "next/cache";
import { requirePermission } from "@/lib/admin/guard";
import { CatalogItemsBc, CatalogPagesBc, db } from "@/lib/db";
import { PERMS } from "@/lib/permissions";
import { prisma } from "@/lib/prisma";
import { rcon } from "@/lib/services/rcon";
import { logStaffActivity } from "@/lib/services/staff-activity";
@@ -55,10 +56,10 @@ export async function updateBcPage({
if (Object.keys(data).length === 0) {
return { ok: false as const, error: "No valid fields to update" };
}
await prisma.catalogPagesBc.update({
where: { id },
data: data as any,
});
await db
.update(CatalogPagesBc)
.set(data as Partial<typeof CatalogPagesBc.$inferInsert>)
.where(eq(CatalogPagesBc.id, id));
await rcon.updateCatalog();
await logStaffActivity({
staffId: staff.id,
@@ -73,7 +74,7 @@ export async function updateBcPage({
export async function deleteBcItem({ id }: { id: number }) {
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 logStaffActivity({
staffId: staff.id,
@@ -101,10 +102,10 @@ export async function updateBcItem({
if (Object.keys(safe).length === 0) {
return { ok: false as const, error: "No valid fields to update" };
}
await prisma.catalogItemsBc.update({
where: { id },
data: safe as any,
});
await db
.update(CatalogItemsBc)
.set(safe as Partial<typeof CatalogItemsBc.$inferInsert>)
.where(eq(CatalogItemsBc.id, id));
await rcon.updateCatalog();
await logStaffActivity({
staffId: staff.id,
@@ -128,19 +129,18 @@ export async function createBcItem({
extradata: string;
}) {
const staff = await requirePermission(PERMS.CATALOG_EDIT);
const created = await prisma.catalogItemsBc.create({
data: { pageId, ...data },
});
const [result] = await db.insert(CatalogItemsBc).values({ pageId, ...data });
const createdId = Number(result.insertId);
await rcon.updateCatalog();
await logStaffActivity({
staffId: staff.id,
action: "bc_item_create",
description: `Created BC catalog item #${created.id}`,
description: `Created BC catalog item #${createdId}`,
targetType: "catalog_item_bc",
targetId: created.id,
targetId: createdId,
});
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({
@@ -151,15 +151,19 @@ export async function toggleBcPage({
field: "enabled" | "visible";
}) {
await requirePermission(PERMS.CATALOG_EDIT);
const page = await prisma.catalogPagesBc.findUnique({
where: { id },
select: { enabled: true, visible: true },
});
const [page] = await db
.select({
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" };
await prisma.catalogPagesBc.update({
where: { id },
data: { [field]: page[field] === "1" ? "0" : "1" },
});
await db
.update(CatalogPagesBc)
.set({ [field]: page[field] === "1" ? "0" : "1" })
.where(eq(CatalogPagesBc.id, id));
await rcon.updateCatalog();
revalidatePath("/admin/catalog/builder-club");
return { ok: true as const };
@@ -176,31 +180,30 @@ export async function createBcPage(input: {
orderNum?: number;
}) {
const staff = await requirePermission(PERMS.CATALOG_EDIT);
const created = await prisma.catalogPagesBc.create({
data: {
caption: input.caption,
parentId: input.parentId,
pageLayout: input.pageLayout ?? "default_3x3",
iconColor: input.iconColor ?? 0,
iconImage: input.iconImage ?? 0,
orderNum: input.orderNum ?? 0,
visible: input.visible ?? "1",
enabled: input.enabled ?? "1",
pageHeadline: "",
pageTeaser: "",
},
const [result] = await db.insert(CatalogPagesBc).values({
caption: input.caption,
parentId: input.parentId,
pageLayout: input.pageLayout ?? "default_3x3",
iconColor: input.iconColor ?? 0,
iconImage: input.iconImage ?? 0,
orderNum: input.orderNum ?? 0,
visible: input.visible ?? "1",
enabled: input.enabled ?? "1",
pageHeadline: "",
pageTeaser: "",
});
const createdId = Number(result.insertId);
await rcon.updateCatalog();
await logStaffActivity({
staffId: staff.id,
action: "bc_page_create",
description: `Created BC catalog page "${input.caption}"`,
targetType: "catalog_page_bc",
targetId: created.id,
targetId: createdId,
});
revalidatePath("/admin/catalog");
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> {
@@ -210,18 +213,19 @@ async function moveBcPage(pageId: number, newParentId: number): Promise<void> {
if (currentId === pageId) {
throw new Error("Cannot move page: would create a circular hierarchy");
}
const parent = await prisma.catalogPagesBc.findUnique({
where: { id: currentId },
select: { parentId: true },
});
const [parent] = await db
.select({ parentId: CatalogPagesBc.parentId })
.from(CatalogPagesBc)
.where(eq(CatalogPagesBc.id, currentId))
.limit(1);
if (!parent || parent.parentId <= 0) break;
currentId = parent.parentId;
}
}
await prisma.catalogPagesBc.update({
where: { id: pageId },
data: { parentId: newParentId },
});
await db
.update(CatalogPagesBc)
.set({ parentId: newParentId })
.where(eq(CatalogPagesBc.id, pageId));
}
export async function reorderBcTreePage(input: {
@@ -240,10 +244,10 @@ export async function reorderBcTreePage(input: {
};
}
}
await prisma.catalogPagesBc.update({
where: { id: input.pageId },
data: { orderNum: input.newOrderNum },
});
await db
.update(CatalogPagesBc)
.set({ orderNum: input.newOrderNum })
.where(eq(CatalogPagesBc.id, input.pageId));
await rcon.updateCatalog();
revalidatePath("/admin/catalog");
revalidatePath("/admin/catalog/builder-club");
@@ -255,43 +259,48 @@ export async function deleteBcTreePage(input: {
mode: "reparent" | "cascade";
}) {
await requirePermission(PERMS.CATALOG_EDIT);
const page = await prisma.catalogPagesBc.findUnique({
where: { id: input.pageId },
select: { parentId: true },
});
const [page] = await db
.select({ parentId: CatalogPagesBc.parentId })
.from(CatalogPagesBc)
.where(eq(CatalogPagesBc.id, input.pageId))
.limit(1);
if (!page) return { ok: false as const, error: "Page not found" };
if (input.mode === "reparent") {
await prisma.$transaction([
prisma.catalogPagesBc.updateMany({
where: { parentId: input.pageId },
data: { parentId: page.parentId },
}),
prisma.catalogItemsBc.deleteMany({ where: { pageId: input.pageId } }),
prisma.catalogPagesBc.delete({ where: { id: input.pageId } }),
]);
await db.transaction(async (tx) => {
await tx
.update(CatalogPagesBc)
.set({ parentId: page.parentId })
.where(eq(CatalogPagesBc.parentId, input.pageId));
await tx
.delete(CatalogItemsBc)
.where(eq(CatalogItemsBc.pageId, input.pageId));
await tx
.delete(CatalogPagesBc)
.where(eq(CatalogPagesBc.id, input.pageId));
});
} else {
const toDelete: number[] = [input.pageId];
const queue: number[] = [input.pageId];
while (queue.length > 0) {
const children = await prisma.catalogPagesBc.findMany({
where: { parentId: { in: queue } },
select: { id: true },
});
const children = await db
.select({ id: CatalogPagesBc.id })
.from(CatalogPagesBc)
.where(inArray(CatalogPagesBc.parentId, queue));
queue.length = 0;
for (const child of children) {
toDelete.push(child.id);
queue.push(child.id);
}
}
await prisma.$transaction([
prisma.catalogItemsBc.deleteMany({
where: { pageId: { in: toDelete } },
}),
prisma.catalogPagesBc.deleteMany({
where: { id: { in: toDelete } },
}),
]);
await db.transaction(async (tx) => {
await tx
.delete(CatalogItemsBc)
.where(inArray(CatalogItemsBc.pageId, toDelete));
await tx
.delete(CatalogPagesBc)
.where(inArray(CatalogPagesBc.id, toDelete));
});
}
await rcon.updateCatalog();
+74 -56
View File
@@ -1,10 +1,10 @@
"use server";
import { sql } from "drizzle-orm";
import { eq, inArray, like, or, sql } from "drizzle-orm";
import { revalidatePath } from "next/cache";
import { requirePermission } from "@/lib/admin/guard";
import { CatalogItems, db, ItemsBase } from "@/lib/db";
import { PERMS } from "@/lib/permissions";
import { prisma } from "@/lib/prisma";
import { logAudit } from "@/lib/services/audit";
import { allocateCatalogItemId } from "@/lib/services/furni-import";
import { rcon } from "@/lib/services/rcon";
@@ -87,7 +87,7 @@ async function insertCatalogItemRow(data: {
}): Promise<number> {
const pageIdStr = String(data.pageId);
return allocateCatalogItemId(async (nextId) => {
await prisma.$executeRaw`
await db.execute(sql`
INSERT INTO catalog_items (
id, page_id, item_ids, catalog_name,
cost_credits, cost_points, points_type, amount,
@@ -100,7 +100,7 @@ async function insertCatalogItemRow(data: {
${data.limitedSells}, ${data.limitedStack}, ${data.extradata},
${data.haveOffer}, ${data.clubOnly}
)
`;
`);
return nextId;
});
}
@@ -127,10 +127,14 @@ export async function createCatalogItem(data: {
if (!catalogName) {
const firstId = Number.parseInt(data.itemIds.split(";")[0] || "", 10);
if (firstId > 0) {
const base = await prisma.itemsBase.findUnique({
where: { id: firstId },
select: { publicName: true, itemName: true },
});
const [base] = await db
.select({
publicName: ItemsBase.publicName,
itemName: ItemsBase.itemName,
})
.from(ItemsBase)
.where(eq(ItemsBase.id, firstId))
.limit(1);
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 bases = await prisma.itemsBase.findMany({
where: { id: { in: baseIds } },
select: { id: true, publicName: true, itemName: true },
});
const bases = await db
.select({
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]));
let created = 0;
@@ -225,7 +233,7 @@ export async function bulkCreateCatalogItems({
export async function deleteCatalogItems({ ids }: { ids: number[] }) {
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 logStaffActivity({
staffId: staff.id,
@@ -249,11 +257,14 @@ export async function moveCatalogItems({
return { ok: true as const, data: {} };
}
const pageIdStr = String(targetPageId);
await prisma.$executeRaw`
await db.execute(sql`
UPDATE catalog_items
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();
revalidatePath("/admin/catalog");
return { ok: true as const, data: {} };
@@ -266,7 +277,10 @@ export async function reorderCatalogItems({
}) {
await requirePermission(PERMS.CATALOG_EDIT);
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();
revalidatePath("/admin/catalog");
@@ -292,25 +306,25 @@ export async function updateCatalogItem({
const pageIdRaw = safeCatalog.pageId;
if (pageIdRaw !== undefined) {
const pageIdStr = String(pageIdRaw);
await prisma.$executeRaw`
await db.execute(sql`
UPDATE catalog_items SET page_id = ${pageIdStr} WHERE id = ${id}
`;
`);
delete safeCatalog.pageId;
}
if (Object.keys(safeCatalog).length > 0) {
await prisma.catalogItems.update({
where: { id },
data: safeCatalog as any,
});
await db
.update(CatalogItems)
.set(safeCatalog as Partial<typeof CatalogItems.$inferInsert>)
.where(eq(CatalogItems.id, id));
}
if (baseItem) {
const safeBase = pickAllowed(baseItem.fields, ITEMS_BASE_FIELDS);
if (Object.keys(safeBase).length > 0) {
await prisma.itemsBase.update({
where: { id: baseItem.id },
data: safeBase as any,
});
await db
.update(ItemsBase)
.set(safeBase as Partial<typeof ItemsBase.$inferInsert>)
.where(eq(ItemsBase.id, baseItem.id));
}
}
await rcon.updateCatalog();
@@ -355,16 +369,17 @@ export async function translateCatalogItems(input: {
}> = [];
for (const item of items) {
const base = await prisma.itemsBase.findUnique({
where: { id: item.id },
select: {
id: true,
publicName: true,
itemName: true,
type: true,
spriteId: true,
},
});
const [base] = await db
.select({
id: ItemsBase.id,
publicName: ItemsBase.publicName,
itemName: ItemsBase.itemName,
type: ItemsBase.type,
spriteId: ItemsBase.spriteId,
})
.from(ItemsBase)
.where(eq(ItemsBase.id, item.id))
.limit(1);
if (!base) continue;
const nextName = item.publicName?.trim() ?? "";
@@ -372,28 +387,31 @@ export async function translateCatalogItems(input: {
const nameChanged = nextName !== "" && nextName !== (base.publicName ?? "");
if (nameChanged) {
await prisma.itemsBase.update({
where: { id: base.id },
data: { publicName: nextName },
});
await db
.update(ItemsBase)
.set({ publicName: nextName })
.where(eq(ItemsBase.id, base.id));
const idStr = String(base.id);
const related = await prisma.catalogItems.findMany({
where: {
OR: [
{ itemIds: idStr },
{ itemIds: { startsWith: `${idStr};` } },
{ itemIds: { contains: `;${idStr};` } },
{ itemIds: { endsWith: `;${idStr}` } },
],
},
select: { id: true, catalogName: true },
});
const related = await db
.select({
id: CatalogItems.id,
catalogName: CatalogItems.catalogName,
})
.from(CatalogItems)
.where(
or(
eq(CatalogItems.itemIds, idStr),
like(CatalogItems.itemIds, `${idStr};%`),
like(CatalogItems.itemIds, `%;${idStr};%`),
like(CatalogItems.itemIds, `%;${idStr}`),
),
);
for (const row of related) {
if (row.catalogName !== nextName) {
await prisma.catalogItems.update({
where: { id: row.id },
data: { catalogName: nextName },
});
await db
.update(CatalogItems)
.set({ catalogName: nextName })
.where(eq(CatalogItems.id, row.id));
}
}
namesUpdated++;
+41 -37
View File
@@ -1,9 +1,10 @@
"use server";
import { eq } from "drizzle-orm";
import { revalidatePath } from "next/cache";
import { requirePermission } from "@/lib/admin/guard";
import { CatalogPages, db } from "@/lib/db";
import { PERMS } from "@/lib/permissions";
import { prisma } from "@/lib/prisma";
import type { ActionResult } from "@/lib/safe-action-shared";
import { deletePage, movePage } from "@/lib/services/catalog-tree";
import { rcon } from "@/lib/services/rcon";
@@ -54,10 +55,10 @@ export async function updateCatalogPage({
if (typeof data.caption === "string" && !data.captionSave) {
data.captionSave = data.caption.slice(0, 25);
}
await prisma.catalogPages.update({
where: { id },
data: data as any,
});
await db
.update(CatalogPages)
.set(data as Partial<typeof CatalogPages.$inferInsert>)
.where(eq(CatalogPages.id, id));
await rcon.updateCatalog();
await logStaffActivity({
staffId: staff.id,
@@ -93,17 +94,21 @@ export async function toggleCatalogPage({
action: "toggleEnabled" | "toggleVisible";
}) {
await requirePermission(PERMS.CATALOG_EDIT);
const page = await prisma.catalogPages.findUnique({
where: { id },
select: { enabled: true, visible: true },
});
const [page] = await db
.select({
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" };
const field = action === "toggleEnabled" ? "enabled" : "visible";
const current = action === "toggleEnabled" ? page.enabled : page.visible;
await prisma.catalogPages.update({
where: { id },
data: { [field]: current === "1" ? "0" : "1" },
});
await db
.update(CatalogPages)
.set({ [field]: current === "1" ? "0" : "1" })
.where(eq(CatalogPages.id, id));
await rcon.updateCatalog();
revalidatePath("/admin/catalog");
return { ok: true as const, data: {} };
@@ -121,35 +126,34 @@ export async function createCatalogPage(input: {
orderNum?: number;
}): Promise<ActionResult<{ id: number }>> {
const staff = await requirePermission(PERMS.CATALOG_EDIT);
const created = await prisma.catalogPages.create({
data: {
caption: input.caption,
parentId: input.parentId,
pageLayout: input.pageLayout ?? "default_3x3",
captionSave: input.caption.slice(0, 25),
iconColor: input.iconColor ?? 0,
iconImage: input.iconImage ?? 0,
minRank: input.minRank ?? 1,
orderNum: input.orderNum ?? 0,
visible: input.visible ?? "1",
enabled: input.enabled ?? "1",
clubOnly: "0",
vipOnly: "0",
pageHeadline: "",
pageTeaser: "",
includes: "",
},
const [result] = await db.insert(CatalogPages).values({
caption: input.caption,
parentId: input.parentId,
pageLayout: input.pageLayout ?? "default_3x3",
captionSave: input.caption.slice(0, 25),
iconColor: input.iconColor ?? 0,
iconImage: input.iconImage ?? 0,
minRank: input.minRank ?? 1,
orderNum: input.orderNum ?? 0,
visible: input.visible ?? "1",
enabled: input.enabled ?? "1",
clubOnly: "0",
vipOnly: "0",
pageHeadline: "",
pageTeaser: "",
includes: "",
});
const createdId = Number(result.insertId);
await rcon.updateCatalog();
await logStaffActivity({
staffId: staff.id,
action: "catalog_page_create",
description: `Created catalog page "${input.caption}"`,
targetType: "catalog_page",
targetId: created.id,
targetId: createdId,
});
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: {
@@ -168,10 +172,10 @@ export async function reorderTreePage(input: {
};
}
}
await prisma.catalogPages.update({
where: { id: input.pageId },
data: { orderNum: input.newOrderNum },
});
await db
.update(CatalogPages)
.set({ orderNum: input.newOrderNum })
.where(eq(CatalogPages.id, input.pageId));
await rcon.updateCatalog();
revalidatePath("/admin/catalog");
return { ok: true as const, data: {} };
+20 -12
View File
@@ -1,9 +1,10 @@
"use server";
import { eq, sql } from "drizzle-orm";
import { revalidatePath } from "next/cache";
import { z } from "zod";
import { db, User } from "@/lib/db";
import { PERMS } from "@/lib/permissions";
import { prisma } from "@/lib/prisma";
import { adminAction } from "@/lib/safe-action";
import { ActionError, actionOk } from "@/lib/safe-action-shared";
import { rcon } from "@/lib/services/rcon";
@@ -199,15 +200,22 @@ export const setRank = adminAction(
async (ctx) => {
const staffRank = Number(ctx.session.user.rank);
const isSuper = ctx.permissions.isSuperAdmin;
const target = await prisma.user.findUnique({
where: { id: ctx.data.userId },
select: { rank: true },
});
const [target] = await db
.select({ rank: User.rank })
.from(User)
.where(eq(User.id, ctx.data.userId))
.limit(1);
if (!target) throw new ActionError("User not found");
const rankExists = await prisma.$queryRaw<{ id: number }[]>`
SELECT id FROM permission_ranks WHERE id = ${ctx.data.rank} LIMIT 1
`.catch(() => [] as { id: number }[]);
let rankExists: { id: number }[] = [];
try {
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 (!isSuper) {
@@ -222,10 +230,10 @@ export const setRank = adminAction(
}
await requireRconOk(await rcon.setRank(ctx.data.userId, ctx.data.rank));
await prisma.user.update({
where: { id: ctx.data.userId },
data: { rank: ctx.data.rank },
});
await db
.update(User)
.set({ rank: ctx.data.rank })
.where(eq(User.id, ctx.data.userId));
revalidatePath(PATH);
return actionOk();
},
+40 -25
View File
@@ -1,9 +1,10 @@
"use server";
import { and, eq, max, sql } from "drizzle-orm";
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
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 { rcon } from "@/lib/services/rcon";
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) {
outcome = "ratelimit";
} else {
const badge = await prisma.websiteDrawbadges.findUnique({
where: { id: BigInt(rawId) },
select: { id: true, badgePath: true, published: true },
});
const [badge] = await db
.select({
id: WebsiteDrawbadges.id,
badgePath: WebsiteDrawbadges.badgePath,
published: WebsiteDrawbadges.published,
})
.from(WebsiteDrawbadges)
.where(eq(WebsiteDrawbadges.id, BigInt(rawId)))
.limit(1);
if (!badge?.published) {
outcome = "invalid";
@@ -78,34 +84,43 @@ export async function buyBadge(formData: FormData): Promise<void> {
const price = await resolvePrice();
// Re-read the buyer's live credit balance and verify it covers the cost.
const buyer = await prisma.user.findUnique({
where: { id: userId },
select: { credits: true },
});
const [buyer] = await db
.select({ credits: User.credits })
.from(User)
.where(eq(User.id, userId))
.limit(1);
if (!buyer || buyer.credits < price) {
outcome = "credits";
} else {
// Atomically deduct credits and persist the badge so a failure
// between the two operations cannot orphan the user.
if (price > 0) {
await prisma.$transaction(async (tx) => {
await tx.user.update({
where: { id: userId },
data: { credits: { decrement: price } },
});
await db.transaction(async (tx) => {
await tx
.update(User)
.set({ credits: sql`${User.credits} - ${price}` })
.where(eq(User.id, userId));
const existing = await tx.usersBadges.findFirst({
where: { userId, badgeCode: code },
select: { id: true },
});
const [existing] = await tx
.select({ id: UsersBadges.id })
.from(UsersBadges)
.where(
and(
eq(UsersBadges.userId, userId),
eq(UsersBadges.badgeCode, code),
),
)
.limit(1);
if (!existing) {
const max = await tx.usersBadges.aggregate({
where: { userId },
_max: { slotId: true },
});
const slotId = (max._max.slotId ?? 0) + 1;
await tx.usersBadges.create({
data: { userId, slotId, badgeCode: code },
const [agg] = await tx
.select({ maxSlot: max(UsersBadges.slotId) })
.from(UsersBadges)
.where(eq(UsersBadges.userId, userId));
const slotId = (agg?.maxSlot ?? 0) + 1;
await tx.insert(UsersBadges).values({
userId,
slotId,
badgeCode: code,
});
}
});
+16 -6
View File
@@ -1,16 +1,27 @@
// @ts-nocheck
import { describe, expect, it, vi } from "vitest";
import { prisma } from "@/lib/prisma";
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", () => ({
PERMS: { SETTINGS_EDIT: "settings.edit" },
}));
vi.mock("@/lib/prisma", () => ({
prisma: { emulatorSettings: { upsert: vi.fn() } },
vi.mock("@/lib/db", () => ({
db: {
insert: vi.fn(() => ({ values: insertValues })),
},
EmulatorSettings: { key: "key", value: "value" },
}));
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/services/audit", () => ({ logAudit: vi.fn() }));
@@ -18,7 +29,6 @@ vi.mock("@/lib/services/rcon", () => ({ rcon: { updateConfig: vi.fn() } }));
describe("saveEmulatorSettings", () => {
it("saves settings and calls rcon update", async () => {
vi.mocked(prisma.emulatorSettings.upsert).mockResolvedValue({} as never);
const handler = (await import("./emulator").then(
(m) => m.saveEmulatorSettings,
)) as unknown as (ctx: {
@@ -31,7 +41,7 @@ describe("saveEmulatorSettings", () => {
session: { user: { id: "1" } },
});
expect(prisma.emulatorSettings.upsert).toHaveBeenCalledTimes(2);
expect(insertValues).toHaveBeenCalledTimes(2);
expect(rcon.updateConfig).toHaveBeenCalled();
expect(result).toBe("ok");
});
+5 -6
View File
@@ -1,8 +1,8 @@
"use server";
import { z } from "zod";
import { db, EmulatorSettings } from "@/lib/db";
import { PERMS } from "@/lib/permissions";
import { prisma } from "@/lib/prisma";
import { adminAction } from "@/lib/safe-action";
import { actionOk } from "@/lib/safe-action-shared";
import { logAudit } from "@/lib/services/audit";
@@ -18,11 +18,10 @@ export const saveEmulatorSettings = adminAction(
const entries = Object.entries(ctx.data.settings);
for (const [key, value] of entries) {
await prisma.emulatorSettings.upsert({
where: { key },
update: { value: String(value) },
create: { key, value: String(value) },
});
await db
.insert(EmulatorSettings)
.values({ key, value: String(value) })
.onDuplicateKeyUpdate({ set: { value: String(value) } });
}
await rcon.updateConfig();
+103 -56
View File
@@ -1,9 +1,17 @@
"use server";
import { and, count, eq } from "drizzle-orm";
import { revalidatePath } from "next/cache";
import { z } from "zod";
import {
db,
WebsiteEvent,
WebsiteEventPrize,
WebsiteEventRegistration,
WebsiteEventType,
WebsiteEventWinner,
} from "@/lib/db";
import { PERMS } from "@/lib/permissions";
import { prisma } from "@/lib/prisma";
import { adminAction, authAction } from "@/lib/safe-action";
import { ActionError, actionError, actionOk } from "@/lib/safe-action-shared";
import { logAudit } from "@/lib/services/audit";
@@ -21,17 +29,16 @@ import {
export const createEventType = adminAction(
{ permission: PERMS.EVENTS_EDIT, schema: eventTypeSchema },
async (ctx) => {
const eventType = await prisma.websiteEventType.create({
data: ctx.data,
});
const [result] = await db.insert(WebsiteEventType).values(ctx.data);
const eventTypeId = Number(result.insertId);
logAudit({
userId: ctx.session.user.id,
action: "event_type_create",
target: "WebsiteEventType",
targetId: eventType.id,
after: { name: eventType.name },
targetId: eventTypeId,
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 },
async (ctx) => {
const { id, ...data } = ctx.data;
const existing = await prisma.websiteEventType.findUnique({
where: { id },
});
const [existing] = await db
.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");
await prisma.websiteEventType.update({ where: { id }, data });
await db
.update(WebsiteEventType)
.set(data)
.where(eq(WebsiteEventType.id, id));
logAudit({
userId: ctx.session.user.id,
action: "event_type_update",
@@ -68,12 +80,16 @@ const deleteEventTypeInput = z.object({
export const deleteEventType = adminAction(
{ permission: PERMS.EVENTS_EDIT, schema: deleteEventTypeInput },
async (ctx) => {
const existing = await prisma.websiteEventType.findUnique({
where: { id: ctx.data.id },
});
const [existing] = await db
.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");
await prisma.websiteEventType.delete({ where: { id: ctx.data.id } });
await db
.delete(WebsiteEventType)
.where(eq(WebsiteEventType.id, ctx.data.id));
logAudit({
userId: ctx.session.user.id,
action: "event_type_delete",
@@ -90,20 +106,21 @@ export const deleteEventType = adminAction(
export const createEvent = adminAction(
{ permission: PERMS.EVENTS_EDIT, schema: createEventSchema },
async (ctx) => {
const event = await prisma.websiteEvent.create({
data: {
...ctx.data,
hostUserId: Number(ctx.session.user.id),
},
const now = new Date();
const [result] = await db.insert(WebsiteEvent).values({
...ctx.data,
hostUserId: Number(ctx.session.user.id),
updatedAt: now,
});
const eventId = Number(result.insertId);
logAudit({
userId: ctx.session.user.id,
action: "event_create",
target: "WebsiteEvent",
targetId: event.id,
after: { title: event.title },
targetId: eventId,
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 },
async (ctx) => {
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");
await prisma.websiteEvent.update({ where: { id }, data });
await db
.update(WebsiteEvent)
.set({ ...data, updatedAt: new Date() })
.where(eq(WebsiteEvent.id, id));
logAudit({
userId: ctx.session.user.id,
action: "event_update",
@@ -138,12 +166,14 @@ const deleteEventInput = z.object({
export const deleteEvent = adminAction(
{ permission: PERMS.EVENTS_EDIT, schema: deleteEventInput },
async (ctx) => {
const existing = await prisma.websiteEvent.findUnique({
where: { id: ctx.data.id },
});
const [existing] = await db
.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");
await prisma.websiteEvent.delete({ where: { id: ctx.data.id } });
await db.delete(WebsiteEvent).where(eq(WebsiteEvent.id, ctx.data.id));
logAudit({
userId: ctx.session.user.id,
action: "event_delete",
@@ -160,8 +190,8 @@ export const deleteEvent = adminAction(
export const addEventPrize = adminAction(
{ permission: PERMS.EVENTS_EDIT, schema: eventPrizeSchema },
async (ctx) => {
const prize = await prisma.websiteEventPrize.create({ data: ctx.data });
return actionOk({ id: prize.id });
const [result] = await db.insert(WebsiteEventPrize).values(ctx.data);
return actionOk({ id: Number(result.insertId) });
},
);
@@ -170,7 +200,9 @@ const deletePrizeInput = z.object({ id: z.coerce.number().int().positive() });
export const deleteEventPrize = adminAction(
{ permission: PERMS.EVENTS_EDIT, schema: deletePrizeInput },
async (ctx) => {
await prisma.websiteEventPrize.delete({ where: { id: ctx.data.id } });
await db
.delete(WebsiteEventPrize)
.where(eq(WebsiteEventPrize.id, ctx.data.id));
return actionOk();
},
);
@@ -180,19 +212,20 @@ export const deleteEventPrize = adminAction(
export const addEventWinner = adminAction(
{ permission: PERMS.EVENTS_EDIT, schema: eventWinnerSchema },
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({
userId: ctx.session.user.id,
action: "event_winner_add",
target: "WebsiteEventWinner",
targetId: winner.id,
targetId: winnerId,
after: {
eventId: ctx.data.eventId,
userId: ctx.data.userId,
position: ctx.data.position,
},
});
return actionOk({ id: winner.id });
return actionOk({ id: winnerId });
},
);
@@ -211,13 +244,18 @@ export const registerForEvent = authAction(
return actionError("Unauthorized");
}
const event = await prisma.websiteEvent.findUnique({
where: { id: ctx.data.eventId },
include: {
type: true,
_count: { select: { registrations: true } },
},
});
const [event] = await db
.select({
id: WebsiteEvent.id,
status: WebsiteEvent.status,
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.status !== "published") {
@@ -226,28 +264,37 @@ export const registerForEvent = authAction(
if (event.endsAt && event.endsAt.getTime() < Date.now()) {
return actionError("This event has already ended");
}
if (event.type.minRank > 0) {
if (event.minRank > 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");
}
}
if (
event.maxPlayers != null &&
event._count.registrations >= event.maxPlayers
) {
return actionError("This event is full");
if (event.maxPlayers != null) {
const [regCount] = await db
.select({ value: count() })
.from(WebsiteEventRegistration)
.where(eq(WebsiteEventRegistration.eventId, event.id));
if ((regCount?.value ?? 0) >= event.maxPlayers) {
return actionError("This event is full");
}
}
const existing = await prisma.websiteEventRegistration.findUnique({
where: {
eventId_userId: { eventId: event.id, userId },
},
});
const [existing] = await db
.select({ id: WebsiteEventRegistration.id })
.from(WebsiteEventRegistration)
.where(
and(
eq(WebsiteEventRegistration.eventId, event.id),
eq(WebsiteEventRegistration.userId, userId),
),
)
.limit(1);
if (existing) return actionError("You are already registered");
await prisma.websiteEventRegistration.create({
data: { eventId: event.id, userId },
await db.insert(WebsiteEventRegistration).values({
eventId: event.id,
userId,
});
revalidatePath("/events");
+7 -9
View File
@@ -3,7 +3,7 @@
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
import { db, WebsiteUserGuestbooks } from "@/lib/db";
import { clientIp, rateLimit } from "@/lib/rate-limit";
import { isAllowed } from "@/lib/services/moderation";
@@ -78,14 +78,12 @@ export async function postGuestbook(formData: FormData): Promise<void> {
outcome = "moderated";
} else {
const now = new Date();
await prisma.websiteUserGuestbooks.create({
data: {
profileId,
userId,
message,
createdAt: now,
updatedAt: now,
},
await db.insert(WebsiteUserGuestbooks).values({
profileId,
userId,
message,
createdAt: now,
updatedAt: now,
});
outcome = "posted";
}
+84 -67
View File
@@ -1,11 +1,17 @@
"use server";
import { eq } from "drizzle-orm";
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import { z } from "zod";
import { positiveBigInt } from "@/lib/api";
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 { moderateOrThrow } from "@/lib/services/moderation";
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)) {
ticketTitle = `[Ban appeal] ${ticketTitle}`.slice(0, 255);
}
const existing = await prisma.websiteHelpCenterCategories.findFirst(
{
where: { name: { equals: "Ban appeal" } },
select: { id: true },
},
);
const [existing] = await db
.select({ id: WebsiteHelpCenterCategories.id })
.from(WebsiteHelpCenterCategories)
.where(eq(WebsiteHelpCenterCategories.name, "Ban appeal"))
.limit(1);
if (existing) {
categoryId = existing.id;
} else {
try {
const created = await prisma.websiteHelpCenterCategories.create(
{
data: {
name: "Ban appeal",
content:
"Appeals for account bans. Staff can lift bans from the ticket.",
position: 0,
},
select: { id: true },
},
);
categoryId = created.id;
} catch {
const again =
await prisma.websiteHelpCenterCategories.findFirst({
where: { name: { equals: "Ban appeal" } },
select: { id: true },
const [created] = await db
.insert(WebsiteHelpCenterCategories)
.values({
name: "Ban appeal",
content:
"Appeals for account bans. Staff can lift bans from the ticket.",
position: 0,
});
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;
}
}
@@ -148,16 +150,14 @@ export async function createTicket(formData: FormData): Promise<void> {
}
const now = new Date();
await prisma.websiteHelpCenterTickets.create({
data: {
userId,
title: ticketTitle,
content,
categoryId,
open: true,
createdAt: now,
updatedAt: now,
},
await db.insert(WebsiteHelpCenterTickets).values({
userId,
title: ticketTitle,
content,
categoryId,
open: true,
createdAt: now,
updatedAt: now,
});
outcome = "created";
}
@@ -204,10 +204,15 @@ export async function replyHelpTicket(formData: FormData): Promise<void> {
if (!parsed.success) {
outcome = "invalid";
} else {
const ticket = await prisma.websiteHelpCenterTickets.findUnique({
where: { id: ticketId },
select: { id: true, userId: true, open: true },
});
const [ticket] = await db
.select({
id: WebsiteHelpCenterTickets.id,
userId: WebsiteHelpCenterTickets.userId,
open: WebsiteHelpCenterTickets.open,
})
.from(WebsiteHelpCenterTickets)
.where(eq(WebsiteHelpCenterTickets.id, ticketId))
.limit(1);
if (!ticket || ticket.userId !== userId) {
outcome = "not_found";
@@ -223,30 +228,37 @@ export async function replyHelpTicket(formData: FormData): Promise<void> {
}
if (!moderated) {
const created = await prisma.$transaction((tx) =>
const created = await db.transaction(async (tx) =>
createOwnedTicketReply(
{
findTicket: (id) =>
tx.websiteHelpCenterTickets.findUnique({
where: { id },
select: { id: true, userId: true, open: true },
}),
createReply: (data) =>
tx.websiteHelpCenterTicketReplies.create({
data,
select: {
id: true,
userId: true,
content: true,
createdAt: true,
},
}),
findTicket: async (id) => {
const [row] = await tx
.select({
id: WebsiteHelpCenterTickets.id,
userId: WebsiteHelpCenterTickets.userId,
open: WebsiteHelpCenterTickets.open,
})
.from(WebsiteHelpCenterTickets)
.where(eq(WebsiteHelpCenterTickets.id, id))
.limit(1);
return row ?? null;
},
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) =>
tx.websiteHelpCenterTickets.update({
where: { id },
data: { updatedAt },
select: { id: true },
}),
tx
.update(WebsiteHelpCenterTickets)
.set({ updatedAt })
.where(eq(WebsiteHelpCenterTickets.id, id)),
},
{
ticketId,
@@ -289,10 +301,15 @@ export async function closeHelpTicket(formData: FormData): Promise<void> {
if (!(await rateLimit(`ticket-close:${userId}`, 10, 60_000)).ok) {
outcome = "ratelimit";
} else {
const ticket = await prisma.websiteHelpCenterTickets.findUnique({
where: { id: ticketId },
select: { id: true, userId: true, open: true },
});
const [ticket] = await db
.select({
id: WebsiteHelpCenterTickets.id,
userId: WebsiteHelpCenterTickets.userId,
open: WebsiteHelpCenterTickets.open,
})
.from(WebsiteHelpCenterTickets)
.where(eq(WebsiteHelpCenterTickets.id, ticketId))
.limit(1);
if (!ticket || ticket.userId !== userId) {
outcome = "not_found";
@@ -300,10 +317,10 @@ export async function closeHelpTicket(formData: FormData): Promise<void> {
outcome = "closed_ticket";
} else {
const now = new Date();
await prisma.websiteHelpCenterTickets.update({
where: { id: ticketId },
data: { open: false, updatedAt: now },
});
await db
.update(WebsiteHelpCenterTickets)
.set({ open: false, updatedAt: now })
.where(eq(WebsiteHelpCenterTickets.id, ticketId));
outcome = "closed";
}
}
+11 -9
View File
@@ -1,9 +1,10 @@
"use server";
import { eq } from "drizzle-orm";
import { revalidatePath } from "next/cache";
import { z } from "zod";
import { db, ItemsBase } from "@/lib/db";
import { PERMS } from "@/lib/permissions";
import { prisma } from "@/lib/prisma";
import { adminAction } from "@/lib/safe-action";
import { ActionError, actionOk } from "@/lib/safe-action-shared";
import { rcon } from "@/lib/services/rcon";
@@ -63,10 +64,11 @@ export const updateItemsBase = adminAction(
throw new ActionError("No valid fields to update");
}
const existing = await prisma.itemsBase.findUnique({
where: { id },
select: { id: true },
});
const [existing] = await db
.select({ id: ItemsBase.id })
.from(ItemsBase)
.where(eq(ItemsBase.id, id))
.limit(1);
if (!existing) throw new ActionError("Item not found");
// Coerce common numeric / decimal fields from form strings.
@@ -94,10 +96,10 @@ export const updateItemsBase = adminAction(
data.stackHeight = Number(data.stackHeight);
}
await prisma.itemsBase.update({
where: { id },
data: data as any,
});
await db
.update(ItemsBase)
.set(data as Partial<typeof ItemsBase.$inferInsert>)
.where(eq(ItemsBase.id, id));
await rcon.updateCatalog().catch(() => false);
await logStaffActivity({
staffId: Number(ctx.session.user.id),
+126 -78
View File
@@ -1,9 +1,16 @@
"use server";
import { and, eq, or } from "drizzle-orm";
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
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";
type FriendOutcome =
@@ -76,10 +83,15 @@ export async function acceptFriend(formData: FormData): Promise<void> {
outcome = "invalid";
} else {
// The request must exist AND be addressed to the session user.
const request = await prisma.messengerFriendrequests.findUnique({
where: { id: requestId },
select: { id: true, userFromId: true, userToId: true },
});
const [request] = await db
.select({
id: MessengerFriendrequests.id,
userFromId: MessengerFriendrequests.userFromId,
userToId: MessengerFriendrequests.userToId,
})
.from(MessengerFriendrequests)
.where(eq(MessengerFriendrequests.id, requestId))
.limit(1);
if (!request) {
outcome = "not_found";
} else if (request.userToId !== meId) {
@@ -92,44 +104,55 @@ export async function acceptFriend(formData: FormData): Promise<void> {
friendId === meId
) {
// Malformed/self request — clear it and treat as not found.
await prisma.messengerFriendrequests.delete({
where: { id: requestId },
});
await db
.delete(MessengerFriendrequests)
.where(eq(MessengerFriendrequests.id, requestId));
outcome = "not_found";
} else {
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.
const existing = await tx.messengerFriendships.findFirst({
where: {
OR: [
{ userOneId: meId, userTwoId: friendId },
{ userOneId: friendId, userTwoId: meId },
],
},
select: { id: true },
});
const [existing] = await tx
.select({ id: MessengerFriendships.id })
.from(MessengerFriendships)
.where(
or(
and(
eq(MessengerFriendships.userOneId, meId),
eq(MessengerFriendships.userTwoId, friendId),
),
and(
eq(MessengerFriendships.userOneId, friendId),
eq(MessengerFriendships.userTwoId, meId),
),
),
)
.limit(1);
if (!existing) {
await tx.messengerFriendships.createMany({
data: [
{ userOneId: meId, userTwoId: friendId, friendsSince },
{ userOneId: friendId, userTwoId: meId, friendsSince },
],
});
await tx.insert(MessengerFriendships).values([
{ userOneId: meId, userTwoId: friendId, friendsSince },
{ userOneId: friendId, userTwoId: meId, friendsSince },
]);
}
// Clear this request and any reverse pending request between the pair.
await tx.messengerFriendrequests.deleteMany({
where: {
OR: [
{ id: requestId },
{ userFromId: meId, userToId: friendId },
{ userFromId: friendId, userToId: meId },
],
},
});
await tx
.delete(MessengerFriendrequests)
.where(
or(
eq(MessengerFriendrequests.id, requestId),
and(
eq(MessengerFriendrequests.userFromId, meId),
eq(MessengerFriendrequests.userToId, friendId),
),
and(
eq(MessengerFriendrequests.userFromId, friendId),
eq(MessengerFriendrequests.userToId, meId),
),
),
);
});
outcome = "accepted";
@@ -180,18 +203,22 @@ export async function declineFriendRequest(formData: FormData): Promise<void> {
if (!Number.isInteger(requestId) || requestId <= 0) {
outcome = "invalid";
} else {
const request = await prisma.messengerFriendrequests.findUnique({
where: { id: requestId },
select: { id: true, userToId: true },
});
const [request] = await db
.select({
id: MessengerFriendrequests.id,
userToId: MessengerFriendrequests.userToId,
})
.from(MessengerFriendrequests)
.where(eq(MessengerFriendrequests.id, requestId))
.limit(1);
if (!request) {
outcome = "not_found";
} else if (request.userToId !== meId) {
outcome = "unauthorized";
} else {
await prisma.messengerFriendrequests.delete({
where: { id: requestId },
});
await db
.delete(MessengerFriendrequests)
.where(eq(MessengerFriendrequests.id, requestId));
outcome = "declined";
}
}
@@ -239,26 +266,41 @@ export async function removeFriendship(formData: FormData): Promise<void> {
if (!Number.isInteger(friendId) || friendId <= 0 || friendId === meId) {
outcome = "invalid";
} else {
const deleted = await prisma.$transaction(async (tx) => {
const result = await tx.messengerFriendships.deleteMany({
where: {
OR: [
{ userOneId: meId, userTwoId: friendId },
{ userOneId: friendId, userTwoId: meId },
],
},
});
const deleted = await db.transaction(async (tx) => {
const result = await tx
.delete(MessengerFriendships)
.where(
or(
and(
eq(MessengerFriendships.userOneId, meId),
eq(MessengerFriendships.userTwoId, friendId),
),
and(
eq(MessengerFriendships.userOneId, friendId),
eq(MessengerFriendships.userTwoId, meId),
),
),
);
await tx.messengerFriendrequests.deleteMany({
where: {
OR: [
{ userFromId: meId, userToId: friendId },
{ userFromId: friendId, userToId: meId },
],
},
});
await tx
.delete(MessengerFriendrequests)
.where(
or(
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";
@@ -310,33 +352,39 @@ export async function sendOfflineMessage(formData: FormData): Promise<void> {
} else if (!message) {
outcome = "empty";
} else {
const friendship = await prisma.messengerFriendships.findFirst({
where: {
OR: [
{ userOneId: meId, userTwoId: friendId },
{ userOneId: friendId, userTwoId: meId },
],
},
select: { id: true },
});
const [friendship] = await db
.select({ id: MessengerFriendships.id })
.from(MessengerFriendships)
.where(
or(
and(
eq(MessengerFriendships.userOneId, meId),
eq(MessengerFriendships.userTwoId, friendId),
),
and(
eq(MessengerFriendships.userOneId, friendId),
eq(MessengerFriendships.userTwoId, meId),
),
),
)
.limit(1);
if (!friendship) {
outcome = "not_friend";
} else {
const recipient = await prisma.user.findUnique({
where: { id: friendId },
select: { id: true },
});
const [recipient] = await db
.select({ id: User.id })
.from(User)
.where(eq(User.id, friendId))
.limit(1);
if (!recipient) {
outcome = "invalid";
} else {
await prisma.messengerOffline.create({
data: {
userId: friendId,
userFromId: meId,
message,
sendedOn: Math.floor(Date.now() / 1000),
},
await db.insert(MessengerOffline).values({
userId: friendId,
userFromId: meId,
message,
sendedOn: Math.floor(Date.now() / 1000),
});
outcome = "sent";
}
+27 -19
View File
@@ -1,10 +1,11 @@
"use server";
import { eq } from "drizzle-orm";
import { z } from "zod";
import { db, SupportTickets } from "@/lib/db";
import { actionOk, adminAction } from "@/lib/foundation/action";
import { NotFoundError } from "@/lib/foundation/errors";
import { PERMS } from "@/lib/permissions";
import { prisma } from "@/lib/prisma";
import { logAudit } from "@/lib/services/audit";
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(
{ permission: CFH_PERM, schema: cfhIdSchema },
async (ctx) => {
const ticket = await prisma.supportTickets.findUnique({
where: { id: ctx.data.ticketId },
});
const [ticket] = await db
.select({ id: SupportTickets.id })
.from(SupportTickets)
.where(eq(SupportTickets.id, ctx.data.ticketId))
.limit(1);
if (!ticket) throw new NotFoundError("SupportTicket", ctx.data.ticketId);
await prisma.supportTickets.update({
where: { id: ctx.data.ticketId },
data: { modId: ctx.session.user.id, state: 1 },
});
await db
.update(SupportTickets)
.set({ modId: ctx.session.user.id, state: 1 })
.where(eq(SupportTickets.id, ctx.data.ticketId));
logAudit({
userId: ctx.session.user.id,
@@ -47,15 +50,20 @@ const cfhStateSchema = z.object({
export const updateCfhState = adminAction(
{ permission: CFH_PERM, schema: cfhStateSchema },
async (ctx) => {
const ticket = await prisma.supportTickets.findUnique({
where: { id: ctx.data.ticketId },
});
const [ticket] = await db
.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);
await prisma.supportTickets.update({
where: { id: ctx.data.ticketId },
data: { state: ctx.data.state, modId: ctx.session.user.id },
});
await db
.update(SupportTickets)
.set({ state: ctx.data.state, modId: ctx.session.user.id })
.where(eq(SupportTickets.id, ctx.data.ticketId));
logAudit({
userId: ctx.session.user.id,
@@ -73,10 +81,10 @@ export const updateCfhState = adminAction(
export const closeCfhTicket = adminAction(
{ permission: CFH_PERM, schema: cfhIdSchema },
async (ctx) => {
await prisma.supportTickets.update({
where: { id: ctx.data.ticketId },
data: { state: 2, modId: ctx.session.user.id },
});
await db
.update(SupportTickets)
.set({ state: 2, modId: ctx.session.user.id })
.where(eq(SupportTickets.id, ctx.data.ticketId));
logAudit({
userId: ctx.session.user.id,
+25 -15
View File
@@ -1,8 +1,9 @@
"use server";
import { asc, count, desc, eq, gte, ne, sql } from "drizzle-orm";
import { requirePermission } from "@/lib/admin/guard";
import { db, User } from "@/lib/db";
import { PERMS } from "@/lib/permissions";
import { prisma } from "@/lib/prisma";
export interface MultiAccountCluster {
key: string;
@@ -26,25 +27,34 @@ export async function detectMultiAccounts({
await requirePermission(PERMS.USERS_VIEW);
const clusters: MultiAccountCluster[] = [];
const ipGroups = await prisma.user.groupBy({
by: ["ipCurrent"],
where: { ipCurrent: { not: "" } },
_count: { id: true },
having: { id: { _count: { gte: minAccounts } } },
orderBy: { _count: { id: "desc" } },
take: limit,
});
const accountCount = count(User.id);
const ipGroups = await db
.select({
ipCurrent: User.ipCurrent,
accountCount,
})
.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) {
const users = await prisma.user.findMany({
where: { ipCurrent: group.ipCurrent },
select: { id: true, username: true, rank: true, online: true },
orderBy: { id: "asc" },
});
const users = await db
.select({
id: User.id,
username: User.username,
rank: User.rank,
online: User.online,
})
.from(User)
.where(eq(User.ipCurrent, group.ipCurrent))
.orderBy(asc(User.id));
clusters.push({
key: group.ipCurrent,
label: `IP: ${group.ipCurrent}`,
accountCount: group._count.id,
accountCount: Number(group.accountCount),
accounts: users.map((u) => ({
id: u.id,
username: u.username,
+30 -21
View File
@@ -1,10 +1,11 @@
"use server";
import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
import { eq } from "drizzle-orm";
import { redirect } from "next/navigation";
import { env } from "@/env";
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 { captchaConfig, verifyCaptcha } from "@/lib/services/captcha";
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.
if (allowed && /^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(email)) {
try {
const user = await prisma.user.findFirst({
where: { mail: email },
select: { id: true },
});
const [user] = await db
.select({ id: User.id })
.from(User)
.where(eq(User.mail, email))
.limit(1);
if (user) {
const token = randomBytes(32).toString("hex");
await prisma.passwordReset.upsert({
where: { email },
update: { token: sha256(token), createdAt: new Date() },
create: { email, token: sha256(token), createdAt: new Date() },
});
const hashed = sha256(token);
const createdAt = new Date();
await db
.insert(PasswordReset)
.values({ email, token: hashed, createdAt })
.onDuplicateKeyUpdate({ set: { token: hashed, createdAt } });
const link = `${env.APP_URL}/reset?email=${encodeURIComponent(email)}&token=${token}`;
await sendMail(
email,
@@ -86,7 +89,11 @@ export async function resetPassword(formData: FormData): Promise<void> {
if (!error) {
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
? Date.now() - row.createdAt.getTime() < TOKEN_TTL_MS
: false;
@@ -98,19 +105,21 @@ export async function resetPassword(formData: FormData): Promise<void> {
if (!row || !fresh || !match) {
error = "This reset link is invalid or has expired";
} else {
const user = await prisma.user.findFirst({
where: { mail: email },
select: { id: true },
});
const [user] = await db
.select({ id: User.id })
.from(User)
.where(eq(User.mail, email))
.limit(1);
if (!user) {
error = "Account not found";
} else {
await prisma.user.update({
where: { id: user.id },
data: { password: await hashPassword(password) },
});
await prisma.passwordReset
.delete({ where: { email } })
await db
.update(User)
.set({ password: await hashPassword(password) })
.where(eq(User.id, user.id));
await db
.delete(PasswordReset)
.where(eq(PasswordReset.email, email))
.catch(() => {});
}
}
+74 -41
View File
@@ -1,9 +1,15 @@
"use server";
import { and, eq } from "drizzle-orm";
import { revalidatePath } from "next/cache";
import { z } from "zod";
import {
db,
WebsitePoll,
WebsitePollQuestion,
WebsitePollVote,
} from "@/lib/db";
import { PERMS } from "@/lib/permissions";
import { prisma } from "@/lib/prisma";
import { adminAction, authAction } from "@/lib/safe-action";
import { ActionError, actionError, actionOk } from "@/lib/safe-action-shared";
import { logAudit } from "@/lib/services/audit";
@@ -19,15 +25,20 @@ import {
export const createPoll = adminAction(
{ permission: PERMS.POLLS_EDIT, schema: createPollSchema },
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({
userId: ctx.session.user.id,
action: "poll_create",
target: "WebsitePoll",
targetId: poll.id,
after: { title: poll.title },
targetId: pollId,
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 },
async (ctx) => {
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");
await prisma.websitePoll.update({ where: { id }, data });
await db
.update(WebsitePoll)
.set({ ...data, updatedAt: new Date() })
.where(eq(WebsitePoll.id, id));
logAudit({
userId: ctx.session.user.id,
action: "poll_update",
@@ -62,12 +84,14 @@ const deletePollInput = z.object({
export const deletePoll = adminAction(
{ permission: PERMS.POLLS_EDIT, schema: deletePollInput },
async (ctx) => {
const existing = await prisma.websitePoll.findUnique({
where: { id: ctx.data.id },
});
const [existing] = await db
.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");
await prisma.websitePoll.delete({ where: { id: ctx.data.id } });
await db.delete(WebsitePoll).where(eq(WebsitePoll.id, ctx.data.id));
logAudit({
userId: ctx.session.user.id,
action: "poll_delete",
@@ -84,10 +108,8 @@ export const deletePoll = adminAction(
export const addPollQuestion = adminAction(
{ permission: PERMS.POLLS_EDIT, schema: pollQuestionSchema },
async (ctx) => {
const question = await prisma.websitePollQuestion.create({
data: ctx.data,
});
return actionOk({ id: question.id });
const [result] = await db.insert(WebsitePollQuestion).values(ctx.data);
return actionOk({ id: Number(result.insertId) });
},
);
@@ -99,7 +121,10 @@ export const updatePollQuestion = adminAction(
{ permission: PERMS.POLLS_EDIT, schema: updateQuestionInput },
async (ctx) => {
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 });
},
);
@@ -111,7 +136,9 @@ const deleteQuestionInput = z.object({
export const deletePollQuestion = adminAction(
{ permission: PERMS.POLLS_EDIT, schema: deleteQuestionInput },
async (ctx) => {
await prisma.websitePollQuestion.delete({ where: { id: ctx.data.id } });
await db
.delete(WebsitePollQuestion)
.where(eq(WebsitePollQuestion.id, ctx.data.id));
return actionOk();
},
);
@@ -138,10 +165,11 @@ export const voteOnPoll = authAction(
return actionError("Unauthorized");
}
const poll = await prisma.websitePoll.findUnique({
where: { id: ctx.data.pollId },
include: { questions: true },
});
const [poll] = await db
.select()
.from(WebsitePoll)
.where(eq(WebsitePoll.id, ctx.data.pollId))
.limit(1);
if (!poll) return actionError("Poll not found");
if (poll.status !== "active") {
@@ -155,7 +183,12 @@ export const voteOnPoll = authAction(
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>();
for (const vote of ctx.data.votes) {
@@ -194,30 +227,30 @@ export const voteOnPoll = authAction(
}
}
const existing = await prisma.websitePollVote.findUnique({
where: {
questionId_userId: {
questionId: vote.questionId,
userId,
},
},
});
const [existing] = await db
.select({ id: WebsitePollVote.id })
.from(WebsitePollVote)
.where(
and(
eq(WebsitePollVote.questionId, vote.questionId),
eq(WebsitePollVote.userId, userId),
),
)
.limit(1);
if (existing) {
return actionError("You have already voted on this poll");
}
}
await prisma.$transaction(
ctx.data.votes.map((vote) =>
prisma.websitePollVote.create({
data: {
questionId: vote.questionId,
userId,
answer: vote.answer.trim(),
},
}),
),
);
await db.transaction(async (tx) => {
for (const vote of ctx.data.votes) {
await tx.insert(WebsitePollVote).values({
questionId: vote.questionId,
userId,
answer: vote.answer.trim(),
});
}
});
revalidatePath("/polls");
revalidatePath(`/polls/${poll.id}`);
+24 -18
View File
@@ -1,14 +1,14 @@
"use server";
import { eq, sql } from "drizzle-orm";
import { z } from "zod";
import { db, User } from "@/lib/db";
import { PERMS } from "@/lib/permissions";
import { prisma } from "@/lib/prisma";
import { adminAction } from "@/lib/safe-action";
import { ActionError, actionOk } from "@/lib/safe-action-shared";
// Models custom_prefixes / custom_prefix_blacklist / custom_prefix_settings
// are not represented in prisma/schema.prisma yet — we use raw queries with
// tagged template literals, which parameterize all interpolated values.
// are not represented in src/db/schema.ts yet — we use parameterized raw SQL.
// ── Create prefix ───────────────────────────────────────────────────
@@ -26,15 +26,17 @@ export const createPrefix = adminAction(
async (ctx) => {
const { username, text, color, icon, effect, active } = ctx.data;
const users = await prisma.$queryRaw<{ id: number }[]>`
SELECT id FROM users WHERE username = ${username} LIMIT 1
`;
if (users.length === 0) throw new ActionError("User not found");
const [user] = await db
.select({ id: User.id })
.from(User)
.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)
VALUES (${users[0].id}, ${text}, ${color}, ${icon || ""}, ${effect || ""}, ${active})
`;
VALUES (${user.id}, ${text}, ${color}, ${icon || ""}, ${effect || ""}, ${active})
`);
return actionOk();
},
@@ -56,11 +58,11 @@ export const updatePrefix = adminAction(
async (ctx) => {
const { id, text, color, icon, effect, active } = ctx.data;
await prisma.$executeRaw`
await db.execute(sql`
UPDATE custom_prefixes
SET text = ${text}, color = ${color}, icon = ${icon || ""}, effect = ${effect || ""}, active = ${active ?? 1}
WHERE id = ${id}
`;
`);
return actionOk();
},
@@ -75,7 +77,9 @@ const deletePrefixSchema = z.object({
export const deletePrefix = adminAction(
{ permission: PERMS.PREFIXES_EDIT, schema: deletePrefixSchema },
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();
},
);
@@ -89,9 +93,9 @@ const addBlacklistWordSchema = z.object({
export const addBlacklistWord = adminAction(
{ permission: PERMS.PREFIXES_EDIT, schema: addBlacklistWordSchema },
async (ctx) => {
await prisma.$executeRaw`
await db.execute(sql`
INSERT INTO custom_prefix_blacklist (word) VALUES (${ctx.data.word.trim()})
`;
`);
return actionOk();
},
);
@@ -105,7 +109,9 @@ const removeBlacklistWordSchema = z.object({
export const removeBlacklistWord = adminAction(
{ permission: PERMS.PREFIXES_EDIT, schema: removeBlacklistWordSchema },
async (ctx) => {
await prisma.$executeRaw`DELETE FROM custom_prefix_blacklist WHERE id = ${ctx.data.id}`;
await db.execute(
sql`DELETE FROM custom_prefix_blacklist WHERE id = ${ctx.data.id}`,
);
return actionOk();
},
);
@@ -135,11 +141,11 @@ export const updatePrefixSettings = adminAction(
async (ctx) => {
for (const [key, value] of Object.entries(ctx.data.settings)) {
if (!SETTINGS_WHITELIST.has(key)) continue;
await prisma.$executeRaw`
await db.execute(sql`
INSERT INTO custom_prefix_settings (\`key\`, \`value\`)
VALUES (${key}, ${value})
ON DUPLICATE KEY UPDATE \`value\` = ${value}
`;
`);
}
return actionOk();
},
+21 -20
View File
@@ -1,11 +1,12 @@
"use server";
import { count, eq } from "drizzle-orm";
import { redirect } from "next/navigation";
import { z } from "zod";
import { sendVerification } from "@/actions/email-verify";
import { hashPassword } from "@/lib/auth/password";
import { db, User } from "@/lib/db";
import { logger } from "@/lib/logger";
import { prisma } from "@/lib/prisma";
import { clientIp, rateLimit } from "@/lib/rate-limit";
import { captchaConfig, verifyCaptcha } from "@/lib/services/captcha";
import { checkVpn } from "@/lib/services/ip-lookup";
@@ -86,19 +87,22 @@ export async function register(
// Max accounts per IP (0 / unset = unlimited), mirrors AtomCMS.
const max = Number(await siteSettings.get("max_accounts_per_ip", "0")) || 0;
if (max > 0) {
const count = await prisma.user
.count({ where: { ipRegister: ip } })
.catch(() => 0);
if (count >= max)
const [row] = await db
.select({ total: count() })
.from(User)
.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.";
}
// Uniqueness check.
try {
const existing = await prisma.user.findUnique({
where: { username },
select: { id: true },
});
const [existing] = await db
.select({ id: User.id })
.from(User)
.where(eq(User.username, username))
.limit(1);
if (existing) return "That username is already taken";
} catch {
logger.warn("Username uniqueness check failed during registration");
@@ -107,17 +111,14 @@ export async function register(
const now = Math.floor(Date.now() / 1000);
try {
await prisma.user.create({
data: {
username,
password: await hashPassword(password),
mail: hasEmail ? mail : null,
accountCreated: now,
ipRegister: ip,
ipCurrent: ip,
look,
},
select: { id: true },
await db.insert(User).values({
username,
password: await hashPassword(password),
mail: hasEmail ? mail : null,
accountCreated: now,
ipRegister: ip,
ipCurrent: ip,
look,
});
if (hasEmail) {
+12 -12
View File
@@ -1,9 +1,10 @@
"use server";
import { and, eq, inArray } from "drizzle-orm";
import { revalidatePath } from "next/cache";
import { requirePermission } from "@/lib/admin/guard";
import { db, Items, Rooms } from "@/lib/db";
import { PERMS } from "@/lib/permissions";
import { prisma } from "@/lib/prisma";
import { rcon } from "@/lib/services/rcon";
import { logStaffActivity } from "@/lib/services/staff-activity";
@@ -14,10 +15,10 @@ export async function updateRoomItem(payload: Record<string, unknown>) {
itemId: number;
[key: string]: unknown;
};
await prisma.items.update({
where: { id: itemId },
data: data as any,
});
await db
.update(Items)
.set(data as Partial<typeof Items.$inferInsert>)
.where(eq(Items.id, itemId));
await logStaffActivity({
staffId: staff.id,
action: "room_item_update",
@@ -36,7 +37,9 @@ export async function bulkDeleteRoomItems({
itemIds: number[];
}) {
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({
staffId: staff.id,
action: "room_items_bulk_delete",
@@ -54,7 +57,7 @@ export async function deleteRoomItem({
itemId: number;
}) {
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({
staffId: staff.id,
action: "room_item_delete",
@@ -86,7 +89,7 @@ export async function roomRconAction({
export async function deleteRoom({ id }: { id: number }) {
const staff = await requirePermission(PERMS.ROOMS_DELETE);
await prisma.rooms.delete({ where: { id } });
await db.delete(Rooms).where(eq(Rooms.id, id));
await logStaffActivity({
staffId: staff.id,
action: "room_delete",
@@ -108,10 +111,7 @@ export async function updateRoom({
usersMax?: number;
}) {
const staff = await requirePermission(PERMS.ROOMS_EDIT);
await prisma.rooms.update({
where: { id },
data: data as any,
});
await db.update(Rooms).set(data).where(eq(Rooms.id, id));
await logStaffActivity({
staffId: staff.id,
action: "room_update",
+13 -9
View File
@@ -2,9 +2,10 @@
import { mkdir, unlink, writeFile } from "node:fs/promises";
import path from "node:path";
import { eq } from "drizzle-orm";
import { revalidatePath } from "next/cache";
import { db, WebsiteSetting } from "@/lib/db";
import { resolveMediaPath } from "@/lib/media-storage";
import { prisma } from "@/lib/prisma";
import { siteSettings } from "@/lib/services/site-settings";
const FAVICON_DIR = resolveMediaPath("favicon");
@@ -74,11 +75,10 @@ export async function saveFavicon(
}
}
await prisma.websiteSetting.upsert({
where: { key: "cms_favicon" },
update: { value: url },
create: { key: "cms_favicon", value: url, comment: "Favicon URL" },
});
await db
.insert(WebsiteSetting)
.values({ key: "cms_favicon", value: url, comment: "Favicon URL" })
.onDuplicateKeyUpdate({ set: { value: url } });
siteSettings.reload();
revalidatePath("/", "layout");
@@ -115,9 +115,13 @@ export async function deleteFavicon(): Promise<{
}
}
await prisma.websiteSetting
.delete({ where: { key: "cms_favicon" } })
.catch(() => {});
try {
await db
.delete(WebsiteSetting)
.where(eq(WebsiteSetting.key, "cms_favicon"));
} catch {
/* ignore missing row */
}
siteSettings.reload();
revalidatePath("/", "layout");
revalidatePath("/admin/favicon");
+5 -6
View File
@@ -3,8 +3,8 @@
import { mkdir, writeFile } from "node:fs/promises";
import path from "node:path";
import { revalidatePath } from "next/cache";
import { db, WebsiteSetting } from "@/lib/db";
import { resolveMediaPath } from "@/lib/media-storage";
import { prisma } from "@/lib/prisma";
import { siteSettings } from "@/lib/services/site-settings";
const MEDIA_DIR = resolveMediaPath("logo");
@@ -41,11 +41,10 @@ export async function saveLogo(
const url = `/api/media/logo/${filename}`;
await prisma.websiteSetting.upsert({
where: { key: "cms_logo" },
update: { value: url },
create: { key: "cms_logo", value: url, comment: "Logo (generator)" },
});
await db
.insert(WebsiteSetting)
.values({ key: "cms_logo", value: url, comment: "Logo (generator)" })
.onDuplicateKeyUpdate({ set: { value: url } });
siteSettings.reload();
revalidatePath("/", "layout");
+17 -8
View File
@@ -1,10 +1,11 @@
"use server";
import { and, eq, sql } from "drizzle-orm";
import { auth, signOut } from "@/lib/auth";
import { invalidateJwtVersionCache } from "@/lib/auth/jwt-version-cache";
import { personalTokenScope } from "@/lib/auth/personal-token-scope";
import { db, PersonalAccessTokens, User } from "@/lib/db";
import { logger } from "@/lib/logger";
import { prisma } from "@/lib/prisma";
/**
* 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 {
await prisma.user.update({
where: { id: userId },
data: { websiteJwtVersion: { increment: 1 } },
});
await db
.update(User)
.set({
websiteJwtVersion: sql`${User.websiteJwtVersion} + 1`,
})
.where(eq(User.id, userId));
await invalidateJwtVersionCache(userId);
} catch (err) {
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).
try {
await prisma.personalAccessTokens.deleteMany({
where: personalTokenScope(userId),
});
const scope = personalTokenScope(userId);
await db
.delete(PersonalAccessTokens)
.where(
and(
eq(PersonalAccessTokens.tokenableId, scope.tokenableId),
eq(PersonalAccessTokens.tokenableType, scope.tokenableType),
),
);
} catch (err) {
logger.warn(
"Failed to revoke personal access tokens during sign-out-everywhere",
+53 -42
View File
@@ -1,13 +1,14 @@
"use server";
import { and, eq, max, sql } from "drizzle-orm";
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
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 { creditsPerUnit } from "@/lib/services/paypal";
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);
@@ -86,19 +87,20 @@ export async function buyShopArticle(formData: FormData): Promise<void> {
if (!/^\d+$/.test(rawId)) {
outcome = "invalid";
} else {
const article = await prisma.websiteShopArticles.findUnique({
where: { id: BigInt(rawId) },
select: {
id: true,
name: true,
costs: true,
credits: true,
duckets: true,
diamonds: true,
badges: true,
giveRank: true,
},
});
const [article] = await db
.select({
id: WebsiteShopArticles.id,
name: WebsiteShopArticles.name,
costs: WebsiteShopArticles.costs,
credits: WebsiteShopArticles.credits,
duckets: WebsiteShopArticles.duckets,
diamonds: WebsiteShopArticles.diamonds,
badges: WebsiteShopArticles.badges,
giveRank: WebsiteShopArticles.giveRank,
})
.from(WebsiteShopArticles)
.where(eq(WebsiteShopArticles.id, BigInt(rawId)))
.limit(1);
if (!article) {
outcome = "invalid";
@@ -106,21 +108,22 @@ export async function buyShopArticle(formData: FormData): Promise<void> {
packageName = article.name;
const price = creditPriceFromCosts(article.costs);
const buyer = await prisma.user.findUnique({
where: { id: userId },
select: { credits: true, rank: true },
});
const [buyer] = await db
.select({ credits: User.credits, rank: User.rank })
.from(User)
.where(eq(User.id, userId))
.limit(1);
if (!buyer || buyer.credits < price) {
outcome = "credits";
} else {
const badgeCodes = parseBadgeCodes(article.badges);
await prisma.$transaction(async (tx) => {
await db.transaction(async (tx) => {
if (price > 0) {
await tx.user.update({
where: { id: userId },
data: { credits: { decrement: price } },
});
await tx
.update(User)
.set({ credits: sql`${User.credits} - ${price}` })
.where(eq(User.id, userId));
}
if (
@@ -128,44 +131,52 @@ export async function buyShopArticle(formData: FormData): Promise<void> {
article.giveRank > 0 &&
article.giveRank > buyer.rank
) {
await tx.user.update({
where: { id: userId },
data: { rank: article.giveRank },
});
await tx
.update(User)
.set({ rank: article.giveRank })
.where(eq(User.id, userId));
}
for (const code of badgeCodes) {
const existing = await tx.usersBadges.findFirst({
where: { userId, badgeCode: code },
select: { id: true },
});
const [existing] = await tx
.select({ id: UsersBadges.id })
.from(UsersBadges)
.where(
and(
eq(UsersBadges.userId, userId),
eq(UsersBadges.badgeCode, code),
),
)
.limit(1);
if (!existing) {
const max = await tx.usersBadges.aggregate({
where: { userId },
_max: { slotId: true },
});
const slotId = (max._max.slotId ?? 0) + 1;
await tx.usersBadges.create({
data: { userId, slotId, badgeCode: code },
const [agg] = await tx
.select({ maxSlot: max(UsersBadges.slotId) })
.from(UsersBadges)
.where(eq(UsersBadges.userId, userId));
const slotId = (agg?.maxSlot ?? 0) + 1;
await tx.insert(UsersBadges).values({
userId,
slotId,
badgeCode: code,
});
}
}
});
await sendCurrency(
{ rcon, db: prisma },
{ rcon, db: currencyDb },
userId,
"credits",
article.credits,
);
await sendCurrency(
{ rcon, db: prisma },
{ rcon, db: currencyDb },
userId,
"duckets",
article.duckets,
);
await sendCurrency(
{ rcon, db: prisma },
{ rcon, db: currencyDb },
userId,
"diamonds",
article.diamonds,
+102 -72
View File
@@ -1,9 +1,17 @@
"use server";
import { and, eq, or } from "drizzle-orm";
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
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";
// 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";
} else {
// Guard against duplicate pending requests and already-existing friendships.
const [outgoingRequest, incomingRequest, existingFriendship] =
const [[outgoingRequest], [incomingRequest], [existingFriendship]] =
await Promise.all([
prisma.messengerFriendrequests.findFirst({
where: { userFromId: fromId, userToId: toId },
select: { id: true },
}),
prisma.messengerFriendrequests.findFirst({
where: { userFromId: toId, userToId: fromId },
select: { id: true },
}),
prisma.messengerFriendships.findFirst({
where: {
OR: [
{ userOneId: fromId, userTwoId: toId },
{ userOneId: toId, userTwoId: fromId },
],
},
select: { id: true },
}),
db
.select({ id: MessengerFriendrequests.id })
.from(MessengerFriendrequests)
.where(
and(
eq(MessengerFriendrequests.userFromId, fromId),
eq(MessengerFriendrequests.userToId, toId),
),
)
.limit(1),
db
.select({ id: MessengerFriendrequests.id })
.from(MessengerFriendrequests)
.where(
and(
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) {
@@ -147,8 +174,9 @@ export async function sendFriendRequest(formData: FormData): Promise<void> {
// creating a duplicate reverse row.
outcome = "incoming_pending";
} else {
await prisma.messengerFriendrequests.create({
data: { userFromId: fromId, userToId: toId },
await db.insert(MessengerFriendrequests).values({
userFromId: fromId,
userToId: toId,
});
outcome = "sent";
}
@@ -210,39 +238,36 @@ export async function postThread(formData: FormData): Promise<void> {
} else {
const now = Math.floor(Date.now() / 1000);
const guild = await prisma.guilds.findUnique({
where: { id: guildId },
select: { id: true },
});
const [guild] = await db
.select({ id: Guilds.id })
.from(Guilds)
.where(eq(Guilds.id, guildId))
.limit(1);
if (!guild) {
outcome = "not_found";
} else {
await prisma.$transaction(async (tx) => {
const thread = await tx.guildsForumsThreads.create({
data: {
guildId,
openerId,
subject,
postsCount: 1,
createdAt: now,
updatedAt: now,
state: 0,
pinned: 0,
locked: 0,
adminId: 0,
},
select: { id: true },
await db.transaction(async (tx) => {
const [result] = await tx.insert(GuildsForumsThreads).values({
guildId,
openerId,
subject,
postsCount: 1,
createdAt: now,
updatedAt: now,
state: 0,
pinned: 0,
locked: 0,
adminId: 0,
});
const threadId = Number(result.insertId);
await tx.guildsForumsComments.create({
data: {
threadId: thread.id,
userId: openerId,
message,
createdAt: now,
state: 0,
adminId: 0,
},
await tx.insert(GuildsForumsComments).values({
threadId,
userId: openerId,
message,
createdAt: now,
state: 0,
adminId: 0,
});
});
outcome = "posted";
@@ -304,39 +329,44 @@ export async function replyToThread(formData: FormData): Promise<void> {
} else {
const now = Math.floor(Date.now() / 1000);
const thread = await prisma.guildsForumsThreads.findFirst({
where: { id: threadId, guildId, state: 0 },
select: {
id: true,
locked: true,
postsCount: true,
},
});
const [thread] = await db
.select({
id: GuildsForumsThreads.id,
locked: GuildsForumsThreads.locked,
postsCount: GuildsForumsThreads.postsCount,
})
.from(GuildsForumsThreads)
.where(
and(
eq(GuildsForumsThreads.id, threadId),
eq(GuildsForumsThreads.guildId, guildId),
eq(GuildsForumsThreads.state, 0),
),
)
.limit(1);
if (!thread) {
outcome = "not_found";
} else if (thread.locked) {
outcome = "locked";
} else {
await prisma.$transaction(async (tx) => {
await tx.guildsForumsComments.create({
data: {
threadId: thread.id,
userId,
message,
createdAt: now,
state: 0,
adminId: 0,
},
await db.transaction(async (tx) => {
await tx.insert(GuildsForumsComments).values({
threadId: thread.id,
userId,
message,
createdAt: now,
state: 0,
adminId: 0,
});
await tx.guildsForumsThreads.update({
where: { id: thread.id },
data: {
await tx
.update(GuildsForumsThreads)
.set({
postsCount: (thread.postsCount ?? 0) + 1,
updatedAt: now,
},
});
})
.where(eq(GuildsForumsThreads.id, thread.id));
});
outcome = "replied";
}
+7 -6
View File
@@ -1,13 +1,14 @@
"use server";
import { eq } from "drizzle-orm";
import { revalidatePath } from "next/cache";
import { requirePermission } from "@/lib/admin/guard";
import { db, Soundtracks } from "@/lib/db";
import { PERMS } from "@/lib/permissions";
import { prisma } from "@/lib/prisma";
export async function deleteSoundtrack({ id }: { id: number }) {
await requirePermission(PERMS.CATALOG_EDIT);
await prisma.soundtracks.delete({ where: { id } });
await db.delete(Soundtracks).where(eq(Soundtracks.id, id));
revalidatePath("/admin/sounds");
}
@@ -25,9 +26,9 @@ export async function updateSoundtrack({
length: number;
}) {
await requirePermission(PERMS.CATALOG_EDIT);
await prisma.soundtracks.update({
where: { id },
data: { name, author, track, length },
});
await db
.update(Soundtracks)
.set({ name, author, track, length })
.where(eq(Soundtracks.id, id));
revalidatePath("/admin/sounds");
}
+16 -8
View File
@@ -1,8 +1,9 @@
"use server";
import { eq } from "drizzle-orm";
import { z } from "zod";
import { db, WebsiteTicketTemplate } from "@/lib/db";
import { PERMS } from "@/lib/permissions";
import { prisma } from "@/lib/prisma";
import { adminAction } from "@/lib/safe-action";
import { ActionError, actionOk } from "@/lib/safe-action-shared";
@@ -16,8 +17,8 @@ const templateSchema = z.object({
export const createTemplate = adminAction(
{ permission: PERMS.TICKETS_EDIT, schema: templateSchema },
async (ctx) => {
const tpl = await prisma.websiteTicketTemplate.create({ data: ctx.data });
return actionOk({ id: tpl.id });
const [result] = await db.insert(WebsiteTicketTemplate).values(ctx.data);
return actionOk({ id: Number(result.insertId) });
},
);
@@ -29,11 +30,16 @@ export const updateTemplate = adminAction(
{ permission: PERMS.TICKETS_EDIT, schema: updateTemplateInput },
async (ctx) => {
const { id, ...data } = ctx.data;
const existing = await prisma.websiteTicketTemplate.findUnique({
where: { id },
});
const [existing] = await db
.select({ id: WebsiteTicketTemplate.id })
.from(WebsiteTicketTemplate)
.where(eq(WebsiteTicketTemplate.id, id))
.limit(1);
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 });
},
);
@@ -45,7 +51,9 @@ const deleteTemplateInput = z.object({
export const deleteTemplate = adminAction(
{ permission: PERMS.TICKETS_EDIT, schema: deleteTemplateInput },
async (ctx) => {
await prisma.websiteTicketTemplate.delete({ where: { id: ctx.data.id } });
await db
.delete(WebsiteTicketTemplate)
.where(eq(WebsiteTicketTemplate.id, ctx.data.id));
return actionOk();
},
);
+91 -76
View File
@@ -1,7 +1,8 @@
"use server";
import { eq } from "drizzle-orm";
import { db, WebsiteTicket, WebsiteTicketMessage } from "@/lib/db";
import { PERMS } from "@/lib/permissions";
import { prisma } from "@/lib/prisma";
import { adminAction, authAction } from "@/lib/safe-action";
import { ActionError, actionOk } from "@/lib/safe-action-shared";
import { logAudit } from "@/lib/services/audit";
@@ -24,32 +25,31 @@ export const createTicket = authAction(
rateLimitWindowMs: 60_000,
},
async (ctx) => {
const ticket = await prisma.websiteTicket.create({
data: {
subject: ctx.data.subject,
category: ctx.data.category,
creatorId: ctx.session.user.id,
},
const now = new Date();
const [result] = await db.insert(WebsiteTicket).values({
subject: ctx.data.subject,
category: ctx.data.category,
creatorId: ctx.session.user.id,
updatedAt: now,
});
const ticketId = Number(result.insertId);
// Create the first message
await prisma.websiteTicketMessage.create({
data: {
ticketId: ticket.id,
userId: ctx.session.user.id,
message: ctx.data.message,
isStaff: 0,
},
await db.insert(WebsiteTicketMessage).values({
ticketId,
userId: ctx.session.user.id,
message: ctx.data.message,
isStaff: 0,
});
notify({
action: "ticket_create",
actor: ctx.session.user.username,
target: `#${ticket.id} - ${ticket.subject}`,
details: `Category: ${ticket.category}`,
target: `#${ticketId} - ${ctx.data.subject}`,
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,
},
async (ctx) => {
const ticket = await prisma.websiteTicket.findUnique({
where: { id: ctx.data.ticketId },
});
const [ticket] = await db
.select()
.from(WebsiteTicket)
.where(eq(WebsiteTicket.id, ctx.data.ticketId))
.limit(1);
if (!ticket) throw new ActionError("Ticket not found");
if (ticket.creatorId !== ctx.session.user.id)
throw new ActionError("Unauthorized");
if (ticket.status === "closed") throw new ActionError("Ticket is closed");
await prisma.websiteTicketMessage.create({
data: {
ticketId: ctx.data.ticketId,
userId: ctx.session.user.id,
message: ctx.data.message,
isStaff: 0,
},
await db.insert(WebsiteTicketMessage).values({
ticketId: ctx.data.ticketId,
userId: ctx.session.user.id,
message: ctx.data.message,
isStaff: 0,
});
// If ticket was in "waiting" (waiting for user), move back to open
if (ticket.status === "waiting") {
await prisma.websiteTicket.update({
where: { id: ctx.data.ticketId },
data: { status: "open" },
});
await db
.update(WebsiteTicket)
.set({ status: "open", updatedAt: new Date() })
.where(eq(WebsiteTicket.id, ctx.data.ticketId));
}
return actionOk();
@@ -99,9 +99,11 @@ export const closeTicketByUser = authAction(
rateLimitWindowMs: 60_000,
},
async (ctx) => {
const ticket = await prisma.websiteTicket.findUnique({
where: { id: ctx.data.ticketId },
});
const [ticket] = await db
.select()
.from(WebsiteTicket)
.where(eq(WebsiteTicket.id, ctx.data.ticketId))
.limit(1);
if (!ticket) throw new ActionError("Ticket not found");
if (ticket.creatorId !== ctx.session.user.id)
@@ -109,10 +111,10 @@ export const closeTicketByUser = authAction(
if (ticket.status === "closed")
throw new ActionError("Ticket is already closed");
await prisma.websiteTicket.update({
where: { id: ctx.data.ticketId },
data: { status: "closed", closedAt: new Date() },
});
await db
.update(WebsiteTicket)
.set({ status: "closed", closedAt: new Date(), updatedAt: new Date() })
.where(eq(WebsiteTicket.id, ctx.data.ticketId));
return actionOk();
},
@@ -126,31 +128,34 @@ export const adminReplyTicket = adminAction(
schema: replyTicketSchema,
},
async (ctx) => {
const ticket = await prisma.websiteTicket.findUnique({
where: { id: ctx.data.ticketId },
});
const [ticket] = await db
.select()
.from(WebsiteTicket)
.where(eq(WebsiteTicket.id, ctx.data.ticketId))
.limit(1);
if (!ticket) throw new ActionError("Ticket not found");
await prisma.websiteTicketMessage.create({
data: {
ticketId: ctx.data.ticketId,
userId: ctx.session.user.id,
message: ctx.data.message,
isStaff: 1,
},
await db.insert(WebsiteTicketMessage).values({
ticketId: ctx.data.ticketId,
userId: ctx.session.user.id,
message: ctx.data.message,
isStaff: 1,
});
// Auto-assign if not assigned yet
const updates: Record<string, unknown> = { status: "waiting" };
const updates: Partial<typeof WebsiteTicket.$inferInsert> = {
status: "waiting",
updatedAt: new Date(),
};
if (!ticket.assigneeId) {
updates.assigneeId = ctx.session.user.id;
}
await prisma.websiteTicket.update({
where: { id: ctx.data.ticketId },
data: updates,
});
await db
.update(WebsiteTicket)
.set(updates)
.where(eq(WebsiteTicket.id, ctx.data.ticketId));
logAudit({
userId: ctx.session.user.id,
@@ -169,13 +174,18 @@ export const updateTicketStatus = adminAction(
schema: updateTicketStatusSchema,
},
async (ctx) => {
const ticket = await prisma.websiteTicket.findUnique({
where: { id: ctx.data.ticketId },
});
const [ticket] = await db
.select()
.from(WebsiteTicket)
.where(eq(WebsiteTicket.id, ctx.data.ticketId))
.limit(1);
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") {
data.closedAt = new Date();
}
@@ -183,10 +193,10 @@ export const updateTicketStatus = adminAction(
data.assigneeId = ctx.session.user.id;
}
await prisma.websiteTicket.update({
where: { id: ctx.data.ticketId },
data,
});
await db
.update(WebsiteTicket)
.set(data)
.where(eq(WebsiteTicket.id, ctx.data.ticketId));
logAudit({
userId: ctx.session.user.id,
@@ -207,19 +217,22 @@ export const assignTicket = adminAction(
schema: assignTicketSchema,
},
async (ctx) => {
const ticket = await prisma.websiteTicket.findUnique({
where: { id: ctx.data.ticketId },
});
const [ticket] = await db
.select()
.from(WebsiteTicket)
.where(eq(WebsiteTicket.id, ctx.data.ticketId))
.limit(1);
if (!ticket) throw new ActionError("Ticket not found");
await prisma.websiteTicket.update({
where: { id: ctx.data.ticketId },
data: {
await db
.update(WebsiteTicket)
.set({
assigneeId: ctx.data.assigneeId,
status: ctx.data.assigneeId ? "in_progress" : "open",
},
});
updatedAt: new Date(),
})
.where(eq(WebsiteTicket.id, ctx.data.ticketId));
logAudit({
userId: ctx.session.user.id,
@@ -240,16 +253,18 @@ export const updateTicketPriority = adminAction(
schema: updateTicketPrioritySchema,
},
async (ctx) => {
const ticket = await prisma.websiteTicket.findUnique({
where: { id: ctx.data.ticketId },
});
const [ticket] = await db
.select()
.from(WebsiteTicket)
.where(eq(WebsiteTicket.id, ctx.data.ticketId))
.limit(1);
if (!ticket) throw new ActionError("Ticket not found");
await prisma.websiteTicket.update({
where: { id: ctx.data.ticketId },
data: { priority: ctx.data.priority },
});
await db
.update(WebsiteTicket)
.set({ priority: ctx.data.priority, updatedAt: new Date() })
.where(eq(WebsiteTicket.id, ctx.data.ticketId));
logAudit({
userId: ctx.session.user.id,
+24 -19
View File
@@ -1,13 +1,14 @@
"use server";
import { randomBytes } from "node:crypto";
import { eq } from "drizzle-orm";
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import { env } from "@/env";
import { auth } from "@/lib/auth";
import { LaravelEncrypter } from "@/lib/auth/laravel-encrypter";
import { generateTotpSecret, verifyTotp } from "@/lib/auth/totp";
import { prisma } from "@/lib/prisma";
import { db, User } from "@/lib/db";
import { rateLimit } from "@/lib/rate-limit";
async function sessionUserId(): Promise<number> {
@@ -34,10 +35,14 @@ async function verifyTwoFactorCode(
userId: number,
code: string,
): Promise<{ ok: boolean; updatedRecoveryCodes?: string | null }> {
const user = await prisma.user.findUnique({
where: { id: userId },
select: { twoFactorSecret: true, twoFactorRecoveryCodes: true },
});
const [user] = await db
.select({
twoFactorSecret: User.twoFactorSecret,
twoFactorRecoveryCodes: User.twoFactorRecoveryCodes,
})
.from(User)
.where(eq(User.id, userId))
.limit(1);
if (!user?.twoFactorSecret) return { ok: false };
// Try TOTP first
@@ -76,14 +81,14 @@ export async function beginTwoFactor(): Promise<void> {
const secret = generateTotpSecret();
const encrypted = new LaravelEncrypter(env.APP_KEY).encrypt(secret);
const codes = generateRecoveryCodes();
await prisma.user.update({
where: { id },
data: {
await db
.update(User)
.set({
twoFactorSecret: encrypted,
twoFactorConfirmedAt: null,
twoFactorRecoveryCodes: JSON.stringify(codes),
},
});
})
.where(eq(User.id, id));
revalidatePath("/settings/2fa");
}
@@ -102,10 +107,10 @@ export async function confirmTwoFactor(formData: FormData): Promise<void> {
const { ok } = await verifyTwoFactorCode(id, code);
if (!ok) redirect("/settings/2fa?error=badcode");
await prisma.user.update({
where: { id },
data: { twoFactorConfirmedAt: new Date() },
});
await db
.update(User)
.set({ twoFactorConfirmedAt: new Date() })
.where(eq(User.id, id));
redirect("/settings/2fa?enabled=1");
}
@@ -123,13 +128,13 @@ export async function disableTwoFactor(formData: FormData): Promise<void> {
const { ok } = await verifyTwoFactorCode(id, code);
if (!ok) redirect("/settings/2fa?error=badcode");
await prisma.user.update({
where: { id },
data: {
await db
.update(User)
.set({
twoFactorSecret: null,
twoFactorRecoveryCodes: null,
twoFactorConfirmedAt: null,
},
});
})
.where(eq(User.id, id));
redirect("/settings/2fa?disabled=1");
}
+6 -5
View File
@@ -1,10 +1,11 @@
"use server";
import { eq } from "drizzle-orm";
import { revalidatePath } from "next/cache";
import { z } from "zod";
import { db, User } from "@/lib/db";
import { actionOk, authAction } from "@/lib/foundation/action";
import { DatabaseError } from "@/lib/foundation/errors";
import { prisma } from "@/lib/prisma";
import { rcon } from "@/lib/services/rcon";
const MOTTO_MAX = 127;
@@ -17,10 +18,10 @@ const mottoSchema = z.object({
const updateMottoAction = authAction({ schema: mottoSchema }, async (ctx) => {
try {
await prisma.user.update({
where: { id: ctx.session.user.id },
data: { motto: ctx.data.motto },
});
await db
.update(User)
.set({ motto: ctx.data.motto })
.where(eq(User.id, ctx.session.user.id));
} catch {
throw new DatabaseError("Failed to update motto");
}
+120 -77
View File
@@ -1,11 +1,19 @@
"use server";
import crypto from "node:crypto";
import { and, eq } from "drizzle-orm";
import { z } from "zod";
import { invalidateLoginCache } from "@/lib/auth";
import { hashPassword } from "@/lib/auth/password";
import {
Ban,
db,
User,
UsersBadges,
UsersCurrency,
UsersSettings,
} from "@/lib/db";
import { PERMS } from "@/lib/permissions";
import { prisma } from "@/lib/prisma";
import { adminAction } from "@/lib/safe-action";
import { ActionError, actionOk } from "@/lib/safe-action-shared";
import { logAudit } from "@/lib/services/audit";
@@ -21,6 +29,27 @@ import {
const DEFAULT_LOOK =
"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(
{ permission: PERMS.USERS_EDIT, schema: createUserSchema },
async (ctx) => {
@@ -34,32 +63,29 @@ export const createUser = adminAction(
const now = Math.floor(Date.now() / 1000);
try {
const user = await prisma.$transaction(async (tx) => {
const created = await tx.user.create({
data: {
username,
mail,
password: hashedPassword,
rank,
motto: motto || "I'm new here!",
look: DEFAULT_LOOK,
credits: 5000,
pixels: 5000,
accountCreated: now,
ipRegister: "0.0.0.0",
ipCurrent: "0.0.0.0",
},
const user = await db.transaction(async (tx) => {
const [result] = await tx.insert(User).values({
username,
mail,
password: hashedPassword,
rank,
motto: motto || "I'm new here!",
look: DEFAULT_LOOK,
credits: 5000,
pixels: 5000,
accountCreated: now,
ipRegister: "0.0.0.0",
ipCurrent: "0.0.0.0",
});
const id = Number(result.insertId);
await tx.usersSettings.create({ data: { userId: created.id } });
await tx.usersCurrency.createMany({
data: [
{ userId: created.id, type: 0, amount: 5000 },
{ userId: created.id, type: 5, amount: 5000 },
],
});
await tx.insert(UsersSettings).values({ userId: id });
await tx.insert(UsersCurrency).values([
{ userId: id, type: 0, amount: 5000 },
{ userId: id, type: 5, amount: 5000 },
]);
return created;
return { id, username };
});
logAudit({
@@ -80,21 +106,11 @@ export const createUser = adminAction(
return actionOk({ id: user.id, username: user.username });
} catch (err) {
if (
err &&
typeof err === "object" &&
"code" in 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");
const field = duplicateField(err);
if (field === "username") throw new ActionError("Username already taken");
if (field === "mail") throw new ActionError("Email already registered");
if (isDuplicateKey(err))
throw new ActionError("Username or email already in use");
}
throw err;
}
},
@@ -119,22 +135,32 @@ export const updateUser = adminAction(
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);
if (diamonds !== undefined) {
await prisma.usersCurrency.upsert({
where: { userId_type: { userId: id, type: 5 } },
update: { amount: diamonds },
create: { userId: id, type: 5, amount: diamonds },
});
await db
.insert(UsersCurrency)
.values({ userId: id, type: 5, amount: diamonds })
.onDuplicateKeyUpdate({ set: { amount: diamonds } });
}
if (duckets !== undefined) {
await prisma.usersCurrency.upsert({
where: { userId_type: { userId: id, type: 0 } },
update: { amount: duckets },
create: { userId: id, type: 0, amount: duckets },
});
await db
.insert(UsersCurrency)
.values({ userId: id, type: 0, amount: duckets })
.onDuplicateKeyUpdate({ set: { amount: duckets } });
}
logAudit({
@@ -173,17 +199,15 @@ export const banUser = adminAction(
const now = Math.floor(Date.now() / 1000);
const banExpire = duration > 0 ? now + duration * 3600 : 0;
await prisma.ban.create({
data: {
userId,
userStaffId: ctx.session.user.id,
timestamp: now,
banExpire,
banReason: reason,
type: type || "account",
ip: ip || "",
machineId: "",
},
await db.insert(Ban).values({
userId,
userStaffId: ctx.session.user.id,
timestamp: now,
banExpire,
banReason: reason,
type: type || "account",
ip: ip || "",
machineId: "",
});
await rcon.disconnectUser(userId);
@@ -216,7 +240,7 @@ export const unbanUser = adminAction(
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({
userId: ctx.session.user.id,
@@ -242,12 +266,19 @@ export const giveBadge = adminAction(
await guardRank(userId, ctx.session.user.rank);
const existing = await prisma.usersBadges.findFirst({
where: { userId, badgeCode },
});
const [existing] = await db
.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");
await prisma.usersBadges.create({ data: { userId, badgeCode } });
await db.insert(UsersBadges).values({ userId, badgeCode });
await rcon.giveBadge(userId, badgeCode);
return actionOk();
@@ -268,12 +299,19 @@ export const removeBadge = adminAction(
await guardRank(userId, ctx.session.user.rank);
const existing = await prisma.usersBadges.findFirst({
where: { userId, badgeCode },
});
const [existing] = await db
.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");
await prisma.usersBadges.delete({ where: { id: existing.id } });
await db.delete(UsersBadges).where(eq(UsersBadges.id, existing.id));
await rcon.removeBadge(userId, badgeCode);
return actionOk();
@@ -283,10 +321,15 @@ export const removeBadge = adminAction(
// ── Rank guard helper ───────────────────────────────────────────────
async function guardRank(targetUserId: number, sessionRank: number) {
const target = await prisma.user.findUnique({
where: { id: targetUserId },
select: { username: true, rank: true, mail: true },
});
const [target] = await db
.select({
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.rank >= sessionRank && sessionRank < 7) {
throw new ActionError("Cannot modify user with equal or higher rank");
@@ -311,10 +354,10 @@ export const resetPassword = adminAction(
.slice(0, 16);
const hashed = await hashPassword(newPassword);
await prisma.user.update({
where: { id: ctx.data.userId },
data: { password: hashed },
});
await db
.update(User)
.set({ password: hashed })
.where(eq(User.id, ctx.data.userId));
invalidateLoginCache(target.username);
logAudit({
+52 -30
View File
@@ -1,11 +1,12 @@
"use server";
import { and, eq, sql } from "drizzle-orm";
import { revalidatePath } from "next/cache";
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 { 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. */
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,
* 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
* wallet currency.
*/
@@ -65,16 +66,18 @@ export async function redeem(
expiresAt: Date | null;
} | null;
try {
voucher = await prisma.websiteShopVouchers.findUnique({
where: { code },
select: {
id: true,
amount: true,
maxUses: true,
useCount: true,
expiresAt: true,
},
});
const [row] = await db
.select({
id: WebsiteShopVouchers.id,
amount: WebsiteShopVouchers.amount,
maxUses: WebsiteShopVouchers.maxUses,
useCount: WebsiteShopVouchers.useCount,
expiresAt: WebsiteShopVouchers.expiresAt,
})
.from(WebsiteShopVouchers)
.where(eq(WebsiteShopVouchers.code, code))
.limit(1);
voucher = row ?? null;
} catch {
return {
ok: false,
@@ -95,10 +98,16 @@ export async function redeem(
// One redemption per user.
try {
const already = await prisma.websiteUsedShopVouchers.findFirst({
where: { userId, voucherId: voucher.id },
select: { id: true },
});
const [already] = await db
.select({ id: WebsiteUsedShopVouchers.id })
.from(WebsiteUsedShopVouchers)
.where(
and(
eq(WebsiteUsedShopVouchers.userId, userId),
eq(WebsiteUsedShopVouchers.voucherId, voucher.id),
),
)
.limit(1);
if (already) {
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.
try {
await prisma.websiteUsedShopVouchers.create({
data: { userId, voucherId: voucher.id },
await db.insert(WebsiteUsedShopVouchers).values({
userId,
voucherId: voucher.id,
});
} catch {
// 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.
try {
await sendCurrency({ rcon, db: prisma }, userId, "credits", voucher.amount);
await sendCurrency(
{ rcon, db: currencyDb },
userId,
"credits",
voucher.amount,
);
} catch {
// sendCurrency already falls back to a direct DB write; if it still throws,
// the used-row stands and the balance simply wasn't credited — surface that.
@@ -134,16 +149,23 @@ export async function redeem(
// Bump use_count and expire the voucher once the cap is reached.
try {
const updated = await prisma.websiteShopVouchers.update({
where: { id: voucher.id },
data: { useCount: { increment: 1 } },
select: { maxUses: true, useCount: true },
});
if (updated.maxUses && updated.useCount >= updated.maxUses) {
await prisma.websiteShopVouchers.update({
where: { id: voucher.id },
data: { expiresAt: new Date() },
});
await db
.update(WebsiteShopVouchers)
.set({ useCount: sql`${WebsiteShopVouchers.useCount} + 1` })
.where(eq(WebsiteShopVouchers.id, voucher.id));
const [updated] = await db
.select({
maxUses: WebsiteShopVouchers.maxUses,
useCount: WebsiteShopVouchers.useCount,
})
.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 {
// Reward already delivered; the counter bump is best-effort.
+17 -7
View File
@@ -1,9 +1,10 @@
"use server";
import { and, eq } from "drizzle-orm";
import { revalidateTag } from "next/cache";
import { z } from "zod";
import { db, UserWatch } from "@/lib/db";
import { PERMS } from "@/lib/permission-slugs";
import { prisma } from "@/lib/prisma";
import { adminAction } from "@/lib/safe-action";
import { actionOk } from "@/lib/safe-action-shared";
@@ -24,18 +25,27 @@ export const toggleUserWatch = adminAction(
const staffId = ctx.session.user.id;
const { targetUserId, reason } = ctx.data;
const existing = await prisma.userWatch.findUnique({
where: { staffId_targetUserId: { staffId, targetUserId } },
});
const [existing] = await db
.select({ id: UserWatch.id })
.from(UserWatch)
.where(
and(
eq(UserWatch.staffId, staffId),
eq(UserWatch.targetUserId, targetUserId),
),
)
.limit(1);
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 });
return actionOk({ watching: false });
}
await prisma.userWatch.create({
data: { staffId, targetUserId, reason: reason ?? "" },
await db.insert(UserWatch).values({
staffId,
targetUserId,
reason: reason ?? "",
});
revalidateTag(`user-watch:${staffId}`, { expire: 0 });
return actionOk({ watching: true });
+22 -16
View File
@@ -1,12 +1,20 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const findMany = vi.hoisted(() => vi.fn());
const create = vi.hoisted(() => vi.fn());
const selectFrom = vi.hoisted(() => vi.fn());
const insertValues = vi.hoisted(() => vi.fn());
const getBool = vi.hoisted(() => vi.fn());
const get = vi.hoisted(() => vi.fn());
vi.mock("@/lib/prisma", () => ({
prisma: { websiteIpBlacklist: { findMany, create } },
vi.mock("@/lib/db", () => ({
db: {
select: () => ({
from: () => Promise.resolve(selectFrom()),
}),
insert: () => ({
values: (data: unknown) => insertValues(data),
}),
},
WebsiteIpBlacklist: { ipAddress: "WebsiteIpBlacklist.ipAddress" },
}));
vi.mock("@/lib/services/alert", () => ({
@@ -26,23 +34,23 @@ describe("isIpBlacklisted", () => {
expect(await isIpBlacklisted("127.0.0.1")).toBe(false);
expect(await isIpBlacklisted("192.168.1.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 () => {
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("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);
});
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("9.9.9.9")).toBe(false);
});
@@ -52,7 +60,7 @@ describe("recordRequest", () => {
beforeEach(() => {
getBool.mockReset();
get.mockReset();
create.mockReset();
insertValues.mockReset();
});
it("ignores private IPs", async () => {
@@ -72,17 +80,15 @@ describe("recordRequest", () => {
if (_key === "abuse_guard_threshold") return "3";
return fallback;
});
create.mockResolvedValue({});
insertValues.mockResolvedValue({});
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");
expect(create).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({ ipAddress: "1.2.3.4" }),
}),
expect(insertValues).toHaveBeenCalledWith(
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 { siteSettings } from "@/lib/services/site-settings";
@@ -54,9 +54,9 @@ export async function isIpBlacklisted(ip: string): Promise<boolean> {
const now = Date.now();
if (now - blacklistLoadedAt >= BLACKLIST_TTL) {
try {
const rows = await prisma.websiteIpBlacklist.findMany({
select: { ipAddress: true },
});
const rows = await db
.select({ ipAddress: WebsiteIpBlacklist.ipAddress })
.from(WebsiteIpBlacklist);
blacklist = new Set(rows.map((r) => r.ipAddress));
blacklistLoadedAt = now;
} catch {
@@ -91,8 +91,10 @@ export async function recordRequest(ip: string): Promise<void> {
recentlyBlocked.add(ip);
setTimeout(() => recentlyBlocked.delete(ip), 60_000);
try {
await prisma.websiteIpBlacklist.create({
data: { ipAddress: ip, createdAt: new Date(), updatedAt: new Date() },
await db.insert(WebsiteIpBlacklist).values({
ipAddress: ip,
createdAt: new Date(),
updatedAt: new Date(),
});
blacklistLoadedAt = 0;
await ddosDetected(ip, b.count);
+15 -10
View File
@@ -1,9 +1,14 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const findMany = vi.hoisted(() => vi.fn());
const selectFrom = vi.hoisted(() => vi.fn());
vi.mock("@/lib/prisma", () => ({
prisma: { websiteWordfilter: { findMany } },
vi.mock("@/lib/db", () => ({
db: {
select: () => ({
from: () => Promise.resolve(selectFrom()),
}),
},
WebsiteWordfilter: { word: "WebsiteWordfilter.word" },
}));
vi.mock("@/env", () => ({
@@ -15,19 +20,19 @@ vi.mock("@/env", () => ({
import { isAllowed, moderateOrThrow, reloadWordFilter } from "./moderation";
beforeEach(() => {
findMany.mockReset();
selectFrom.mockReset();
reloadWordFilter();
});
describe("moderation", () => {
it("allows clean text", async () => {
findMany.mockResolvedValue([{ word: "badword" }]);
selectFrom.mockResolvedValue([{ word: "badword" }]);
const result = await isAllowed("hello world");
expect(result).toEqual({ ok: true });
});
it("blocks text containing a filtered word", async () => {
findMany.mockResolvedValue([{ word: "badword" }]);
selectFrom.mockResolvedValue([{ word: "badword" }]);
const result = await isAllowed("this contains badword here");
expect(result).toEqual({
ok: false,
@@ -41,21 +46,21 @@ describe("moderation", () => {
});
it("caches the word list and respects TTL", async () => {
findMany.mockResolvedValue([{ word: "first" }]);
selectFrom.mockResolvedValue([{ word: "first" }]);
await isAllowed("test");
await isAllowed("test");
expect(findMany).toHaveBeenCalledTimes(1);
expect(selectFrom).toHaveBeenCalledTimes(1);
});
it("moderateOrThrow throws on blocked content", async () => {
findMany.mockResolvedValue([{ word: "bad" }]);
selectFrom.mockResolvedValue([{ word: "bad" }]);
await expect(moderateOrThrow("this is bad")).rejects.toThrow(
"Blocked by word filter",
);
});
it("moderateOrThrow resolves on clean content", async () => {
findMany.mockResolvedValue([{ word: "bad" }]);
selectFrom.mockResolvedValue([{ word: "bad" }]);
await expect(moderateOrThrow("clean text")).resolves.toBeUndefined();
});
});
+5 -5
View File
@@ -1,11 +1,11 @@
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
// (article comments, mottos, registration, etc.) before they touch the DB.
//
// 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
// site-settings, so the common path never hits the DB.
// 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
) {
try {
const rows = await prisma.websiteWordfilter.findMany({
select: { word: true },
});
const rows = await db
.select({ word: WebsiteWordfilter.word })
.from(WebsiteWordfilter);
wordFilterCache = rows
.map((r) => r.word.trim().toLowerCase())
.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";
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 {
rcon: Pick<
RconClient,
+20 -10
View File
@@ -1,31 +1,41 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const { findMany } = vi.hoisted(() => ({ findMany: vi.fn() }));
vi.mock("@/lib/prisma", () => ({
prisma: { websiteSetting: { findMany } },
const { selectFrom } = vi.hoisted(() => ({ selectFrom: vi.fn() }));
vi.mock("@/lib/db", () => ({
db: {
select: () => ({
from: () => Promise.resolve(selectFrom()),
}),
insert: () => ({
values: () => ({
onDuplicateKeyUpdate: () => Promise.resolve(),
}),
}),
},
WebsiteSetting: { key: "WebsiteSetting.key", value: "WebsiteSetting.value" },
}));
import { siteSettings } from "./site-settings";
beforeEach(async () => {
findMany.mockReset();
selectFrom.mockReset();
await siteSettings.reload();
});
describe("siteSettings", () => {
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");
});
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")).toBeNull();
});
it("coerces '1'/'0' string booleans", async () => {
findMany.mockResolvedValue([
selectFrom.mockResolvedValue([
{ key: "maintenance_enabled", value: "1" },
{ key: "radio_enabled", value: "0" },
]);
@@ -35,12 +45,12 @@ describe("siteSettings", () => {
});
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.getBool("hotel_name");
expect(findMany).toHaveBeenCalledTimes(1);
expect(selectFrom).toHaveBeenCalledTimes(1);
await siteSettings.reload();
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 { FALLBACK_HOTEL_NAME } from "@/lib/brand";
import { db, WebsiteSetting } from "@/lib/db";
import { logger } from "@/lib/logger";
import { prisma } from "@/lib/prisma";
import { redis } from "@/lib/redis";
const DEFAULTS: Record<string, string> = {
@@ -21,9 +21,9 @@ class SiteSettings {
private async loadFromDb(): Promise<Map<string, string>> {
try {
const rows = await prisma.websiteSetting.findMany({
select: { key: true, value: true },
});
const rows = await db
.select({ key: WebsiteSetting.key, value: WebsiteSetting.value })
.from(WebsiteSetting);
return new Map(rows.map((r) => [r.key, r.value]));
} catch {
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> {
await prisma.websiteSetting.upsert({
where: { key },
create: { key, value },
update: { value },
});
await db
.insert(WebsiteSetting)
.values({ key, value })
.onDuplicateKeyUpdate({ set: { value } });
await this.reload();
}
+9 -11
View File
@@ -1,5 +1,5 @@
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 —
@@ -23,16 +23,14 @@ export async function logStaffActivity(opts: {
} catch {
ip = null;
}
await prisma.staffActivities.create({
data: {
userId: BigInt(opts.staffId),
action: opts.action.slice(0, 50),
description: opts.description,
targetType: opts.targetType ?? null,
targetId: opts.targetId != null ? BigInt(opts.targetId) : null,
ipAddress: ip,
createdAt: new Date(),
},
await db.insert(StaffActivities).values({
userId: BigInt(opts.staffId),
action: opts.action.slice(0, 50),
description: opts.description,
targetType: opts.targetType ?? null,
targetId: opts.targetId != null ? BigInt(opts.targetId) : null,
ipAddress: ip,
createdAt: new Date(),
});
} catch {
// swallow — audit logging is best-effort