From 9aa4f331bf00611747ddfba0053c216ebfcf46f0 Mon Sep 17 00:00:00 2001 From: simoleo89 Date: Sat, 1 Aug 2026 14:15:36 +0200 Subject: [PATCH] refactor(db): migrate app pages and APIs from Prisma facade to Drizzle (4) Co-authored-by: Cursor --- src/app/admin/tickets/[id]/page.tsx | 29 +- src/app/admin/tickets/page.tsx | 162 +++++++---- src/app/admin/tickets/templates/page.tsx | 10 +- src/app/admin/transactions/page.tsx | 122 +++++---- src/app/admin/translations/emulator/page.tsx | 69 ++--- .../admin/users/_lib/load-user-sanctions.ts | 163 ++++++------ src/app/admin/users/_lib/load-user.ts | 132 +++++---- src/app/admin/users/edit/[id]/page.tsx | 11 +- src/app/admin/vouchers/page.tsx | 29 +- src/app/admin/wordfilter/page.tsx | 13 +- src/app/admin/writeable-boxes/page.tsx | 13 +- src/app/api/admin/analytics/export/route.ts | 21 +- src/app/api/admin/catalog/tree/route.ts | 251 +++++++++++------- src/app/api/admin/furni/search/route.ts | 43 +-- .../api/admin/import/clone/sync-all/route.ts | 12 +- .../admin/import/furni/batch-regen/route.ts | 22 +- .../admin/import/furni/nitro-editor/route.ts | 29 +- .../api/admin/import/furni/resync/route.ts | 69 ++--- src/app/api/admin/import/furni/route.ts | 186 +++++++------ .../admin/permissions/assignments/route.ts | 35 ++- src/app/api/admin/permissions/roles/route.ts | 37 ++- src/app/api/admin/prefixes/blacklist/route.ts | 15 +- src/app/api/admin/prefixes/route.ts | 42 +-- src/app/api/admin/prefixes/settings/route.ts | 13 +- src/app/api/admin/users/actions/route.ts | 25 +- src/app/api/articles/[slug]/comment/route.ts | 27 +- src/app/api/articles/[slug]/route.ts | 30 ++- src/app/api/articles/route.ts | 38 +-- src/app/api/badges/leaderboard/route.ts | 141 +++++----- src/app/api/guilds/[id]/route.ts | 36 +-- src/app/api/guilds/route.ts | 30 ++- src/app/api/health/route.ts | 6 +- src/app/api/home/route.ts | 33 +-- src/app/api/leaderboard/route.ts | 47 ++-- src/app/api/me/route.ts | 38 +-- src/app/api/me/tokens/route.ts | 39 ++- src/app/api/online/count/route.ts | 15 +- src/app/api/online/route.ts | 18 +- src/app/api/paypal/capture/route.ts | 131 +++++---- src/app/api/paypal/create/route.ts | 4 +- 40 files changed, 1259 insertions(+), 927 deletions(-) diff --git a/src/app/admin/tickets/[id]/page.tsx b/src/app/admin/tickets/[id]/page.tsx index 2caf28f93d..7fa922c6f1 100644 --- a/src/app/admin/tickets/[id]/page.tsx +++ b/src/app/admin/tickets/[id]/page.tsx @@ -1,9 +1,10 @@ +import { asc, eq, gte } from "drizzle-orm"; import { notFound, redirect } from "next/navigation"; import { getTranslations } from "next-intl/server"; import { TicketQueueBanner } from "@/components/admin/ticket-queue-banner"; import { getMinStaffRank } from "@/lib/admin/min-staff-rank"; +import { db, User, WebsiteTicket } from "@/lib/db"; import { canAccess, getAdminContext, PERMS } from "@/lib/permissions"; -import { prisma } from "@/lib/prisma"; import { AdminTicketDetail } from "./admin-ticket-detail"; export default async function AdminTicketDetailPage({ @@ -21,11 +22,11 @@ export default async function AdminTicketDetailPage({ const ticketId = Number(id); if (Number.isNaN(ticketId)) notFound(); - const ticket = await prisma.websiteTicket.findUnique({ - where: { id: ticketId }, - include: { + const ticket = await db.query.WebsiteTicket.findFirst({ + where: eq(WebsiteTicket.id, ticketId), + with: { creator: { - select: { + columns: { id: true, username: true, look: true, @@ -35,12 +36,12 @@ export default async function AdminTicketDetailPage({ accountCreated: true, }, }, - assignee: { select: { id: true, username: true } }, + assignee: { columns: { id: true, username: true } }, messages: { - orderBy: { createdAt: "asc" }, - include: { + orderBy: (m, { asc: a }) => [a(m.createdAt)], + with: { user: { - select: { id: true, username: true, look: true, rank: true }, + columns: { id: true, username: true, look: true, rank: true }, }, }, }, @@ -51,11 +52,11 @@ export default async function AdminTicketDetailPage({ // Staff assignees: min_staff_rank and above const minStaffRank = await getMinStaffRank(); - const staffMembers = await prisma.user.findMany({ - where: { rank: { gte: minStaffRank } }, - select: { id: true, username: true }, - orderBy: { username: "asc" }, - }); + const staffMembers = await db + .select({ id: User.id, username: User.username }) + .from(User) + .where(gte(User.rank, minStaffRank)) + .orderBy(asc(User.username)); const canEdit = canAccess(permissions, PERMS.TICKETS_EDIT, session.user.rank); diff --git a/src/app/admin/tickets/page.tsx b/src/app/admin/tickets/page.tsx index 498c7eb708..287be4771c 100644 --- a/src/app/admin/tickets/page.tsx +++ b/src/app/admin/tickets/page.tsx @@ -1,12 +1,25 @@ +import { + and, + asc, + count, + desc, + eq, + like, + ne, + or, + type SQL, + sql, +} from "drizzle-orm"; +import { alias } from "drizzle-orm/mysql-core"; import { redirect } from "next/navigation"; import { getTranslations } from "next-intl/server"; import { StatusCard } from "@/components/admin/dashboard"; import { TicketQueueBanner } from "@/components/admin/ticket-queue-banner"; import { fetchTicketQueueOpenCounts } from "@/lib/admin/ticket-queue-counts"; import { calcPagination, parseListParams } from "@/lib/admin-helpers"; +import { db, User, WebsiteTicket } from "@/lib/db"; import { formatDate } from "@/lib/format-date"; import { canAccess, getAdminContext, PERMS } from "@/lib/permissions"; -import { prisma } from "@/lib/prisma"; import { type TicketRow, TicketsTable } from "./tickets-table"; export default async function AdminTicketsPage({ @@ -24,92 +37,123 @@ export default async function AdminTicketsPage({ const parsed = parseListParams(new URLSearchParams(raw)); const statusFilter = raw.filter_status || raw.status || "active"; - const conditions: any[] = []; + const Creator = alias(User, "ticket_creator"); + const Assignee = alias(User, "ticket_assignee"); + + const conditions: SQL[] = []; if (statusFilter === "active") { - conditions.push({ status: { not: "closed" } }); + conditions.push(ne(WebsiteTicket.status, "closed")); } else if (statusFilter && statusFilter !== "all") { - conditions.push({ status: statusFilter }); + conditions.push(eq(WebsiteTicket.status, statusFilter)); } if (parsed.search.trim()) { const q = parsed.search.trim(); const asId = Number(q); - conditions.push({ - OR: [ - { subject: { contains: q } }, - { category: { contains: q } }, - { creator: { username: { contains: q } } }, - { assignee: { username: { contains: q } } }, - ...(Number.isFinite(asId) && asId > 0 ? [{ id: asId }] : []), - ], - }); + const searchOr = [ + like(WebsiteTicket.subject, `%${q}%`), + like(WebsiteTicket.category, `%${q}%`), + like(Creator.username, `%${q}%`), + like(Assignee.username, `%${q}%`), + ...(Number.isFinite(asId) && asId > 0 + ? [eq(WebsiteTicket.id, asId)] + : []), + ]; + conditions.push(or(...searchOr)!); } - const where: any = - conditions.length === 0 - ? {} - : conditions.length === 1 - ? conditions[0] - : { AND: conditions }; + const where = conditions.length > 0 ? and(...conditions) : undefined; - const SORT_MAP: Record = { - subject: { subject: "asc" }, - status: { status: "asc" }, - priority: { priority: "asc" }, - assignee: { assigneeId: "asc" }, - date: { createdAt: "desc" }, - id: { id: "desc" }, - }; + const orderCol = + parsed.sort === "subject" + ? WebsiteTicket.subject + : parsed.sort === "status" + ? WebsiteTicket.status + : parsed.sort === "priority" + ? WebsiteTicket.priority + : parsed.sort === "assignee" + ? WebsiteTicket.assigneeId + : parsed.sort === "id" + ? WebsiteTicket.id + : WebsiteTicket.createdAt; + const orderBy = parsed.order === "asc" ? asc(orderCol) : desc(orderCol); - const baseOrder = SORT_MAP[parsed.sort ?? "date"] ?? { createdAt: "desc" }; - const orderField = Object.keys(baseOrder)[0] as keyof typeof baseOrder; - const orderBy = { - [orderField]: parsed.order, - } as any; - - const [total, openCount, inProgressCount, waitingCount, closedCount, queues] = + const [totalRows, openRows, inProgressRows, waitingRows, closedRows, queues] = await Promise.all([ - prisma.websiteTicket.count({ where }).catch(() => 0), - prisma.websiteTicket.count({ where: { status: "open" } }).catch(() => 0), - prisma.websiteTicket - .count({ where: { status: "in_progress" } }) - .catch(() => 0), - prisma.websiteTicket - .count({ where: { status: "waiting" } }) - .catch(() => 0), - prisma.websiteTicket - .count({ where: { status: "closed" } }) - .catch(() => 0), + db + .select({ total: count() }) + .from(WebsiteTicket) + .leftJoin(Creator, eq(WebsiteTicket.creatorId, Creator.id)) + .leftJoin(Assignee, eq(WebsiteTicket.assigneeId, Assignee.id)) + .where(where) + .catch(() => [{ total: 0 }]), + db + .select({ total: count() }) + .from(WebsiteTicket) + .where(eq(WebsiteTicket.status, "open")) + .catch(() => [{ total: 0 }]), + db + .select({ total: count() }) + .from(WebsiteTicket) + .where(eq(WebsiteTicket.status, "in_progress")) + .catch(() => [{ total: 0 }]), + db + .select({ total: count() }) + .from(WebsiteTicket) + .where(eq(WebsiteTicket.status, "waiting")) + .catch(() => [{ total: 0 }]), + db + .select({ total: count() }) + .from(WebsiteTicket) + .where(eq(WebsiteTicket.status, "closed")) + .catch(() => [{ total: 0 }]), fetchTicketQueueOpenCounts(), ]); + const total = Number(totalRows[0]?.total ?? 0); + const openCount = Number(openRows[0]?.total ?? 0); + const inProgressCount = Number(inProgressRows[0]?.total ?? 0); + const waitingCount = Number(waitingRows[0]?.total ?? 0); + const closedCount = Number(closedRows[0]?.total ?? 0); + const pagination = calcPagination(total, parsed.page, parsed.perPage); - const tickets = await prisma.websiteTicket - .findMany({ - where, - orderBy, - skip: pagination.offset, - take: pagination.perPage, - include: { - creator: { select: { id: true, username: true } }, - assignee: { select: { id: true, username: true } }, - _count: { select: { messages: true } }, - }, + const tickets = await db + .select({ + id: WebsiteTicket.id, + subject: WebsiteTicket.subject, + creatorId: WebsiteTicket.creatorId, + category: WebsiteTicket.category, + status: WebsiteTicket.status, + priority: WebsiteTicket.priority, + createdAt: WebsiteTicket.createdAt, + creatorUsername: Creator.username, + assigneeUsername: Assignee.username, + messageCount: sql`( + SELECT COUNT(*) FROM website_ticket_messages m + WHERE m.ticket_id = ${WebsiteTicket.id} + )`.mapWith(Number), }) + .from(WebsiteTicket) + .leftJoin(Creator, eq(WebsiteTicket.creatorId, Creator.id)) + .leftJoin(Assignee, eq(WebsiteTicket.assigneeId, Assignee.id)) + .where(where) + .orderBy(orderBy) + .limit(pagination.perPage) + .offset(pagination.offset) .catch(() => []); const rows: TicketRow[] = tickets.map((ticket) => ({ id: ticket.id, subject: ticket.subject, - creator: ticket.creator.username, + creator: ticket.creatorUsername ?? "—", creatorId: ticket.creatorId, category: ticket.category, - messages: ticket._count.messages, + messages: Number(ticket.messageCount ?? 0), status: ticket.status, priority: ticket.priority, - assignee: ticket.assignee?.username ?? "—", + assignee: ticket.assigneeUsername ?? "—", date: formatDate(ticket.createdAt, "date"), })); diff --git a/src/app/admin/tickets/templates/page.tsx b/src/app/admin/tickets/templates/page.tsx index 15cf13080d..8c8cfe3dfd 100644 --- a/src/app/admin/tickets/templates/page.tsx +++ b/src/app/admin/tickets/templates/page.tsx @@ -1,6 +1,7 @@ +import { asc } from "drizzle-orm"; import { redirect } from "next/navigation"; +import { db, WebsiteTicketTemplate } from "@/lib/db"; import { canAccess, getAdminContext, PERMS } from "@/lib/permissions"; -import { prisma } from "@/lib/prisma"; import { TicketTemplatesManager } from "./templates-manager"; export default async function TicketTemplatesPage() { @@ -9,9 +10,10 @@ export default async function TicketTemplatesPage() { redirect("/admin/tickets"); } - const templates = await prisma.websiteTicketTemplate.findMany({ - orderBy: { sortOrder: "asc" }, - }); + const templates = await db + .select() + .from(WebsiteTicketTemplate) + .orderBy(asc(WebsiteTicketTemplate.sortOrder)); return (
diff --git a/src/app/admin/transactions/page.tsx b/src/app/admin/transactions/page.tsx index b779741b32..cc9726c072 100644 --- a/src/app/admin/transactions/page.tsx +++ b/src/app/admin/transactions/page.tsx @@ -1,10 +1,21 @@ +import { + and, + asc, + count, + desc, + eq, + inArray, + like, + or, + type SQL, +} from "drizzle-orm"; import { redirect } from "next/navigation"; import { getTranslations } from "next-intl/server"; import { StatusCard } from "@/components/admin/dashboard"; import { calcPagination, parseListParams } from "@/lib/admin-helpers"; +import { db, User, WebsitePaypalTransactions } from "@/lib/db"; import { formatDate } from "@/lib/format-date"; import { canAccess, getAdminContext, PERMS } from "@/lib/permissions"; -import { prisma } from "@/lib/prisma"; import { type TransactionRow, TransactionsTable } from "./transactions-table"; const PAID_STATUS = ["completed", "paid", "success", "approved"] as const; @@ -33,15 +44,6 @@ function formatAmount(amount: number, currency: string): string { return `${value} ${currency}`; } -const SORT_MAP: Record = { - id: { id: "desc" }, - amount: { amount: "desc" }, - date: { createdAt: "desc" }, - status: { status: "desc" }, - package: { description: "desc" }, - payer: { userId: "desc" }, -}; - export default async function AdminTransactions({ searchParams, }: { @@ -57,14 +59,20 @@ export default async function AdminTransactions({ const parsed = parseListParams(new URLSearchParams(raw)); const statusFilter = raw.filter_status ?? ""; - const where: any = {}; + const conditions: SQL[] = []; if (statusFilter === "paid") { - where.status = { in: [...PAID_STATUS] }; + conditions.push( + inArray(WebsitePaypalTransactions.status, [...PAID_STATUS]), + ); } else if (statusFilter === "failed") { - where.status = { in: [...FAILED_STATUS] }; + conditions.push( + inArray(WebsitePaypalTransactions.status, [...FAILED_STATUS]), + ); } else if (statusFilter === "pending") { - where.status = { in: [...PENDING_STATUS] }; + conditions.push( + inArray(WebsitePaypalTransactions.status, [...PENDING_STATUS]), + ); } if (parsed.search.trim()) { @@ -72,55 +80,67 @@ export default async function AdminTransactions({ const asId = Number(q); let userIds: number[] = []; try { - const users = await prisma.user.findMany({ - where: { username: { contains: q } }, - select: { id: true }, - take: 50, - }); + const users = await db + .select({ id: User.id }) + .from(User) + .where(like(User.username, `%${q}%`)) + .limit(50); userIds = users.map((u) => u.id); } catch { userIds = []; } - where.AND = [ - ...(where.status ? [{ status: where.status }] : []), - { - OR: [ - { transactionId: { contains: q } }, - { description: { contains: q } }, - ...(Number.isFinite(asId) && asId > 0 ? [{ userId: asId }] : []), - ...(userIds.length ? [{ userId: { in: userIds } }] : []), - ], - }, - ]; - delete where.status; + conditions.push( + or( + like(WebsitePaypalTransactions.transactionId, `%${q}%`), + like(WebsitePaypalTransactions.description, `%${q}%`), + ...(Number.isFinite(asId) && asId > 0 + ? [eq(WebsitePaypalTransactions.userId, asId)] + : []), + ...(userIds.length + ? [inArray(WebsitePaypalTransactions.userId, userIds)] + : []), + )!, + ); } + const where = conditions.length > 0 ? and(...conditions) : undefined; + let total = 0; try { - total = await prisma.websitePaypalTransactions.count({ where }); + const [totalRow] = await db + .select({ total: count() }) + .from(WebsitePaypalTransactions) + .where(where); + total = Number(totalRow?.total ?? 0); } catch { total = 0; } const pagination = calcPagination(total, parsed.page, parsed.perPage); - const sortKey = parsed.sort ?? "id"; - const baseOrder = SORT_MAP[sortKey] ?? { id: "desc" as const }; - const orderField = Object.keys(baseOrder)[0] as keyof typeof baseOrder; - const orderBy = { - [orderField]: parsed.order, - } as any; + const orderCol = + parsed.sort === "amount" + ? WebsitePaypalTransactions.amount + : parsed.sort === "date" + ? WebsitePaypalTransactions.createdAt + : parsed.sort === "status" + ? WebsitePaypalTransactions.status + : parsed.sort === "package" + ? WebsitePaypalTransactions.description + : parsed.sort === "payer" + ? WebsitePaypalTransactions.userId + : WebsitePaypalTransactions.id; + const orderBy = parsed.order === "asc" ? asc(orderCol) : desc(orderCol); - let transactions: Awaited< - ReturnType - > = []; + let transactions: (typeof WebsitePaypalTransactions.$inferSelect)[] = []; try { - transactions = await prisma.websitePaypalTransactions.findMany({ - where, - orderBy, - skip: pagination.offset, - take: pagination.perPage, - }); + transactions = await db + .select() + .from(WebsitePaypalTransactions) + .where(where) + .orderBy(orderBy) + .limit(pagination.perPage) + .offset(pagination.offset); } catch { transactions = []; } @@ -129,10 +149,10 @@ export default async function AdminTransactions({ let names = new Map(); if (userIds.length > 0) { try { - const users = await prisma.user.findMany({ - where: { id: { in: userIds } }, - select: { id: true, username: true }, - }); + const users = await db + .select({ id: User.id, username: User.username }) + .from(User) + .where(inArray(User.id, userIds)); names = new Map(users.map((u) => [u.id, u.username])); } catch { names = new Map(); diff --git a/src/app/admin/translations/emulator/page.tsx b/src/app/admin/translations/emulator/page.tsx index 95ea8cd40f..cc4e61c198 100644 --- a/src/app/admin/translations/emulator/page.tsx +++ b/src/app/admin/translations/emulator/page.tsx @@ -1,20 +1,21 @@ +import { and, asc, count, like, or, type SQL } from "drizzle-orm"; import { ExternalLink } from "lucide-react"; import Link from "next/link"; import { getTranslations } from "next-intl/server"; import { EmulatorSettingsForm } from "@/components/admin/emulator-settings-form"; import { Button } from "@/components/ui/button"; import { calcPagination } from "@/lib/admin-helpers"; -import { prisma } from "@/lib/prisma"; +import { db, EmulatorSettings } from "@/lib/db"; const TRANSLATION_KEY_PATTERNS = [ - "commands.description.%", - "commands.keys.%", - "commands.cmd_%", - "wordfilter.%", - "pet.%message%", - "matchmaking.%message%", - "message.%", - "notification.%", + "commands.description.", + "commands.keys.", + "commands.cmd_", + "wordfilter.", + "pet.", + "matchmaking.", + "message.", + "notification.", ] as const; export default async function EmulatorTranslationsPage({ @@ -29,35 +30,39 @@ export default async function EmulatorTranslationsPage({ const perPage = Math.min(Math.max(parseInt(sp.perPage || "50", 10), 10), 200); const page = Math.max(parseInt(sp.page || "1", 10), 1); - const patternFilters: any[] = TRANSLATION_KEY_PATTERNS.map((p) => ({ - key: { contains: p.replace(/%/g, "") }, - })); + const patternFilters = or( + ...TRANSLATION_KEY_PATTERNS.map((p) => like(EmulatorSettings.key, `${p}%`)), + )!; - const baseFilter: any = { OR: patternFilters }; - - const conditions: any[] = [baseFilter]; - if (group) conditions.push({ key: { startsWith: `${group}.` } }); + const conditions: SQL[] = [patternFilters]; + if (group) conditions.push(like(EmulatorSettings.key, `${group}.%`)); if (search) { - conditions.push({ - OR: [{ key: { contains: search } }, { value: { contains: search } }], - }); + conditions.push( + or( + like(EmulatorSettings.key, `%${search}%`), + like(EmulatorSettings.value, `%${search}%`), + )!, + ); } - const where: any = { AND: conditions }; + const where = and(...conditions); - const [settings, total, allKeys] = await Promise.all([ - prisma.emulatorSettings.findMany({ - where, - orderBy: { key: "asc" }, - skip: (page - 1) * perPage, - take: perPage, - }), - prisma.emulatorSettings.count({ where }), - prisma.emulatorSettings.findMany({ - where: baseFilter, - select: { key: true }, - }), + const [settings, totalRows, allKeys] = await Promise.all([ + db + .select() + .from(EmulatorSettings) + .where(where) + .orderBy(asc(EmulatorSettings.key)) + .limit(perPage) + .offset((page - 1) * perPage), + db.select({ total: count() }).from(EmulatorSettings).where(where), + db + .select({ key: EmulatorSettings.key }) + .from(EmulatorSettings) + .where(patternFilters), ]); + const total = Number(totalRows[0]?.total ?? 0); + const groupCounts = new Map(); for (const { key } of allKeys) { const g = key.includes(".") ? key.split(".")[0] : "general"; diff --git a/src/app/admin/users/_lib/load-user-sanctions.ts b/src/app/admin/users/_lib/load-user-sanctions.ts index 0f30ecb3df..1d3f1a1226 100644 --- a/src/app/admin/users/_lib/load-user-sanctions.ts +++ b/src/app/admin/users/_lib/load-user-sanctions.ts @@ -1,6 +1,15 @@ +import { and, desc, eq, inArray, or } from "drizzle-orm"; import { isBanActive, unixNow } from "@/lib/bans"; +import { + AdminAuditLog, + Ban, + db, + StaffActivities, + SupportTickets, + User, + UsersSettings, +} from "@/lib/db"; import { formatDate } from "@/lib/format-date"; -import { prisma } from "@/lib/prisma"; export type SanctionEvent = { id: string; @@ -37,84 +46,89 @@ export async function loadUserSanctions( const now = unixNow(); const events: SanctionEvent[] = []; - const [bans, audits, staffActs, cfh, settings] = await Promise.all([ - prisma.ban - .findMany({ - where: { userId }, - orderBy: { timestamp: "desc" }, - take: 40, - select: { - id: true, - banReason: true, - type: true, - timestamp: true, - banExpire: true, - userStaffId: true, - }, + const [bans, audits, staffActs, cfh, settingsRows] = await Promise.all([ + db + .select({ + id: Ban.id, + banReason: Ban.banReason, + type: Ban.type, + timestamp: Ban.timestamp, + banExpire: Ban.banExpire, + userStaffId: Ban.userStaffId, }) + .from(Ban) + .where(eq(Ban.userId, userId)) + .orderBy(desc(Ban.timestamp)) + .limit(40) .catch(() => []), - prisma.adminAuditLog - .findMany({ - where: { - target: "User", - targetId: userId, - action: { in: [...AUDIT_ACTIONS] }, - }, - orderBy: { id: "desc" }, - take: 40, - select: { - id: true, - action: true, - details: true, - createdAt: true, - userId: true, - user: { select: { username: true } }, - }, + db + .select({ + id: AdminAuditLog.id, + action: AdminAuditLog.action, + details: AdminAuditLog.details, + createdAt: AdminAuditLog.createdAt, + userId: AdminAuditLog.userId, + actorUsername: User.username, }) + .from(AdminAuditLog) + .leftJoin(User, eq(AdminAuditLog.userId, User.id)) + .where( + and( + eq(AdminAuditLog.target, "User"), + eq(AdminAuditLog.targetId, userId), + inArray(AdminAuditLog.action, [...AUDIT_ACTIONS]), + ), + ) + .orderBy(desc(AdminAuditLog.id)) + .limit(40) .catch(() => []), - prisma.staffActivities - .findMany({ - where: { - targetType: "user", - targetId: BigInt(userId), - }, - orderBy: { id: "desc" }, - take: 40, - select: { - id: true, - action: true, - description: true, - createdAt: true, - userId: true, - }, + db + .select({ + id: StaffActivities.id, + action: StaffActivities.action, + description: StaffActivities.description, + createdAt: StaffActivities.createdAt, + userId: StaffActivities.userId, }) + .from(StaffActivities) + .where( + and( + eq(StaffActivities.targetType, "user"), + eq(StaffActivities.targetId, BigInt(userId)), + ), + ) + .orderBy(desc(StaffActivities.id)) + .limit(40) .catch(() => []), - prisma.supportTickets - .findMany({ - where: { - OR: [{ reportedId: userId }, { senderId: userId }], - }, - orderBy: { id: "desc" }, - take: 20, - select: { - id: true, - issue: true, - state: true, - timestamp: true, - senderId: true, - reportedId: true, - modId: true, - }, + db + .select({ + id: SupportTickets.id, + issue: SupportTickets.issue, + state: SupportTickets.state, + timestamp: SupportTickets.timestamp, + senderId: SupportTickets.senderId, + reportedId: SupportTickets.reportedId, + modId: SupportTickets.modId, }) + .from(SupportTickets) + .where( + or( + eq(SupportTickets.reportedId, userId), + eq(SupportTickets.senderId, userId), + ), + ) + .orderBy(desc(SupportTickets.id)) + .limit(20) + .catch(() => []), + db + .select({ muteEndTimestamp: UsersSettings.muteEndTimestamp }) + .from(UsersSettings) + .where(eq(UsersSettings.userId, userId)) + .limit(1) .catch(() => []), - prisma.usersSettings - .findUnique({ - where: { userId }, - select: { muteEndTimestamp: true }, - }) - .catch(() => null), ]); + const settings = settingsRows[0] ?? null; const muteUntil = settings?.muteEndTimestamp ?? 0; if (muteUntil > now) { events.push({ @@ -134,11 +148,10 @@ export async function loadUserSanctions( ]; const staffUsers = staffIds.length > 0 - ? await prisma.user - .findMany({ - where: { id: { in: staffIds } }, - select: { id: true, username: true }, - }) + ? await db + .select({ id: User.id, username: User.username }) + .from(User) + .where(inArray(User.id, staffIds)) .catch(() => []) : []; const staffName = new Map(staffUsers.map((u) => [u.id, u.username])); @@ -172,7 +185,7 @@ export async function loadUserSanctions( kind, label: a.action, detail: a.details?.slice(0, 200) || "—", - actor: a.user?.username, + actor: a.actorUsername ?? undefined, }); } diff --git a/src/app/admin/users/_lib/load-user.ts b/src/app/admin/users/_lib/load-user.ts index f34b42da59..c51c6590e7 100644 --- a/src/app/admin/users/_lib/load-user.ts +++ b/src/app/admin/users/_lib/load-user.ts @@ -1,5 +1,18 @@ +import { asc, count, desc, eq, or, sql } from "drizzle-orm"; import { notFound } from "next/navigation"; -import { prisma } from "@/lib/prisma"; +import { + db, + Rooms, + RoomTradeLog, + User, + UsersBadges, + UsersCurrency, +} from "@/lib/db"; + +async function rawRows(query: ReturnType): Promise { + const [rows] = (await db.execute(query)) as unknown as [T[], unknown]; + return rows ?? []; +} export async function loadUserById(id: string) { const numericId = Number(id); @@ -7,9 +20,11 @@ export async function loadUserById(id: string) { Number.isSafeInteger(numericId) && numericId > 0 && String(numericId) === id; - const user = await prisma.user.findUnique({ - where: isNumeric ? { id: numericId } : { username: id }, - }); + const [user] = await db + .select() + .from(User) + .where(isNumeric ? eq(User.id, numericId) : eq(User.username, id)) + .limit(1); if (!user) notFound(); const [ @@ -17,67 +32,76 @@ export async function loadUserById(id: string) { badges, bansReceived, permissionRows, - roomCount, - tradeCount, + roomCountRows, + tradeCountRows, userRooms, chatLogs, commandLogs, ] = await Promise.all([ - prisma.usersCurrency.findMany({ where: { userId: user.id } }), - prisma.usersBadges.findMany({ - where: { userId: user.id }, - orderBy: { slotId: "asc" }, - }), - prisma.$queryRaw< - Array<{ - id: number; - banReason: string; - type: string; - timestamp: number; - banExpire: number; - staffName: string; - }> - >` + db.select().from(UsersCurrency).where(eq(UsersCurrency.userId, user.id)), + db + .select() + .from(UsersBadges) + .where(eq(UsersBadges.userId, user.id)) + .orderBy(asc(UsersBadges.slotId)), + rawRows<{ + id: number; + banReason: string; + type: string; + timestamp: number; + banExpire: number; + staffName: string; + }>(sql` SELECT b.id, b.ban_reason AS banReason, b.type, b.timestamp, b.ban_expire AS banExpire, COALESCE(s.username, 'Unknown') AS staffName FROM bans b LEFT JOIN users s ON s.id = b.user_staff_id - WHERE b.user_id = ${user.id} ORDER BY b.id DESC LIMIT 5`, - prisma.$queryRaw>` - SELECT COALESCE(rank_name, CONCAT('Rank ', id)) AS rankName FROM permissions WHERE id = ${user.rank} LIMIT 1`, - prisma.rooms.count({ where: { ownerId: user.id } }), - prisma.roomTradeLog.count({ - where: { OR: [{ userOneId: user.id }, { userTwoId: user.id }] }, - }), - prisma.rooms.findMany({ - where: { ownerId: user.id }, - orderBy: { users: "desc" }, - take: 20, - select: { - id: true, - name: true, - state: true, - users: true, - usersMax: true, - }, - }), - prisma.$queryRaw< - Array<{ id: number; roomId: number; timestamp: number; message: string }> - >` + WHERE b.user_id = ${user.id} ORDER BY b.id DESC LIMIT 5`), + rawRows<{ rankName: string }>(sql` + SELECT COALESCE(rank_name, CONCAT('Rank ', id)) AS rankName FROM permissions WHERE id = ${user.rank} LIMIT 1`), + db.select({ total: count() }).from(Rooms).where(eq(Rooms.ownerId, user.id)), + db + .select({ total: count() }) + .from(RoomTradeLog) + .where( + or( + eq(RoomTradeLog.userOneId, user.id), + eq(RoomTradeLog.userTwoId, user.id), + ), + ), + db + .select({ + id: Rooms.id, + name: Rooms.name, + state: Rooms.state, + users: Rooms.users, + usersMax: Rooms.usersMax, + }) + .from(Rooms) + .where(eq(Rooms.ownerId, user.id)) + .orderBy(desc(Rooms.users)) + .limit(20), + rawRows<{ + id: number; + roomId: number; + timestamp: number; + message: string; + }>(sql` SELECT ROW_NUMBER() OVER (ORDER BY timestamp DESC) AS id, room_id AS roomId, timestamp, message - FROM chatlogs_room WHERE user_from_id = ${user.id} ORDER BY timestamp DESC LIMIT 20`, - prisma.$queryRaw< - Array<{ - id: number; - timestamp: number; - command: string; - params: string; - succes: string; - }> - >` + FROM chatlogs_room WHERE user_from_id = ${user.id} ORDER BY timestamp DESC LIMIT 20`), + rawRows<{ + id: number; + timestamp: number; + command: string; + params: string; + succes: string; + }>(sql` SELECT ROW_NUMBER() OVER (ORDER BY timestamp DESC) AS id, timestamp, command, params, succes - FROM commandlogs WHERE user_id = ${user.id} ORDER BY timestamp DESC LIMIT 10`, + FROM commandlogs WHERE user_id = ${user.id} ORDER BY timestamp DESC LIMIT 10`), ]); + const roomCount = Number(roomCountRows[0]?.total ?? 0); + const tradeCount = Number(tradeCountRows[0]?.total ?? 0); + const diamonds = currencies.find((currency) => currency.type === 5)?.amount ?? 0; const duckets = diff --git a/src/app/admin/users/edit/[id]/page.tsx b/src/app/admin/users/edit/[id]/page.tsx index b3089100cc..ea33179f13 100644 --- a/src/app/admin/users/edit/[id]/page.tsx +++ b/src/app/admin/users/edit/[id]/page.tsx @@ -1,7 +1,8 @@ +import { sql } from "drizzle-orm"; import { redirect } from "next/navigation"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { db } from "@/lib/db"; import { canAccess, getAdminContext, PERMS } from "@/lib/permissions"; -import { Prisma, prisma } from "@/lib/prisma"; import { getWatchedUserIds } from "@/lib/services/watch"; import { UserAccountInfo } from "../../_components/user-account-info"; import { UserHero } from "../../_components/user-hero"; @@ -28,10 +29,10 @@ export default async function UserEditPage({ const watched = await getWatchedUserIds(session.user.id); // Fetch rank list from permissions table for the rank dropdown - const rawRanks: { id: number; rank_name: string }[] = await prisma.$queryRaw( - Prisma.sql`SELECT id, rank_name FROM permissions ORDER BY id ASC`, - ); - const ranks = rawRanks.map((r) => ({ + const [rawRanks] = (await db.execute( + sql`SELECT id, rank_name FROM permissions ORDER BY id ASC`, + )) as unknown as [{ id: number; rank_name: string }[], unknown]; + const ranks = (rawRanks ?? []).map((r) => ({ id: Number(r.id), name: String(r.rank_name || `Rank ${r.id}`), })); diff --git a/src/app/admin/vouchers/page.tsx b/src/app/admin/vouchers/page.tsx index fe112a572b..603d8becf7 100644 --- a/src/app/admin/vouchers/page.tsx +++ b/src/app/admin/vouchers/page.tsx @@ -1,6 +1,7 @@ +import { desc } from "drizzle-orm"; import { redirect } from "next/navigation"; +import { db, WebsiteShopVouchers } from "@/lib/db"; import { canAccess, getAdminContext, PERMS } from "@/lib/permissions"; -import { prisma } from "@/lib/prisma"; import { type VoucherRow, VouchersClient } from "./vouchers-client"; export default async function AdminVouchers() { @@ -14,19 +15,19 @@ export default async function AdminVouchers() { let rows: VoucherRow[] = []; try { - const vouchers = await prisma.websiteShopVouchers.findMany({ - select: { - id: true, - code: true, - amount: true, - maxUses: true, - useCount: true, - expiresAt: true, - createdAt: true, - }, - orderBy: { id: "desc" }, - take: 200, - }); + const vouchers = await db + .select({ + id: WebsiteShopVouchers.id, + code: WebsiteShopVouchers.code, + amount: WebsiteShopVouchers.amount, + maxUses: WebsiteShopVouchers.maxUses, + useCount: WebsiteShopVouchers.useCount, + expiresAt: WebsiteShopVouchers.expiresAt, + createdAt: WebsiteShopVouchers.createdAt, + }) + .from(WebsiteShopVouchers) + .orderBy(desc(WebsiteShopVouchers.id)) + .limit(200); rows = vouchers.map((v) => { const expired = v.expiresAt ? v.expiresAt.getTime() <= now : false; const usedUp = v.maxUses > 0 && v.useCount >= v.maxUses; diff --git a/src/app/admin/wordfilter/page.tsx b/src/app/admin/wordfilter/page.tsx index 813c22eae5..8872d8e866 100644 --- a/src/app/admin/wordfilter/page.tsx +++ b/src/app/admin/wordfilter/page.tsx @@ -1,9 +1,10 @@ +import { asc } from "drizzle-orm"; import { Filter } from "lucide-react"; import { redirect } from "next/navigation"; import { getTranslations } from "next-intl/server"; import { AdminPageShell } from "@/components/admin/admin-page-shell"; +import { db, WebsiteWordfilter } from "@/lib/db"; import { canAccess, getAdminContext, PERMS } from "@/lib/permissions"; -import { prisma } from "@/lib/prisma"; import { WordFilterClient } from "./wordfilter-client"; export default async function AdminWordFilter() { @@ -21,11 +22,11 @@ export default async function AdminWordFilter() { let words: { id: string; word: string }[] = []; try { - const rows = await prisma.websiteWordfilter.findMany({ - select: { id: true, word: true }, - orderBy: { word: "asc" }, - take: 500, - }); + const rows = await db + .select({ id: WebsiteWordfilter.id, word: WebsiteWordfilter.word }) + .from(WebsiteWordfilter) + .orderBy(asc(WebsiteWordfilter.word)) + .limit(500); words = rows.map((w) => ({ id: String(w.id), word: w.word })); } catch { words = []; diff --git a/src/app/admin/writeable-boxes/page.tsx b/src/app/admin/writeable-boxes/page.tsx index 2d5d44a606..197f3f3d9a 100644 --- a/src/app/admin/writeable-boxes/page.tsx +++ b/src/app/admin/writeable-boxes/page.tsx @@ -1,3 +1,4 @@ +import { asc } from "drizzle-orm"; import { redirect } from "next/navigation"; import { getTranslations } from "next-intl/server"; import { @@ -8,8 +9,8 @@ import { } from "@/actions/admin-writeable-boxes"; import { StatusCard } from "@/components/admin/dashboard"; import { Button } from "@/components/ui/button"; +import { db, WebsiteWriteableBoxes } from "@/lib/db"; import { canAccess, getAdminContext, PERMS } from "@/lib/permissions"; -import { prisma } from "@/lib/prisma"; export const metadata = { title: "Writeable boxes" }; @@ -20,11 +21,11 @@ export default async function AdminWriteableBoxes() { } const t = await getTranslations("pages.admin.writeableBoxes"); - const boxes = await prisma.websiteWriteableBoxes - .findMany({ - orderBy: [{ position: "asc" }, { id: "asc" }], - take: 500, - }) + const boxes = await db + .select() + .from(WebsiteWriteableBoxes) + .orderBy(asc(WebsiteWriteableBoxes.position), asc(WebsiteWriteableBoxes.id)) + .limit(500) .catch(() => []); const activeCount = boxes.filter((b) => b.isActive).length; diff --git a/src/app/api/admin/analytics/export/route.ts b/src/app/api/admin/analytics/export/route.ts index 3df6bb3e28..7101009c84 100644 --- a/src/app/api/admin/analytics/export/route.ts +++ b/src/app/api/admin/analytics/export/route.ts @@ -1,7 +1,8 @@ +import { sql } from "drizzle-orm"; import { withAdmin } from "@/lib/api-handler"; import { apiError } from "@/lib/api-response"; +import { db } from "@/lib/db"; import { PERMS } from "@/lib/permission-slugs"; -import { prisma } from "@/lib/prisma"; const VALID_REPORTS = new Set(["registrations", "online-by-hour", "economy"]); @@ -19,13 +20,13 @@ function csvRow(values: unknown[]): string { } async function streamRegistrations(): Promise> { - const rows = await prisma.$queryRaw<{ day: string; cnt: bigint }[]>` + const [rows] = (await db.execute(sql` SELECT FROM_UNIXTIME(account_created, '%Y-%m-%d') AS day, COUNT(*) AS cnt FROM users WHERE account_created > UNIX_TIMESTAMP(NOW() - INTERVAL 90 DAY) GROUP BY day ORDER BY day ASC - `; + `)) as unknown as [{ day: string; cnt: bigint }[], unknown]; const encoder = new TextEncoder(); return new ReadableStream({ start(controller) { @@ -39,13 +40,13 @@ async function streamRegistrations(): Promise> { } async function streamOnlineByHour(): Promise> { - const rows = await prisma.$queryRaw<{ hour: number; cnt: bigint }[]>` + const [rows] = (await db.execute(sql` SELECT HOUR(FROM_UNIXTIME(last_online)) AS hour, COUNT(*) AS cnt FROM users WHERE last_online > UNIX_TIMESTAMP(NOW() - INTERVAL 30 DAY) GROUP BY hour ORDER BY hour ASC - `; + `)) as unknown as [{ hour: number; cnt: bigint }[], unknown]; const encoder = new TextEncoder(); return new ReadableStream({ start(controller) { @@ -61,11 +62,13 @@ async function streamOnlineByHour(): Promise> { } async function streamEconomy(): Promise> { - const [totals] = await prisma.$queryRaw< - { total_credits: bigint; total_pixels: bigint }[] - >` + const [rows] = (await db.execute(sql` SELECT SUM(credits) AS total_credits, SUM(pixels) AS total_pixels FROM users - `; + `)) as unknown as [ + { total_credits: bigint; total_pixels: bigint }[], + unknown, + ]; + const totals = rows[0]; const encoder = new TextEncoder(); return new ReadableStream({ start(controller) { diff --git a/src/app/api/admin/catalog/tree/route.ts b/src/app/api/admin/catalog/tree/route.ts index 471ee40855..44d3986e6d 100644 --- a/src/app/api/admin/catalog/tree/route.ts +++ b/src/app/api/admin/catalog/tree/route.ts @@ -1,7 +1,8 @@ +import { asc, count, eq, inArray, like, or } from "drizzle-orm"; import { withAdmin } from "@/lib/api-handler"; import { apiError, apiOk } from "@/lib/api-response"; +import { CatalogItemsBc, CatalogPages, CatalogPagesBc, db } from "@/lib/db"; import { PERMS } from "@/lib/permissions"; -import { prisma } from "@/lib/prisma"; import { deletePage, getAncestors, @@ -26,10 +27,16 @@ type PageRow = { async function getBcTreeFlat(): Promise { const [allPages, itemCounts] = await Promise.all([ - prisma.catalogPagesBc.findMany({ orderBy: { orderNum: "asc" } }), - prisma.catalogItemsBc.groupBy({ by: ["pageId"], _count: true }), + db.select().from(CatalogPagesBc).orderBy(asc(CatalogPagesBc.orderNum)), + db + .select({ + pageId: CatalogItemsBc.pageId, + total: count(), + }) + .from(CatalogItemsBc) + .groupBy(CatalogItemsBc.pageId), ]); - const itemCountMap = new Map(itemCounts.map((c) => [c.pageId, c._count])); + const itemCountMap = new Map(itemCounts.map((c) => [c.pageId, c.total])); const childCountMap = new Map(); for (const p of allPages) { childCountMap.set(p.parentId, (childCountMap.get(p.parentId) ?? 0) + 1); @@ -91,37 +98,49 @@ async function getChildren( ): Promise { // Roots in Habbo DBs are usually parent_id = -1, sometimes 0. const parentFilter = - parentId <= 0 ? { OR: [{ parentId: -1 }, { parentId: 0 }] } : { parentId }; + parentId <= 0 + ? or(eq(CatalogPages.parentId, -1), eq(CatalogPages.parentId, 0)) + : eq(CatalogPages.parentId, parentId); + const parentFilterBc = + parentId <= 0 + ? or(eq(CatalogPagesBc.parentId, -1), eq(CatalogPagesBc.parentId, 0)) + : eq(CatalogPagesBc.parentId, parentId); if (isBc) { - const pages = await prisma.catalogPagesBc.findMany({ - where: parentFilter, - orderBy: { orderNum: "asc" }, - }); + const pages = await db + .select() + .from(CatalogPagesBc) + .where(parentFilterBc) + .orderBy(asc(CatalogPagesBc.orderNum)); const ids = pages.map((p) => toInt(p.id)); const [childCounts, itemCounts] = await Promise.all([ ids.length - ? prisma.catalogPagesBc.groupBy({ - by: ["parentId"], - where: { parentId: { in: ids } }, - _count: true, - }) + ? db + .select({ + parentId: CatalogPagesBc.parentId, + total: count(), + }) + .from(CatalogPagesBc) + .where(inArray(CatalogPagesBc.parentId, ids)) + .groupBy(CatalogPagesBc.parentId) : Promise.resolve([]), ids.length - ? prisma.catalogItemsBc - .groupBy({ - by: ["pageId"], - where: { pageId: { in: ids } }, - _count: true, + ? db + .select({ + pageId: CatalogItemsBc.pageId, + total: count(), }) + .from(CatalogItemsBc) + .where(inArray(CatalogItemsBc.pageId, ids)) + .groupBy(CatalogItemsBc.pageId) .catch(() => []) : Promise.resolve([]), ]); const childMap = Object.fromEntries( - childCounts.map((c) => [toInt(c.parentId), c._count]), + childCounts.map((c) => [toInt(c.parentId), c.total]), ); const itemMap = Object.fromEntries( - itemCounts.map((c) => [toInt(c.pageId), c._count]), + itemCounts.map((c) => [toInt(c.pageId), c.total]), ); return pages.map((p) => { const id = toInt(p.id); @@ -143,23 +162,27 @@ async function getChildren( }); } - const pages = await prisma.catalogPages.findMany({ - where: parentFilter, - orderBy: { orderNum: "asc" }, - }); + const pages = await db + .select() + .from(CatalogPages) + .where(parentFilter) + .orderBy(asc(CatalogPages.orderNum)); const ids = pages.map((p) => toInt(p.id)); const [childCounts, itemMap] = await Promise.all([ ids.length - ? prisma.catalogPages.groupBy({ - by: ["parentId"], - where: { parentId: { in: ids } }, - _count: true, - }) + ? db + .select({ + parentId: CatalogPages.parentId, + total: count(), + }) + .from(CatalogPages) + .where(inArray(CatalogPages.parentId, ids)) + .groupBy(CatalogPages.parentId) : Promise.resolve([]), getCatalogItemCounts(ids), ]); const childMap = Object.fromEntries( - childCounts.map((c) => [toInt(c.parentId), c._count]), + childCounts.map((c) => [toInt(c.parentId), c.total]), ); return pages.map((p) => { const id = toInt(p.id); @@ -184,19 +207,17 @@ async function getChildren( async function searchPages(q: string, isBc: boolean): Promise { const needle = q.trim(); const idExact = Number.parseInt(needle, 10); - const idFilter = - Number.isFinite(idExact) && String(idExact) === needle - ? [{ id: idExact }] - : []; + const idOk = Number.isFinite(idExact) && String(idExact) === needle; if (isBc) { - const pages = await prisma.catalogPagesBc.findMany({ - where: { - OR: [{ caption: { contains: needle } }, ...idFilter], - }, - orderBy: { orderNum: "asc" }, - take: 50, - }); + const conditions = [like(CatalogPagesBc.caption, `%${needle}%`)]; + if (idOk) conditions.push(eq(CatalogPagesBc.id, idExact)); + const pages = await db + .select() + .from(CatalogPagesBc) + .where(or(...conditions)) + .orderBy(asc(CatalogPagesBc.orderNum)) + .limit(50); return pages.map((p) => toTreeNode( { @@ -216,26 +237,30 @@ async function searchPages(q: string, isBc: boolean): Promise { ); } - const pages = await prisma.catalogPages.findMany({ - where: { - OR: [{ caption: { contains: needle } }, ...idFilter], - }, - orderBy: { orderNum: "asc" }, - take: 50, - }); + const conditions = [like(CatalogPages.caption, `%${needle}%`)]; + if (idOk) conditions.push(eq(CatalogPages.id, idExact)); + const pages = await db + .select() + .from(CatalogPages) + .where(or(...conditions)) + .orderBy(asc(CatalogPages.orderNum)) + .limit(50); const ids = pages.map((p) => toInt(p.id)); const [childCounts, itemMap] = await Promise.all([ ids.length - ? prisma.catalogPages.groupBy({ - by: ["parentId"], - where: { parentId: { in: ids } }, - _count: true, - }) + ? db + .select({ + parentId: CatalogPages.parentId, + total: count(), + }) + .from(CatalogPages) + .where(inArray(CatalogPages.parentId, ids)) + .groupBy(CatalogPages.parentId) : Promise.resolve([]), getCatalogItemCounts(ids), ]); const childMap = Object.fromEntries( - childCounts.map((c) => [toInt(c.parentId), c._count]), + childCounts.map((c) => [toInt(c.parentId), c.total]), ); return pages.map((p) => { const id = toInt(p.id); @@ -273,11 +298,19 @@ export const GET = withAdmin( const id = Number(pageIdRaw); if (!Number.isFinite(id) || id <= 0) return apiError("Invalid pageId"); if (isBc) { - const page = await prisma.catalogPagesBc.findUnique({ where: { id } }); + const [page] = await db + .select() + .from(CatalogPagesBc) + .where(eq(CatalogPagesBc.id, id)) + .limit(1); if (!page) return apiError("Page not found", 404); return apiOk({ page }); } - const page = await prisma.catalogPages.findUnique({ where: { id } }); + const [page] = await db + .select() + .from(CatalogPages) + .where(eq(CatalogPages.id, id)) + .limit(1); if (!page) return apiError("Page not found", 404); return apiOk({ page }); } @@ -290,10 +323,15 @@ export const GET = withAdmin( const chain: Array<{ id: number; caption: string }> = []; let currentId = id; for (let i = 0; i < 20; i++) { - const page = await prisma.catalogPagesBc.findUnique({ - where: { id: currentId }, - select: { id: true, caption: true, parentId: true }, - }); + const [page] = await db + .select({ + id: CatalogPagesBc.id, + caption: CatalogPagesBc.caption, + parentId: CatalogPagesBc.parentId, + }) + .from(CatalogPagesBc) + .where(eq(CatalogPagesBc.id, currentId)) + .limit(1); if (!page) break; chain.unshift({ id: page.id, caption: page.caption }); if (page.parentId <= 0) break; @@ -340,29 +378,37 @@ export const PATCH = withAdmin( } const isBc = body.catalog === "bc"; if (isBc) { - const page = await prisma.catalogPagesBc.findUnique({ - where: { id: pageId }, - select: { enabled: true, visible: true }, - }); + const [page] = await db + .select({ + enabled: CatalogPagesBc.enabled, + visible: CatalogPagesBc.visible, + }) + .from(CatalogPagesBc) + .where(eq(CatalogPagesBc.id, pageId)) + .limit(1); if (!page) return apiError("Page not found", 404); const current = field === "enabled" ? page.enabled : page.visible; - await prisma.catalogPagesBc.update({ - where: { id: pageId }, - data: { [field]: current === "1" ? "0" : "1" }, - }); + await db + .update(CatalogPagesBc) + .set({ [field]: current === "1" ? "0" : "1" }) + .where(eq(CatalogPagesBc.id, pageId)); await rcon.updateCatalog(); return apiOk({}); } - const page = await prisma.catalogPages.findUnique({ - where: { id: pageId }, - select: { enabled: true, visible: true }, - }); + const [page] = await db + .select({ + enabled: CatalogPages.enabled, + visible: CatalogPages.visible, + }) + .from(CatalogPages) + .where(eq(CatalogPages.id, pageId)) + .limit(1); if (!page) return apiError("Page not found", 404); const current = field === "enabled" ? page.enabled : page.visible; - await prisma.catalogPages.update({ - where: { id: pageId }, - data: { [field]: current === "1" ? "0" : "1" }, - }); + await db + .update(CatalogPages) + .set({ [field]: current === "1" ? "0" : "1" }) + .where(eq(CatalogPages.id, pageId)); await rcon.updateCatalog(); return apiOk({}); }, @@ -379,43 +425,46 @@ export const DELETE = withAdmin( return apiError("Invalid pageId"); } if (isBc) { - const page = await prisma.catalogPagesBc.findUnique({ - where: { id: pageId }, - select: { parentId: true }, - }); + const [page] = await db + .select({ parentId: CatalogPagesBc.parentId }) + .from(CatalogPagesBc) + .where(eq(CatalogPagesBc.id, pageId)) + .limit(1); if (!page) return apiError("Page not found", 404); if (mode === "reparent") { - await prisma.$transaction([ - prisma.catalogPagesBc.updateMany({ - where: { parentId: pageId }, - data: { parentId: page.parentId }, - }), - prisma.catalogItemsBc.deleteMany({ where: { pageId } }), - prisma.catalogPagesBc.delete({ where: { id: pageId } }), - ]); + await db.transaction(async (tx) => { + await tx + .update(CatalogPagesBc) + .set({ parentId: page.parentId }) + .where(eq(CatalogPagesBc.parentId, pageId)); + await tx + .delete(CatalogItemsBc) + .where(eq(CatalogItemsBc.pageId, pageId)); + await tx.delete(CatalogPagesBc).where(eq(CatalogPagesBc.id, pageId)); + }); } else { // Deep cascade: collect all descendants const toDelete: number[] = [pageId]; const queue: number[] = [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(); return apiOk({}); diff --git a/src/app/api/admin/furni/search/route.ts b/src/app/api/admin/furni/search/route.ts index c39824764c..206c4f0e4c 100644 --- a/src/app/api/admin/furni/search/route.ts +++ b/src/app/api/admin/furni/search/route.ts @@ -1,7 +1,8 @@ +import { asc, eq, like, or } from "drizzle-orm"; import { withAdmin } from "@/lib/api-handler"; import { apiOk } from "@/lib/api-response"; +import { db, ItemsBase } from "@/lib/db"; import { PERMS } from "@/lib/permissions"; -import { prisma } from "@/lib/prisma"; /** * Search items_base for Quick Add / catalog tooling. @@ -27,26 +28,26 @@ export const GET = withAdmin( } const idExact = Number.parseInt(q, 10); - const rows = await prisma.itemsBase.findMany({ - where: { - OR: [ - { itemName: { contains: q } }, - { publicName: { contains: q } }, - ...(Number.isFinite(idExact) && String(idExact) === q - ? [{ id: idExact }] - : []), - ], - }, - select: { - id: true, - itemName: true, - publicName: true, - type: true, - spriteId: true, - }, - take: limit, - orderBy: { id: "asc" }, - }); + const conditions = [ + like(ItemsBase.itemName, `%${q}%`), + like(ItemsBase.publicName, `%${q}%`), + ]; + if (Number.isFinite(idExact) && String(idExact) === q) { + conditions.push(eq(ItemsBase.id, idExact)); + } + + const rows = await db + .select({ + id: ItemsBase.id, + itemName: ItemsBase.itemName, + publicName: ItemsBase.publicName, + type: ItemsBase.type, + spriteId: ItemsBase.spriteId, + }) + .from(ItemsBase) + .where(or(...conditions)) + .orderBy(asc(ItemsBase.id)) + .limit(limit); return apiOk({ results: rows.map((r) => ({ diff --git a/src/app/api/admin/import/clone/sync-all/route.ts b/src/app/api/admin/import/clone/sync-all/route.ts index bcc9d8b62f..3d6376a694 100644 --- a/src/app/api/admin/import/clone/sync-all/route.ts +++ b/src/app/api/admin/import/clone/sync-all/route.ts @@ -1,6 +1,7 @@ +import { inArray } from "drizzle-orm"; import { withAdmin } from "@/lib/api-handler"; +import { db, ItemsBase } from "@/lib/db"; import { PERMS } from "@/lib/permissions"; -import { prisma } from "@/lib/prisma"; import { logAudit } from "@/lib/services/audit"; import { cloneSingleFurni, @@ -51,11 +52,10 @@ export const POST = withAdmin( const classnames = entries.map((e) => e.classname); if (classnames.length === 0) continue; - const placeholders = classnames.map(() => "?").join(","); - const rows = await prisma.$queryRawUnsafe>( - `SELECT item_name FROM items_base WHERE item_name IN (${placeholders})`, - ...classnames, - ); + const rows = await db + .select({ item_name: ItemsBase.itemName }) + .from(ItemsBase) + .where(inArray(ItemsBase.itemName, classnames)); const have = new Set(rows.map((r) => r.item_name)); for (const entry of entries) { diff --git a/src/app/api/admin/import/furni/batch-regen/route.ts b/src/app/api/admin/import/furni/batch-regen/route.ts index 89c732dfa3..68e78cbd07 100644 --- a/src/app/api/admin/import/furni/batch-regen/route.ts +++ b/src/app/api/admin/import/furni/batch-regen/route.ts @@ -1,9 +1,10 @@ import { existsSync, promises as fs } from "node:fs"; import path from "node:path"; +import { eq } from "drizzle-orm"; import { withAdmin } from "@/lib/api-handler"; import { apiError } from "@/lib/api-response"; +import { db, ItemsBase } from "@/lib/db"; import { PERMS } from "@/lib/permissions"; -import { prisma } from "@/lib/prisma"; import { getFurniAssetDirs } from "@/lib/services/furni-asset-dirs"; import { appendFurniEntriesBatch, @@ -120,15 +121,16 @@ export const POST = withAdmin( // Try spriteId as fallback revision const revsToTry: number[] = []; if (rev > 0) revsToTry.push(rev); - const dbItem = await prisma.itemsBase.findFirst({ - where: { itemName: item.classname }, - select: { - id: true, - spriteId: true, - publicName: true, - type: true, - }, - }); + const [dbItem] = await db + .select({ + id: ItemsBase.id, + spriteId: ItemsBase.spriteId, + publicName: ItemsBase.publicName, + type: ItemsBase.type, + }) + .from(ItemsBase) + .where(eq(ItemsBase.itemName, item.classname)) + .limit(1); if (dbItem?.spriteId && !revsToTry.includes(dbItem.spriteId)) { revsToTry.push(dbItem.spriteId); } diff --git a/src/app/api/admin/import/furni/nitro-editor/route.ts b/src/app/api/admin/import/furni/nitro-editor/route.ts index cceda03c5d..c69031162c 100644 --- a/src/app/api/admin/import/furni/nitro-editor/route.ts +++ b/src/app/api/admin/import/furni/nitro-editor/route.ts @@ -1,10 +1,11 @@ import { existsSync, promises as fs } from "node:fs"; import path from "node:path"; +import { eq, sql } from "drizzle-orm"; import type { NextRequest } from "next/server"; import { withAdmin } from "@/lib/api-handler"; import { apiError, apiOk } from "@/lib/api-response"; +import { db, ItemsBase } from "@/lib/db"; import { PERMS } from "@/lib/permissions"; -import { prisma } from "@/lib/prisma"; import { logAudit } from "@/lib/services/audit"; import { getFurniAssetDirs } from "@/lib/services/furni-asset-dirs"; import { @@ -47,16 +48,17 @@ export const GET = withAdmin( const { json } = parseNitroBundle(buffer); // Fetch interaction flags from DB - const items = await prisma.$queryRaw< + const [items] = (await db.execute(sql` + SELECT allow_sit, allow_lay, allow_walk + FROM items_base WHERE item_name = ${rawClassname} LIMIT 1 + `)) as unknown as [ Array<{ allow_sit: string; allow_lay: string; allow_walk: string; - }> - >` - SELECT allow_sit, allow_lay, allow_walk - FROM items_base WHERE item_name = ${rawClassname} LIMIT 1 - `; + }>, + unknown, + ]; const dbRow = items[0]; const flags = { @@ -153,14 +155,14 @@ export const PUT = withAdmin( // 5. Update items_base (best-effort) try { - await prisma.$executeRaw` + await db.execute(sql` UPDATE items_base SET width = ${dims.x}, length = ${dims.y}, stack_height = ${dims.z}, allow_sit = ${cansiton ? "1" : "0"}, allow_lay = ${canlayon ? "1" : "0"}, allow_walk = ${canstandon ? "1" : "0"} WHERE item_name = ${rawClassname} - `; + `); } catch (err) { console.warn( "[nitro-editor] items_base update failed:", @@ -180,10 +182,11 @@ export const PUT = withAdmin( // 7. Audit log try { - const dbItem = await prisma.itemsBase.findFirst({ - where: { itemName: rawClassname }, - select: { id: true }, - }); + const [dbItem] = await db + .select({ id: ItemsBase.id }) + .from(ItemsBase) + .where(eq(ItemsBase.itemName, rawClassname)) + .limit(1); await logAudit({ userId: ctx.session.user.id, action: "furni_edit_nitro", diff --git a/src/app/api/admin/import/furni/resync/route.ts b/src/app/api/admin/import/furni/resync/route.ts index 4a105822e2..79998c4eb7 100644 --- a/src/app/api/admin/import/furni/resync/route.ts +++ b/src/app/api/admin/import/furni/resync/route.ts @@ -1,8 +1,9 @@ import { existsSync } from "node:fs"; +import { asc, inArray, sql } from "drizzle-orm"; import { withAdmin } from "@/lib/api-handler"; import { apiOk } from "@/lib/api-response"; +import { db, ItemsBase } from "@/lib/db"; import { PERMS } from "@/lib/permissions"; -import { prisma } from "@/lib/prisma"; import { appendFurniEntriesBatch, buildFurniEntry, @@ -64,19 +65,19 @@ export const POST = withAdmin( for (let i = 0; i < targetIds.length; i += CHUNK) { const slice = targetIds.slice(i, i + CHUNK); - const rows = await prisma.itemsBase.findMany({ - where: { id: { in: slice } }, - select: { - id: true, - spriteId: true, - itemName: true, - publicName: true, - type: true, - width: true, - length: true, - stackHeight: true, - }, - }); + const rows = await db + .select({ + id: ItemsBase.id, + spriteId: ItemsBase.spriteId, + itemName: ItemsBase.itemName, + publicName: ItemsBase.publicName, + type: ItemsBase.type, + width: ItemsBase.width, + length: ItemsBase.length, + stackHeight: ItemsBase.stackHeight, + }) + .from(ItemsBase) + .where(inArray(ItemsBase.id, slice)); const entries: Array<{ entry: Record; @@ -158,20 +159,20 @@ async function resolveTargetIds( days: number, ): Promise { if (mode === "all") { - const rows = await prisma.itemsBase.findMany({ - select: { id: true }, - orderBy: { id: "asc" }, - }); + const rows = await db + .select({ id: ItemsBase.id }) + .from(ItemsBase) + .orderBy(asc(ItemsBase.id)); return rows.map((r) => r.id); } if (mode === "missing" || mode === "broken") { const furniDataPath = await getFurnitureDataPath(); if (!existsSync(furniDataPath)) { - const rows = await prisma.itemsBase.findMany({ - select: { id: true }, - orderBy: { id: "asc" }, - }); + const rows = await db + .select({ id: ItemsBase.id }) + .from(ItemsBase) + .orderBy(asc(ItemsBase.id)); return rows.map((r) => r.id); } const furniData = (await readFurniData()) as { @@ -184,16 +185,16 @@ async function resolveTargetIds( if (typeof e?.classname === "string") byClassname.set(e.classname, e); } } - const rows = await prisma.itemsBase.findMany({ - select: { - id: true, - itemName: true, - spriteId: true, - width: true, - length: true, - }, - orderBy: { id: "asc" }, - }); + const rows = await db + .select({ + id: ItemsBase.id, + itemName: ItemsBase.itemName, + spriteId: ItemsBase.spriteId, + width: ItemsBase.width, + length: ItemsBase.length, + }) + .from(ItemsBase) + .orderBy(asc(ItemsBase.id)); if (mode === "missing") { return rows.filter((r) => !byClassname.has(r.itemName)).map((r) => r.id); @@ -220,7 +221,7 @@ async function resolveTargetIds( Date.now() - days * 24 * 60 * 60 * 1000, ).toISOString(); const sinceMysql = sinceIso.slice(0, 19).replace("T", " "); - const auditRows = await prisma.$queryRaw>` + const [auditRows] = (await db.execute(sql` SELECT DISTINCT target_id FROM admin_audit_log WHERE action = 'furni_import' @@ -228,6 +229,6 @@ async function resolveTargetIds( AND target_id IS NOT NULL AND created_at >= ${sinceMysql} ORDER BY target_id ASC - `; + `)) as unknown as [Array<{ target_id: number }>, unknown]; return auditRows.map((r) => r.target_id).filter(Boolean); } diff --git a/src/app/api/admin/import/furni/route.ts b/src/app/api/admin/import/furni/route.ts index e1a62ae1d6..99a732ae90 100644 --- a/src/app/api/admin/import/furni/route.ts +++ b/src/app/api/admin/import/furni/route.ts @@ -1,9 +1,10 @@ import { existsSync, promises as fs } from "node:fs"; import path from "node:path"; +import { and, eq, inArray, sql } from "drizzle-orm"; import { withAdmin } from "@/lib/api-handler"; import { apiError, apiOk } from "@/lib/api-response"; +import { CatalogPages, db, ItemsBase } from "@/lib/db"; import { PERMS } from "@/lib/permissions"; -import { prisma } from "@/lib/prisma"; import { logAudit } from "@/lib/services/audit"; import { getFurniAssetDirs } from "@/lib/services/furni-asset-dirs"; import { @@ -97,29 +98,32 @@ export const GET = withAdmin( if (action === "stats") { const [totalResult, catalogResult, nitroMissingResult] = await Promise.all([ - prisma.$queryRaw< - [{ cnt: bigint }] - >`SELECT COUNT(*) as cnt FROM items_base`, - prisma.$queryRaw< - [{ cnt: bigint }] - >`SELECT COUNT(DISTINCT item_ids) as cnt FROM catalog_items`, - prisma.$queryRaw< - [{ cnt: bigint }] - >`SELECT COUNT(*) as cnt FROM items_base WHERE item_name NOT IN (SELECT catalog_name FROM catalog_items)`, + db.execute(sql`SELECT COUNT(*) as cnt FROM items_base`), + db.execute( + sql`SELECT COUNT(DISTINCT item_ids) as cnt FROM catalog_items`, + ), + db.execute( + sql`SELECT COUNT(*) as cnt FROM items_base WHERE item_name NOT IN (SELECT catalog_name FROM catalog_items)`, + ), ]); - const totalInDb = Number(totalResult[0].cnt); - const inCatalog = Number(catalogResult[0].cnt); - const notInCatalog = Number(nitroMissingResult[0].cnt); + const totalInDb = Number( + (totalResult as unknown as [[{ cnt: bigint }], unknown])[0][0].cnt, + ); + const inCatalog = Number( + (catalogResult as unknown as [[{ cnt: bigint }], unknown])[0][0].cnt, + ); + const notInCatalog = Number( + (nitroMissingResult as unknown as [[{ cnt: bigint }], unknown])[0][0] + .cnt, + ); // Count missing .nitro files by checking filesystem - const importedItems = await prisma.$queryRaw< - Array<{ item_name: string }> - >` + const [importedItems] = (await db.execute(sql` SELECT ib.item_name FROM items_base ib INNER JOIN catalog_items ci ON ci.catalog_name = ib.item_name LIMIT 10000 - `; + `)) as unknown as [Array<{ item_name: string }>, unknown]; let missingNitro = 0; for (const item of importedItems) { @@ -146,17 +150,18 @@ export const GET = withAdmin( ); const perPage = 50; - const importedItems = await prisma.$queryRaw< + const [importedItems] = (await db.execute(sql` + SELECT ib.item_name, ib.public_name, ib.sprite_id, ib.type FROM items_base ib + INNER JOIN catalog_items ci ON ci.catalog_name = ib.item_name + `)) as unknown as [ Array<{ item_name: string; public_name: string; sprite_id: number; type: string; - }> - >` - SELECT ib.item_name, ib.public_name, ib.sprite_id, ib.type FROM items_base ib - INNER JOIN catalog_items ci ON ci.catalog_name = ib.item_name - `; + }>, + unknown, + ]; // Filter to only those missing .nitro on filesystem const missing = importedItems.filter((item) => { @@ -209,9 +214,9 @@ export const GET = withAdmin( // Pull every imported classname once so we can both (a) sort non-imported // items to the top *before* pagination and (b) skip the per-page lookup // below. Single-column indexed query — fast even at 10k+ rows. - const allImported = await prisma.itemsBase.findMany({ - select: { itemName: true }, - }); + const allImported = await db + .select({ itemName: ItemsBase.itemName }) + .from(ItemsBase); const importedSet = new Set( allImported.map((e) => e.itemName).filter((n): n is string => !!n), ); @@ -442,10 +447,11 @@ export const PATCH = withAdmin( // Download SWF if not present — try multiple revision candidates if (!existsSync(swfPath)) { // Build list of revisions to try: known rev, spriteId, common fallbacks - const spriteId = await prisma.itemsBase.findFirst({ - where: { itemName: item.classname }, - select: { spriteId: true }, - }); + const [spriteId] = await db + .select({ spriteId: ItemsBase.spriteId }) + .from(ItemsBase) + .where(eq(ItemsBase.itemName, item.classname)) + .limit(1); const revsToTry = [ ...new Set([rev, spriteId?.spriteId || 0].filter((r) => r > 0)), ]; @@ -500,15 +506,16 @@ export const PATCH = withAdmin( ); if (!alreadyInFD) { - const dbItem = await prisma.itemsBase.findFirst({ - where: { itemName: item.classname }, - select: { - id: true, - spriteId: true, - publicName: true, - type: true, - }, - }); + const [dbItem] = await db + .select({ + id: ItemsBase.id, + spriteId: ItemsBase.spriteId, + publicName: ItemsBase.publicName, + type: ItemsBase.type, + }) + .from(ItemsBase) + .where(eq(ItemsBase.itemName, item.classname)) + .limit(1); const furnidata = await getHabboItFurnidata(); const fd = furnidata.get(item.classname) || furnidata.get(baseClassname); @@ -560,10 +567,11 @@ export const PUT = withAdmin( const isDryRun = request.nextUrl.searchParams.get("dryrun") === "1"; // 1. Find the root "imported_furni" page - const parentPage = await prisma.catalogPages.findFirst({ - where: { captionSave: IMPORTED_PAGE_CAPTION_SAVE }, - select: { id: true }, - }); + const [parentPage] = await db + .select({ id: CatalogPages.id }) + .from(CatalogPages) + .where(eq(CatalogPages.captionSave, IMPORTED_PAGE_CAPTION_SAVE)) + .limit(1); if (!parentPage) { return apiOk({ dryRun: isDryRun, @@ -574,10 +582,13 @@ export const PUT = withAdmin( } // 2. Collect ALL imported page IDs (parent + every imp_* sub-page) - const subPages = await prisma.catalogPages.findMany({ - where: { parentId: parentPage.id }, - select: { id: true, captionSave: true }, - }); + const subPages = await db + .select({ + id: CatalogPages.id, + captionSave: CatalogPages.captionSave, + }) + .from(CatalogPages) + .where(eq(CatalogPages.parentId, parentPage.id)); const allImportedPageIds = [ String(parentPage.id), ...subPages.map((p) => String(p.id)), @@ -585,19 +596,23 @@ export const PUT = withAdmin( // 3. Find ALL catalog_items across all imported pages // page_id is VARCHAR in DB so we use string placeholders - const inPlaceholders = allImportedPageIds.map(() => "?").join(","); - const allItems = await prisma.$queryRawUnsafe< + const [allItems] = (await db.execute(sql` + SELECT id, item_ids, catalog_name, page_id, cost_credits + FROM catalog_items + WHERE CAST(page_id AS CHAR) IN (${sql.join( + allImportedPageIds.map((id) => sql`${id}`), + sql`, `, + )}) + `)) as unknown as [ Array<{ id: number; item_ids: string; catalog_name: string; page_id: string; cost_credits: number; - }> - >( - `SELECT id, item_ids, catalog_name, page_id, cost_credits FROM catalog_items WHERE page_id IN (${inPlaceholders})`, - ...allImportedPageIds, - ); + }>, + unknown, + ]; if (allItems.length === 0) { return apiOk({ @@ -614,10 +629,14 @@ export const PUT = withAdmin( ].filter((n) => !Number.isNaN(n)); const dbItems = uniqueItemIds.length > 0 - ? await prisma.itemsBase.findMany({ - where: { id: { in: uniqueItemIds } }, - select: { id: true, itemName: true, type: true }, - }) + ? await db + .select({ + id: ItemsBase.id, + itemName: ItemsBase.itemName, + type: ItemsBase.type, + }) + .from(ItemsBase) + .where(inArray(ItemsBase.id, uniqueItemIds)) : []; const typeMap = new Map(); for (const di of dbItems) { @@ -641,14 +660,20 @@ export const PUT = withAdmin( // Create the page (only happens once per new category) const catInfo = CATEGORY_PAGE[catKey] || CATEGORY_PAGE.other; const parentId = parentPage?.id; - await prisma.$executeRaw` + await db.execute(sql` INSERT INTO catalog_pages (caption_save, caption, page_layout, parent_id, min_rank, order_num, icon_image, enabled, visible, includes, page_headline, page_teaser, page_special, page_text1, page_text2, page_text_details, page_text_teaser) VALUES (${captionSave}, ${catInfo.label}, 'default_3x3', ${parentId}, '1', ${catInfo.order}, ${catInfo.icon}, '1', '1', '', '', '', '', '', '', '', '') - `; - const created = await prisma.catalogPages.findFirst({ - where: { captionSave, parentId }, - select: { id: true }, - }); + `); + const [created] = await db + .select({ id: CatalogPages.id }) + .from(CatalogPages) + .where( + and( + eq(CatalogPages.captionSave, captionSave), + eq(CatalogPages.parentId, parentId!), + ), + ) + .limit(1); categoryPageMap.set(captionSave, created?.id); return String(created?.id); } @@ -719,15 +744,17 @@ export const PUT = withAdmin( for (const [batchKey, ids] of updateBatches) { const [pageId, credits, points, pointsType] = batchKey.split("|"); try { - const idPlaceholders = ids.map(() => "?").join(","); - await prisma.$queryRawUnsafe( - `UPDATE catalog_items SET page_id = ?, cost_credits = ?, cost_points = ?, points_type = ? WHERE id IN (${idPlaceholders})`, - pageId, - Number(credits), - Number(points), - Number(pointsType), - ...ids, - ); + await db.execute(sql` + UPDATE catalog_items + SET page_id = ${pageId}, + cost_credits = ${Number(credits)}, + cost_points = ${Number(points)}, + points_type = ${Number(pointsType)} + WHERE id IN (${sql.join( + ids.map((id) => sql`${id}`), + sql`, `, + )}) + `); } catch (err) { errors.push(`batch update: ${(err as Error).message}`); } @@ -741,15 +768,14 @@ export const PUT = withAdmin( const subPageIdStr = String(subPage.id); if (!pagesWithItems.has(subPageIdStr)) { try { - const remaining = await prisma.$queryRawUnsafe< - Array<{ cnt: bigint }> - >( - `SELECT COUNT(*) as cnt FROM catalog_items WHERE page_id = ?`, - subPageIdStr, - ); + const [remaining] = (await db.execute( + sql`SELECT COUNT(*) as cnt FROM catalog_items WHERE CAST(page_id AS CHAR) = ${subPageIdStr}`, + )) as unknown as [Array<{ cnt: bigint }>, unknown]; const count = Number(remaining[0]?.cnt ?? 0); if (count === 0) { - await prisma.$executeRaw`DELETE FROM catalog_pages WHERE id = ${subPage.id}`; + await db.execute( + sql`DELETE FROM catalog_pages WHERE id = ${subPage.id}`, + ); pagesDeleted++; } } catch (err) { diff --git a/src/app/api/admin/permissions/assignments/route.ts b/src/app/api/admin/permissions/assignments/route.ts index b92cc51df8..c6b989bd5c 100644 --- a/src/app/api/admin/permissions/assignments/route.ts +++ b/src/app/api/admin/permissions/assignments/route.ts @@ -1,9 +1,10 @@ +import { and, eq } from "drizzle-orm"; import { revalidateTag } from "next/cache"; import { NextResponse } from "next/server"; import { withAdmin } from "@/lib/api-handler"; import { apiError } from "@/lib/api-response"; +import { AclModelRole, db } from "@/lib/db"; import { PERMS } from "@/lib/permissions"; -import { prisma } from "@/lib/prisma"; export const POST = withAdmin( { permission: PERMS.PERMISSIONS_MANAGE }, @@ -20,16 +21,32 @@ export const POST = withAdmin( return apiError("Valid userId and roleId required", 400); } if (body.action === "remove") { - await prisma.aclModelRole.deleteMany({ - where: { modelType: "User", modelId: userId, roleId }, - }); + await db + .delete(AclModelRole) + .where( + and( + eq(AclModelRole.modelType, "User"), + eq(AclModelRole.modelId, userId), + eq(AclModelRole.roleId, roleId), + ), + ); } else { - const existing = await prisma.aclModelRole.findFirst({ - where: { modelType: "User", modelId: userId, roleId }, - }); + const [existing] = await db + .select({ modelId: AclModelRole.modelId }) + .from(AclModelRole) + .where( + and( + eq(AclModelRole.modelType, "User"), + eq(AclModelRole.modelId, userId), + eq(AclModelRole.roleId, roleId), + ), + ) + .limit(1); if (!existing) - await prisma.aclModelRole.create({ - data: { modelType: "User", modelId: userId, roleId }, + await db.insert(AclModelRole).values({ + modelType: "User", + modelId: userId, + roleId, }); } revalidateTag("permissions", { expire: 0 }); diff --git a/src/app/api/admin/permissions/roles/route.ts b/src/app/api/admin/permissions/roles/route.ts index 869b5e6d81..7cac828e09 100644 --- a/src/app/api/admin/permissions/roles/route.ts +++ b/src/app/api/admin/permissions/roles/route.ts @@ -1,9 +1,11 @@ +import { and, eq } from "drizzle-orm"; +import type { ResultSetHeader } from "mysql2"; import { revalidateTag } from "next/cache"; import { NextResponse } from "next/server"; import { withAdmin } from "@/lib/api-handler"; import { apiError } from "@/lib/api-response"; +import { AclModelPermission, AclModelRole, AclRole, db } from "@/lib/db"; import { PERMS } from "@/lib/permissions"; -import { prisma } from "@/lib/prisma"; export const POST = withAdmin( { permission: PERMS.PERMISSIONS_MANAGE }, @@ -15,7 +17,10 @@ export const POST = withAdmin( const title = String(body.title ?? "").trim(); if (!/^[a-z0-9._-]{2,64}$/.test(slug) || !title) return apiError("Valid slug and title required", 400); - const role = await prisma.aclRole.create({ data: { slug, title } }); + const [result] = (await db + .insert(AclRole) + .values({ slug, title })) as unknown as [ResultSetHeader]; + const role = { id: Number(result.insertId), slug, title }; revalidateTag("permissions", { expire: 0 }); return NextResponse.json({ role }); }, @@ -27,20 +32,26 @@ export const DELETE = withAdmin( const id = Number(request.nextUrl.searchParams.get("id")); if (!Number.isInteger(id) || id <= 0) return apiError("Valid ID required", 400); - const role = await prisma.aclRole.findUnique({ - where: { id }, - select: { slug: true }, - }); + const [role] = await db + .select({ slug: AclRole.slug }) + .from(AclRole) + .where(eq(AclRole.id, id)) + .limit(1); if (!role) return apiError("Role not found", 404); if (role.slug.startsWith("rank_")) return apiError("Rank roles must be managed through emulator ranks", 409); - await prisma.$transaction([ - prisma.aclModelPermission.deleteMany({ - where: { modelType: "Role", modelId: id }, - }), - prisma.aclModelRole.deleteMany({ where: { roleId: id } }), - prisma.aclRole.delete({ where: { id } }), - ]); + await db.transaction(async (tx) => { + await tx + .delete(AclModelPermission) + .where( + and( + eq(AclModelPermission.modelType, "Role"), + eq(AclModelPermission.modelId, id), + ), + ); + await tx.delete(AclModelRole).where(eq(AclModelRole.roleId, id)); + await tx.delete(AclRole).where(eq(AclRole.id, id)); + }); revalidateTag("permissions", { expire: 0 }); return NextResponse.json({ success: true }); }, diff --git a/src/app/api/admin/prefixes/blacklist/route.ts b/src/app/api/admin/prefixes/blacklist/route.ts index c755d21205..9c2b01dda5 100644 --- a/src/app/api/admin/prefixes/blacklist/route.ts +++ b/src/app/api/admin/prefixes/blacklist/route.ts @@ -1,7 +1,8 @@ +import { sql } from "drizzle-orm"; import { withAdmin } from "@/lib/api-handler"; import { apiError, apiOk } from "@/lib/api-response"; +import { db } from "@/lib/db"; import { PERMS } from "@/lib/permissions"; -import { prisma } from "@/lib/prisma"; interface BlacklistWord { id: number; @@ -9,9 +10,9 @@ interface BlacklistWord { } export const GET = withAdmin({ permission: PERMS.PREFIXES_VIEW }, async () => { - const words = await prisma.$queryRaw` - SELECT id, word FROM custom_prefix_blacklist ORDER BY word ASC - `; + const [words] = (await db.execute( + sql`SELECT id, word FROM custom_prefix_blacklist ORDER BY word ASC`, + )) as unknown as [BlacklistWord[], unknown]; return apiOk({ words: words.map((w) => ({ ...w, created_at: "" })), @@ -26,7 +27,9 @@ export const POST = withAdmin( if (!word) return apiError("Word is required"); - await prisma.$executeRaw`INSERT INTO custom_prefix_blacklist (word) VALUES (${word})`; + await db.execute( + sql`INSERT INTO custom_prefix_blacklist (word) VALUES (${word})`, + ); return apiOk(); }, @@ -41,7 +44,7 @@ export const DELETE = withAdmin( if (!Number.isInteger(id) || id <= 0) return apiError("Missing or invalid word id"); - await prisma.$executeRaw`DELETE FROM custom_prefix_blacklist WHERE id = ${id}`; + await db.execute(sql`DELETE FROM custom_prefix_blacklist WHERE id = ${id}`); return apiOk({ deleted: id }); }, diff --git a/src/app/api/admin/prefixes/route.ts b/src/app/api/admin/prefixes/route.ts index b008d00ac9..5a1cd2526d 100644 --- a/src/app/api/admin/prefixes/route.ts +++ b/src/app/api/admin/prefixes/route.ts @@ -1,7 +1,8 @@ +import { sql } from "drizzle-orm"; import { withAdmin } from "@/lib/api-handler"; import { apiError, apiOk } from "@/lib/api-response"; +import { db } from "@/lib/db"; import { PERMS } from "@/lib/permissions"; -import { Prisma, prisma } from "@/lib/prisma"; interface UserPrefix { id: number; @@ -24,23 +25,24 @@ export const GET = withAdmin( const offset = (page - 1) * limit; const whereFragment = q - ? Prisma.sql`WHERE up.text LIKE ${`%${q}%`} OR u.username LIKE ${`%${q}%`}` - : Prisma.empty; + ? sql`WHERE up.text LIKE ${`%${q}%`} OR u.username LIKE ${`%${q}%`}` + : sql``; - const prefixes = await prisma.$queryRaw( - Prisma.sql`SELECT up.id, up.user_id, up.text, up.color, up.icon, up.effect, up.active, u.username + const [prefixes] = + (await db.execute(sql`SELECT up.id, up.user_id, up.text, up.color, up.icon, up.effect, up.active, u.username FROM custom_prefixes up LEFT JOIN users u ON u.id = up.user_id ${whereFragment} ORDER BY up.id DESC - LIMIT ${limit} OFFSET ${offset}`, - ); + LIMIT ${limit} OFFSET ${offset}`)) as unknown as [ + UserPrefix[], + unknown, + ]; - const countResult = await prisma.$queryRaw<[{ total: bigint }]>( - Prisma.sql`SELECT COUNT(*) as total FROM custom_prefixes up + const [countResult] = + (await db.execute(sql`SELECT COUNT(*) as total FROM custom_prefixes up LEFT JOIN users u ON u.id = up.user_id - ${whereFragment}`, - ); + ${whereFragment}`)) as unknown as [[{ total: bigint }], unknown]; const total = Number(countResult[0]?.total || 0); @@ -69,9 +71,9 @@ export const POST = withAdmin( return apiError("Missing required fields: username, text, color"); } - const users = await prisma.$queryRaw<{ id: number }[]>( - Prisma.sql`SELECT id FROM users WHERE username = ${username} LIMIT 1`, - ); + const [users] = (await db.execute( + sql`SELECT id FROM users WHERE username = ${username} LIMIT 1`, + )) as unknown as [{ id: number }[], unknown]; if (!users || users.length === 0) { return apiError("User not found"); @@ -79,8 +81,8 @@ export const POST = withAdmin( const userId = users[0].id; - await prisma.$executeRaw( - Prisma.sql`INSERT INTO custom_prefixes (user_id, text, color, icon, effect, active) + await db.execute( + sql`INSERT INTO custom_prefixes (user_id, text, color, icon, effect, active) VALUES (${userId}, ${text}, ${color}, ${icon || ""}, ${effect || ""}, ${active ? 1 : 0})`, ); @@ -96,8 +98,8 @@ export const PUT = withAdmin( if (!id) return apiError("Missing prefix id"); - await prisma.$executeRaw( - Prisma.sql`UPDATE custom_prefixes + await db.execute( + sql`UPDATE custom_prefixes SET text = ${text}, color = ${color}, icon = ${icon || ""}, effect = ${effect || ""}, active = ${active ? 1 : 0} WHERE id = ${id}`, ); @@ -114,9 +116,7 @@ export const DELETE = withAdmin( if (!id) return apiError("Missing prefix id"); - await prisma.$executeRaw( - Prisma.sql`DELETE FROM custom_prefixes WHERE id = ${id}`, - ); + await db.execute(sql`DELETE FROM custom_prefixes WHERE id = ${id}`); return apiOk({ deleted: id }); }, diff --git a/src/app/api/admin/prefixes/settings/route.ts b/src/app/api/admin/prefixes/settings/route.ts index 101f37bac9..d95238cd96 100644 --- a/src/app/api/admin/prefixes/settings/route.ts +++ b/src/app/api/admin/prefixes/settings/route.ts @@ -1,7 +1,8 @@ +import { sql } from "drizzle-orm"; import { withAdmin } from "@/lib/api-handler"; import { apiError, apiOk } from "@/lib/api-response"; +import { db } from "@/lib/db"; import { PERMS } from "@/lib/permissions"; -import { prisma } from "@/lib/prisma"; interface PrefixSetting { key: string; @@ -31,9 +32,9 @@ const DEFAULT_SETTINGS: Record = { }; export const GET = withAdmin({ permission: PERMS.PREFIXES_VIEW }, async () => { - const settings = await prisma.$queryRaw` - SELECT \`key\`, \`value\` FROM custom_prefix_settings - `; + const [settings] = (await db.execute( + sql`SELECT \`key\`, \`value\` FROM custom_prefix_settings`, + )) as unknown as [PrefixSetting[], unknown]; const result: Record = { ...DEFAULT_SETTINGS }; for (const s of settings) { @@ -58,11 +59,11 @@ export const PUT = withAdmin( return apiError(`Invalid setting key: ${key}`); } const strValue = String(value); - await prisma.$executeRaw` + await db.execute(sql` INSERT INTO custom_prefix_settings (\`key\`, \`value\`) VALUES (${key}, ${strValue}) ON DUPLICATE KEY UPDATE \`value\` = ${strValue} - `; + `); } return apiOk(); diff --git a/src/app/api/admin/users/actions/route.ts b/src/app/api/admin/users/actions/route.ts index 2945a06e20..551aed09eb 100644 --- a/src/app/api/admin/users/actions/route.ts +++ b/src/app/api/admin/users/actions/route.ts @@ -1,7 +1,8 @@ +import { eq, sql } from "drizzle-orm"; import { NextResponse } from "next/server"; import { withAdmin } from "@/lib/api-handler"; +import { 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"; @@ -31,9 +32,14 @@ export const POST = withAdmin( ); } - const rankExists = await prisma.$queryRaw<{ id: number }[]>` - SELECT id FROM permission_ranks WHERE id = ${rank} LIMIT 1 - `.catch(() => [] as { id: number }[]); + const [rankExists] = (await db + .execute( + sql`SELECT id FROM permission_ranks WHERE id = ${rank} LIMIT 1`, + ) + .catch(() => [[]] as unknown as [unknown[], unknown])) as unknown as [ + { id: number }[], + unknown, + ]; if (rankExists.length === 0) { return NextResponse.json( { success: false, message: "Rank does not exist" }, @@ -41,10 +47,11 @@ export const POST = withAdmin( ); } - const target = await prisma.user.findUnique({ - where: { id: userId }, - select: { rank: true }, - }); + const [target] = await db + .select({ rank: User.rank }) + .from(User) + .where(eq(User.id, userId)) + .limit(1); if (!target) { return NextResponse.json( { success: false, message: "User not found" }, @@ -74,7 +81,7 @@ export const POST = withAdmin( } } - await prisma.user.update({ where: { id: userId }, data: { rank } }); + await db.update(User).set({ rank }).where(eq(User.id, userId)); await rcon.setRank(userId, rank); await logStaffActivity({ staffId, diff --git a/src/app/api/articles/[slug]/comment/route.ts b/src/app/api/articles/[slug]/comment/route.ts index 7139e829cd..90fe51f4d8 100644 --- a/src/app/api/articles/[slug]/comment/route.ts +++ b/src/app/api/articles/[slug]/comment/route.ts @@ -6,9 +6,10 @@ // chars (matches the VARCHAR(255) column). Fails soft — never returns a 500 for // DB issues, just a generic error envelope. +import { eq } from "drizzle-orm"; import { apiError, apiJson } from "@/lib/api"; import { bearerUserId } from "@/lib/api-auth"; -import { prisma } from "@/lib/prisma"; +import { db, WebsiteArticleComments, WebsiteArticles } from "@/lib/db"; export const dynamic = "force-dynamic"; @@ -31,24 +32,22 @@ export async function POST( } try { - const article = await prisma.websiteArticles.findUnique({ - where: { slug }, - select: { id: true }, - }); + const [article] = await db + .select({ id: WebsiteArticles.id }) + .from(WebsiteArticles) + .where(eq(WebsiteArticles.slug, slug)) + .limit(1); if (!article) { return apiError("Article not found", 404); } const now = new Date(); - await prisma.websiteArticleComments.create({ - data: { - articleId: article.id, - userId: uid, - comment, - createdAt: now, - updatedAt: now, - }, - select: { id: true }, + await db.insert(WebsiteArticleComments).values({ + articleId: article.id, + userId: uid, + comment, + createdAt: now, + updatedAt: now, }); return apiJson({ ok: true }); diff --git a/src/app/api/articles/[slug]/route.ts b/src/app/api/articles/[slug]/route.ts index 22624f9ab6..3feac23f41 100644 --- a/src/app/api/articles/[slug]/route.ts +++ b/src/app/api/articles/[slug]/route.ts @@ -1,5 +1,6 @@ +import { eq } from "drizzle-orm"; import { apiJson } from "@/lib/api"; -import { prisma } from "@/lib/prisma"; +import { db, WebsiteArticles } from "@/lib/db"; export const dynamic = "force-dynamic"; @@ -14,19 +15,20 @@ export async function GET( const { slug } = await params; try { - const article = await prisma.websiteArticles.findUnique({ - where: { slug }, - select: { - id: true, - title: true, - slug: true, - shortStory: true, - fullStory: true, - image: true, - createdAt: true, - updatedAt: true, - }, - }); + const [article] = await db + .select({ + id: WebsiteArticles.id, + title: WebsiteArticles.title, + slug: WebsiteArticles.slug, + shortStory: WebsiteArticles.shortStory, + fullStory: WebsiteArticles.fullStory, + image: WebsiteArticles.image, + createdAt: WebsiteArticles.createdAt, + updatedAt: WebsiteArticles.updatedAt, + }) + .from(WebsiteArticles) + .where(eq(WebsiteArticles.slug, slug)) + .limit(1); if (!article) { return apiJson({ error: "Article not found" }, { status: 404 }); diff --git a/src/app/api/articles/route.ts b/src/app/api/articles/route.ts index eff39bcd48..0fcabb49b5 100644 --- a/src/app/api/articles/route.ts +++ b/src/app/api/articles/route.ts @@ -1,34 +1,36 @@ +import { count, desc } from "drizzle-orm"; import { apiJson, pagination } from "@/lib/api"; -import { prisma } from "@/lib/prisma"; +import { db, WebsiteArticles } from "@/lib/db"; export const dynamic = "force-dynamic"; /** * GET /api/articles — paginated list of website_articles, newest first. - * Mirrors the /news page query (prisma.websiteArticles). Returns list cards - * (shortStory only, never fullStory) plus pagination metadata. + * Mirrors the /news page query. Returns list cards (shortStory only, never + * fullStory) plus pagination metadata. */ export async function GET(req: Request) { const sp = new URL(req.url).searchParams; const { page, perPage, skip, take } = pagination(sp); try { - const [total, articles] = await Promise.all([ - prisma.websiteArticles.count(), - prisma.websiteArticles.findMany({ - select: { - id: true, - title: true, - slug: true, - shortStory: true, - image: true, - createdAt: true, - }, - orderBy: { createdAt: "desc" }, - skip, - take, - }), + const [totalRows, articles] = await Promise.all([ + db.select({ total: count() }).from(WebsiteArticles), + db + .select({ + id: WebsiteArticles.id, + title: WebsiteArticles.title, + slug: WebsiteArticles.slug, + shortStory: WebsiteArticles.shortStory, + image: WebsiteArticles.image, + createdAt: WebsiteArticles.createdAt, + }) + .from(WebsiteArticles) + .orderBy(desc(WebsiteArticles.createdAt)) + .limit(take) + .offset(skip), ]); + const total = totalRows[0]?.total ?? 0; return apiJson({ data: articles, diff --git a/src/app/api/badges/leaderboard/route.ts b/src/app/api/badges/leaderboard/route.ts index 8deedef504..e378a7adfd 100644 --- a/src/app/api/badges/leaderboard/route.ts +++ b/src/app/api/badges/leaderboard/route.ts @@ -1,8 +1,9 @@ +import { eq, sql } from "drizzle-orm"; import { apiJson } from "@/lib/api"; import { bearerUserId } from "@/lib/api-auth"; import { auth } from "@/lib/auth"; +import { db, User, UsersSettings } from "@/lib/db"; import { logger } from "@/lib/logger"; -import { Prisma, prisma } from "@/lib/prisma"; export const dynamic = "force-dynamic"; @@ -60,21 +61,27 @@ function rankEntries( .map((e, i) => ({ ...e, rank: i + 1 })); } +async function rawRows(query: ReturnType): Promise { + const [rows] = (await db.execute(query)) as unknown as [T[], unknown]; + return rows; +} + async function loadTotalBadgesBoard( userId: number | null, ): Promise { - const rows = await prisma.$queryRaw< - Array<{ userId: bigint; username: string; look: string; cnt: bigint }> - >( - Prisma.sql` + const rows = await rawRows<{ + userId: bigint; + username: string; + look: string; + cnt: bigint; + }>(sql` SELECT ub.user_id AS userId, u.username, u.look, COUNT(*) AS cnt FROM users_badges ub JOIN users u ON u.id = ub.user_id GROUP BY ub.user_id ORDER BY cnt DESC LIMIT 20 - `, - ); + `); const entries = rankEntries( rows.map((r) => ({ @@ -90,8 +97,8 @@ async function loadTotalBadgesBoard( entries.length > 0 ? Number( ( - await prisma.$queryRaw>( - Prisma.sql`SELECT COUNT(DISTINCT user_id) AS cnt FROM users_badges`, + await rawRows<{ cnt: bigint }>( + sql`SELECT COUNT(DISTINCT user_id) AS cnt FROM users_badges`, ) )[0]?.cnt ?? 0, ) @@ -99,8 +106,8 @@ async function loadTotalBadgesBoard( let viewerEntry: Partial | undefined; if (userId) { - const viewerRow = await prisma.$queryRaw>( - Prisma.sql`SELECT COUNT(*) AS cnt FROM users_badges WHERE user_id = ${userId}`, + const viewerRow = await rawRows<{ cnt: bigint }>( + sql`SELECT COUNT(*) AS cnt FROM users_badges WHERE user_id = ${userId}`, ); const viewerScore = Number(viewerRow[0]?.cnt ?? 0); if (viewerScore > 0) { @@ -108,20 +115,19 @@ async function loadTotalBadgesBoard( entries.length > 0 ? Number( ( - await prisma.$queryRaw>( - Prisma.sql` + await rawRows<{ cnt: bigint }>(sql` SELECT COUNT(*) + 1 AS cnt FROM (SELECT user_id, COUNT(*) AS total FROM users_badges GROUP BY user_id) t WHERE t.total > ${viewerScore} - `, - ) + `) )[0]?.cnt ?? entries.length + 1, ) : 1; - const viewerUser = await prisma.user.findUnique({ - where: { id: userId }, - select: { username: true, look: true }, - }); + const [viewerUser] = await db + .select({ username: User.username, look: User.look }) + .from(User) + .where(eq(User.id, userId)) + .limit(1); if (viewerUser) { viewerEntry = { userId, @@ -140,17 +146,18 @@ async function loadTotalBadgesBoard( async function loadAchievementBoard( userId: number | null, ): Promise { - const rows = await prisma.$queryRaw< - Array<{ userId: bigint; username: string; look: string; score: number }> - >( - Prisma.sql` + const rows = await rawRows<{ + userId: bigint; + username: string; + look: string; + score: number; + }>(sql` SELECT us.user_id AS userId, u.username, u.look, us.achievement_score AS score FROM users_settings us JOIN users u ON u.id = us.user_id ORDER BY us.achievement_score DESC LIMIT 20 - `, - ); + `); const entries = rankEntries( rows.map((r) => ({ @@ -166,8 +173,8 @@ async function loadAchievementBoard( entries.length > 0 ? Number( ( - await prisma.$queryRaw>( - Prisma.sql`SELECT COUNT(*) AS cnt FROM users_settings WHERE achievement_score > 0`, + await rawRows<{ cnt: bigint }>( + sql`SELECT COUNT(*) AS cnt FROM users_settings WHERE achievement_score > 0`, ) )[0]?.cnt ?? 0, ) @@ -175,23 +182,23 @@ async function loadAchievementBoard( let viewerEntry: Partial | undefined; if (userId) { - const viewerUser = await prisma.user.findUnique({ - where: { id: userId }, - select: { username: true, look: true }, - }); + const [viewerUser] = await db + .select({ username: User.username, look: User.look }) + .from(User) + .where(eq(User.id, userId)) + .limit(1); if (viewerUser) { - const viewerSettings = await prisma.usersSettings.findUnique({ - where: { userId }, - select: { achievementScore: true }, - }); + const [viewerSettings] = await db + .select({ achievementScore: UsersSettings.achievementScore }) + .from(UsersSettings) + .where(eq(UsersSettings.userId, userId)) + .limit(1); if (viewerSettings && viewerSettings.achievementScore > 0) { const viewerRank = Number( ( - await prisma.$queryRaw>( - Prisma.sql` + await rawRows<{ cnt: bigint }>(sql` SELECT COUNT(*) + 1 AS cnt FROM users_settings WHERE achievement_score > ${viewerSettings.achievementScore} - `, - ) + `) )[0]?.cnt ?? entries.length + 1, ); viewerEntry = { @@ -215,12 +222,17 @@ async function loadRarityBoard( ): Promise { if (badgeCodes.length === 0) return { entries: [], totalPlayers: 0 }; - const codes = Prisma.join(badgeCodes); + const codes = sql.join( + badgeCodes.map((c) => sql`${c}`), + sql`, `, + ); - const rows = await prisma.$queryRaw< - Array<{ userId: bigint; username: string; look: string; cnt: bigint }> - >( - Prisma.sql` + const rows = await rawRows<{ + userId: bigint; + username: string; + look: string; + cnt: bigint; + }>(sql` SELECT ub.user_id AS userId, u.username, u.look, COUNT(*) AS cnt FROM users_badges ub JOIN users u ON u.id = ub.user_id @@ -228,8 +240,7 @@ async function loadRarityBoard( GROUP BY ub.user_id ORDER BY cnt DESC LIMIT 20 - `, - ); + `); const entries = rankEntries( rows.map((r) => ({ @@ -245,39 +256,34 @@ async function loadRarityBoard( rows.length > 0 ? Number( ( - await prisma.$queryRaw>( - Prisma.sql` + await rawRows<{ cnt: bigint }>(sql` SELECT COUNT(DISTINCT user_id) AS cnt FROM users_badges WHERE badge_code IN (${codes}) - `, - ) + `) )[0]?.cnt ?? 0, ) : 0; let viewerEntry: Partial | undefined; if (userId) { - const viewerRow = await prisma.$queryRaw>( - Prisma.sql` + const viewerRow = await rawRows<{ cnt: bigint }>(sql` SELECT COUNT(*) AS cnt FROM users_badges WHERE user_id = ${userId} AND badge_code IN (${codes}) - `, - ); + `); const viewerScore = Number(viewerRow[0]?.cnt ?? 0); if (viewerScore > 0) { - const viewerUser = await prisma.user.findUnique({ - where: { id: userId }, - select: { username: true, look: true }, - }); + const [viewerUser] = await db + .select({ username: User.username, look: User.look }) + .from(User) + .where(eq(User.id, userId)) + .limit(1); if (viewerUser) { const viewerRank = Number( ( - await prisma.$queryRaw>( - Prisma.sql` + await rawRows<{ cnt: bigint }>(sql` SELECT COUNT(*) + 1 AS cnt FROM (SELECT user_id, COUNT(*) AS total FROM users_badges WHERE badge_code IN (${codes}) GROUP BY user_id) t WHERE t.total > ${viewerScore} - `, - ) + `) )[0]?.cnt ?? entries.length + 1, ); viewerEntry = { @@ -302,15 +308,14 @@ export async function GET(req: Request) { userId = session?.user?.id ? Number(session.user.id) : null; } - const badgeStatsRaw = await prisma.$queryRaw< - Array<{ badgeCode: string; ownerCount: bigint }> - >( - Prisma.sql` + const badgeStatsRaw = await rawRows<{ + badgeCode: string; + ownerCount: bigint; + }>(sql` SELECT badge_code AS badgeCode, COUNT(DISTINCT user_id) AS ownerCount FROM users_badges GROUP BY badge_code - `, - ); + `); const badgeStats: BadgeLeaderboardStat[] = badgeStatsRaw.map((r) => ({ badgeCode: r.badgeCode, diff --git a/src/app/api/guilds/[id]/route.ts b/src/app/api/guilds/[id]/route.ts index d635e137c4..0c5d6b20bd 100644 --- a/src/app/api/guilds/[id]/route.ts +++ b/src/app/api/guilds/[id]/route.ts @@ -2,8 +2,9 @@ // (guilds_members). AtomCMS JSON API parity. Returns { error } (404) when the // id is unknown. The owner is the user_id column (accessor userId), surfaced // here as ownerId. +import { count, eq } from "drizzle-orm"; import { apiJson } from "@/lib/api"; -import { prisma } from "@/lib/prisma"; +import { db, Guilds, GuildsMembers } from "@/lib/db"; export const dynamic = "force-dynamic"; @@ -19,18 +20,19 @@ export async function GET( const guildId = Number(id); try { - const guild = await prisma.guilds.findUnique({ - where: { id: guildId }, - select: { - id: true, - name: true, - description: true, - userId: true, - roomId: true, - badge: true, - dateCreated: true, - }, - }); + const [guild] = await db + .select({ + id: Guilds.id, + name: Guilds.name, + description: Guilds.description, + userId: Guilds.userId, + roomId: Guilds.roomId, + badge: Guilds.badge, + dateCreated: Guilds.dateCreated, + }) + .from(Guilds) + .where(eq(Guilds.id, guildId)) + .limit(1); if (!guild) { return apiJson({ error: "Guild not found" }, { status: 404 }); @@ -39,9 +41,11 @@ export async function GET( // Member count is a separate guarded query (no relation is modelled). let memberCount = 0; try { - memberCount = await prisma.guildsMembers.count({ - where: { guildId: guild.id }, - }); + const [row] = await db + .select({ total: count() }) + .from(GuildsMembers) + .where(eq(GuildsMembers.guildId, guild.id)); + memberCount = row?.total ?? 0; } catch { memberCount = 0; } diff --git a/src/app/api/guilds/route.ts b/src/app/api/guilds/route.ts index 06d39285d8..16b84ffbfe 100644 --- a/src/app/api/guilds/route.ts +++ b/src/app/api/guilds/route.ts @@ -2,8 +2,9 @@ // paginated list ordered newest first (by id), exposing the basic public // fields. The owner is stored as the user_id column (accessor userId), surfaced // here as ownerId for API clarity. +import { count, desc } from "drizzle-orm"; import { apiJson, pagination } from "@/lib/api"; -import { prisma } from "@/lib/prisma"; +import { db, Guilds } from "@/lib/db"; export const dynamic = "force-dynamic"; @@ -12,20 +13,21 @@ export async function GET(req: Request) { const { page, perPage, skip, take } = pagination(sp); try { - const [total, rows] = await Promise.all([ - prisma.guilds.count(), - prisma.guilds.findMany({ - orderBy: { id: "desc" }, - skip, - take, - select: { - id: true, - name: true, - description: true, - userId: true, - }, - }), + const [totalRows, rows] = await Promise.all([ + db.select({ total: count() }).from(Guilds), + db + .select({ + id: Guilds.id, + name: Guilds.name, + description: Guilds.description, + userId: Guilds.userId, + }) + .from(Guilds) + .orderBy(desc(Guilds.id)) + .limit(take) + .offset(skip), ]); + const total = totalRows[0]?.total ?? 0; // Rename userId → ownerId for the public payload. const data = rows.map(({ userId, ...rest }) => ({ diff --git a/src/app/api/health/route.ts b/src/app/api/health/route.ts index affe01e11c..1677988705 100644 --- a/src/app/api/health/route.ts +++ b/src/app/api/health/route.ts @@ -1,6 +1,7 @@ +import { sql } from "drizzle-orm"; import { env } from "@/env"; import { apiJson } from "@/lib/api"; -import { prisma } from "@/lib/prisma"; +import { db } from "@/lib/db"; import { redis } from "@/lib/redis"; import { rcon } from "@/lib/services/rcon"; @@ -13,7 +14,8 @@ export const dynamic = "force-dynamic"; * only care about reachability. */ export async function GET() { - const database = await prisma.$queryRaw`SELECT 1` + const database = await db + .execute(sql`SELECT 1`) .then(() => true) .catch(() => false); diff --git a/src/app/api/home/route.ts b/src/app/api/home/route.ts index d435e91968..8538f334cd 100644 --- a/src/app/api/home/route.ts +++ b/src/app/api/home/route.ts @@ -1,7 +1,8 @@ +import { count, desc, eq } from "drizzle-orm"; import { apiJson } from "@/lib/api"; import { FALLBACK_HOTEL_NAME } from "@/lib/brand"; +import { db, User, WebsiteArticles } from "@/lib/db"; import { resolveHotelName } from "@/lib/hotel-name"; -import { prisma } from "@/lib/prisma"; import { apiCacheKey, redisCache } from "@/lib/redis-cache"; export const dynamic = "force-dynamic"; @@ -14,23 +15,23 @@ export const dynamic = "force-dynamic"; export async function GET(_req: Request) { try { const data = await redisCache(apiCacheKey("home"), 15, async () => { - const [articles, online, hotelName] = await Promise.all([ - prisma.websiteArticles.findMany({ - select: { - id: true, - title: true, - slug: true, - shortStory: true, - image: true, - createdAt: true, - }, - orderBy: { createdAt: "desc" }, - take: 4, - }), - prisma.user.count({ where: { online: "1" } }), + const [articles, onlineRows, hotelName] = await Promise.all([ + db + .select({ + id: WebsiteArticles.id, + title: WebsiteArticles.title, + slug: WebsiteArticles.slug, + shortStory: WebsiteArticles.shortStory, + image: WebsiteArticles.image, + createdAt: WebsiteArticles.createdAt, + }) + .from(WebsiteArticles) + .orderBy(desc(WebsiteArticles.createdAt)) + .limit(4), + db.select({ total: count() }).from(User).where(eq(User.online, "1")), resolveHotelName(), ]); - return { articles, online, hotelName }; + return { articles, online: onlineRows[0]?.total ?? 0, hotelName }; }); return apiJson(data); } catch { diff --git a/src/app/api/leaderboard/route.ts b/src/app/api/leaderboard/route.ts index f9b4e736df..e533a299ad 100644 --- a/src/app/api/leaderboard/route.ts +++ b/src/app/api/leaderboard/route.ts @@ -1,8 +1,9 @@ +import { desc, eq, inArray } from "drizzle-orm"; import { apiJson } from "@/lib/api"; -import { prisma } from "@/lib/prisma"; +import { db, User, UsersCurrency } from "@/lib/db"; // Public REST API — leaderboard. Mirrors src/app/leaderboard/page.tsx. -// AtomCMS-faithful currency type ids (see prisma/schema.prisma UsersCurrency): +// AtomCMS-faithful currency type ids (see UsersCurrency): // Credits = -1 (lives on users.credits), Duckets = 0, Diamonds = 5. export const dynamic = "force-dynamic"; @@ -16,11 +17,15 @@ const CURRENCY_TYPE: Record, number> = { type Row = { rank: number; username: string; look: string; value: number }; async function loadCreditsRows(): Promise { - const users = await prisma.user.findMany({ - orderBy: { credits: "desc" }, - take: 20, - select: { username: true, look: true, credits: true }, - }); + const users = await db + .select({ + username: User.username, + look: User.look, + credits: User.credits, + }) + .from(User) + .orderBy(desc(User.credits)) + .limit(20); return users.map((u, i) => ({ rank: i + 1, username: u.username, @@ -30,18 +35,26 @@ async function loadCreditsRows(): Promise { } async function loadCurrencyRows(type: number): Promise { - const top = await prisma.usersCurrency.findMany({ - where: { type }, - orderBy: { amount: "desc" }, - take: 20, - select: { userId: true, amount: true }, - }); + const top = await db + .select({ + userId: UsersCurrency.userId, + amount: UsersCurrency.amount, + }) + .from(UsersCurrency) + .where(eq(UsersCurrency.type, type)) + .orderBy(desc(UsersCurrency.amount)) + .limit(20); if (top.length === 0) return []; - const users = await prisma.user.findMany({ - where: { id: { in: top.map((t) => t.userId) } }, - select: { id: true, username: true, look: true }, - }); + const users = await db + .select({ id: User.id, username: User.username, look: User.look }) + .from(User) + .where( + inArray( + User.id, + top.map((t) => t.userId), + ), + ); const byId = new Map(users.map((u) => [u.id, u])); return top diff --git a/src/app/api/me/route.ts b/src/app/api/me/route.ts index caed6e2d57..b7d8f8fa8c 100644 --- a/src/app/api/me/route.ts +++ b/src/app/api/me/route.ts @@ -1,13 +1,14 @@ // Public REST API — the currently signed-in user. // -// Reads the NextAuth session, then re-queries prisma.user by the session id to +// Reads the NextAuth session, then re-queries users by the session id to // return a safe field set (never password / auth_ticket / 2FA secrets / pincode // / mail). Returns { user: null } only when unauthenticated or missing. +import { eq } from "drizzle-orm"; import { apiJson, apiUnavailable } from "@/lib/api"; import { auth } from "@/lib/auth"; import { sessionUserId } from "@/lib/auth/session-user"; -import { prisma } from "@/lib/prisma"; +import { db, User } from "@/lib/db"; export const dynamic = "force-dynamic"; @@ -19,22 +20,23 @@ export async function GET(_req: Request) { } try { - const user = await prisma.user.findUnique({ - where: { id }, - select: { - id: true, - username: true, - look: true, - motto: true, - rank: true, - credits: true, - pixels: true, - points: true, - gender: true, - online: true, - accountCreated: true, - }, - }); + const [user] = await db + .select({ + id: User.id, + username: User.username, + look: User.look, + motto: User.motto, + rank: User.rank, + credits: User.credits, + pixels: User.pixels, + points: User.points, + gender: User.gender, + online: User.online, + accountCreated: User.accountCreated, + }) + .from(User) + .where(eq(User.id, id)) + .limit(1); if (!user) { return apiJson({ user: null }); diff --git a/src/app/api/me/tokens/route.ts b/src/app/api/me/tokens/route.ts index 8389cad229..0d6b3a07af 100644 --- a/src/app/api/me/tokens/route.ts +++ b/src/app/api/me/tokens/route.ts @@ -8,11 +8,13 @@ // to the user via personal_access_tokens.tokenable_id (a BigInt). NOTE: the live // table has no expires_at column, so it is never read or written here. +import { and, desc, eq } from "drizzle-orm"; +import type { ResultSetHeader } from "mysql2"; import { apiError, apiJson, apiUnavailable, positiveBigInt } from "@/lib/api"; import { auth } from "@/lib/auth"; import { personalTokenScope } from "@/lib/auth/personal-token-scope"; import { sessionUserId } from "@/lib/auth/session-user"; -import { prisma } from "@/lib/prisma"; +import { db, PersonalAccessTokens } from "@/lib/db"; export const dynamic = "force-dynamic"; @@ -26,11 +28,21 @@ export async function GET(_req: Request) { if (!id) return apiError("Unauthorized", 401); try { - const tokens = await prisma.personalAccessTokens.findMany({ - where: personalTokenScope(id), - select: { id: true, name: true, lastUsedAt: true }, - orderBy: { id: "desc" }, - }); + const scope = personalTokenScope(id); + const tokens = await db + .select({ + id: PersonalAccessTokens.id, + name: PersonalAccessTokens.name, + lastUsedAt: PersonalAccessTokens.lastUsedAt, + }) + .from(PersonalAccessTokens) + .where( + and( + eq(PersonalAccessTokens.tokenableId, scope.tokenableId), + eq(PersonalAccessTokens.tokenableType, scope.tokenableType), + ), + ) + .orderBy(desc(PersonalAccessTokens.id)); // Never expose the token hash. return apiJson({ data: tokens }); } catch { @@ -50,10 +62,17 @@ export async function DELETE(req: Request) { try { // Scope the delete to the owner so users cannot revoke others' tokens. - const result = await prisma.personalAccessTokens.deleteMany({ - where: { id: parsedTokenId, ...personalTokenScope(id) }, - }); - if (result.count === 0) { + const scope = personalTokenScope(id); + const [result] = (await db + .delete(PersonalAccessTokens) + .where( + and( + eq(PersonalAccessTokens.id, parsedTokenId), + eq(PersonalAccessTokens.tokenableId, scope.tokenableId), + eq(PersonalAccessTokens.tokenableType, scope.tokenableType), + ), + )) as unknown as [ResultSetHeader]; + if (!result.affectedRows) { return apiError("Token not found", 404); } return apiJson({ ok: true }); diff --git a/src/app/api/online/count/route.ts b/src/app/api/online/count/route.ts index f53f62d838..38da24ba0b 100644 --- a/src/app/api/online/count/route.ts +++ b/src/app/api/online/count/route.ts @@ -3,18 +3,23 @@ // `online` is the emulator's string flag "1" / "0" (see User model). Returns // { count: 0 } (never 500) on DB failure. +import { count, eq } from "drizzle-orm"; import { apiJson } from "@/lib/api"; import { cached } from "@/lib/cache"; -import { prisma } from "@/lib/prisma"; +import { db, User } from "@/lib/db"; export const dynamic = "force-dynamic"; export async function GET(_req: Request) { try { - const count = await cached("online_count", 10_000, () => - prisma.user.count({ where: { online: "1" } }), - ); - return apiJson({ count }); + const result = await cached("online_count", 10_000, async () => { + const [row] = await db + .select({ total: count() }) + .from(User) + .where(eq(User.online, "1")); + return row?.total ?? 0; + }); + return apiJson({ count: result }); } catch { return apiJson({ count: 0 }); } diff --git a/src/app/api/online/route.ts b/src/app/api/online/route.ts index 58f34bf6c0..0a29b237b5 100644 --- a/src/app/api/online/route.ts +++ b/src/app/api/online/route.ts @@ -1,23 +1,23 @@ // Public REST API — list of currently-online users (username + look only). // -// `online` is stored by the emulator as the string "1" / "0" (see User model in -// prisma/schema.prisma). Capped at 100 rows. Returns empty data (never 500) on -// DB failure. +// `online` is stored by the emulator as the string "1" / "0" (see User model). +// Capped at 100 rows. Returns empty data (never 500) on DB failure. +import { eq } from "drizzle-orm"; import { apiJson } from "@/lib/api"; import { cached } from "@/lib/cache"; -import { prisma } from "@/lib/prisma"; +import { db, User } from "@/lib/db"; export const dynamic = "force-dynamic"; export async function GET(_req: Request) { try { const users = await cached("online_users", 10_000, async () => - prisma.user.findMany({ - where: { online: "1" }, - select: { username: true, look: true }, - take: 100, - }), + db + .select({ username: User.username, look: User.look }) + .from(User) + .where(eq(User.online, "1")) + .limit(100), ); return apiJson({ users }); diff --git a/src/app/api/paypal/capture/route.ts b/src/app/api/paypal/capture/route.ts index bc8b4196d6..045d690214 100644 --- a/src/app/api/paypal/capture/route.ts +++ b/src/app/api/paypal/capture/route.ts @@ -1,9 +1,11 @@ +import { and, eq } from "drizzle-orm"; +import type { ResultSetHeader } from "mysql2"; import { NextResponse } from "next/server"; import { auth } from "@/lib/auth"; import { sessionUserId } from "@/lib/auth/session-user"; +import { db, WebsitePaypalTransactions } from "@/lib/db"; import { resolveHotelName } from "@/lib/hotel-name"; import { logger } from "@/lib/logger"; -import { prisma } from "@/lib/prisma"; import { logServerError } from "@/lib/server-log"; import { type CaptureResult, @@ -18,7 +20,7 @@ import { TopupCaptureError, } from "@/lib/services/paypal-topup"; import { rcon } from "@/lib/services/rcon"; -import { sendCurrency } from "@/lib/services/send-currency"; +import { currencyDb, sendCurrency } from "@/lib/services/send-currency"; export const dynamic = "force-dynamic"; @@ -79,11 +81,18 @@ export async function POST(req: Request): Promise { authorized = await authorizeTopupCapture( userId, orderId, - async (transactionId) => - prisma.websitePaypalTransactions.findFirst({ - where: { transactionId }, - select: { userId: true, status: true, amount: true }, - }), + async (transactionId) => { + const [row] = await db + .select({ + userId: WebsitePaypalTransactions.userId, + status: WebsitePaypalTransactions.status, + amount: WebsitePaypalTransactions.amount, + }) + .from(WebsitePaypalTransactions) + .where(eq(WebsitePaypalTransactions.transactionId, transactionId)) + .limit(1); + return row ?? null; + }, ); } catch (error) { if (error instanceof TopupCaptureError) { @@ -103,21 +112,30 @@ export async function POST(req: Request): Promise { const amount = authorized.amount ?? 0; const credits = Math.floor(amount * creditsPerUnit()); try { - await claimTopupDelivery(() => - prisma.websitePaypalTransactions.updateMany({ - where: { - userId, - transactionId: orderId, - status: "CAPTURED_PENDING_CREDIT", - }, - data: { status: "CREDIT_DELIVERING", updatedAt: new Date() }, - }), - ); - await sendCurrency({ rcon, db: prisma }, userId, "credits", credits); - await prisma.websitePaypalTransactions.updateMany({ - where: { userId, transactionId: orderId, status: "CREDIT_DELIVERING" }, - data: { status: "COMPLETED", updatedAt: new Date() }, + await claimTopupDelivery(async () => { + const [result] = (await db + .update(WebsitePaypalTransactions) + .set({ status: "CREDIT_DELIVERING", updatedAt: new Date() }) + .where( + and( + eq(WebsitePaypalTransactions.userId, userId), + eq(WebsitePaypalTransactions.transactionId, orderId), + eq(WebsitePaypalTransactions.status, "CAPTURED_PENDING_CREDIT"), + ), + )) as unknown as [ResultSetHeader]; + return { count: result.affectedRows }; }); + await sendCurrency({ rcon, db: currencyDb }, userId, "credits", credits); + await db + .update(WebsitePaypalTransactions) + .set({ status: "COMPLETED", updatedAt: new Date() }) + .where( + and( + eq(WebsitePaypalTransactions.userId, userId), + eq(WebsitePaypalTransactions.transactionId, orderId), + eq(WebsitePaypalTransactions.status, "CREDIT_DELIVERING"), + ), + ); return NextResponse.json({ ok: true, recovered: true, @@ -162,17 +180,23 @@ export async function POST(req: Request): Promise { if (result.status !== "COMPLETED") { // Record the non-completed attempt so support can trace it. try { - await prisma.websitePaypalTransactions.updateMany({ - where: { userId, transactionId: orderId, status: "CREATED" }, - data: { + await db + .update(WebsitePaypalTransactions) + .set({ status: result.status, description: `${hotelName} top-up (not completed)`, amount: result.amount, currency: result.currency, createdAt: new Date(), updatedAt: new Date(), - }, - }); + }) + .where( + and( + eq(WebsitePaypalTransactions.userId, userId), + eq(WebsitePaypalTransactions.transactionId, orderId), + eq(WebsitePaypalTransactions.status, "CREATED"), + ), + ); } catch { /* best-effort logging */ } @@ -187,18 +211,24 @@ export async function POST(req: Request): Promise { // Record the transaction BEFORE crediting so a crash mid-grant can't be // reprocessed into a double credit (the idempotency check above keys on this). try { - const claimed = await prisma.websitePaypalTransactions.updateMany({ - where: { userId, transactionId: orderId, status: "CREATED" }, - data: { + const [claimed] = (await db + .update(WebsitePaypalTransactions) + .set({ status: "CAPTURED_PENDING_CREDIT", description: `${hotelName} top-up: ${credits} credits`, amount: result.amount, currency: result.currency, createdAt: new Date(), updatedAt: new Date(), - }, - }); - if (claimed.count !== 1) + }) + .where( + and( + eq(WebsitePaypalTransactions.userId, userId), + eq(WebsitePaypalTransactions.transactionId, orderId), + eq(WebsitePaypalTransactions.status, "CREATED"), + ), + )) as unknown as [ResultSetHeader]; + if (claimed.affectedRows !== 1) throw new Error("Top-up order was already claimed"); } catch (e) { logger.error("PayPal capture record failed", { @@ -218,21 +248,30 @@ export async function POST(req: Request): Promise { // external delivery outcome is ambiguous, CREDIT_DELIVERING remains visible // for staff reconciliation instead of automatically risking a second grant. try { - await claimTopupDelivery(() => - prisma.websitePaypalTransactions.updateMany({ - where: { - userId, - transactionId: orderId, - status: "CAPTURED_PENDING_CREDIT", - }, - data: { status: "CREDIT_DELIVERING", updatedAt: new Date() }, - }), - ); - await sendCurrency({ rcon, db: prisma }, userId, "credits", credits); - await prisma.websitePaypalTransactions.updateMany({ - where: { userId, transactionId: orderId, status: "CREDIT_DELIVERING" }, - data: { status: "COMPLETED", updatedAt: new Date() }, + await claimTopupDelivery(async () => { + const [claimResult] = (await db + .update(WebsitePaypalTransactions) + .set({ status: "CREDIT_DELIVERING", updatedAt: new Date() }) + .where( + and( + eq(WebsitePaypalTransactions.userId, userId), + eq(WebsitePaypalTransactions.transactionId, orderId), + eq(WebsitePaypalTransactions.status, "CAPTURED_PENDING_CREDIT"), + ), + )) as unknown as [ResultSetHeader]; + return { count: claimResult.affectedRows }; }); + await sendCurrency({ rcon, db: currencyDb }, userId, "credits", credits); + await db + .update(WebsitePaypalTransactions) + .set({ status: "COMPLETED", updatedAt: new Date() }) + .where( + and( + eq(WebsitePaypalTransactions.userId, userId), + eq(WebsitePaypalTransactions.transactionId, orderId), + eq(WebsitePaypalTransactions.status, "CREDIT_DELIVERING"), + ), + ); } catch (e) { logger.error("PayPal capture credit failed", { module: "paypal/capture", diff --git a/src/app/api/paypal/create/route.ts b/src/app/api/paypal/create/route.ts index c0e8a30290..e06fb6ad52 100644 --- a/src/app/api/paypal/create/route.ts +++ b/src/app/api/paypal/create/route.ts @@ -2,9 +2,9 @@ import { NextResponse } from "next/server"; import { env } from "@/env"; import { auth } from "@/lib/auth"; import { sessionUserId } from "@/lib/auth/session-user"; +import { db, WebsitePaypalTransactions } from "@/lib/db"; import { resolveHotelName } from "@/lib/hotel-name"; import { logger } from "@/lib/logger"; -import { prisma } from "@/lib/prisma"; import { createOrder, creditsPerUnit, @@ -87,7 +87,7 @@ export async function POST(req: Request): Promise { await recordCreatedTopup( { userId, orderId: order.id, amount, currency: PAYPAL_CURRENCY, credits }, - (data) => prisma.websitePaypalTransactions.create({ data }), + (data) => db.insert(WebsitePaypalTransactions).values(data), ); return NextResponse.json({