From ed9c23c7023ca9ae4d4c945686de5097f12ce0b8 Mon Sep 17 00:00:00 2001 From: simoleo89 Date: Fri, 31 Jul 2026 21:35:05 +0200 Subject: [PATCH] refactor(db): migrate staff and app actions from Prisma facade to Drizzle Co-authored-by: Cursor --- src/actions/admin-ads.test.ts | 34 +++-- src/actions/admin-ads.ts | 36 +++-- src/actions/admin-applications.ts | 7 +- src/actions/admin-articles.ts | 66 ++++---- src/actions/admin-badges.ts | 28 ++-- src/actions/admin-bans.test.ts | 41 +++-- src/actions/admin-bans.ts | 36 ++--- src/actions/admin-email-templates.ts | 29 ++-- src/actions/admin-emulator.ts | 20 ++- src/actions/admin-guilds.test.ts | 91 ++++++----- src/actions/admin-guilds.ts | 55 ++++--- src/actions/admin-help-tickets.ts | 121 +++++++++------ src/actions/admin-help.test.ts | 39 +++-- src/actions/admin-help.ts | 46 +++--- src/actions/admin-ip.test.ts | 41 +++-- src/actions/admin-ip.ts | 23 ++- src/actions/admin-maintenance.test.ts | 88 ++++++----- src/actions/admin-maintenance.ts | 17 ++- src/actions/admin-marketplace.ts | 26 ++-- src/actions/admin-radio-api-keys.ts | 48 +++--- src/actions/admin-radio-autodj.ts | 38 ++--- src/actions/admin-radio-extra.ts | 83 +++++----- src/actions/admin-radio-moderation.ts | 5 +- src/actions/admin-radio-points.ts | 17 ++- src/actions/admin-rare-values.ts | 37 +++-- src/actions/admin-settings.ts | 28 ++-- src/actions/admin-shop.ts | 75 ++++----- src/actions/admin-tags.test.ts | 90 +++++------ src/actions/admin-tags.ts | 33 ++-- src/actions/admin-teams.test.ts | 30 ++-- src/actions/admin-teams.ts | 23 ++- src/actions/admin-theme.ts | 11 +- src/actions/admin-vouchers.ts | 28 ++-- src/actions/admin-vpn.test.ts | 24 ++- src/actions/admin-vpn.ts | 11 +- src/actions/admin-wordfilter.ts | 14 +- src/actions/admin-writeable-boxes.ts | 59 +++---- src/actions/applications.ts | 47 ++++-- src/actions/article-comments.ts | 26 ++-- src/actions/article-reactions.ts | 67 +++++--- src/actions/auth-precheck.ts | 23 +-- src/actions/badges.ts | 37 +++-- src/actions/banners.ts | 25 ++- src/actions/bulk-users.test.ts | 107 ++++++++++--- src/actions/bulk-users.ts | 138 +++++++++-------- src/actions/catalog-bc.ts | 159 ++++++++++--------- src/actions/catalog-items.ts | 130 +++++++++------- src/actions/catalog.ts | 78 +++++----- src/actions/commandocentrum.ts | 32 ++-- src/actions/draw-badge.ts | 65 +++++--- src/actions/emulator.test.ts | 22 ++- src/actions/emulator.ts | 11 +- src/actions/events.ts | 159 ++++++++++++------- src/actions/guestbook.ts | 16 +- src/actions/help-tickets.ts | 151 ++++++++++-------- src/actions/items-base.ts | 20 +-- src/actions/messenger.ts | 204 +++++++++++++++---------- src/actions/moderation.ts | 46 +++--- src/actions/multi-account-detect.ts | 40 +++-- src/actions/password-reset.ts | 51 ++++--- src/actions/polls.ts | 115 +++++++++----- src/actions/prefixes.ts | 42 ++--- src/actions/register.ts | 41 ++--- src/actions/rooms.ts | 24 +-- src/actions/save-favicon.ts | 22 +-- src/actions/save-logo.ts | 11 +- src/actions/sessions.ts | 25 ++- src/actions/shop.ts | 95 +++++++----- src/actions/social.ts | 174 ++++++++++++--------- src/actions/soundtracks.ts | 13 +- src/actions/ticket-templates.ts | 24 ++- src/actions/tickets.ts | 167 +++++++++++--------- src/actions/twofactor.ts | 43 +++--- src/actions/user-settings.ts | 11 +- src/actions/users.ts | 197 ++++++++++++++---------- src/actions/voucher.ts | 82 ++++++---- src/actions/watch.ts | 24 ++- src/lib/services/abuse-guard.test.ts | 38 +++-- src/lib/services/abuse-guard.ts | 14 +- src/lib/services/moderation.test.ts | 25 +-- src/lib/services/moderation.ts | 10 +- src/lib/services/send-currency.ts | 28 ++++ src/lib/services/site-settings.test.ts | 30 ++-- src/lib/services/site-settings.ts | 17 +-- src/lib/services/staff-activity.ts | 20 ++- 85 files changed, 2612 insertions(+), 1832 deletions(-) diff --git a/src/actions/admin-ads.test.ts b/src/actions/admin-ads.test.ts index 1dc8b57f11..0f53a96f66 100644 --- a/src/actions/admin-ads.test.ts +++ b/src/actions/admin-ads.test.ts @@ -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) => ({ 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 } }; diff --git a/src/actions/admin-ads.ts b/src/actions/admin-ads.ts index 2ed559282d..5ee9f59172 100644 --- a/src/actions/admin-ads.ts +++ b/src/actions/admin-ads.ts @@ -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 { 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 { 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 { 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", diff --git a/src/actions/admin-applications.ts b/src/actions/admin-applications.ts index 2192f4b045..22bce9fb24 100644 --- a/src/actions/admin-applications.ts +++ b/src/actions/admin-applications.ts @@ -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 { await requirePermission(PERMS.USERS_EDIT); @@ -12,7 +13,9 @@ export async function dismissApplication(formData: FormData): Promise { 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 } diff --git a/src/actions/admin-articles.ts b/src/actions/admin-articles.ts index 82f518a420..5369ed9c76 100644 --- a/src/actions/admin-articles.ts +++ b/src/actions/admin-articles.ts @@ -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 { 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 { @@ -41,17 +47,15 @@ export async function createArticle(formData: FormData): Promise { 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 { 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 { .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 { 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"); } diff --git a/src/actions/admin-badges.ts b/src/actions/admin-badges.ts index 1835b6c2e9..1f24bb9faf 100644 --- a/src/actions/admin-badges.ts +++ b/src/actions/admin-badges.ts @@ -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 { @@ -23,19 +24,20 @@ export async function giveBadge(formData: FormData): Promise { // 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. diff --git a/src/actions/admin-bans.test.ts b/src/actions/admin-bans.test.ts index 457ba77606..37b50d29cd 100644 --- a/src/actions/admin-bans.test.ts +++ b/src/actions/admin-bans.test.ts @@ -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) => ({ 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"); }); }); diff --git a/src/actions/admin-bans.ts b/src/actions/admin-bans.ts index 5fe769dfc3..a6ed9deb77 100644 --- a/src/actions/admin-bans.ts +++ b/src/actions/admin-bans.ts @@ -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 { // 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 { 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", diff --git a/src/actions/admin-email-templates.ts b/src/actions/admin-email-templates.ts index 70990457ab..12e4725411 100644 --- a/src/actions/admin-email-templates.ts +++ b/src/actions/admin-email-templates.ts @@ -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 { await requirePermission(PERMS.PAGES_EDIT); @@ -23,14 +24,12 @@ export async function createEmailTemplate(formData: FormData): Promise { 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 { 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 { 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"); } diff --git a/src/actions/admin-emulator.ts b/src/actions/admin-emulator.ts index 962de38b65..2e08cce741 100644 --- a/src/actions/admin-emulator.ts +++ b/src/actions/admin-emulator.ts @@ -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 { .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 { .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"); } diff --git a/src/actions/admin-guilds.test.ts b/src/actions/admin-guilds.test.ts index 3b2829b1ee..07b3b1f4dd 100644 --- a/src/actions/admin-guilds.test.ts +++ b/src/actions/admin-guilds.test.ts @@ -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)(tx); - }); + selectLimit.mockResolvedValue([{ id: 1, name: "TestGuild", userId: 42 }]); + transactionFn.mockImplementation( + async (fn: (tx: unknown) => Promise) => { + 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(); }); }); diff --git a/src/actions/admin-guilds.ts b/src/actions/admin-guilds.ts index 8b70eca2b8..26c827c5c3 100644 --- a/src/actions/admin-guilds.ts +++ b/src/actions/admin-guilds.ts @@ -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 { 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({ diff --git a/src/actions/admin-help-tickets.ts b/src/actions/admin-help-tickets.ts index 43bc3e26ba..e2a1594278 100644 --- a/src/actions/admin-help-tickets.ts +++ b/src/actions/admin-help-tickets.ts @@ -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), diff --git a/src/actions/admin-help.test.ts b/src/actions/admin-help.test.ts index 1f38d30efd..2009298ba0 100644 --- a/src/actions/admin-help.test.ts +++ b/src/actions/admin-help.test.ts @@ -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) => ({ 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: "

Answer

", }) 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"); }); }); diff --git a/src/actions/admin-help.ts b/src/actions/admin-help.ts index 9e4f330076..a59258ff41 100644 --- a/src/actions/admin-help.ts +++ b/src/actions/admin-help.ts @@ -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 { 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 { 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 { 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 { 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", diff --git a/src/actions/admin-ip.test.ts b/src/actions/admin-ip.test.ts index 0d83147d50..13354e38dc 100644 --- a/src/actions/admin-ip.test.ts +++ b/src/actions/admin-ip.test.ts @@ -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) => ({ 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(); }); }); diff --git a/src/actions/admin-ip.ts b/src/actions/admin-ip.ts index 797b101dab..9afc11c2de 100644 --- a/src/actions/admin-ip.ts +++ b/src/actions/admin-ip.ts @@ -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 { 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 { .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 { 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 { .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"); } diff --git a/src/actions/admin-maintenance.test.ts b/src/actions/admin-maintenance.test.ts index 7c21e59e7f..27bbd97f7b 100644 --- a/src/actions/admin-maintenance.test.ts +++ b/src/actions/admin-maintenance.test.ts @@ -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", }), ); }); diff --git a/src/actions/admin-maintenance.ts b/src/actions/admin-maintenance.ts index 5636c55b55..2571658112 100644 --- a/src/actions/admin-maintenance.ts +++ b/src/actions/admin-maintenance.ts @@ -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 = { }; async function upsertSetting(key: string, value: string): Promise { - 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 { diff --git a/src/actions/admin-marketplace.ts b/src/actions/admin-marketplace.ts index 3903680538..13639e18c3 100644 --- a/src/actions/admin-marketplace.ts +++ b/src/actions/admin-marketplace.ts @@ -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, diff --git a/src/actions/admin-radio-api-keys.ts b/src/actions/admin-radio-api-keys.ts index b1e84b8a0e..e89a88eede 100644 --- a/src/actions/admin-radio-api-keys.ts +++ b/src/actions/admin-radio-api-keys.ts @@ -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 { 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 { 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 { 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", diff --git a/src/actions/admin-radio-autodj.ts b/src/actions/admin-radio-autodj.ts index 3ea9cd78c0..5671873f33 100644 --- a/src/actions/admin-radio-autodj.ts +++ b/src/actions/admin-radio-autodj.ts @@ -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 { 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 { 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 { 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", diff --git a/src/actions/admin-radio-extra.ts b/src/actions/admin-radio-extra.ts index e96c9ee820..7f3debab83 100644 --- a/src/actions/admin-radio-extra.ts +++ b/src/actions/admin-radio-extra.ts @@ -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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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. } diff --git a/src/actions/admin-radio-moderation.ts b/src/actions/admin-radio-moderation.ts index 779b17734a..3472c1e3b6 100644 --- a/src/actions/admin-radio-moderation.ts +++ b/src/actions/admin-radio-moderation.ts @@ -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 { 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", diff --git a/src/actions/admin-radio-points.ts b/src/actions/admin-radio-points.ts index 96da35db3d..44d9febad4 100644 --- a/src/actions/admin-radio-points.ts +++ b/src/actions/admin-radio-points.ts @@ -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 { }; 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(); diff --git a/src/actions/admin-rare-values.ts b/src/actions/admin-rare-values.ts index 9933a4b5b4..8eac1cdb4a 100644 --- a/src/actions/admin-rare-values.ts +++ b/src/actions/admin-rare-values.ts @@ -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 { await requirePermission(PERMS.SHOP_EDIT); @@ -24,8 +25,10 @@ export async function createCategory(formData: FormData): Promise { 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 { 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 { .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 { 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. } diff --git a/src/actions/admin-settings.ts b/src/actions/admin-settings.ts index d738bc91c8..cdc187c020 100644 --- a/src/actions/admin-settings.ts +++ b/src/actions/admin-settings.ts @@ -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 { 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 { .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 { .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"); diff --git a/src/actions/admin-shop.ts b/src/actions/admin-shop.ts index a59b084a12..aa8feb917e 100644 --- a/src/actions/admin-shop.ts +++ b/src/actions/admin-shop.ts @@ -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 { 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 { 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 { .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 { 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", diff --git a/src/actions/admin-tags.test.ts b/src/actions/admin-tags.test.ts index 24e3d90989..733c985373 100644 --- a/src/actions/admin-tags.test.ts +++ b/src/actions/admin-tags.test.ts @@ -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) => ({ 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(); }); }); diff --git a/src/actions/admin-tags.ts b/src/actions/admin-tags.ts index 7d08f355fe..f372d6429c 100644 --- a/src/actions/admin-tags.ts +++ b/src/actions/admin-tags.ts @@ -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 { 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 { 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 { 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", diff --git a/src/actions/admin-teams.test.ts b/src/actions/admin-teams.test.ts index 310de2d9f8..d30a24fb67 100644 --- a/src/actions/admin-teams.test.ts +++ b/src/actions/admin-teams.test.ts @@ -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) => ({ 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"); }); }); diff --git a/src/actions/admin-teams.ts b/src/actions/admin-teams.ts index 3e47c340d8..5d4a246976 100644 --- a/src/actions/admin-teams.ts +++ b/src/actions/admin-teams.ts @@ -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 { await requirePermission(PERMS.USERS_EDIT); @@ -26,16 +27,14 @@ export async function createTeam(formData: FormData): Promise { 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 { 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"); } diff --git a/src/actions/admin-theme.ts b/src/actions/admin-theme.ts index 8e12e04f81..8dc3e1e7ce 100644 --- a/src/actions/admin-theme.ts +++ b/src/actions/admin-theme.ts @@ -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 { - 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 { diff --git a/src/actions/admin-vouchers.ts b/src/actions/admin-vouchers.ts index fe9458d2c6..28feeb585e 100644 --- a/src/actions/admin-vouchers.ts +++ b/src/actions/admin-vouchers.ts @@ -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) { diff --git a/src/actions/admin-vpn.test.ts b/src/actions/admin-vpn.test.ts index fe00f70c52..90eb3970ce 100644 --- a/src/actions/admin-vpn.test.ts +++ b/src/actions/admin-vpn.test.ts @@ -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) => ({ 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"); }); diff --git a/src/actions/admin-vpn.ts b/src/actions/admin-vpn.ts index 2385c0e595..521023c66b 100644 --- a/src/actions/admin-vpn.ts +++ b/src/actions/admin-vpn.ts @@ -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 { - 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 { diff --git a/src/actions/admin-wordfilter.ts b/src/actions/admin-wordfilter.ts index 586b0a6db9..66fa389608 100644 --- a/src/actions/admin-wordfilter.ts +++ b/src/actions/admin-wordfilter.ts @@ -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 { 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"); diff --git a/src/actions/admin-writeable-boxes.ts b/src/actions/admin-writeable-boxes.ts index 95b14eda7b..d628221e3e 100644 --- a/src/actions/admin-writeable-boxes.ts +++ b/src/actions/admin-writeable-boxes.ts @@ -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 { 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 { 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 { 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 { 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 { 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", diff --git a/src/actions/applications.ts b/src/actions/applications.ts index ee0a146664..12d07834b9 100644 --- a/src/actions/applications.ts +++ b/src/actions/applications.ts @@ -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 { 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 { 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"; } diff --git a/src/actions/article-comments.ts b/src/actions/article-comments.ts index 1c0c5d66ae..50046204eb 100644 --- a/src/actions/article-comments.ts +++ b/src/actions/article-comments.ts @@ -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 { 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"; } diff --git a/src/actions/article-reactions.ts b/src/actions/article-reactions.ts index f231d6fe7d..aed2dcf0a8 100644 --- a/src/actions/article-reactions.ts +++ b/src/actions/article-reactions.ts @@ -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 { } 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, }); } } diff --git a/src/actions/auth-precheck.ts b/src/actions/auth-precheck.ts index f60a00a089..7b09a8e2f6 100644 --- a/src/actions/auth-precheck.ts +++ b/src/actions/auth-precheck.ts @@ -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"; } diff --git a/src/actions/badges.ts b/src/actions/badges.ts index d1dfdc68d7..e0f947ba9d 100644 --- a/src/actions/badges.ts +++ b/src/actions/badges.ts @@ -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"); } diff --git a/src/actions/banners.ts b/src/actions/banners.ts index 252517dc86..e73fcbf41d 100644 --- a/src/actions/banners.ts +++ b/src/actions/banners.ts @@ -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", diff --git a/src/actions/bulk-users.test.ts b/src/actions/bulk-users.test.ts index 1934b18cc2..22f2124b08 100644 --- a/src/actions/bulk-users.test.ts +++ b/src/actions/bulk-users.test.ts @@ -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"); }); }); diff --git a/src/actions/bulk-users.ts b/src/actions/bulk-users.ts index 63ff9ffad5..a6a3d057ac 100644 --- a/src/actions/bulk-users.ts +++ b/src/actions/bulk-users.ts @@ -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> { 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 { diff --git a/src/actions/catalog-bc.ts b/src/actions/catalog-bc.ts index 24269e6452..de924b9f1a 100644 --- a/src/actions/catalog-bc.ts +++ b/src/actions/catalog-bc.ts @@ -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) + .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) + .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 { @@ -210,18 +213,19 @@ async function moveBcPage(pageId: number, newParentId: number): Promise { 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(); diff --git a/src/actions/catalog-items.ts b/src/actions/catalog-items.ts index d0de66fb73..b4b2c069ef 100644 --- a/src/actions/catalog-items.ts +++ b/src/actions/catalog-items.ts @@ -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 { 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) + .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) + .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++; diff --git a/src/actions/catalog.ts b/src/actions/catalog.ts index 718c133d60..8a4b14bd6a 100644 --- a/src/actions/catalog.ts +++ b/src/actions/catalog.ts @@ -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) + .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> { 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: {} }; diff --git a/src/actions/commandocentrum.ts b/src/actions/commandocentrum.ts index 235af851bc..025c497ab3 100644 --- a/src/actions/commandocentrum.ts +++ b/src/actions/commandocentrum.ts @@ -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(); }, diff --git a/src/actions/draw-badge.ts b/src/actions/draw-badge.ts index 34b672cdff..5ab8903eb8 100644 --- a/src/actions/draw-badge.ts +++ b/src/actions/draw-badge.ts @@ -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 { 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 { 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, }); } }); diff --git a/src/actions/emulator.test.ts b/src/actions/emulator.test.ts index f629e18f3a..b8673932be 100644 --- a/src/actions/emulator.test.ts +++ b/src/actions/emulator.test.ts @@ -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"); }); diff --git a/src/actions/emulator.ts b/src/actions/emulator.ts index 22a610ab65..4c420aad3d 100644 --- a/src/actions/emulator.ts +++ b/src/actions/emulator.ts @@ -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(); diff --git a/src/actions/events.ts b/src/actions/events.ts index 95789fba2c..634812f5d4 100644 --- a/src/actions/events.ts +++ b/src/actions/events.ts @@ -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"); diff --git a/src/actions/guestbook.ts b/src/actions/guestbook.ts index 8ddce867b6..943ca7100d 100644 --- a/src/actions/guestbook.ts +++ b/src/actions/guestbook.ts @@ -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 { 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"; } diff --git a/src/actions/help-tickets.ts b/src/actions/help-tickets.ts index 25968a85b7..798bbf8e92 100644 --- a/src/actions/help-tickets.ts +++ b/src/actions/help-tickets.ts @@ -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 { 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 { } 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 { 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 { } 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 { 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 { 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"; } } diff --git a/src/actions/items-base.ts b/src/actions/items-base.ts index 8dab8204c5..f99d203c1f 100644 --- a/src/actions/items-base.ts +++ b/src/actions/items-base.ts @@ -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) + .where(eq(ItemsBase.id, id)); await rcon.updateCatalog().catch(() => false); await logStaffActivity({ staffId: Number(ctx.session.user.id), diff --git a/src/actions/messenger.ts b/src/actions/messenger.ts index 78d87a0545..ef0bcbbb0e 100644 --- a/src/actions/messenger.ts +++ b/src/actions/messenger.ts @@ -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 { 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 { 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 { 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 { 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 { } 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"; } diff --git a/src/actions/moderation.ts b/src/actions/moderation.ts index 7e2237480e..4a2ec30ad5 100644 --- a/src/actions/moderation.ts +++ b/src/actions/moderation.ts @@ -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, diff --git a/src/actions/multi-account-detect.ts b/src/actions/multi-account-detect.ts index 5562ae4082..605bf101a8 100644 --- a/src/actions/multi-account-detect.ts +++ b/src/actions/multi-account-detect.ts @@ -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, diff --git a/src/actions/password-reset.ts b/src/actions/password-reset.ts index 393839e121..dba9cb18af 100644 --- a/src/actions/password-reset.ts +++ b/src/actions/password-reset.ts @@ -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 { // 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 { 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 { 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(() => {}); } } diff --git a/src/actions/polls.ts b/src/actions/polls.ts index d9da76577d..e936b5ca32 100644 --- a/src/actions/polls.ts +++ b/src/actions/polls.ts @@ -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(); 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}`); diff --git a/src/actions/prefixes.ts b/src/actions/prefixes.ts index b0b6ddfd70..d1263311bc 100644 --- a/src/actions/prefixes.ts +++ b/src/actions/prefixes.ts @@ -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(); }, diff --git a/src/actions/register.ts b/src/actions/register.ts index aad299194e..78edb645a2 100644 --- a/src/actions/register.ts +++ b/src/actions/register.ts @@ -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) { diff --git a/src/actions/rooms.ts b/src/actions/rooms.ts index bb7cca7f81..43381dddb8 100644 --- a/src/actions/rooms.ts +++ b/src/actions/rooms.ts @@ -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) { itemId: number; [key: string]: unknown; }; - await prisma.items.update({ - where: { id: itemId }, - data: data as any, - }); + await db + .update(Items) + .set(data as Partial) + .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", diff --git a/src/actions/save-favicon.ts b/src/actions/save-favicon.ts index 6dfc005f55..6e3e54fe92 100644 --- a/src/actions/save-favicon.ts +++ b/src/actions/save-favicon.ts @@ -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"); diff --git a/src/actions/save-logo.ts b/src/actions/save-logo.ts index 8b92d9e0e3..6fe690cd57 100644 --- a/src/actions/save-logo.ts +++ b/src/actions/save-logo.ts @@ -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"); diff --git a/src/actions/sessions.ts b/src/actions/sessions.ts index 91efa3471c..33b5fceeb5 100644 --- a/src/actions/sessions.ts +++ b/src/actions/sessions.ts @@ -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 { } 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 { // 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", diff --git a/src/actions/shop.ts b/src/actions/shop.ts index 42abb6c6e2..e3b70a5f1a 100644 --- a/src/actions/shop.ts +++ b/src/actions/shop.ts @@ -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 { 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 { 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 { 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, diff --git a/src/actions/social.ts b/src/actions/social.ts index 127dfbfa52..9dde1c080d 100644 --- a/src/actions/social.ts +++ b/src/actions/social.ts @@ -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 { 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 { // 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 { } 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 { } 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"; } diff --git a/src/actions/soundtracks.ts b/src/actions/soundtracks.ts index 97a489de4c..74456f10e0 100644 --- a/src/actions/soundtracks.ts +++ b/src/actions/soundtracks.ts @@ -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"); } diff --git a/src/actions/ticket-templates.ts b/src/actions/ticket-templates.ts index ad1626c006..d6ff64092d 100644 --- a/src/actions/ticket-templates.ts +++ b/src/actions/ticket-templates.ts @@ -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(); }, ); diff --git a/src/actions/tickets.ts b/src/actions/tickets.ts index 81fa9abf7c..268559a6b5 100644 --- a/src/actions/tickets.ts +++ b/src/actions/tickets.ts @@ -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 = { status: "waiting" }; + const updates: Partial = { + 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 = { status: ctx.data.status }; + const data: Partial = { + 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, diff --git a/src/actions/twofactor.ts b/src/actions/twofactor.ts index 7705f63c8e..ab96bb36f3 100644 --- a/src/actions/twofactor.ts +++ b/src/actions/twofactor.ts @@ -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 { @@ -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 { 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 { 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 { 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"); } diff --git a/src/actions/user-settings.ts b/src/actions/user-settings.ts index 45d3968705..6174e95fa0 100644 --- a/src/actions/user-settings.ts +++ b/src/actions/user-settings.ts @@ -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"); } diff --git a/src/actions/users.ts b/src/actions/users.ts index 5a780c9e71..746c6dca48 100644 --- a/src/actions/users.ts +++ b/src/actions/users.ts @@ -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({ diff --git a/src/actions/voucher.ts b/src/actions/voucher.ts index 2a5309caf2..38714be40e 100644 --- a/src/actions/voucher.ts +++ b/src/actions/voucher.ts @@ -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 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. diff --git a/src/actions/watch.ts b/src/actions/watch.ts index 6115a0ef15..464a76a884 100644 --- a/src/actions/watch.ts +++ b/src/actions/watch.ts @@ -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 }); diff --git a/src/lib/services/abuse-guard.test.ts b/src/lib/services/abuse-guard.test.ts index 935d12835b..0b009feb04 100644 --- a/src/lib/services/abuse-guard.test.ts +++ b/src/lib/services/abuse-guard.test.ts @@ -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" }), ); }); }); diff --git a/src/lib/services/abuse-guard.ts b/src/lib/services/abuse-guard.ts index fd77cec79a..fa3057c6af 100644 --- a/src/lib/services/abuse-guard.ts +++ b/src/lib/services/abuse-guard.ts @@ -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 { 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 { 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); diff --git a/src/lib/services/moderation.test.ts b/src/lib/services/moderation.test.ts index d363fa5748..81294d0fda 100644 --- a/src/lib/services/moderation.test.ts +++ b/src/lib/services/moderation.test.ts @@ -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(); }); }); diff --git a/src/lib/services/moderation.ts b/src/lib/services/moderation.ts index 5c93810259..4e123b9124 100644 --- a/src/lib/services/moderation.ts +++ b/src/lib/services/moderation.ts @@ -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 { 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); diff --git a/src/lib/services/send-currency.ts b/src/lib/services/send-currency.ts index 244ba9d31b..c567d4e477 100644 --- a/src/lib/services/send-currency.ts +++ b/src/lib/services/send-currency.ts @@ -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, diff --git a/src/lib/services/site-settings.test.ts b/src/lib/services/site-settings.test.ts index f9c59c6d49..3d80e7e87e 100644 --- a/src/lib/services/site-settings.test.ts +++ b/src/lib/services/site-settings.test.ts @@ -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); }); }); diff --git a/src/lib/services/site-settings.ts b/src/lib/services/site-settings.ts index 29be937b67..a44392c6b7 100644 --- a/src/lib/services/site-settings.ts +++ b/src/lib/services/site-settings.ts @@ -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 = { @@ -21,9 +21,9 @@ class SiteSettings { private async loadFromDb(): Promise> { 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 { - await prisma.websiteSetting.upsert({ - where: { key }, - create: { key, value }, - update: { value }, - }); + await db + .insert(WebsiteSetting) + .values({ key, value }) + .onDuplicateKeyUpdate({ set: { value } }); await this.reload(); } diff --git a/src/lib/services/staff-activity.ts b/src/lib/services/staff-activity.ts index 3182dc9d7d..6a62a7489c 100644 --- a/src/lib/services/staff-activity.ts +++ b/src/lib/services/staff-activity.ts @@ -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