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