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
}
File diff suppressed because it is too large. Load diff
+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");
}
File diff suppressed because it is too large. Load diff
+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({
File diff suppressed because it is too large. Load diff
+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");
}
File diff suppressed because it is too large. Load diff
+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",
File diff suppressed because it is too large. Load diff
+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");
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+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");
File diff suppressed because it is too large. Load diff
+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",
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+41 -37
View File
File diff suppressed because it is too large. Load diff
+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
File diff suppressed because it is too large. Load diff
+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";
}
File diff suppressed because it is too large. Load diff
+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
File diff suppressed because it is too large. Load diff
+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
File diff suppressed because it is too large. Load diff
+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
File diff suppressed because it is too large. Load diff
+102 -72
View File
File diff suppressed because it is too large. Load diff
+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
File diff suppressed because it is too large. Load diff
+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
File diff suppressed because it is too large. Load diff
+52 -30
View File
File diff suppressed because it is too large. Load diff
+17 -7
View File
@@ -1,9 +1,10 @@
"use server";
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