refactor(db): migrate app pages and APIs from Prisma facade to Drizzle (4)

Co-authored-by: Cursor <[email protected]>
This commit is contained in:
SimoandCursor committed 2026-08-01 14:15:36 +02:00
1 parent 580972c0a0
commit 9aa4f331bf
40 files changed
+1259 -927

No files matched your search

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