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

Co-authored-by: Cursor <[email protected]>
This commit is contained in:
SimoandCursor committed 2026-08-01 14:15:28 +02:00
1 parent 7c39aef5d9
commit 2cd0863cb8
40 files changed
+1069 -726

No files matched your search

+24 -12
View File
@@ -1,7 +1,8 @@
import { asc, count } from "drizzle-orm";
import Link from "next/link"; import Link from "next/link";
import { getTranslations } from "next-intl/server"; import { getTranslations } from "next-intl/server";
import { ContentCard, EmptyState } from "@/components/public/ui"; import { ContentCard, EmptyState } from "@/components/public/ui";
import { prisma } from "@/lib/prisma"; import { db, WebsiteRareValueCategories, WebsiteRareValues } from "@/lib/db";
import { siteSettings } from "@/lib/services/site-settings"; import { siteSettings } from "@/lib/services/site-settings";
export const revalidate = 300; export const revalidate = 300;
@@ -19,10 +20,18 @@ export default async function RareValuesPage() {
const t = await getTranslations("pages.rares"); const t = await getTranslations("pages.rares");
let categories: CategoryRow[] = []; let categories: CategoryRow[] = [];
try { try {
categories = await prisma.websiteRareValueCategories.findMany({ categories = await db
orderBy: [{ priority: "asc" }, { name: "asc" }], .select({
select: { id: true, name: true, badge: true, priority: true }, id: WebsiteRareValueCategories.id,
}); name: WebsiteRareValueCategories.name,
badge: WebsiteRareValueCategories.badge,
priority: WebsiteRareValueCategories.priority,
})
.from(WebsiteRareValueCategories)
.orderBy(
asc(WebsiteRareValueCategories.priority),
asc(WebsiteRareValueCategories.name),
);
} catch { } catch {
categories = []; categories = [];
} }
@@ -30,12 +39,15 @@ export default async function RareValuesPage() {
// Per-category rare counts (grouped so we avoid N queries). // Per-category rare counts (grouped so we avoid N queries).
const counts = new Map<string, number>(); const counts = new Map<string, number>();
try { try {
const grouped = await prisma.websiteRareValues.groupBy({ const grouped = await db
by: ["categoryId"], .select({
_count: { _all: true }, categoryId: WebsiteRareValues.categoryId,
}); value: count(),
})
.from(WebsiteRareValues)
.groupBy(WebsiteRareValues.categoryId);
for (const g of grouped) { for (const g of grouped) {
counts.set(String(g.categoryId), g._count._all); counts.set(String(g.categoryId), Number(g.value));
} }
} catch { } catch {
// leave counts empty -> shown as 0 // leave counts empty -> shown as 0
@@ -55,7 +67,7 @@ export default async function RareValuesPage() {
<div className="card-grid sm-2 lg-3"> <div className="card-grid sm-2 lg-3">
{categories.map((c) => { {categories.map((c) => {
const id = String(c.id); const id = String(c.id);
const count = counts.get(id) ?? 0; const rareCount = counts.get(id) ?? 0;
const badgeSrc = const badgeSrc =
badgesBase && c.badge ? `${badgesBase}/${c.badge}.gif` : ""; badgesBase && c.badge ? `${badgesBase}/${c.badge}.gif` : "";
return ( return (
@@ -83,7 +95,7 @@ export default async function RareValuesPage() {
<div className="content-card-head-text"> <div className="content-card-head-text">
<p className="content-card-title">{c.name}</p> <p className="content-card-title">{c.name}</p>
<p className="content-card-subtitle"> <p className="content-card-subtitle">
{t("rareCount", { count })} {t("rareCount", { count: rareCount })}
</p> </p>
</div> </div>
</div> </div>
+8 -5
View File
@@ -1,8 +1,9 @@
import { eq } 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 { ContentCard, StatBlock } from "@/components/public/ui"; import { ContentCard, StatBlock } from "@/components/public/ui";
import { auth } from "@/lib/auth"; import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma"; import { db, User } from "@/lib/db";
import RedeemForm from "./RedeemForm"; import RedeemForm from "./RedeemForm";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
@@ -17,10 +18,12 @@ export default async function RedeemPage() {
// Show the signed-in user's current credit balance for context (fail soft). // Show the signed-in user's current credit balance for context (fail soft).
let user: { username: string; credits: number } | null = null; let user: { username: string; credits: number } | null = null;
try { try {
user = await prisma.user.findUnique({ const [row] = await db
where: { id }, .select({ username: User.username, credits: User.credits })
select: { username: true, credits: true }, .from(User)
}); .where(eq(User.id, id))
.limit(1);
user = row ?? null;
} catch { } catch {
user = null; user = null;
} }
+17 -14
View File
@@ -1,3 +1,4 @@
import { count, desc, eq } from "drizzle-orm";
import { headers } from "next/headers"; import { headers } from "next/headers";
import Image from "next/image"; import Image from "next/image";
import Link from "next/link"; import Link from "next/link";
@@ -8,9 +9,9 @@ import { LanguageSwitcher } from "@/components/language-switcher";
import { Reveal } from "@/components/motion-reveal"; import { Reveal } from "@/components/motion-reveal";
import { ThemeSwitcher } from "@/components/theme-switcher"; import { ThemeSwitcher } from "@/components/theme-switcher";
import { cached } from "@/lib/cache"; import { cached } from "@/lib/cache";
import { db, User } from "@/lib/db";
import { avatarImageUrl } from "@/lib/format"; import { avatarImageUrl } from "@/lib/format";
import { resolveHotelName } from "@/lib/hotel-name"; import { resolveHotelName } from "@/lib/hotel-name";
import { prisma } from "@/lib/prisma";
import { captchaConfig } from "@/lib/services/captcha"; import { captchaConfig } from "@/lib/services/captcha";
import { siteSettings } from "@/lib/services/site-settings"; import { siteSettings } from "@/lib/services/site-settings";
@@ -32,21 +33,23 @@ export default async function RegisterPage() {
const [online, recentUsers, latestUsers] = await Promise.all([ const [online, recentUsers, latestUsers] = await Promise.all([
cached("online_count", 10_000, () => cached("online_count", 10_000, () =>
prisma.user.count({ where: { online: "1" } }), db
.select({ total: count() })
.from(User)
.where(eq(User.online, "1"))
.then((rows) => rows[0]?.total ?? 0),
).catch(() => 0), ).catch(() => 0),
prisma.user db
.findMany({ .select({ username: User.username, look: User.look })
where: { online: "1" }, .from(User)
select: { username: true, look: true }, .where(eq(User.online, "1"))
take: 8, .limit(8)
})
.catch(() => []), .catch(() => []),
prisma.user db
.findMany({ .select({ username: User.username, look: User.look })
orderBy: { accountCreated: "desc" }, .from(User)
select: { username: true, look: true }, .orderBy(desc(User.accountCreated))
take: 8, .limit(8)
})
.catch(() => []), .catch(() => []),
]); ]);
+28 -24
View File
@@ -1,8 +1,9 @@
import { eq } from "drizzle-orm";
import type { Metadata } from "next"; import type { Metadata } from "next";
import Link from "next/link"; import Link from "next/link";
import { notFound } from "next/navigation"; import { notFound } from "next/navigation";
import { ContentCard, EmptyState, StatBlock } from "@/components/public/ui"; import { ContentCard, EmptyState, StatBlock } from "@/components/public/ui";
import { prisma } from "@/lib/prisma"; import { db, Rooms, User } from "@/lib/db";
export const revalidate = 300; export const revalidate = 300;
@@ -31,24 +32,25 @@ function parseRoomId(raw: string): number | null {
async function getRoom(raw: string) { async function getRoom(raw: string) {
const id = parseRoomId(raw); const id = parseRoomId(raw);
if (id === null) return null; if (id === null) return null;
return prisma.rooms const [room] = await db
.findUnique({ .select({
where: { id }, id: Rooms.id,
select: { ownerId: Rooms.ownerId,
id: true, ownerName: Rooms.ownerName,
ownerId: true, name: Rooms.name,
ownerName: true, description: Rooms.description,
name: true, state: Rooms.state,
description: true, users: Rooms.users,
state: true, usersMax: Rooms.usersMax,
users: true, category: Rooms.category,
usersMax: true, score: Rooms.score,
category: true, tags: Rooms.tags,
score: true,
tags: true,
},
}) })
.catch(() => null); .from(Rooms)
.where(eq(Rooms.id, id))
.limit(1)
.catch(() => []);
return room ?? null;
} }
export async function generateMetadata({ export async function generateMetadata({
@@ -83,12 +85,14 @@ export default async function RoomPage({
// owner_name column if the account row is missing or the query degrades. // owner_name column if the account row is missing or the query degrades.
const owner = const owner =
room.ownerId > 0 room.ownerId > 0
? await prisma.user ? (
.findUnique({ await db
where: { id: room.ownerId }, .select({ username: User.username })
select: { username: true }, .from(User)
}) .where(eq(User.id, room.ownerId))
.catch(() => null) .limit(1)
.catch(() => [])
)[0]
: null; : null;
const ownerName = owner?.username || room.ownerName || "Unknown"; const ownerName = owner?.username || room.ownerName || "Unknown";
+23 -29
View File
@@ -1,8 +1,9 @@
import { and, eq, like } from "drizzle-orm";
import type { Metadata } from "next"; import type { Metadata } from "next";
import Image from "next/image"; import Image from "next/image";
import Link from "next/link"; import Link from "next/link";
import { db, Rooms, User } from "@/lib/db";
import { avatarImageUrl } from "@/lib/format"; import { avatarImageUrl } from "@/lib/format";
import { prisma } from "@/lib/prisma";
import { siteSettings } from "@/lib/services/site-settings"; import { siteSettings } from "@/lib/services/site-settings";
export const metadata: Metadata = { title: "Search" }; export const metadata: Metadata = { title: "Search" };
@@ -43,38 +44,31 @@ export default async function SearchPage({
); );
} }
const users = await prisma.user const users = await db
.findMany({ .select({
where: { id: User.id,
username: { contains: query }, username: User.username,
}, look: User.look,
select: { motto: User.motto,
id: true, online: User.online,
username: true, rank: User.rank,
look: true,
motto: true,
online: true,
rank: true,
},
take: 30,
}) })
.from(User)
.where(like(User.username, `%${query}%`))
.limit(30)
.catch(() => []); .catch(() => []);
const rooms = await prisma.rooms const rooms = await db
.findMany({ .select({
where: { id: Rooms.id,
name: { contains: query }, name: Rooms.name,
state: "open", ownerName: Rooms.ownerName,
}, users: Rooms.users,
select: { usersMax: Rooms.usersMax,
id: true,
name: true,
ownerName: true,
users: true,
usersMax: true,
},
take: 12,
}) })
.from(Rooms)
.where(and(like(Rooms.name, `%${query}%`), eq(Rooms.state, "open")))
.limit(12)
.catch(() => []); .catch(() => []);
return ( return (
+12 -9
View File
@@ -1,3 +1,4 @@
import { eq } from "drizzle-orm";
import Link from "next/link"; import Link from "next/link";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { getTranslations } from "next-intl/server"; import { getTranslations } from "next-intl/server";
@@ -11,8 +12,8 @@ import { env } from "@/env";
import { auth } from "@/lib/auth"; import { auth } from "@/lib/auth";
import { LaravelEncrypter } from "@/lib/auth/laravel-encrypter"; import { LaravelEncrypter } from "@/lib/auth/laravel-encrypter";
import { totpKeyUri } from "@/lib/auth/totp"; import { totpKeyUri } from "@/lib/auth/totp";
import { db, User } from "@/lib/db";
import { resolveHotelName } from "@/lib/hotel-name"; import { resolveHotelName } from "@/lib/hotel-name";
import { prisma } from "@/lib/prisma";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
@@ -37,14 +38,16 @@ export default async function TwoFactorPage({
twoFactorRecoveryCodes: string | null; twoFactorRecoveryCodes: string | null;
} | null = null; } | null = null;
try { try {
user = await prisma.user.findUnique({ const [row] = await db
where: { id }, .select({
select: { twoFactorSecret: User.twoFactorSecret,
twoFactorSecret: true, twoFactorConfirmedAt: User.twoFactorConfirmedAt,
twoFactorConfirmedAt: true, twoFactorRecoveryCodes: User.twoFactorRecoveryCodes,
twoFactorRecoveryCodes: true, })
}, .from(User)
}); .where(eq(User.id, id))
.limit(1);
user = row ?? null;
} catch { } catch {
user = null; user = null;
} }
+13 -5
View File
@@ -1,3 +1,4 @@
import { eq } from "drizzle-orm";
import Image from "next/image"; import Image from "next/image";
import Link from "next/link"; import Link from "next/link";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
@@ -5,8 +6,8 @@ import { getTranslations } from "next-intl/server";
import { updateMotto } from "@/actions/user-settings"; import { updateMotto } from "@/actions/user-settings";
import { Reveal } from "@/components/motion-reveal"; import { Reveal } from "@/components/motion-reveal";
import { auth } from "@/lib/auth"; import { auth } from "@/lib/auth";
import { db, User } from "@/lib/db";
import { avatarImageUrl } from "@/lib/format"; import { avatarImageUrl } from "@/lib/format";
import { prisma } from "@/lib/prisma";
import { siteSettings } from "@/lib/services/site-settings"; import { siteSettings } from "@/lib/services/site-settings";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
@@ -25,10 +26,17 @@ export default async function SettingsPage() {
mail: string | null; mail: string | null;
} | null = null; } | null = null;
try { try {
user = await prisma.user.findUnique({ const [row] = await db
where: { id }, .select({
select: { username: true, motto: true, look: true, mail: true }, username: User.username,
}); motto: User.motto,
look: User.look,
mail: User.mail,
})
.from(User)
.where(eq(User.id, id))
.limit(1);
user = row ?? null;
} catch { } catch {
user = null; user = null;
} }
+22 -11
View File
@@ -1,10 +1,11 @@
import { desc, eq, 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 { signOutEverywhere } from "@/actions/sessions"; import { signOutEverywhere } from "@/actions/sessions";
import { ContentCard, EmptyState } from "@/components/public/ui"; import { ContentCard, EmptyState } from "@/components/public/ui";
import { auth } from "@/lib/auth"; import { auth } from "@/lib/auth";
import { db, WebsiteLoginLogs } from "@/lib/db";
import { formatDate } from "@/lib/format-date"; import { formatDate } from "@/lib/format-date";
import { prisma } from "@/lib/prisma";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
@@ -54,20 +55,30 @@ export default async function SessionsPage({
const userId = Number(session.user.id); const userId = Number(session.user.id);
const { signedOutAll } = await searchParams; const { signedOutAll } = await searchParams;
const logins = await prisma.websiteLoginLogs const logins = await db
.findMany({ .select({
where: { userId }, id: WebsiteLoginLogs.id,
orderBy: { id: "desc" }, ip: WebsiteLoginLogs.ip,
take: 30, userAgent: WebsiteLoginLogs.userAgent,
select: { id: true, ip: true, userAgent: true, createdAt: true }, createdAt: WebsiteLoginLogs.createdAt,
}) })
.from(WebsiteLoginLogs)
.where(eq(WebsiteLoginLogs.userId, userId))
.orderBy(desc(WebsiteLoginLogs.id))
.limit(30)
.catch(() => []); .catch(() => []);
type Failed = { id: number; ip_address: string; attempted_at: Date | string }; type Failed = { id: number; ip_address: string; attempted_at: Date | string };
const failed = await prisma.$queryRaw<Failed[]>` let failed: Failed[] = [];
SELECT id, ip_address, attempted_at FROM failed_logins try {
WHERE username = ${session.user.name} ORDER BY id DESC LIMIT 15 const [rows] = (await db.execute(sql`
`.catch(() => [] as Failed[]); SELECT id, ip_address, attempted_at FROM failed_logins
WHERE username = ${session.user.name} ORDER BY id DESC LIMIT 15
`)) as unknown as [Failed[], unknown];
failed = rows ?? [];
} catch {
failed = [];
}
return ( return (
<main style={{ display: "grid", gap: "1.5rem" }}> <main style={{ display: "grid", gap: "1.5rem" }}>
+31 -42
View File
@@ -1,11 +1,12 @@
import { asc, eq } from "drizzle-orm";
import Link from "next/link"; import Link from "next/link";
import { getTranslations } from "next-intl/server"; import { getTranslations } from "next-intl/server";
import type { CSSProperties } from "react"; import type { CSSProperties } from "react";
import { buyShopArticle } from "@/actions/shop"; import { buyShopArticle } from "@/actions/shop";
import { ContentCard, EmptyState } from "@/components/public/ui"; import { ContentCard, EmptyState } from "@/components/public/ui";
import { auth } from "@/lib/auth"; import { auth } from "@/lib/auth";
import { db, WebsiteShopArticles, WebsiteShopCategories } from "@/lib/db";
import { excerpt } from "@/lib/format"; import { excerpt } from "@/lib/format";
import { prisma } from "@/lib/prisma";
import { creditsPerUnit } from "@/lib/services/paypal"; import { creditsPerUnit } from "@/lib/services/paypal";
export const revalidate = 300; export const revalidate = 300;
@@ -62,18 +63,18 @@ type ShopCategory = {
icon: string | null; icon: string | null;
}; };
const ARTICLE_SELECT = { const articleSelect = {
id: true, id: WebsiteShopArticles.id,
name: true, name: WebsiteShopArticles.name,
info: true, info: WebsiteShopArticles.info,
iconUrl: true, iconUrl: WebsiteShopArticles.iconUrl,
color: true, color: WebsiteShopArticles.color,
costs: true, costs: WebsiteShopArticles.costs,
credits: true, credits: WebsiteShopArticles.credits,
duckets: true, duckets: WebsiteShopArticles.duckets,
diamonds: true, diamonds: WebsiteShopArticles.diamonds,
badges: true, badges: WebsiteShopArticles.badges,
position: true, position: WebsiteShopArticles.position,
} as const; } as const;
export default async function ShopPage({ export default async function ShopPage({
@@ -115,43 +116,31 @@ export default async function ShopPage({
// Categories for the filter bar. // Categories for the filter bar.
let categories: ShopCategory[] = []; let categories: ShopCategory[] = [];
try { try {
categories = await prisma.websiteShopCategories.findMany({ categories = await db
select: { id: true, name: true, icon: true }, .select({
orderBy: { name: "asc" }, id: WebsiteShopCategories.id,
}); name: WebsiteShopCategories.name,
icon: WebsiteShopCategories.icon,
})
.from(WebsiteShopCategories)
.orderBy(asc(WebsiteShopCategories.name));
} catch { } catch {
categories = []; categories = [];
} }
// Articles. The category foreign key (website_shop_category_id) is not modelled
// in the typed Prisma client, so when filtering we resolve the matching ids via
// a guarded raw query, then load the rows through the typed client.
let articles: ShopArticle[] = []; let articles: ShopArticle[] = [];
try { try {
if (categoryId) { if (categoryId) {
let ids: bigint[] = []; articles = await db
try { .select(articleSelect)
const rows = await prisma.$queryRawUnsafe<{ id: bigint }[]>( .from(WebsiteShopArticles)
"SELECT id FROM website_shop_articles WHERE website_shop_category_id = ?", .where(eq(WebsiteShopArticles.categoryId, BigInt(categoryId)))
BigInt(categoryId), .orderBy(asc(WebsiteShopArticles.position));
);
ids = rows.map((r) => r.id);
} catch {
ids = [];
}
articles = ids.length
? await prisma.websiteShopArticles.findMany({
where: { id: { in: ids } },
select: ARTICLE_SELECT,
orderBy: { position: "asc" },
})
: [];
} else { } else {
articles = await prisma.websiteShopArticles.findMany({ articles = await db
select: ARTICLE_SELECT, .select(articleSelect)
orderBy: { position: "asc" }, .from(WebsiteShopArticles)
}); .orderBy(asc(WebsiteShopArticles.position));
} }
} catch { } catch {
articles = []; articles = [];
+8 -5
View File
@@ -1,8 +1,9 @@
import { eq } 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 { ContentCard, StatBlock } from "@/components/public/ui"; import { ContentCard, StatBlock } from "@/components/public/ui";
import { auth } from "@/lib/auth"; import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma"; import { db, User } from "@/lib/db";
import { import {
creditsPerUnit, creditsPerUnit,
isPayPalConfigured, isPayPalConfigured,
@@ -27,10 +28,12 @@ export default async function TopUpPage({
// Current credits balance for context (fail soft). // Current credits balance for context (fail soft).
let user: { username: string; credits: number } | null = null; let user: { username: string; credits: number } | null = null;
try { try {
user = await prisma.user.findUnique({ const [row] = await db
where: { id }, .select({ username: User.username, credits: User.credits })
select: { username: true, credits: true }, .from(User)
}); .where(eq(User.id, id))
.limit(1);
user = row ?? null;
} catch { } catch {
user = null; user = null;
} }
+31 -25
View File
@@ -1,7 +1,8 @@
import { asc, desc, eq, gte } from "drizzle-orm";
import Link from "next/link"; import Link from "next/link";
import { getTranslations } from "next-intl/server"; import { getTranslations } from "next-intl/server";
import { ContentCard, EmptyState } from "@/components/public/ui"; import { ContentCard, EmptyState } from "@/components/public/ui";
import { prisma } from "@/lib/prisma"; import { db, User, WebsiteTeams } from "@/lib/db";
import { siteSettings } from "@/lib/services/site-settings"; import { siteSettings } from "@/lib/services/site-settings";
export const revalidate = 300; export const revalidate = 300;
@@ -30,30 +31,35 @@ export default async function StaffPage() {
let staff: StaffMember[] = []; let staff: StaffMember[] = [];
try { try {
staff = await prisma.user.findMany({ staff = await db
where: { rank: { gte: minStaffRank } }, .select({
select: { username: true, look: true, rank: true, motto: true }, username: User.username,
orderBy: [{ rank: "desc" }, { username: "asc" }], look: User.look,
take: 100, rank: User.rank,
}); motto: User.motto,
})
.from(User)
.where(gte(User.rank, minStaffRank))
.orderBy(desc(User.rank), asc(User.username))
.limit(100);
} catch { } catch {
staff = []; staff = [];
} }
let teams: Team[] = []; let teams: Team[] = [];
try { try {
const rows = await prisma.websiteTeams.findMany({ const rows = await db
where: { hiddenRank: false }, .select({
select: { id: WebsiteTeams.id,
id: true, rankName: WebsiteTeams.rankName,
rankName: true, badge: WebsiteTeams.badge,
badge: true, jobDescription: WebsiteTeams.jobDescription,
jobDescription: true, staffColor: WebsiteTeams.staffColor,
staffColor: true, })
}, .from(WebsiteTeams)
orderBy: { id: "asc" }, .where(eq(WebsiteTeams.hiddenRank, false))
}); .orderBy(asc(WebsiteTeams.id));
teams = rows.map((t) => ({ ...t, id: String(t.id) })); teams = rows.map((row) => ({ ...row, id: String(row.id) }));
} catch { } catch {
teams = []; teams = [];
} }
@@ -69,14 +75,14 @@ export default async function StaffPage() {
subtitle={t("ranksSubtitle")} subtitle={t("ranksSubtitle")}
> >
<div className="card-grid sm-2 lg-3"> <div className="card-grid sm-2 lg-3">
{teams.map((t) => ( {teams.map((team) => (
<div key={t.id} className="card"> <div key={team.id} className="card">
<h3 style={{ margin: "0 0 0.25rem", color: t.staffColor }}> <h3 style={{ margin: "0 0 0.25rem", color: team.staffColor }}>
{t.rankName} {team.rankName}
</h3> </h3>
{t.jobDescription ? ( {team.jobDescription ? (
<p className="muted" style={{ margin: 0 }}> <p className="muted" style={{ margin: 0 }}>
{t.jobDescription} {team.jobDescription}
</p> </p>
) : null} ) : null}
</div> </div>
+136 -99
View File
@@ -1,3 +1,4 @@
import { and, asc, desc, eq, inArray, or } from "drizzle-orm";
import type { Metadata } from "next"; import type { Metadata } from "next";
import Link from "next/link"; import Link from "next/link";
import { notFound } from "next/navigation"; import { notFound } from "next/navigation";
@@ -12,9 +13,19 @@ import {
StatBlock, StatBlock,
} from "@/components/public/ui"; } from "@/components/public/ui";
import { auth } from "@/lib/auth"; import { auth } from "@/lib/auth";
import {
CameraWeb,
db,
MessengerFriendrequests,
MessengerFriendships,
Rooms,
User,
UsersBadges,
UsersCurrency,
WebsiteUserGuestbooks,
} from "@/lib/db";
import { avatarImageUrl } from "@/lib/format"; import { avatarImageUrl } from "@/lib/format";
import { formatDate } from "@/lib/format-date"; import { formatDate } from "@/lib/format-date";
import { prisma } from "@/lib/prisma";
import { siteSettings } from "@/lib/services/site-settings"; import { siteSettings } from "@/lib/services/site-settings";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
@@ -47,12 +58,12 @@ export async function generateMetadata({
params: Promise<{ username: string }>; params: Promise<{ username: string }>;
}): Promise<Metadata> { }): Promise<Metadata> {
const { username } = await params; const { username } = await params;
const user = await prisma.user const [user] = await db
.findUnique({ .select({ username: User.username, motto: User.motto })
where: { username }, .from(User)
select: { username: true, motto: true }, .where(eq(User.username, username))
}) .limit(1)
.catch(() => null); .catch(() => []);
if (!user) return { title: "Profile" }; if (!user) return { title: "Profile" };
return { return {
title: user.username, title: user.username,
@@ -78,26 +89,28 @@ export default async function ProfilePage({
const { username } = await params; const { username } = await params;
const { friend, guestbook: guestbookStatus, error } = await searchParams; const { friend, guestbook: guestbookStatus, error } = await searchParams;
const [user, imagerBase, session] = await Promise.all([ const [userRows, imagerBase, session] = await Promise.all([
prisma.user.findUnique({ db
where: { username }, .select({
select: { id: User.id,
id: true, username: User.username,
username: true, motto: User.motto,
motto: true, look: User.look,
look: true, rank: User.rank,
rank: true, credits: User.credits,
credits: true, online: User.online,
online: true, accountCreated: User.accountCreated,
accountCreated: true, })
}, .from(User)
}), .where(eq(User.username, username))
.limit(1),
siteSettings.get( siteSettings.get(
"habbo_imaging_url", "habbo_imaging_url",
"https://www.habbo.com/habbo-imaging/avatarimage", "https://www.habbo.com/habbo-imaging/avatarimage",
), ),
auth(), auth(),
]); ]);
const user = userRows[0] ?? null;
if (!user) notFound(); if (!user) notFound();
@@ -112,34 +125,50 @@ export default async function ProfilePage({
let outgoingPending = false; let outgoingPending = false;
let incomingPending = false; let incomingPending = false;
if (isLoggedIn && !isSelf) { if (isLoggedIn && !isSelf) {
const [friendship, outgoing, incoming] = await Promise.all([ const [friendshipRows, outgoingRows, incomingRows] = await Promise.all([
prisma.messengerFriendships db
.findFirst({ .select({ id: MessengerFriendships.id })
where: { .from(MessengerFriendships)
OR: [ .where(
{ userOneId: meId, userTwoId: user.id }, or(
{ userOneId: user.id, userTwoId: meId }, and(
], eq(MessengerFriendships.userOneId, meId),
}, eq(MessengerFriendships.userTwoId, user.id),
select: { id: true }, ),
}) and(
.catch(() => null), eq(MessengerFriendships.userOneId, user.id),
prisma.messengerFriendrequests eq(MessengerFriendships.userTwoId, meId),
.findFirst({ ),
where: { userFromId: meId, userToId: user.id }, ),
select: { id: true }, )
}) .limit(1)
.catch(() => null), .catch(() => []),
prisma.messengerFriendrequests db
.findFirst({ .select({ id: MessengerFriendrequests.id })
where: { userFromId: user.id, userToId: meId }, .from(MessengerFriendrequests)
select: { id: true }, .where(
}) and(
.catch(() => null), eq(MessengerFriendrequests.userFromId, meId),
eq(MessengerFriendrequests.userToId, user.id),
),
)
.limit(1)
.catch(() => []),
db
.select({ id: MessengerFriendrequests.id })
.from(MessengerFriendrequests)
.where(
and(
eq(MessengerFriendrequests.userFromId, user.id),
eq(MessengerFriendrequests.userToId, meId),
),
)
.limit(1)
.catch(() => []),
]); ]);
isFriend = Boolean(friendship); isFriend = Boolean(friendshipRows[0]);
outgoingPending = Boolean(outgoing); outgoingPending = Boolean(outgoingRows[0]);
incomingPending = Boolean(incoming); incomingPending = Boolean(incomingRows[0]);
} }
const feedbackErrorMessage = const feedbackErrorMessage =
@@ -168,14 +197,18 @@ export default async function ProfilePage({
// Currency ledger rows (duckets/diamonds). Credits live on the users row // Currency ledger rows (duckets/diamonds). Credits live on the users row
// itself; the alt-currencies sit in the users_currency ledger keyed by type. // itself; the alt-currencies sit in the users_currency ledger keyed by type.
const currencyRows = await prisma.usersCurrency const currencyRows = await db
.findMany({ .select({
where: { type: UsersCurrency.type,
userId: user.id, amount: UsersCurrency.amount,
type: { in: [CURRENCY_DUCKETS, CURRENCY_DIAMONDS] },
},
select: { type: true, amount: true },
}) })
.from(UsersCurrency)
.where(
and(
eq(UsersCurrency.userId, user.id),
inArray(UsersCurrency.type, [CURRENCY_DUCKETS, CURRENCY_DIAMONDS]),
),
)
.catch(() => []); .catch(() => []);
const ducketsAmount = const ducketsAmount =
currencyRows.find((c) => c.type === CURRENCY_DUCKETS)?.amount ?? 0; currencyRows.find((c) => c.type === CURRENCY_DUCKETS)?.amount ?? 0;
@@ -207,21 +240,23 @@ export default async function ProfilePage({
// Friends. messenger_friendships stores one directed row per pair keyed on // Friends. messenger_friendships stores one directed row per pair keyed on
// user_one_id; resolve the user_two_id side to usernames/looks for the grid. // user_one_id; resolve the user_two_id side to usernames/looks for the grid.
const friendships = await prisma.messengerFriendships const friendships = await db
.findMany({ .select({ userTwoId: MessengerFriendships.userTwoId })
where: { userOneId: user.id }, .from(MessengerFriendships)
orderBy: { friendsSince: "desc" }, .where(eq(MessengerFriendships.userOneId, user.id))
take: 18, .orderBy(desc(MessengerFriendships.friendsSince))
select: { userTwoId: true }, .limit(18)
})
.catch(() => []); .catch(() => []);
const friendIds = Array.from(new Set(friendships.map((f) => f.userTwoId))); const friendIds = Array.from(new Set(friendships.map((f) => f.userTwoId)));
const friendUsers = friendIds.length const friendUsers = friendIds.length
? await prisma.user ? await db
.findMany({ .select({
where: { id: { in: friendIds } }, id: User.id,
select: { id: true, username: true, look: true }, username: User.username,
look: User.look,
}) })
.from(User)
.where(inArray(User.id, friendIds))
.catch(() => []) .catch(() => [])
: []; : [];
const friendById = new Map(friendUsers.map((f) => [f.id, f])); const friendById = new Map(friendUsers.map((f) => [f.id, f]));
@@ -230,52 +265,54 @@ export default async function ProfilePage({
.filter((f): f is NonNullable<typeof f> => Boolean(f)); .filter((f): f is NonNullable<typeof f> => Boolean(f));
// Rooms owned by this user. // Rooms owned by this user.
const rooms = await prisma.rooms const rooms = await db
.findMany({ .select({
where: { ownerId: user.id }, id: Rooms.id,
orderBy: { users: "desc" }, name: Rooms.name,
take: 8, users: Rooms.users,
select: { usersMax: Rooms.usersMax,
id: true, state: Rooms.state,
name: true,
users: true,
usersMax: true,
state: true,
},
}) })
.from(Rooms)
.where(eq(Rooms.ownerId, user.id))
.orderBy(desc(Rooms.users))
.limit(8)
.catch(() => []); .catch(() => []);
const badges = await prisma.usersBadges const badges = await db
.findMany({ .select()
where: { userId: user.id }, .from(UsersBadges)
orderBy: { slotId: "asc" }, .where(eq(UsersBadges.userId, user.id))
}) .orderBy(asc(UsersBadges.slotId))
.catch(() => []); .catch(() => []);
const photos = await prisma.cameraWeb const photos = await db
.findMany({ .select()
where: { userId: user.id }, .from(CameraWeb)
orderBy: { timestamp: "desc" }, .where(eq(CameraWeb.userId, user.id))
take: 6, .orderBy(desc(CameraWeb.timestamp))
}) .limit(6)
.catch(() => []); .catch(() => []);
const guestbook = await prisma.websiteUserGuestbooks const guestbook = await db
.findMany({ .select()
where: { profileId: user.id }, .from(WebsiteUserGuestbooks)
orderBy: { createdAt: "desc" }, .where(eq(WebsiteUserGuestbooks.profileId, user.id))
take: 30, .orderBy(desc(WebsiteUserGuestbooks.createdAt))
}) .limit(30)
.catch(() => []); .catch(() => []);
// Resolve author usernames/looks for the guestbook entries in one query. // Resolve author usernames/looks for the guestbook entries in one query.
const authorIds = Array.from(new Set(guestbook.map((g) => g.userId))); const authorIds = Array.from(new Set(guestbook.map((g) => g.userId)));
const authors = authorIds.length const authors = authorIds.length
? await prisma.user ? await db
.findMany({ .select({
where: { id: { in: authorIds } }, id: User.id,
select: { id: true, username: true, look: true }, username: User.username,
look: User.look,
}) })
.from(User)
.where(inArray(User.id, authorIds))
.catch(() => []) .catch(() => [])
: []; : [];
const authorById = new Map(authors.map((a) => [a.id, a])); const authorById = new Map(authors.map((a) => [a.id, a]));
+11 -9
View File
@@ -1,8 +1,9 @@
import { eq } from "drizzle-orm";
import { CheckCircle2, Clock, MailX } from "lucide-react"; import { CheckCircle2, Clock, MailX } 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 { isValidVerificationToken } from "@/actions/email-verify"; import { isValidVerificationToken } from "@/actions/email-verify";
import { prisma } from "@/lib/prisma"; import { db, User } from "@/lib/db";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
@@ -88,19 +89,20 @@ export default async function VerifyPage({
const ok = await isValidVerificationToken(normalisedEmail, token); const ok = await isValidVerificationToken(normalisedEmail, token);
if (ok) { if (ok) {
try { try {
const user = await prisma.user.findFirst({ const [user] = await db
where: { mail: normalisedEmail }, .select({ id: User.id, mailVerified: User.mailVerified })
select: { id: true, mailVerified: true }, .from(User)
}); .where(eq(User.mail, normalisedEmail))
.limit(1);
if (!user) { if (!user) {
status = "invalid"; status = "invalid";
} else if (user.mailVerified === "1") { } else if (user.mailVerified === "1") {
status = "already"; status = "already";
} else { } else {
await prisma.user.update({ await db
where: { id: user.id }, .update(User)
data: { mailVerified: "1" }, .set({ mailVerified: "1" })
}); .where(eq(User.id, user.id));
status = "verified"; status = "verified";
} }
} catch { } catch {
+18 -13
View File
@@ -1,8 +1,9 @@
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 { StatusCard } from "@/components/admin/dashboard"; import { StatusCard } from "@/components/admin/dashboard";
import { Achievements, db } from "@/lib/db";
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions"; import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
import { prisma } from "@/lib/prisma";
type Achievement = { type Achievement = {
name: string; name: string;
@@ -24,18 +25,22 @@ export default async function AdminAchievements() {
let achievements: Achievement[]; let achievements: Achievement[];
try { try {
achievements = await prisma.achievements.findMany({ achievements = await db
select: { .select({
name: true, name: Achievements.name,
category: true, category: Achievements.category,
level: true, level: Achievements.level,
rewardAmount: true, rewardAmount: Achievements.rewardAmount,
rewardType: true, rewardType: Achievements.rewardType,
points: true, points: Achievements.points,
progressNeeded: true, progressNeeded: Achievements.progressNeeded,
}, })
orderBy: [{ category: "asc" }, { name: "asc" }, { level: "asc" }], .from(Achievements)
}); .orderBy(
asc(Achievements.category),
asc(Achievements.name),
asc(Achievements.level),
);
} catch { } catch {
achievements = []; achievements = [];
} }
+9 -3
View File
@@ -1,11 +1,12 @@
import { eq } from "drizzle-orm";
import { Megaphone } from "lucide-react"; import { Megaphone } from "lucide-react";
import Link from "next/link"; import Link from "next/link";
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 { updateAd } from "@/actions/admin-ads"; import { updateAd } from "@/actions/admin-ads";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { db, WebsiteAds } from "@/lib/db";
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions"; import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
import { prisma } from "@/lib/prisma";
import { EditAdDeleteButton } from "../ads-table"; import { EditAdDeleteButton } from "../ads-table";
export default async function EditAd({ export default async function EditAd({
@@ -19,9 +20,14 @@ export default async function EditAd({
} }
const { id } = await params; const { id } = await params;
let ad: Awaited<ReturnType<typeof prisma.websiteAds.findUnique>> = null; let ad: typeof WebsiteAds.$inferSelect | null = null;
try { try {
ad = await prisma.websiteAds.findUnique({ where: { id: BigInt(id) } }); const [row] = await db
.select()
.from(WebsiteAds)
.where(eq(WebsiteAds.id, BigInt(id)))
.limit(1);
ad = row ?? null;
} catch { } catch {
notFound(); notFound();
} }
+8 -6
View File
@@ -1,11 +1,12 @@
import { desc } from "drizzle-orm";
import Link from "next/link"; import Link from "next/link";
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 { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { db, WebsiteAds } 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 { AdsTable } from "./ads-table"; import { AdsTable } from "./ads-table";
export default async function AdminAds() { export default async function AdminAds() {
@@ -15,12 +16,13 @@ export default async function AdminAds() {
} }
const t = await getTranslations("pages.admin.ads"); const t = await getTranslations("pages.admin.ads");
let ads: Awaited<ReturnType<typeof prisma.websiteAds.findMany>>; let ads: (typeof WebsiteAds.$inferSelect)[] = [];
try { try {
ads = await prisma.websiteAds.findMany({ ads = await db
orderBy: { id: "desc" }, .select()
take: 500, .from(WebsiteAds)
}); .orderBy(desc(WebsiteAds.id))
.limit(500);
} catch { } catch {
ads = []; ads = [];
} }
+8 -6
View File
@@ -1,11 +1,12 @@
import { desc } 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 { sendHotelAlert } from "@/actions/admin-alerts"; import { sendHotelAlert } from "@/actions/admin-alerts";
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 { AlertLogs, db } 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";
const CRITICAL = new Set(["critical", "error", "danger"]); const CRITICAL = new Set(["critical", "error", "danger"]);
const WARNING = new Set(["warning", "warn"]); const WARNING = new Set(["warning", "warn"]);
@@ -58,12 +59,13 @@ export default async function AdminAlerts() {
const t = await getTranslations("pages.admin.alerts"); const t = await getTranslations("pages.admin.alerts");
let alerts: Awaited<ReturnType<typeof prisma.alertLogs.findMany>>; let alerts: (typeof AlertLogs.$inferSelect)[] = [];
try { try {
alerts = await prisma.alertLogs.findMany({ alerts = await db
orderBy: { id: "desc" }, .select()
take: 100, .from(AlertLogs)
}); .orderBy(desc(AlertLogs.id))
.limit(100);
} catch { } catch {
alerts = []; alerts = [];
} }
+45 -29
View File
@@ -1,10 +1,11 @@
import { count, gte, inArray, sql } from "drizzle-orm";
import { MessageSquare, Shield, Terminal, Users } from "lucide-react"; import { MessageSquare, Shield, Terminal, Users } from "lucide-react";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { getLocale, getTranslations } from "next-intl/server"; import { getLocale, getTranslations } from "next-intl/server";
import { StatsCard } from "@/components/admin/stats-card"; import { StatsCard } from "@/components/admin/stats-card";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Ban, db, User } from "@/lib/db";
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions"; import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
import { prisma } from "@/lib/prisma";
import { redisCache } from "@/lib/redis-cache"; import { redisCache } from "@/lib/redis-cache";
type DayRow = { type DayRow = {
@@ -34,11 +35,16 @@ async function safeCount(
} }
} }
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 safeRawCount( async function safeRawCount(
sql: Promise<{ count: bigint }[]>, query: ReturnType<typeof sql>,
): Promise<{ value: number; ok: boolean }> { ): Promise<{ value: number; ok: boolean }> {
try { try {
const rows = await sql; const rows = await rawRows<{ count: bigint }>(query);
return { value: Number(rows[0]?.count ?? 0), ok: true }; return { value: Number(rows[0]?.count ?? 0), ok: true };
} catch { } catch {
return { value: 0, ok: false }; return { value: 0, ok: false };
@@ -81,24 +87,24 @@ async function getActivityData(
try { try {
const [chatRows, cmdRows, banRows, regRows] = await Promise.all([ const [chatRows, cmdRows, banRows, regRows] = await Promise.all([
prisma.$queryRaw<{ day_bucket: bigint; count: bigint }[]>` rawRows<{ day_bucket: bigint; count: bigint }>(sql`
SELECT FLOOR(timestamp / 86400) AS day_bucket, COUNT(*) AS count SELECT FLOOR(timestamp / 86400) AS day_bucket, COUNT(*) AS count
FROM chatlogs_room WHERE timestamp >= ${rangeStart} GROUP BY day_bucket`.catch( FROM chatlogs_room WHERE timestamp >= ${rangeStart} GROUP BY day_bucket`).catch(
() => [], () => [],
), ),
prisma.$queryRaw<{ day_bucket: bigint; count: bigint }[]>` rawRows<{ day_bucket: bigint; count: bigint }>(sql`
SELECT FLOOR(timestamp / 86400) AS day_bucket, COUNT(*) AS count SELECT FLOOR(timestamp / 86400) AS day_bucket, COUNT(*) AS count
FROM commandlogs WHERE timestamp >= ${rangeStart} GROUP BY day_bucket`.catch( FROM commandlogs WHERE timestamp >= ${rangeStart} GROUP BY day_bucket`).catch(
() => [], () => [],
), ),
prisma.$queryRaw<{ day_bucket: bigint; count: bigint }[]>` rawRows<{ day_bucket: bigint; count: bigint }>(sql`
SELECT FLOOR(timestamp / 86400) AS day_bucket, COUNT(*) AS count SELECT FLOOR(timestamp / 86400) AS day_bucket, COUNT(*) AS count
FROM bans WHERE timestamp >= ${rangeStart} GROUP BY day_bucket`.catch( FROM bans WHERE timestamp >= ${rangeStart} GROUP BY day_bucket`).catch(
() => [], () => [],
), ),
prisma.$queryRaw<{ day_bucket: bigint; count: bigint }[]>` rawRows<{ day_bucket: bigint; count: bigint }>(sql`
SELECT FLOOR(account_created / 86400) AS day_bucket, COUNT(*) AS count SELECT FLOOR(account_created / 86400) AS day_bucket, COUNT(*) AS count
FROM users WHERE account_created >= ${rangeStart} GROUP BY day_bucket`.catch( FROM users WHERE account_created >= ${rangeStart} GROUP BY day_bucket`).catch(
() => [], () => [],
), ),
]); ]);
@@ -124,19 +130,29 @@ async function getActivityData(
const [weekChats, weekCommands, weekBans, weekRegistrations] = const [weekChats, weekCommands, weekBans, weekRegistrations] =
await Promise.all([ await Promise.all([
safeRawCount( safeRawCount(sql`
prisma.$queryRaw<{ count: bigint }[]>` SELECT COUNT(*) AS count FROM chatlogs_room WHERE timestamp >= ${weekAgo}`),
SELECT COUNT(*) AS count FROM chatlogs_room WHERE timestamp >= ${weekAgo}`, safeRawCount(sql`
SELECT COUNT(*) AS count FROM commandlogs WHERE timestamp >= ${weekAgo}`),
safeCount(async () =>
Number(
(
await db
.select({ total: count() })
.from(Ban)
.where(gte(Ban.timestamp, weekAgo))
)[0]?.total ?? 0,
),
), ),
safeRawCount( safeCount(async () =>
prisma.$queryRaw<{ count: bigint }[]>` Number(
SELECT COUNT(*) AS count FROM commandlogs WHERE timestamp >= ${weekAgo}`, (
), await db
safeCount(() => .select({ total: count() })
prisma.ban.count({ where: { timestamp: { gte: weekAgo } } }), .from(User)
), .where(gte(User.accountCreated, weekAgo))
safeCount(() => )[0]?.total ?? 0,
prisma.user.count({ where: { accountCreated: { gte: weekAgo } } }), ),
), ),
]); ]);
@@ -146,18 +162,18 @@ async function getActivityData(
let topChatters: ActivityData["topChatters"] = []; let topChatters: ActivityData["topChatters"] = [];
try { try {
const rows = await prisma.$queryRaw<{ userId: number; messages: bigint }[]>` const rows = await rawRows<{ userId: number; messages: bigint }>(sql`
SELECT user_from_id AS userId, COUNT(*) AS messages SELECT user_from_id AS userId, COUNT(*) AS messages
FROM chatlogs_room WHERE timestamp >= ${weekAgo} FROM chatlogs_room WHERE timestamp >= ${weekAgo}
GROUP BY user_from_id ORDER BY messages DESC LIMIT 10 GROUP BY user_from_id ORDER BY messages DESC LIMIT 10
`; `);
const userIds = rows.map((c) => Number(c.userId)).filter((n) => n > 0); const userIds = rows.map((c) => Number(c.userId)).filter((n) => n > 0);
const users = const users =
userIds.length > 0 userIds.length > 0
? await prisma.user.findMany({ ? 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))
: []; : [];
const userMap = new Map(users.map((u) => [u.id, u.username])); const userMap = new Map(users.map((u) => [u.id, u.username]));
topChatters = rows.map((c) => { topChatters = rows.map((c) => {
+87 -40
View File
@@ -1,3 +1,4 @@
import { and, count, eq, gte, sql, sum } from "drizzle-orm";
import { ArrowLeftRight } from "lucide-react"; import { ArrowLeftRight } from "lucide-react";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { getLocale, getTranslations } from "next-intl/server"; import { getLocale, getTranslations } from "next-intl/server";
@@ -5,8 +6,14 @@ import { RevenueChart } from "@/components/admin/revenue-chart";
import { StatsCard } from "@/components/admin/stats-card"; import { StatsCard } from "@/components/admin/stats-card";
import { CurrencyIcon } from "@/components/shared/currency-icon"; import { CurrencyIcon } from "@/components/shared/currency-icon";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import {
db,
LogsShopPurchases,
MarketplaceItems,
RoomTradeLog,
User,
} from "@/lib/db";
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions"; import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
import { prisma } from "@/lib/prisma";
import { redisCache } from "@/lib/redis-cache"; import { redisCache } from "@/lib/redis-cache";
type EconomyData = { type EconomyData = {
@@ -43,6 +50,11 @@ async function safeCount(fn: () => Promise<number>): Promise<number> {
} }
} }
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 getEconomyData( async function getEconomyData(
locale: string, locale: string,
): Promise<{ data: EconomyData; degraded: boolean }> { ): Promise<{ data: EconomyData; degraded: boolean }> {
@@ -54,49 +66,86 @@ async function getEconomyData(
try { try {
const [creditsAgg, pixelsAgg, pointsAgg] = await Promise.all([ const [creditsAgg, pixelsAgg, pointsAgg] = await Promise.all([
prisma.user.aggregate({ _sum: { credits: true } }).catch(() => { db
degraded = true; .select({ total: sum(User.credits) })
return null; .from(User)
}), .catch(() => {
prisma.user.aggregate({ _sum: { pixels: true } }).catch(() => { degraded = true;
degraded = true; return [{ total: null }];
return null; }),
}), db
prisma.user.aggregate({ _sum: { points: true } }).catch(() => { .select({ total: sum(User.pixels) })
degraded = true; .from(User)
return null; .catch(() => {
}), degraded = true;
return [{ total: null }];
}),
db
.select({ total: sum(User.points) })
.from(User)
.catch(() => {
degraded = true;
return [{ total: null }];
}),
]); ]);
const totalCredits = creditsAgg?._sum.credits ?? 0; const totalCredits = Number(creditsAgg[0]?.total ?? 0);
const totalPixels = pixelsAgg?._sum.pixels ?? 0; const totalPixels = Number(pixelsAgg[0]?.total ?? 0);
const totalPoints = pointsAgg?._sum.points ?? 0; const totalPoints = Number(pointsAgg[0]?.total ?? 0);
let shopPurchases = 0; let shopPurchases = 0;
let shopCreditsSpent = 0; let shopCreditsSpent = 0;
let shopPointsSpent = 0; let shopPointsSpent = 0;
try { try {
const shopRevenue = await prisma.logsShopPurchases.aggregate({ const [shopRevenue] = await db
where: { timestamp: { gte: weekAgo } }, .select({
_sum: { costCredits: true, costPoints: true }, purchases: count(),
_count: true, credits: sum(LogsShopPurchases.costCredits),
}); points: sum(LogsShopPurchases.costPoints),
shopPurchases = })
typeof shopRevenue._count === "number" ? shopRevenue._count : 0; .from(LogsShopPurchases)
shopCreditsSpent = shopRevenue._sum?.costCredits ?? 0; .where(gte(LogsShopPurchases.timestamp, weekAgo));
shopPointsSpent = shopRevenue._sum?.costPoints ?? 0; shopPurchases = Number(shopRevenue?.purchases ?? 0);
shopCreditsSpent = Number(shopRevenue?.credits ?? 0);
shopPointsSpent = Number(shopRevenue?.points ?? 0);
} catch { } catch {
degraded = true; degraded = true;
} }
const [marketplaceActive, marketplaceSold, weekTrades] = await Promise.all([ const [marketplaceActive, marketplaceSold, weekTrades] = await Promise.all([
safeCount(() => prisma.marketplaceItems.count({ where: { state: 1 } })), safeCount(async () =>
safeCount(() => Number(
prisma.marketplaceItems.count({ (
where: { state: 2, soldTimestamp: { gte: weekAgo } }, await db
}), .select({ total: count() })
.from(MarketplaceItems)
.where(eq(MarketplaceItems.state, 1))
)[0]?.total ?? 0,
),
), ),
safeCount(() => safeCount(async () =>
prisma.roomTradeLog.count({ where: { timestamp: { gte: weekAgo } } }), Number(
(
await db
.select({ total: count() })
.from(MarketplaceItems)
.where(
and(
eq(MarketplaceItems.state, 2),
gte(MarketplaceItems.soldTimestamp, weekAgo),
),
)
)[0]?.total ?? 0,
),
),
safeCount(async () =>
Number(
(
await db
.select({ total: count() })
.from(RoomTradeLog)
.where(gte(RoomTradeLog.timestamp, weekAgo))
)[0]?.total ?? 0,
),
), ),
]); ]);
@@ -112,20 +161,18 @@ async function getEconomyData(
} }
try { try {
const revRows = await prisma.$queryRaw< const revRows = await rawRows<{
{ day_bucket: bigint;
day_bucket: bigint; credits: number | bigint;
credits: number | bigint; points: number | bigint;
points: number | bigint; }>(sql`
}[]
>`
SELECT FLOOR(timestamp / 86400) AS day_bucket, SELECT FLOOR(timestamp / 86400) AS day_bucket,
COALESCE(SUM(cost_credits), 0) AS credits, COALESCE(SUM(cost_credits), 0) AS credits,
COALESCE(SUM(cost_points), 0) AS points COALESCE(SUM(cost_points), 0) AS points
FROM logs_shop_purchases FROM logs_shop_purchases
WHERE timestamp >= ${rangeStart} WHERE timestamp >= ${rangeStart}
GROUP BY day_bucket ORDER BY day_bucket GROUP BY day_bucket ORDER BY day_bucket
`; `);
const revMap = new Map( const revMap = new Map(
revRows.map((r) => [ revRows.map((r) => [
Number(r.day_bucket), Number(r.day_bucket),
+80 -26
View File
@@ -1,3 +1,4 @@
import { count, eq, gt, gte, sql } from "drizzle-orm";
import { import {
ArrowLeftRight, ArrowLeftRight,
Clock, Clock,
@@ -12,8 +13,8 @@ import { DashboardChart } from "@/components/admin/dashboard-chart";
import { PeakHoursHeatmap } from "@/components/admin/peak-hours-heatmap"; import { PeakHoursHeatmap } from "@/components/admin/peak-hours-heatmap";
import { StatsCard } from "@/components/admin/stats-card"; import { StatsCard } from "@/components/admin/stats-card";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Ban, db, Rooms, RoomTradeLog, User } from "@/lib/db";
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions"; import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
import { prisma } from "@/lib/prisma";
import { redisCache } from "@/lib/redis-cache"; import { redisCache } from "@/lib/redis-cache";
type AnalyticsData = { type AnalyticsData = {
@@ -57,6 +58,11 @@ async function safeCount(
} }
} }
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 getAnalyticsData( async function getAnalyticsData(
locale: string, locale: string,
): Promise<{ data: AnalyticsData; degraded: boolean }> { ): Promise<{ data: AnalyticsData; degraded: boolean }> {
@@ -78,30 +84,82 @@ async function getAnalyticsData(
weekTrades, weekTrades,
weekBans, weekBans,
] = await Promise.all([ ] = await Promise.all([
safeCount(() => prisma.user.count()), safeCount(async () =>
safeCount(() => prisma.user.count({ where: { online: "1" } })), Number((await db.select({ total: count() }).from(User))[0]?.total ?? 0),
safeCount(() =>
prisma.user.count({ where: { accountCreated: { gte: weekAgo } } }),
), ),
safeCount(() => safeCount(async () =>
prisma.user.count({ where: { accountCreated: { gte: monthAgo } } }), Number(
(
await db
.select({ total: count() })
.from(User)
.where(eq(User.online, "1"))
)[0]?.total ?? 0,
),
),
safeCount(async () =>
Number(
(
await db
.select({ total: count() })
.from(User)
.where(gte(User.accountCreated, weekAgo))
)[0]?.total ?? 0,
),
),
safeCount(async () =>
Number(
(
await db
.select({ total: count() })
.from(User)
.where(gte(User.accountCreated, monthAgo))
)[0]?.total ?? 0,
),
),
safeCount(async () =>
Number(
(await db.select({ total: count() }).from(Rooms))[0]?.total ?? 0,
),
),
safeCount(async () =>
Number(
(
await db
.select({ total: count() })
.from(Rooms)
.where(gt(Rooms.users, 0))
)[0]?.total ?? 0,
),
), ),
safeCount(() => prisma.rooms.count()),
safeCount(() => prisma.rooms.count({ where: { users: { gt: 0 } } })),
(async () => { (async () => {
try { try {
const rows = await prisma.$queryRaw<{ count: bigint }[]>` const rows = await rawRows<{ count: bigint }>(sql`
SELECT COUNT(*) AS count FROM chatlogs_room WHERE timestamp >= ${weekAgo}`; SELECT COUNT(*) AS count FROM chatlogs_room WHERE timestamp >= ${weekAgo}`);
return { value: Number(rows[0]?.count ?? 0), ok: true }; return { value: Number(rows[0]?.count ?? 0), ok: true };
} catch { } catch {
return { value: 0, ok: false }; return { value: 0, ok: false };
} }
})(), })(),
safeCount(() => safeCount(async () =>
prisma.roomTradeLog.count({ where: { timestamp: { gte: weekAgo } } }), Number(
(
await db
.select({ total: count() })
.from(RoomTradeLog)
.where(gte(RoomTradeLog.timestamp, weekAgo))
)[0]?.total ?? 0,
),
), ),
safeCount(() => safeCount(async () =>
prisma.ban.count({ where: { timestamp: { gte: weekAgo } } }), Number(
(
await db
.select({ total: count() })
.from(Ban)
.where(gte(Ban.timestamp, weekAgo))
)[0]?.total ?? 0,
),
), ),
]); ]);
@@ -121,14 +179,12 @@ async function getAnalyticsData(
let registrationChart: { label: string; value: number }[] = []; let registrationChart: { label: string; value: number }[] = [];
try { try {
const regRows = await prisma.$queryRaw< const regRows = await rawRows<{ day_bucket: bigint; count: bigint }>(sql`
{ day_bucket: bigint; count: bigint }[]
>`
SELECT FLOOR(account_created / 86400) AS day_bucket, COUNT(*) AS count SELECT FLOOR(account_created / 86400) AS day_bucket, COUNT(*) AS count
FROM users FROM users
WHERE account_created >= ${monthAgo} WHERE account_created >= ${monthAgo}
GROUP BY day_bucket ORDER BY day_bucket GROUP BY day_bucket ORDER BY day_bucket
`; `);
const regCountMap = new Map( const regCountMap = new Map(
regRows.map((r) => [Number(r.day_bucket), Number(r.count)]), regRows.map((r) => [Number(r.day_bucket), Number(r.count)]),
); );
@@ -152,23 +208,21 @@ async function getAnalyticsData(
const grid: number[][] = Array.from({ length: 7 }, () => Array(24).fill(0)); const grid: number[][] = Array.from({ length: 7 }, () => Array(24).fill(0));
let max = 0; let max = 0;
try { try {
const peakRows = await prisma.$queryRaw< const peakRows = await rawRows<{ d: number; h: number; c: bigint }>(sql`
{ d: number; h: number; c: bigint }[]
>`
SELECT ((DAYOFWEEK(FROM_UNIXTIME(last_online)) + 5) % 7) AS d, SELECT ((DAYOFWEEK(FROM_UNIXTIME(last_online)) + 5) % 7) AS d,
HOUR(FROM_UNIXTIME(last_online)) AS h, HOUR(FROM_UNIXTIME(last_online)) AS h,
COUNT(*) AS c COUNT(*) AS c
FROM users FROM users
WHERE last_online >= ${weekAgo} WHERE last_online >= ${weekAgo}
GROUP BY d, h GROUP BY d, h
`; `);
for (const r of peakRows) { for (const r of peakRows) {
const day = Number(r.d); const day = Number(r.d);
const hour = Number(r.h); const hour = Number(r.h);
if (day < 0 || day > 6 || hour < 0 || hour > 23) continue; if (day < 0 || day > 6 || hour < 0 || hour > 23) continue;
const count = Number(r.c); const cnt = Number(r.c);
grid[day][hour] = count; grid[day][hour] = cnt;
if (count > max) max = count; if (cnt > max) max = cnt;
} }
} catch { } catch {
degraded = true; degraded = true;
+11 -9
View File
@@ -1,12 +1,13 @@
import { desc, inArray } from "drizzle-orm";
import Link from "next/link"; import Link from "next/link";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { getTranslations } from "next-intl/server"; import { getTranslations } from "next-intl/server";
import { dismissApplication } from "@/actions/admin-applications"; import { dismissApplication } from "@/actions/admin-applications";
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, User, WebsiteStaffApplications } 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";
type ApplicationRow = { type ApplicationRow = {
id: bigint; id: bigint;
@@ -25,10 +26,11 @@ export default async function AdminApplications() {
const t = await getTranslations("pages.admin.applications"); const t = await getTranslations("pages.admin.applications");
let applications: ApplicationRow[]; let applications: ApplicationRow[];
try { try {
applications = await prisma.websiteStaffApplications.findMany({ applications = await db
orderBy: { createdAt: "desc" }, .select()
take: 100, .from(WebsiteStaffApplications)
}); .orderBy(desc(WebsiteStaffApplications.createdAt))
.limit(100);
} catch { } catch {
applications = []; applications = [];
} }
@@ -38,10 +40,10 @@ export default async function AdminApplications() {
if (applications.length > 0) { if (applications.length > 0) {
try { try {
const ids = Array.from(new Set(applications.map((a) => a.userId))); const ids = Array.from(new Set(applications.map((a) => a.userId)));
const users = await prisma.user.findMany({ const users = await db
where: { id: { in: ids } }, .select({ id: User.id, username: User.username })
select: { id: true, username: true }, .from(User)
}); .where(inArray(User.id, ids));
for (const u of users) userMap.set(u.id, u.username); for (const u of users) userMap.set(u.id, u.username);
} catch { } catch {
// no DB — fall back to raw ids // no DB — fall back to raw ids
+14 -13
View File
@@ -1,3 +1,4 @@
import { eq } from "drizzle-orm";
import { Newspaper } from "lucide-react"; import { Newspaper } from "lucide-react";
import Link from "next/link"; import Link from "next/link";
import { notFound, redirect } from "next/navigation"; import { notFound, redirect } from "next/navigation";
@@ -5,8 +6,8 @@ import { getTranslations } from "next-intl/server";
import { deleteArticle, updateArticle } from "@/actions/admin-articles"; import { deleteArticle, updateArticle } from "@/actions/admin-articles";
import { ArticleForm } from "@/components/admin/article-form"; import { ArticleForm } from "@/components/admin/article-form";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { db, WebsiteArticles } from "@/lib/db";
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions"; import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
import { prisma } from "@/lib/prisma";
export default async function EditArticle({ export default async function EditArticle({
params, params,
@@ -23,19 +24,19 @@ export default async function EditArticle({
const { id } = await params; const { id } = await params;
const { error } = await searchParams; const { error } = await searchParams;
const t = await getTranslations("pages.admin.articles"); const t = await getTranslations("pages.admin.articles");
const article = await prisma.websiteArticles const [article] = await db
.findUnique({ .select({
where: { id: BigInt(id) }, id: WebsiteArticles.id,
select: { slug: WebsiteArticles.slug,
id: true, title: WebsiteArticles.title,
slug: true, shortStory: WebsiteArticles.shortStory,
title: true, fullStory: WebsiteArticles.fullStory,
shortStory: true, image: WebsiteArticles.image,
fullStory: true,
image: true,
},
}) })
.catch(() => null); .from(WebsiteArticles)
.where(eq(WebsiteArticles.id, BigInt(id)))
.limit(1)
.catch(() => []);
if (!article) notFound(); if (!article) notFound();
return ( return (
+17 -18
View File
@@ -1,3 +1,4 @@
import { desc, inArray } from "drizzle-orm";
import { Newspaper } from "lucide-react"; import { Newspaper } from "lucide-react";
import Link from "next/link"; import Link from "next/link";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
@@ -6,9 +7,9 @@ import { AdminPageShell } from "@/components/admin/admin-page-shell";
import { ArticleCard } from "@/components/admin/article-card"; import { ArticleCard } from "@/components/admin/article-card";
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, User, WebsiteArticles } 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";
export default async function AdminArticles({ export default async function AdminArticles({
searchParams, searchParams,
@@ -22,19 +23,18 @@ export default async function AdminArticles({
const { error } = await searchParams; const { error } = await searchParams;
const t = await getTranslations("pages.admin.articles"); const t = await getTranslations("pages.admin.articles");
const articles = await prisma.websiteArticles const articles = await db
.findMany({ .select({
orderBy: { createdAt: "desc" }, id: WebsiteArticles.id,
select: { title: WebsiteArticles.title,
id: true, slug: WebsiteArticles.slug,
title: true, image: WebsiteArticles.image,
slug: true, createdAt: WebsiteArticles.createdAt,
image: true, userId: WebsiteArticles.userId,
createdAt: true,
userId: true,
},
take: 50,
}) })
.from(WebsiteArticles)
.orderBy(desc(WebsiteArticles.createdAt))
.limit(50)
.catch(() => []); .catch(() => []);
const authorMap = new Map<number, string>(); const authorMap = new Map<number, string>();
@@ -42,11 +42,10 @@ export default async function AdminArticles({
.map((a) => a.userId) .map((a) => a.userId)
.filter((id): id is number => Boolean(id)); .filter((id): id is number => Boolean(id));
if (authorIds.length > 0) { if (authorIds.length > 0) {
const authors = await prisma.user const authors = await db
.findMany({ .select({ id: User.id, username: User.username })
where: { id: { in: authorIds } }, .from(User)
select: { id: true, username: true }, .where(inArray(User.id, authorIds))
})
.catch(() => []); .catch(() => []);
for (const u of authors) authorMap.set(u.id, u.username); for (const u of authors) authorMap.set(u.id, u.username);
} }
+8 -6
View File
@@ -1,10 +1,11 @@
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 { uploadBadge } from "@/actions/admin-badge-upload"; import { uploadBadge } from "@/actions/admin-badge-upload";
import { giveBadge } from "@/actions/admin-badges"; import { giveBadge } from "@/actions/admin-badges";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { db, WebsiteBadges } 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: "Badges" }; export const metadata = { title: "Badges" };
@@ -21,12 +22,13 @@ export default async function AdminBadges({
const t = await getTranslations("pages.admin.badges"); const t = await getTranslations("pages.admin.badges");
const { uploaded, error } = await searchParams; const { uploaded, error } = await searchParams;
let badges: Awaited<ReturnType<typeof prisma.websiteBadges.findMany>>; let badges: (typeof WebsiteBadges.$inferSelect)[] = [];
try { try {
badges = await prisma.websiteBadges.findMany({ badges = await db
orderBy: { badgeName: "asc" }, .select()
take: 500, .from(WebsiteBadges)
}); .orderBy(asc(WebsiteBadges.badgeName))
.limit(500);
} catch { } catch {
badges = []; badges = [];
} }
+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, WebsiteBanner } from "@/lib/db";
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions"; import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
import { prisma } from "@/lib/prisma";
import { BannersManager } from "./banners-manager"; import { BannersManager } from "./banners-manager";
export default async function BannersPage() { export default async function BannersPage() {
@@ -9,9 +10,10 @@ export default async function BannersPage() {
redirect("/admin"); redirect("/admin");
} }
const banners = await prisma.websiteBanner.findMany({ const banners = await db
orderBy: { sortOrder: "asc" }, .select()
}); .from(WebsiteBanner)
.orderBy(asc(WebsiteBanner.sortOrder));
return ( return (
<div className="space-y-6"> <div className="space-y-6">
+10 -8
View File
@@ -1,3 +1,4 @@
import { desc, eq, gt, or } from "drizzle-orm";
import { Ban } from "lucide-react"; import { Ban } 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";
@@ -5,10 +6,10 @@ import { createBan, liftBan } from "@/actions/admin-bans";
import { AdminPageShell } from "@/components/admin/admin-page-shell"; import { AdminPageShell } from "@/components/admin/admin-page-shell";
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 { activeBanWhere, unixNow } from "@/lib/bans"; import { unixNow } from "@/lib/bans";
import { Ban as BanTable, db } 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";
export default async function AdminBans() { export default async function AdminBans() {
const { session, permissions } = await getAdminContext(); const { session, permissions } = await getAdminContext();
@@ -18,13 +19,14 @@ export default async function AdminBans() {
const t = await getTranslations("pages.admin.bans"); const t = await getTranslations("pages.admin.bans");
const now = unixNow(); const now = unixNow();
let bans: Awaited<ReturnType<typeof prisma.ban.findMany>> = []; let bans: (typeof BanTable.$inferSelect)[] = [];
try { try {
bans = await prisma.ban.findMany({ bans = await db
where: activeBanWhere(now), .select()
orderBy: { timestamp: "desc" }, .from(BanTable)
take: 100, .where(or(eq(BanTable.banExpire, 0), gt(BanTable.banExpire, now)))
}); .orderBy(desc(BanTable.timestamp))
.limit(100);
} catch { } catch {
bans = []; bans = [];
} }
+35 -32
View File
@@ -1,9 +1,10 @@
import { asc, eq } from "drizzle-orm";
import Link from "next/link"; import Link from "next/link";
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 { CalendarCampaigns, CalendarRewards, db } 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";
type Campaign = { type Campaign = {
id: number; id: number;
@@ -48,18 +49,20 @@ export default async function AdminCalendarCampaign({
let campaign: Campaign | null = null; let campaign: Campaign | null = null;
try { try {
campaign = await prisma.calendarCampaigns.findUnique({ const [row] = await db
where: { id: campaignId }, .select({
select: { id: CalendarCampaigns.id,
id: true, name: CalendarCampaigns.name,
name: true, image: CalendarCampaigns.image,
image: true, startTimestamp: CalendarCampaigns.startTimestamp,
startTimestamp: true, totalDays: CalendarCampaigns.totalDays,
totalDays: true, lockExpired: CalendarCampaigns.lockExpired,
lockExpired: true, enabled: CalendarCampaigns.enabled,
enabled: true, })
}, .from(CalendarCampaigns)
}); .where(eq(CalendarCampaigns.id, campaignId))
.limit(1);
campaign = row ?? null;
} catch { } catch {
campaign = null; campaign = null;
} }
@@ -67,25 +70,25 @@ export default async function AdminCalendarCampaign({
let rewards: Reward[] = []; let rewards: Reward[] = [];
try { try {
rewards = await prisma.calendarRewards.findMany({ rewards = await db
where: { campaignId }, .select({
select: { id: CalendarRewards.id,
id: true, campaignId: CalendarRewards.campaignId,
campaignId: true, productName: CalendarRewards.productName,
productName: true, customImage: CalendarRewards.customImage,
customImage: true, credits: CalendarRewards.credits,
credits: true, pixels: CalendarRewards.pixels,
pixels: true, points: CalendarRewards.points,
points: true, pointsType: CalendarRewards.pointsType,
pointsType: true, badge: CalendarRewards.badge,
badge: true, itemId: CalendarRewards.itemId,
itemId: true, subscriptionType: CalendarRewards.subscriptionType,
subscriptionType: true, subscriptionDays: CalendarRewards.subscriptionDays,
subscriptionDays: true, })
}, .from(CalendarRewards)
orderBy: { id: "asc" }, .where(eq(CalendarRewards.campaignId, campaignId))
take: 500, .orderBy(asc(CalendarRewards.id))
}); .limit(500);
} catch { } catch {
rewards = []; rewards = [];
} }
+24 -20
View File
@@ -1,10 +1,11 @@
import { count, desc } from "drizzle-orm";
import Link from "next/link"; import Link from "next/link";
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 { CalendarCampaigns, CalendarRewards, db } 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";
type Campaign = { type Campaign = {
id: number; id: number;
@@ -27,19 +28,19 @@ export default async function AdminCalendar() {
let campaigns: Campaign[] = []; let campaigns: Campaign[] = [];
let loadError = false; let loadError = false;
try { try {
campaigns = await prisma.calendarCampaigns.findMany({ campaigns = await db
select: { .select({
id: true, id: CalendarCampaigns.id,
name: true, name: CalendarCampaigns.name,
image: true, image: CalendarCampaigns.image,
startTimestamp: true, startTimestamp: CalendarCampaigns.startTimestamp,
totalDays: true, totalDays: CalendarCampaigns.totalDays,
lockExpired: true, lockExpired: CalendarCampaigns.lockExpired,
enabled: true, enabled: CalendarCampaigns.enabled,
}, })
orderBy: { id: "desc" }, .from(CalendarCampaigns)
take: 200, .orderBy(desc(CalendarCampaigns.id))
}); .limit(200);
} catch { } catch {
campaigns = []; campaigns = [];
loadError = true; loadError = true;
@@ -48,13 +49,16 @@ export default async function AdminCalendar() {
const rewardCounts = new Map<number, number>(); const rewardCounts = new Map<number, number>();
let totalRewards = 0; let totalRewards = 0;
try { try {
const grouped = await prisma.calendarRewards.groupBy({ const grouped = await db
by: ["campaignId"], .select({
_count: { _all: true }, campaignId: CalendarRewards.campaignId,
}); total: count(),
})
.from(CalendarRewards)
.groupBy(CalendarRewards.campaignId);
for (const g of grouped) { for (const g of grouped) {
rewardCounts.set(g.campaignId, g._count._all); rewardCounts.set(g.campaignId, Number(g.total));
totalRewards += g._count._all; totalRewards += Number(g.total);
} }
} catch { } catch {
loadError = true; loadError = true;
+16 -9
View File
@@ -1,9 +1,10 @@
import { asc, eq } from "drizzle-orm";
import { ChevronRight } from "lucide-react"; import { ChevronRight } from "lucide-react";
import Link from "next/link"; import Link from "next/link";
import { notFound, redirect } from "next/navigation"; import { notFound, redirect } from "next/navigation";
import { Fragment } from "react"; import { Fragment } from "react";
import { CatalogPages, db } from "@/lib/db";
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions"; import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
import { prisma } from "@/lib/prisma";
import { loadCatalogItemsData } from "@/lib/services/catalog-items-loader"; import { loadCatalogItemsData } from "@/lib/services/catalog-items-loader";
import { getAncestors } from "@/lib/services/catalog-tree"; import { getAncestors } from "@/lib/services/catalog-tree";
import { CatalogDetailTabs } from "./catalog-detail-tabs"; import { CatalogDetailTabs } from "./catalog-detail-tabs";
@@ -22,18 +23,24 @@ export default async function CatalogEditPage({
const pageId = parseInt(id, 10); const pageId = parseInt(id, 10);
if (Number.isNaN(pageId)) notFound(); if (Number.isNaN(pageId)) notFound();
const catalogPage = await prisma.catalogPages.findUnique({ const [catalogPage] = await db
where: { id: pageId }, .select()
}); .from(CatalogPages)
.where(eq(CatalogPages.id, pageId))
.limit(1);
if (!catalogPage) notFound(); if (!catalogPage) notFound();
const [children, itemsData, ancestors] = await Promise.all([ const [children, itemsData, ancestors] = await Promise.all([
prisma.catalogPages.findMany({ db
where: { parentId: pageId }, .select({
orderBy: { orderNum: "asc" }, id: CatalogPages.id,
select: { id: true, caption: true, enabled: true }, caption: CatalogPages.caption,
}), enabled: CatalogPages.enabled,
})
.from(CatalogPages)
.where(eq(CatalogPages.parentId, pageId))
.orderBy(asc(CatalogPages.orderNum)),
loadCatalogItemsData(pageId), loadCatalogItemsData(pageId),
getAncestors(catalogPage.parentId), getAncestors(catalogPage.parentId),
]); ]);
@@ -1,6 +1,7 @@
import { asc, eq } from "drizzle-orm";
import { notFound, redirect } from "next/navigation"; import { notFound, redirect } from "next/navigation";
import { CatalogItemsBc, CatalogPagesBc, db } from "@/lib/db";
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions"; import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
import { prisma } from "@/lib/prisma";
import { BcPageDetail } from "../bc-manager"; import { BcPageDetail } from "../bc-manager";
export default async function BcPageDetailPage({ export default async function BcPageDetailPage({
@@ -19,13 +20,18 @@ export default async function BcPageDetailPage({
const canEdit = canAccess(permissions, PERMS.CATALOG_EDIT, session.user.rank); const canEdit = canAccess(permissions, PERMS.CATALOG_EDIT, session.user.rank);
const bcPage = await prisma.catalogPagesBc.findUnique({ where: { id } }); const [bcPage] = await db
.select()
.from(CatalogPagesBc)
.where(eq(CatalogPagesBc.id, id))
.limit(1);
if (!bcPage) notFound(); if (!bcPage) notFound();
const bcItems = await prisma.catalogItemsBc.findMany({ const bcItems = await db
where: { pageId: id }, .select()
orderBy: { orderNumber: "asc" }, .from(CatalogItemsBc)
}); .where(eq(CatalogItemsBc.pageId, id))
.orderBy(asc(CatalogItemsBc.orderNumber));
return ( return (
<BcPageDetail <BcPageDetail
+184 -106
View File
@@ -1,16 +1,41 @@
import {
and,
asc,
count,
desc,
eq,
inArray,
like,
or,
type SQL,
sql,
} from "drizzle-orm";
import type { AnyMySqlColumn } from "drizzle-orm/mysql-core";
import { Store } from "lucide-react"; import { Store } 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 { CatalogTree } from "@/components/admin/catalog-tree"; import { CatalogTree } from "@/components/admin/catalog-tree";
import { calcPagination, parseListParams } from "@/lib/admin-helpers"; import { calcPagination, parseListParams } from "@/lib/admin-helpers";
import {
CatalogItems,
CatalogItemsBc,
CatalogPages,
CatalogPagesBc,
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 { getCatalogItemCounts } from "@/lib/services/catalog-tree"; import { getCatalogItemCounts } from "@/lib/services/catalog-tree";
import { CatalogActions } from "./catalog-actions"; import { CatalogActions } from "./catalog-actions";
import { CatalogManagerDialog } from "./catalog-manager-dialog-wrapper"; import { CatalogManagerDialog } from "./catalog-manager-dialog-wrapper";
import { CatalogTable } from "./catalog-table"; import { CatalogTable } from "./catalog-table";
import { CatalogViewToggle } from "./catalog-view-toggle"; import { CatalogViewToggle } from "./catalog-view-toggle";
async function rawRows<T>(query: ReturnType<typeof sql>): Promise<T[]> {
const [rows] = (await db.execute(query)) as unknown as [T[], unknown];
return rows ?? [];
}
export default async function CatalogPagesPage({ export default async function CatalogPagesPage({
params: paramsPromise, params: paramsPromise,
searchParams, searchParams,
@@ -33,7 +58,6 @@ export default async function CatalogPagesPage({
const viewMode = sp.get("view") || "tree"; const viewMode = sp.get("view") || "tree";
const catalogType = sp.get("catalog") || "normal"; const catalogType = sp.get("catalog") || "normal";
// ── Builder Club mode ──────────────────────────────────────────
type TreeNodeShape = { type TreeNodeShape = {
id: number; id: number;
caption: string; caption: string;
@@ -48,41 +72,56 @@ export default async function CatalogPagesPage({
}; };
if (catalogType === "bc") { if (catalogType === "bc") {
const [totalBcPages, totalBcItems, enabledBcPages] = await Promise.all([ const [totalBcPagesRows, totalBcItemsRows, enabledBcPagesRows] =
prisma.catalogPagesBc.count(), await Promise.all([
prisma.catalogItemsBc.count(), db.select({ total: count() }).from(CatalogPagesBc),
prisma.catalogPagesBc.count({ where: { enabled: "1" } }), db.select({ total: count() }).from(CatalogItemsBc),
]); db
.select({ total: count() })
.from(CatalogPagesBc)
.where(eq(CatalogPagesBc.enabled, "1")),
]);
const totalBcPages = Number(totalBcPagesRows[0]?.total ?? 0);
const totalBcItems = Number(totalBcItemsRows[0]?.total ?? 0);
const enabledBcPages = Number(enabledBcPagesRows[0]?.total ?? 0);
// ── BC tree view ──────────────────────────────────────────────
let bcRootPages: TreeNodeShape[] = []; let bcRootPages: TreeNodeShape[] = [];
if (viewMode === "tree") { if (viewMode === "tree") {
const bcRootRaw = await prisma.catalogPagesBc.findMany({ const bcRootRaw = await db
where: { OR: [{ parentId: -1 }, { parentId: 0 }] }, .select()
orderBy: { orderNum: "asc" }, .from(CatalogPagesBc)
}); .where(
or(eq(CatalogPagesBc.parentId, -1), eq(CatalogPagesBc.parentId, 0)),
)
.orderBy(asc(CatalogPagesBc.orderNum));
const bcRootIds = bcRootRaw.map((p) => p.id); const bcRootIds = bcRootRaw.map((p) => p.id);
const [bcChildCounts, bcItemCounts] = await Promise.all([ const [bcChildCounts, bcItemCounts] = await Promise.all([
bcRootIds.length > 0 bcRootIds.length > 0
? prisma.catalogPagesBc.groupBy({ ? db
by: ["parentId"], .select({
where: { parentId: { in: bcRootIds } }, parentId: CatalogPagesBc.parentId,
_count: true, total: count(),
}) })
.from(CatalogPagesBc)
.where(inArray(CatalogPagesBc.parentId, bcRootIds))
.groupBy(CatalogPagesBc.parentId)
: [], : [],
bcRootIds.length > 0 bcRootIds.length > 0
? prisma.catalogItemsBc.groupBy({ ? db
by: ["pageId"], .select({
where: { pageId: { in: bcRootIds } }, pageId: CatalogItemsBc.pageId,
_count: true, total: count(),
}) })
.from(CatalogItemsBc)
.where(inArray(CatalogItemsBc.pageId, bcRootIds))
.groupBy(CatalogItemsBc.pageId)
: [], : [],
]); ]);
const bcChildMap = Object.fromEntries( const bcChildMap = Object.fromEntries(
bcChildCounts.map((c) => [c.parentId, c._count]), bcChildCounts.map((c) => [c.parentId, Number(c.total)]),
); );
const bcItemMap = Object.fromEntries( const bcItemMap = Object.fromEntries(
bcItemCounts.map((c) => [c.pageId, c._count]), bcItemCounts.map((c) => [c.pageId, Number(c.total)]),
); );
bcRootPages = bcRootRaw.map((p) => ({ bcRootPages = bcRootRaw.map((p) => ({
id: p.id, id: p.id,
@@ -98,7 +137,6 @@ export default async function CatalogPagesPage({
})); }));
} }
// ── BC table view ─────────────────────────────────────────────
let bcRows: { let bcRows: {
id: number; id: number;
caption: string; caption: string;
@@ -119,32 +157,38 @@ export default async function CatalogPagesPage({
const parentId = const parentId =
parentFilter !== null ? parseInt(parentFilter, 10) : undefined; parentFilter !== null ? parseInt(parentFilter, 10) : undefined;
const where: Record<string, unknown> = {}; const conditions: SQL[] = [];
const hasParent = parentId !== undefined && !Number.isNaN(parentId); const hasParent = parentId !== undefined && !Number.isNaN(parentId);
if (search) { if (search) {
const pattern = { contains: search }; conditions.push(like(CatalogPagesBc.caption, `%${search}%`));
where.OR = [{ caption: pattern }];
} }
if (hasParent) where.parentId = parentId; if (hasParent) conditions.push(eq(CatalogPagesBc.parentId, parentId));
const where = conditions.length > 0 ? and(...conditions) : undefined;
// Prisma sort key is snake_case for BC model. Translate known keys. const sortKeyMap: Record<string, AnyMySqlColumn> = {
const sortKeyMap: Record<string, string> = { parentId: CatalogPagesBc.parentId,
parentId: "parentId", pageLayout: CatalogPagesBc.pageLayout,
pageLayout: "pageLayout", orderNum: CatalogPagesBc.orderNum,
orderNum: "orderNum", caption: CatalogPagesBc.caption,
id: CatalogPagesBc.id,
}; };
const sortKey = sort ? (sortKeyMap[sort] ?? sort) : "orderNum"; const sortCol = sort
const orderBy = { [sortKey]: sort ? order : "asc" }; ? (sortKeyMap[sort] ?? CatalogPagesBc.orderNum)
: CatalogPagesBc.orderNum;
const orderBy =
(sort ? order : "asc") === "desc" ? desc(sortCol) : asc(sortCol);
const [pages, total] = await Promise.all([ const [pages, totalRows] = await Promise.all([
prisma.catalogPagesBc.findMany({ db
where, .select()
orderBy, .from(CatalogPagesBc)
skip: (page - 1) * perPage, .where(where)
take: perPage, .orderBy(orderBy)
}), .limit(perPage)
prisma.catalogPagesBc.count({ where }), .offset((page - 1) * perPage),
db.select({ total: count() }).from(CatalogPagesBc).where(where),
]); ]);
const total = Number(totalRows[0]?.total ?? 0);
bcPagination = calcPagination(total, page, perPage); bcPagination = calcPagination(total, page, perPage);
@@ -154,31 +198,40 @@ export default async function CatalogPagesPage({
]; ];
const [childCounts, itemCounts, parentPages] = await Promise.all([ const [childCounts, itemCounts, parentPages] = await Promise.all([
pageIds.length > 0 pageIds.length > 0
? prisma.catalogPagesBc.groupBy({ ? db
by: ["parentId"], .select({
where: { parentId: { in: pageIds } }, parentId: CatalogPagesBc.parentId,
_count: true, total: count(),
}) })
.from(CatalogPagesBc)
.where(inArray(CatalogPagesBc.parentId, pageIds))
.groupBy(CatalogPagesBc.parentId)
: [], : [],
pageIds.length > 0 pageIds.length > 0
? prisma.catalogItemsBc.groupBy({ ? db
by: ["pageId"], .select({
where: { pageId: { in: pageIds } }, pageId: CatalogItemsBc.pageId,
_count: true, total: count(),
}) })
.from(CatalogItemsBc)
.where(inArray(CatalogItemsBc.pageId, pageIds))
.groupBy(CatalogItemsBc.pageId)
: [], : [],
parentIds.length > 0 parentIds.length > 0
? prisma.catalogPagesBc.findMany({ ? db
where: { id: { in: parentIds } }, .select({
select: { id: true, caption: true }, id: CatalogPagesBc.id,
}) caption: CatalogPagesBc.caption,
})
.from(CatalogPagesBc)
.where(inArray(CatalogPagesBc.id, parentIds))
: [], : [],
]); ]);
const childCountMap = Object.fromEntries( const childCountMap = Object.fromEntries(
childCounts.map((c) => [c.parentId, c._count]), childCounts.map((c) => [c.parentId, Number(c.total)]),
); );
const itemCountMap = Object.fromEntries( const itemCountMap = Object.fromEntries(
itemCounts.map((c) => [c.pageId, c._count]), itemCounts.map((c) => [c.pageId, Number(c.total)]),
); );
const parentNameMap = Object.fromEntries( const parentNameMap = Object.fromEntries(
parentPages.map((p) => [p.id, p.caption]), parentPages.map((p) => [p.id, p.caption]),
@@ -195,7 +248,7 @@ export default async function CatalogPagesPage({
pageLayout: String(p.pageLayout), pageLayout: String(p.pageLayout),
enabled: String(p.enabled), enabled: String(p.enabled),
visible: String(p.visible), visible: String(p.visible),
minRank: 0, // BC pages have no min rank column minRank: 0,
orderNum: p.orderNum, orderNum: p.orderNum,
itemCount: itemCountMap[p.id] ?? 0, itemCount: itemCountMap[p.id] ?? 0,
childCount: childCountMap[p.id] ?? 0, childCount: childCountMap[p.id] ?? 0,
@@ -252,33 +305,39 @@ export default async function CatalogPagesPage({
); );
} }
// ── Normal catalog mode ──────────────────────────────────────── const [totalPagesRows, totalItemsRows, enabledPagesRows] = await Promise.all([
db.select({ total: count() }).from(CatalogPages),
// Global stats (lightweight counts) db.select({ total: count() }).from(CatalogItems),
const [totalPages, totalItems, enabledPages] = await Promise.all([ db
prisma.catalogPages.count(), .select({ total: count() })
prisma.catalogItems.count(), .from(CatalogPages)
prisma.catalogPages.count({ where: { enabled: "1" } }), .where(eq(CatalogPages.enabled, "1")),
]); ]);
const totalPages = Number(totalPagesRows[0]?.total ?? 0);
const totalItems = Number(totalItemsRows[0]?.total ?? 0);
const enabledPages = Number(enabledPagesRows[0]?.total ?? 0);
// ── Root pages (tree view + Visual Manager seed) ──────────────── const rootPagesRaw = await db
const rootPagesRaw = await prisma.catalogPages.findMany({ .select()
where: { OR: [{ parentId: -1 }, { parentId: 0 }] }, .from(CatalogPages)
orderBy: { orderNum: "asc" }, .where(or(eq(CatalogPages.parentId, -1), eq(CatalogPages.parentId, 0)))
}); .orderBy(asc(CatalogPages.orderNum));
const rootIds = rootPagesRaw.map((p) => p.id); const rootIds = rootPagesRaw.map((p) => p.id);
const [rootChildCounts, rootItemCountMap] = await Promise.all([ const [rootChildCounts, rootItemCountMap] = await Promise.all([
rootIds.length > 0 rootIds.length > 0
? prisma.catalogPages.groupBy({ ? db
by: ["parentId"], .select({
where: { parentId: { in: rootIds } }, parentId: CatalogPages.parentId,
_count: true, total: count(),
}) })
.from(CatalogPages)
.where(inArray(CatalogPages.parentId, rootIds))
.groupBy(CatalogPages.parentId)
: [], : [],
getCatalogItemCounts(rootIds), getCatalogItemCounts(rootIds),
]); ]);
const rootChildMap = Object.fromEntries( const rootChildMap = Object.fromEntries(
rootChildCounts.map((c) => [c.parentId, c._count]), rootChildCounts.map((c) => [c.parentId, Number(c.total)]),
); );
const rootItemMap = Object.fromEntries(rootItemCountMap); const rootItemMap = Object.fromEntries(rootItemCountMap);
const rootPages: TreeNodeShape[] = rootPagesRaw.map((p) => ({ const rootPages: TreeNodeShape[] = rootPagesRaw.map((p) => ({
@@ -294,7 +353,6 @@ export default async function CatalogPagesPage({
itemCount: rootItemMap[p.id] ?? 0, itemCount: rootItemMap[p.id] ?? 0,
})); }));
// ── Table view data (skip if tree) ────────────────────────────
let rows: { let rows: {
id: number; id: number;
caption: string; caption: string;
@@ -315,15 +373,15 @@ export default async function CatalogPagesPage({
const parentId = const parentId =
parentFilter !== null ? parseInt(parentFilter, 10) : undefined; parentFilter !== null ? parseInt(parentFilter, 10) : undefined;
const where: Record<string, unknown> = {}; const conditions: SQL[] = [];
const hasParent = parentId !== undefined && !Number.isNaN(parentId); const hasParent = parentId !== undefined && !Number.isNaN(parentId);
if (search) { if (search) {
const searchPattern = `%${search}%`; const searchPattern = `%${search}%`;
const parentClause = hasParent const parentClause = hasParent
? Prisma.sql`AND cp.parent_id = ${parentId}` ? sql`AND cp.parent_id = ${parentId}`
: Prisma.empty; : sql``;
const matchedIds = await prisma.$queryRaw<{ id: number }[]>(Prisma.sql` const matchedIds = await rawRows<{ id: number }>(sql`
SELECT DISTINCT cp.id FROM catalog_pages cp SELECT DISTINCT cp.id FROM catalog_pages cp
LEFT JOIN catalog_items ci ON CAST(ci.page_id AS CHAR) = CAST(cp.id AS CHAR) LEFT JOIN catalog_items ci ON CAST(ci.page_id AS CHAR) = CAST(cp.id AS CHAR)
LEFT JOIN items_base ib ON FIND_IN_SET(ib.id, REPLACE(ci.item_ids, ';', ',')) LEFT JOIN items_base ib ON FIND_IN_SET(ib.id, REPLACE(ci.item_ids, ';', ','))
@@ -334,48 +392,68 @@ export default async function CatalogPagesPage({
OR ib.item_name LIKE ${searchPattern}) OR ib.item_name LIKE ${searchPattern})
${parentClause} ${parentClause}
`); `);
where.id = { in: matchedIds.map((r) => r.id) }; const ids = matchedIds.map((r) => r.id);
if (ids.length > 0) conditions.push(inArray(CatalogPages.id, ids));
else conditions.push(sql`1 = 0`);
} else if (hasParent) { } else if (hasParent) {
where.parentId = parentId; conditions.push(eq(CatalogPages.parentId, parentId));
} }
const orderBy = sort ? { [sort]: order } : { orderNum: "asc" as const }; const where = conditions.length > 0 ? and(...conditions) : undefined;
const sortColMap: Record<string, AnyMySqlColumn> = {
caption: CatalogPages.caption,
parentId: CatalogPages.parentId,
pageLayout: CatalogPages.pageLayout,
enabled: CatalogPages.enabled,
orderNum: CatalogPages.orderNum,
id: CatalogPages.id,
minRank: CatalogPages.minRank,
};
const sortCol = sort
? (sortColMap[sort] ?? CatalogPages.orderNum)
: CatalogPages.orderNum;
const orderBy =
(sort ? order : "asc") === "desc" ? desc(sortCol) : asc(sortCol);
const [pages, total] = await Promise.all([ const [pages, totalRows] = await Promise.all([
prisma.catalogPages.findMany({ db
where, .select()
orderBy, .from(CatalogPages)
skip: (page - 1) * perPage, .where(where)
take: perPage, .orderBy(orderBy)
}), .limit(perPage)
prisma.catalogPages.count({ where }), .offset((page - 1) * perPage),
db.select({ total: count() }).from(CatalogPages).where(where),
]); ]);
const total = Number(totalRows[0]?.total ?? 0);
pagination = calcPagination(total, page, perPage); pagination = calcPagination(total, page, perPage);
// Count children and items per page, and resolve parent names in parallel
const pageIds = pages.map((p) => p.id); const pageIds = pages.map((p) => p.id);
const parentIds = [ const parentIds = [
...new Set(pages.map((p) => p.parentId).filter((id) => id > 0)), ...new Set(pages.map((p) => p.parentId).filter((id) => id > 0)),
]; ];
const [childCounts, itemCountMapRaw, parentPages] = await Promise.all([ const [childCounts, itemCountMapRaw, parentPages] = await Promise.all([
pageIds.length > 0 pageIds.length > 0
? prisma.catalogPages.groupBy({ ? db
by: ["parentId"], .select({
where: { parentId: { in: pageIds } }, parentId: CatalogPages.parentId,
_count: true, total: count(),
}) })
.from(CatalogPages)
.where(inArray(CatalogPages.parentId, pageIds))
.groupBy(CatalogPages.parentId)
: [], : [],
getCatalogItemCounts(pageIds), getCatalogItemCounts(pageIds),
parentIds.length > 0 parentIds.length > 0
? prisma.catalogPages.findMany({ ? db
where: { id: { in: parentIds } }, .select({ id: CatalogPages.id, caption: CatalogPages.caption })
select: { id: true, caption: true }, .from(CatalogPages)
}) .where(inArray(CatalogPages.id, parentIds))
: [], : [],
]); ]);
const childCountMap = Object.fromEntries( const childCountMap = Object.fromEntries(
childCounts.map((c) => [c.parentId, c._count]), childCounts.map((c) => [c.parentId, Number(c.total)]),
); );
const itemCountMap = Object.fromEntries(itemCountMapRaw); const itemCountMap = Object.fromEntries(itemCountMapRaw);
const parentNameMap = Object.fromEntries( const parentNameMap = Object.fromEntries(
+23 -16
View File
@@ -1,4 +1,5 @@
import os from "node:os"; import os from "node:os";
import { count, desc, inArray } from "drizzle-orm";
import { Terminal } from "lucide-react"; import { Terminal } 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";
@@ -11,9 +12,9 @@ import {
} from "@/components/admin/dashboard"; } from "@/components/admin/dashboard";
import { fetchOpsHealth } from "@/lib/admin/ops-health"; import { fetchOpsHealth } from "@/lib/admin/ops-health";
import { fetchOpsOnlineUsers } from "@/lib/admin/ops-online-users"; import { fetchOpsOnlineUsers } from "@/lib/admin/ops-online-users";
import { db, EmulatorErrors, StaffActivities, User } 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 { siteSettings } from "@/lib/services/site-settings"; import { siteSettings } from "@/lib/services/site-settings";
import { CommandocentrumControls } from "./commandocentrum-controls"; import { CommandocentrumControls } from "./commandocentrum-controls";
@@ -46,22 +47,29 @@ export default async function CommandoCentrum() {
const t = await getTranslations("pages.admin.commandocentrum"); const t = await getTranslations("pages.admin.commandocentrum");
const [totalUsers, errors, health, maintenance, activity, onlineRoster] = const [totalUsersRows, errors, health, maintenance, activity, onlineRoster] =
await Promise.all([ await Promise.all([
prisma.user.count().catch(() => -1), db
prisma.emulatorErrors .select({ total: count() })
.findMany({ orderBy: { timestamp: "desc" }, take: 50 }) .from(User)
.catch(() => [{ total: -1 }]),
db
.select()
.from(EmulatorErrors)
.orderBy(desc(EmulatorErrors.timestamp))
.limit(50)
.catch(() => []), .catch(() => []),
fetchOpsHealth(), fetchOpsHealth(),
siteSettings.getBool("maintenance_enabled", false), siteSettings.getBool("maintenance_enabled", false),
prisma.staffActivities db
.findMany({ orderBy: { createdAt: "desc" }, take: 12 }) .select()
.catch( .from(StaffActivities)
() => .orderBy(desc(StaffActivities.createdAt))
[] as Awaited<ReturnType<typeof prisma.staffActivities.findMany>>, .limit(12)
), .catch(() => [] as (typeof StaffActivities.$inferSelect)[]),
fetchOpsOnlineUsers(40), fetchOpsOnlineUsers(40),
]); ]);
const totalUsers = Number(totalUsersRows[0]?.total ?? -1);
const dbOk = health.dbOk; const dbOk = health.dbOk;
const redisOk = health.redisOk; const redisOk = health.redisOk;
@@ -74,11 +82,10 @@ export default async function CommandoCentrum() {
).filter((n) => n > 0); ).filter((n) => n > 0);
let nameById = new Map<number, string>(); let nameById = new Map<number, string>();
if (staffIds.length) { if (staffIds.length) {
const users = await prisma.user const users = 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(() => []);
nameById = new Map(users.map((u) => [u.id, u.username])); nameById = new Map(users.map((u) => [u.id, u.username]));
} }
+11 -8
View File
@@ -1,8 +1,9 @@
import { count, desc } 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 { Card, CardContent } from "@/components/ui/card"; import { Card, CardContent } from "@/components/ui/card";
import { db, EmulatorErrors } from "@/lib/db";
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions"; import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
import { prisma } from "@/lib/prisma";
export default async function EmulatorErrorsPage({ export default async function EmulatorErrorsPage({
searchParams, searchParams,
@@ -19,15 +20,17 @@ export default async function EmulatorErrorsPage({
const page = Math.max(1, Number(params.page) || 1); const page = Math.max(1, Number(params.page) || 1);
const perPage = 50; const perPage = 50;
const [errors, total] = await Promise.all([ const [errors, totalRows] = await Promise.all([
prisma.emulatorErrors.findMany({ db
orderBy: { id: "desc" }, .select()
skip: (page - 1) * perPage, .from(EmulatorErrors)
take: perPage, .orderBy(desc(EmulatorErrors.id))
}), .limit(perPage)
prisma.emulatorErrors.count(), .offset((page - 1) * perPage),
db.select({ total: count() }).from(EmulatorErrors),
]); ]);
const total = Number(totalRows[0]?.total ?? 0);
const lastPage = Math.ceil(total / perPage); const lastPage = Math.ceil(total / perPage);
return ( return (
+22 -16
View File
@@ -1,3 +1,4 @@
import { count, desc, eq, gte } from "drizzle-orm";
import { AlertTriangle, Database, HeartPulse, Server } from "lucide-react"; import { AlertTriangle, Database, HeartPulse, Server } from "lucide-react";
import Link from "next/link"; import Link from "next/link";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
@@ -5,35 +6,40 @@ import { getTranslations } from "next-intl/server";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { fetchOpsHealth } from "@/lib/admin/ops-health"; import { fetchOpsHealth } from "@/lib/admin/ops-health";
import { db, EmulatorErrors, EmulatorSettings } from "@/lib/db";
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions"; import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
import { prisma } from "@/lib/prisma";
import { HealthCheckClient } from "./health-check-client"; import { HealthCheckClient } from "./health-check-client";
async function getDevOpsData() { async function getDevOpsData() {
const now = Math.floor(Date.now() / 1000); const now = Math.floor(Date.now() / 1000);
const dayAgo = now - 86400; const dayAgo = now - 86400;
const [health, recentErrors, errorsToday, emuVersion] = await Promise.all([ const [health, recentErrors, errorsTodayRows, emuVersionRows] =
fetchOpsHealth(), await Promise.all([
prisma.emulatorErrors.findMany({ fetchOpsHealth(),
orderBy: { id: "desc" }, db
take: 20, .select()
}), .from(EmulatorErrors)
prisma.emulatorErrors.count({ .orderBy(desc(EmulatorErrors.id))
where: { timestamp: { gte: dayAgo } }, .limit(20),
}), db
prisma.emulatorSettings.findFirst({ .select({ total: count() })
where: { key: "version" }, .from(EmulatorErrors)
}), .where(gte(EmulatorErrors.timestamp, dayAgo)),
]); db
.select({ value: EmulatorSettings.value })
.from(EmulatorSettings)
.where(eq(EmulatorSettings.key, "version"))
.limit(1),
]);
return { return {
dbStatus: health.dbOk ? ("healthy" as const) : ("error" as const), dbStatus: health.dbOk ? ("healthy" as const) : ("error" as const),
dbLatency: health.dbLatencyMs, dbLatency: health.dbLatencyMs,
redisOk: health.redisOk, redisOk: health.redisOk,
recentErrors, recentErrors,
errorsToday, errorsToday: Number(errorsTodayRows[0]?.total ?? 0),
emuVersion: emuVersion?.value ?? "Unknown", emuVersion: emuVersionRows[0]?.value ?? "Unknown",
onlineUsers: health.onlineUsers, onlineUsers: health.onlineUsers,
}; };
} }
+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 {
@@ -6,8 +7,8 @@ import {
updateEmailTemplate, updateEmailTemplate,
} from "@/actions/admin-email-templates"; } from "@/actions/admin-email-templates";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { db, EmailTemplates } from "@/lib/db";
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions"; import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
import { prisma } from "@/lib/prisma";
export default async function AdminEmailTemplates() { export default async function AdminEmailTemplates() {
const { session, permissions } = await getAdminContext(); const { session, permissions } = await getAdminContext();
@@ -17,12 +18,12 @@ export default async function AdminEmailTemplates() {
const t = await getTranslations("pages.admin.emailTemplates"); const t = await getTranslations("pages.admin.emailTemplates");
let templates: Awaited<ReturnType<typeof prisma.emailTemplates.findMany>> = let templates: (typeof EmailTemplates.$inferSelect)[] = [];
[];
try { try {
templates = await prisma.emailTemplates.findMany({ templates = await db
orderBy: { name: "asc" }, .select()
}); .from(EmailTemplates)
.orderBy(asc(EmailTemplates.name));
} catch { } catch {
templates = []; templates = [];
} }
+13 -9
View File
@@ -1,3 +1,4 @@
import { asc, count } 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 {
@@ -6,8 +7,8 @@ import {
} from "@/actions/admin-emulator"; } from "@/actions/admin-emulator";
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, EmulatorSettings, EmulatorTexts } from "@/lib/db";
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions"; import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
import { prisma } from "@/lib/prisma";
const TEXTS_LIMIT = 300; const TEXTS_LIMIT = 300;
@@ -21,9 +22,10 @@ export default async function AdminEmulator() {
let settings: { key: string; value: string }[] = []; let settings: { key: string; value: string }[] = [];
try { try {
settings = await prisma.emulatorSettings.findMany({ settings = await db
orderBy: { key: "asc" }, .select({ key: EmulatorSettings.key, value: EmulatorSettings.value })
}); .from(EmulatorSettings)
.orderBy(asc(EmulatorSettings.key));
} catch { } catch {
settings = []; settings = [];
} }
@@ -31,15 +33,17 @@ export default async function AdminEmulator() {
let texts: { key: string; value: string }[] = []; let texts: { key: string; value: string }[] = [];
let textsCount = 0; let textsCount = 0;
try { try {
textsCount = await prisma.emulatorTexts.count(); const [row] = await db.select({ total: count() }).from(EmulatorTexts);
textsCount = Number(row?.total ?? 0);
} catch { } catch {
textsCount = 0; textsCount = 0;
} }
try { try {
texts = await prisma.emulatorTexts.findMany({ texts = await db
orderBy: { key: "asc" }, .select({ key: EmulatorTexts.key, value: EmulatorTexts.value })
take: TEXTS_LIMIT, .from(EmulatorTexts)
}); .orderBy(asc(EmulatorTexts.key))
.limit(TEXTS_LIMIT);
} catch { } catch {
texts = []; texts = [];
} }
+17 -15
View File
@@ -1,11 +1,12 @@
import { asc, eq, inArray } from "drizzle-orm";
import { CalendarDays, Gift, Trophy, Users } from "lucide-react"; import { CalendarDays, Gift, Trophy, Users } from "lucide-react";
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 { AdminBreadcrumb } from "@/components/admin/admin-breadcrumb"; import { AdminBreadcrumb } from "@/components/admin/admin-breadcrumb";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { db, User, WebsiteEvent, WebsiteEventType } from "@/lib/db";
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions"; import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
import { prisma } from "@/lib/prisma";
import { EventForm } from "../event-form"; import { EventForm } from "../event-form";
import { EventPrizesManager } from "./event-prizes"; import { EventPrizesManager } from "./event-prizes";
import { EventRegistrations } from "./event-registrations"; import { EventRegistrations } from "./event-registrations";
@@ -28,22 +29,23 @@ export default async function EventDetailPage({
const t = await getTranslations("pages.admin.events"); const t = await getTranslations("pages.admin.events");
const tHubs = await getTranslations("pages.admin.hubs"); const tHubs = await getTranslations("pages.admin.hubs");
const event = await prisma.websiteEvent.findUnique({ const event = await db.query.WebsiteEvent.findFirst({
where: { id: eventId }, where: eq(WebsiteEvent.id, eventId),
include: { with: {
type: true, type: true,
prizes: { orderBy: { position: "asc" } }, prizes: { orderBy: (p, { asc: a }) => [a(p.position)] },
winners: { orderBy: { position: "asc" } }, winners: { orderBy: (w, { asc: a }) => [a(w.position)] },
registrations: { orderBy: { createdAt: "desc" } }, registrations: { orderBy: (r, { desc: d }) => [d(r.createdAt)] },
}, },
}); });
if (!event) notFound(); if (!event) notFound();
const eventTypes = await prisma.websiteEventType.findMany({ const eventTypes = await db
where: { isActive: 1 }, .select()
orderBy: { name: "asc" }, .from(WebsiteEventType)
}); .where(eq(WebsiteEventType.isActive, 1))
.orderBy(asc(WebsiteEventType.name));
// Fetch usernames for registrations and winners // Fetch usernames for registrations and winners
const userIds = [ const userIds = [
@@ -52,10 +54,10 @@ export default async function EventDetailPage({
]; ];
const users = const users =
userIds.length > 0 userIds.length > 0
? await prisma.user.findMany({ ? await db
where: { id: { in: [...new Set(userIds)] } }, .select({ id: User.id, username: User.username })
select: { id: true, username: true }, .from(User)
}) .where(inArray(User.id, [...new Set(userIds)]))
: []; : [];
const userMap = new Map(users.map((u) => [u.id, u.username])); const userMap = new Map(users.map((u) => [u.id, u.username]));
+7 -5
View File
@@ -1,7 +1,8 @@
import { asc, eq } 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 { db, WebsiteEventType } from "@/lib/db";
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions"; import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
import { prisma } from "@/lib/prisma";
import { EventForm } from "../event-form"; import { EventForm } from "../event-form";
export default async function CreateEventPage() { export default async function CreateEventPage() {
@@ -11,10 +12,11 @@ export default async function CreateEventPage() {
} }
const t = await getTranslations("pages.admin.events"); const t = await getTranslations("pages.admin.events");
const eventTypes = await prisma.websiteEventType.findMany({ const eventTypes = await db
where: { isActive: 1 }, .select()
orderBy: { name: "asc" }, .from(WebsiteEventType)
}); .where(eq(WebsiteEventType.isActive, 1))
.orderBy(asc(WebsiteEventType.name));
return ( return (
<div className="space-y-6"> <div className="space-y-6">
+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, WebsiteEventType } from "@/lib/db";
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions"; import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
import { prisma } from "@/lib/prisma";
import { EventTypesManager } from "./event-types-manager"; import { EventTypesManager } from "./event-types-manager";
export default async function EventTypesPage() { export default async function EventTypesPage() {
@@ -9,9 +10,10 @@ export default async function EventTypesPage() {
redirect("/admin/events"); redirect("/admin/events");
} }
const eventTypes = await prisma.websiteEventType.findMany({ const eventTypes = await db
orderBy: { name: "asc" }, .select()
}); .from(WebsiteEventType)
.orderBy(asc(WebsiteEventType.name));
return ( return (
<div className="space-y-6"> <div className="space-y-6">
+7 -4
View File
@@ -1,6 +1,7 @@
import { eq } from "drizzle-orm";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { db, WebsiteSetting } from "@/lib/db";
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions"; import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
import { prisma } from "@/lib/prisma";
import { FaviconForm } from "./favicon-form"; import { FaviconForm } from "./favicon-form";
export default async function AdminFaviconPage() { export default async function AdminFaviconPage() {
@@ -11,9 +12,11 @@ export default async function AdminFaviconPage() {
let faviconUrl: string | null = null; let faviconUrl: string | null = null;
try { try {
const setting = await prisma.websiteSetting.findUnique({ const [setting] = await db
where: { key: "cms_favicon" }, .select({ value: WebsiteSetting.value })
}); .from(WebsiteSetting)
.where(eq(WebsiteSetting.key, "cms_favicon"))
.limit(1);
faviconUrl = setting?.value ?? null; faviconUrl = setting?.value ?? null;
} catch { } catch {
faviconUrl = null; faviconUrl = null;