refactor(db): migrate staff and app actions from Prisma facade to Drizzle
Co-authored-by: Cursor <[email protected]>
This commit is contained in:
1 parent
9854719cfd
commit
ed9c23c702
85 files changed
+2612
-1832
No files matched your search
@@ -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
@@ -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",
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
@@ -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",
|
||||
|
||||
@@ -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
@@ -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
@@ -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> {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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.
|
||||
}
|
||||
|
||||
@@ -1,16 +1,17 @@
|
||||
"use server";
|
||||
|
||||
import { eq } from "drizzle-orm";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { z } from "zod";
|
||||
import { MANAGED_SETTING_KEYS } from "@/app/admin/settings/cms-settings-config";
|
||||
import { requirePermissionRateLimited } from "@/lib/admin/guard";
|
||||
import { db, WebsiteSetting } from "@/lib/db";
|
||||
import { actionOk, adminAction } from "@/lib/foundation/action";
|
||||
import {
|
||||
HABBO_GAMEDATA_HOTEL_SETTING_KEY,
|
||||
normalizeHabboGamedataHotel,
|
||||
} from "@/lib/habbo-gamedata-hotel";
|
||||
import { PERMS } from "@/lib/permissions";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { clearHabboItCache } from "@/lib/services/habbo-furnidata-cache";
|
||||
import { clearBadgeCache } from "@/lib/services/habboassets";
|
||||
import { siteSettings } from "@/lib/services/site-settings";
|
||||
@@ -48,11 +49,10 @@ export const saveManagedSettings = adminAction(
|
||||
.map(([key, value]) => [key, normalizeSettingValue(key, value)] as const);
|
||||
await Promise.all(
|
||||
entries.map(([key, value]) =>
|
||||
prisma.websiteSetting.upsert({
|
||||
where: { key },
|
||||
update: { value },
|
||||
create: { key, value },
|
||||
}),
|
||||
db
|
||||
.insert(WebsiteSetting)
|
||||
.values({ key, value })
|
||||
.onDuplicateKeyUpdate({ set: { value } }),
|
||||
),
|
||||
);
|
||||
await siteSettings.reload();
|
||||
@@ -76,7 +76,10 @@ export async function updateSetting(formData: FormData): Promise<void> {
|
||||
String(formData.get("value") ?? "").normalize("NFC"),
|
||||
);
|
||||
if (!key) return;
|
||||
await prisma.websiteSetting.update({ where: { key }, data: { value } });
|
||||
await db
|
||||
.update(WebsiteSetting)
|
||||
.set({ value })
|
||||
.where(eq(WebsiteSetting.key, key));
|
||||
await siteSettings.reload();
|
||||
bustGamedataCachesIfNeeded(key);
|
||||
revalidatePath("/admin/settings");
|
||||
@@ -97,11 +100,10 @@ export async function createSetting(formData: FormData): Promise<void> {
|
||||
.trim()
|
||||
.slice(0, 255);
|
||||
if (!key) return;
|
||||
await prisma.websiteSetting.upsert({
|
||||
where: { key },
|
||||
update: { value },
|
||||
create: { key, value, comment: comment || null },
|
||||
});
|
||||
await db
|
||||
.insert(WebsiteSetting)
|
||||
.values({ key, value, comment: comment || null })
|
||||
.onDuplicateKeyUpdate({ set: { value } });
|
||||
await siteSettings.reload();
|
||||
bustGamedataCachesIfNeeded(key);
|
||||
revalidatePath("/admin/settings");
|
||||
@@ -113,7 +115,7 @@ export async function deleteSetting(formData: FormData): Promise<void> {
|
||||
.normalize("NFC")
|
||||
.trim();
|
||||
if (!key) return;
|
||||
await prisma.websiteSetting.delete({ where: { key } });
|
||||
await db.delete(WebsiteSetting).where(eq(WebsiteSetting.key, key));
|
||||
await siteSettings.reload();
|
||||
bustGamedataCachesIfNeeded(key);
|
||||
revalidatePath("/admin/settings");
|
||||
|
||||
+38
-37
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
+19
-14
@@ -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",
|
||||
|
||||
@@ -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
@@ -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");
|
||||
}
|
||||
@@ -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> {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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
@@ -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";
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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
+77
-61
File diff suppressed because it is too large.
Load diff
+84
-75
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
+41
-37
File diff suppressed because it is too large.
Load diff
@@ -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
@@ -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,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
@@ -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
File diff suppressed because it is too large.
Load diff
@@ -3,7 +3,7 @@
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { redirect } from "next/navigation";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { db, WebsiteUserGuestbooks } from "@/lib/db";
|
||||
import { clientIp, rateLimit } from "@/lib/rate-limit";
|
||||
import { isAllowed } from "@/lib/services/moderation";
|
||||
|
||||
@@ -78,14 +78,12 @@ export async function postGuestbook(formData: FormData): Promise<void> {
|
||||
outcome = "moderated";
|
||||
} else {
|
||||
const now = new Date();
|
||||
await prisma.websiteUserGuestbooks.create({
|
||||
data: {
|
||||
profileId,
|
||||
userId,
|
||||
message,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
},
|
||||
await db.insert(WebsiteUserGuestbooks).values({
|
||||
profileId,
|
||||
userId,
|
||||
message,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
});
|
||||
outcome = "posted";
|
||||
}
|
||||
|
||||
+84
-67
File diff suppressed because it is too large.
Load diff
@@ -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
File diff suppressed because it is too large.
Load diff
+27
-19
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
File diff suppressed because it is too large.
Load diff
+24
-18
@@ -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
@@ -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
@@ -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",
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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
@@ -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
File diff suppressed because it is too large.
Load diff
+102
-72
File diff suppressed because it is too large.
Load diff
@@ -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");
|
||||
}
|
||||
@@ -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
File diff suppressed because it is too large.
Load diff
+24
-19
@@ -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");
|
||||
}
|
||||
@@ -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
File diff suppressed because it is too large.
Load diff
+52
-30
File diff suppressed because it is too large.
Load diff
+17
-7
@@ -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 });
|
||||
|
||||
@@ -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" }),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user