From 30b54e99e7b0852441b031fa154a34f5c4820b4a Mon Sep 17 00:00:00 2001 From: simoleo89 Date: Sat, 1 Aug 2026 14:15:39 +0200 Subject: [PATCH] refactor(db): migrate app pages and APIs from Prisma facade to Drizzle (5) Co-authored-by: Cursor --- src/app/api/photos/route.ts | 34 ++-- src/app/api/radio/current-dj/route.ts | 12 +- src/app/api/radio/points/leaderboard/route.ts | 27 ++- src/app/api/radio/points/route.ts | 13 +- src/app/api/radio/shouts/route.ts | 33 ++-- src/app/api/shop/categories/route.ts | 26 +-- src/app/api/shop/route.ts | 70 ++++--- src/app/api/staff/route.ts | 20 +- src/app/api/teams/route.ts | 25 +-- src/app/api/tickets/[id]/reply/route.ts | 57 +++--- src/app/api/tickets/[id]/route.ts | 56 +++--- src/app/api/tickets/route.ts | 48 ++--- src/app/api/users/[username]/route.ts | 28 +-- src/app/api/values/[id]/route.ts | 49 +++-- src/app/api/values/categories/route.ts | 24 ++- src/app/api/values/route.ts | 53 +++--- src/app/client/page.tsx | 15 +- src/app/mod/bans/page.tsx | 32 +++- src/app/mod/cfh/[id]/page.tsx | 72 +++++--- src/app/mod/cfh/page.tsx | 120 ++++++------ src/app/mod/help-tickets/[id]/page.tsx | 71 +++---- src/app/mod/help-tickets/page.tsx | 173 +++++++++++------- src/app/mod/layout.tsx | 15 +- src/app/mod/page.tsx | 45 ++++- src/app/mod/team/page.tsx | 101 +++++----- src/app/mod/tickets/[id]/page.tsx | 114 +++++++----- src/app/mod/tickets/page.tsx | 171 +++++++++++------ src/app/mod/users/[id]/page.tsx | 32 ++-- src/app/mod/users/page.tsx | 89 +++++---- src/app/sitemap.ts | 21 ++- src/components/navigation.tsx | 17 +- src/components/site-header.tsx | 13 +- src/components/top-header.tsx | 78 +++++--- src/lib/access-guard.ts | 23 ++- src/lib/api-auth.ts | 54 +++--- src/lib/auth.ts | 45 ++--- src/lib/auth/jwt-version-cache.test.ts | 25 ++- src/lib/auth/jwt-version-cache.ts | 12 +- src/lib/auth/sso-ticket.test.ts | 33 +++- src/lib/auth/sso-ticket.ts | 21 +-- 40 files changed, 1183 insertions(+), 784 deletions(-) diff --git a/src/app/api/photos/route.ts b/src/app/api/photos/route.ts index 1d81899b..4b01ac92 100644 --- a/src/app/api/photos/route.ts +++ b/src/app/api/photos/route.ts @@ -1,32 +1,34 @@ +import { count, desc } from "drizzle-orm"; import { apiJson, pagination } from "@/lib/api"; -import { prisma } from "@/lib/prisma"; +import { CameraWeb, db } from "@/lib/db"; export const dynamic = "force-dynamic"; /** * GET /api/photos — recent community photos (camera_web), newest first. - * Mirrors the /photos page query (prisma.cameraWeb). Returns id, userId, url - * and timestamp plus pagination metadata. + * Mirrors the /photos page query. Returns id, userId, url and timestamp plus + * pagination metadata. */ export async function GET(req: Request) { const sp = new URL(req.url).searchParams; const { page, perPage, skip, take } = pagination(sp); try { - const [total, photos] = await Promise.all([ - prisma.cameraWeb.count(), - prisma.cameraWeb.findMany({ - select: { - id: true, - userId: true, - url: true, - timestamp: true, - }, - orderBy: { timestamp: "desc" }, - skip, - take, - }), + const [totalRows, photos] = await Promise.all([ + db.select({ total: count() }).from(CameraWeb), + db + .select({ + id: CameraWeb.id, + userId: CameraWeb.userId, + url: CameraWeb.url, + timestamp: CameraWeb.timestamp, + }) + .from(CameraWeb) + .orderBy(desc(CameraWeb.timestamp)) + .limit(take) + .offset(skip), ]); + const total = totalRows[0]?.total ?? 0; return apiJson({ data: photos, diff --git a/src/app/api/radio/current-dj/route.ts b/src/app/api/radio/current-dj/route.ts index a91716d6..1da47b66 100644 --- a/src/app/api/radio/current-dj/route.ts +++ b/src/app/api/radio/current-dj/route.ts @@ -1,5 +1,6 @@ +import { eq } from "drizzle-orm"; import { apiJson } from "@/lib/api"; -import { prisma } from "@/lib/prisma"; +import { db, User } from "@/lib/db"; import { siteSettings } from "@/lib/services/site-settings"; // Current on-air DJ. The DJ is set manually via the radio_current_dj_id setting @@ -17,10 +18,11 @@ export async function GET(_req: Request) { return apiJson({ dj: null }); } - const user = await prisma.user.findUnique({ - where: { id }, - select: { username: true, look: true }, - }); + const [user] = await db + .select({ username: User.username, look: User.look }) + .from(User) + .where(eq(User.id, id)) + .limit(1); if (!user) { return apiJson({ dj: null }); diff --git a/src/app/api/radio/points/leaderboard/route.ts b/src/app/api/radio/points/leaderboard/route.ts index e0353725..d9ac7f66 100644 --- a/src/app/api/radio/points/leaderboard/route.ts +++ b/src/app/api/radio/points/leaderboard/route.ts @@ -1,5 +1,6 @@ +import { inArray, sum } from "drizzle-orm"; import { apiJson } from "@/lib/api"; -import { prisma } from "@/lib/prisma"; +import { db, RadioListenerPoints, User } from "@/lib/db"; // Radio listener-points leaderboard: the top 20 users by total points, summed // across radio_listener_points and joined to users for username/look. Public @@ -10,13 +11,19 @@ export async function GET(_req: Request) { try { // Sum points per user. Sort/slice in JS so we stay adapter-agnostic about // aggregate ordering, then resolve the top 20 to usernames/looks. - const grouped = await prisma.radioListenerPoints.groupBy({ - by: ["userId"], - _sum: { points: true }, - }); + const grouped = await db + .select({ + userId: RadioListenerPoints.userId, + points: sum(RadioListenerPoints.points), + }) + .from(RadioListenerPoints) + .groupBy(RadioListenerPoints.userId); const ranked = grouped - .map((g) => ({ userId: g.userId, points: g._sum.points ?? 0 })) + .map((g) => ({ + userId: Number(g.userId), + points: Number(g.points ?? 0), + })) .sort((a, b) => b.points - a.points) .slice(0, 20); @@ -25,10 +32,10 @@ export async function GET(_req: Request) { } const userIds = ranked.map((r) => r.userId); - const users = await prisma.user.findMany({ - where: { id: { in: userIds } }, - select: { id: true, username: true, look: true }, - }); + const users = await db + .select({ id: User.id, username: User.username, look: User.look }) + .from(User) + .where(inArray(User.id, userIds)); const userById = new Map(users.map((u) => [u.id, u])); const data = ranked.map((r) => { diff --git a/src/app/api/radio/points/route.ts b/src/app/api/radio/points/route.ts index 064129d3..99ae465c 100644 --- a/src/app/api/radio/points/route.ts +++ b/src/app/api/radio/points/route.ts @@ -1,6 +1,7 @@ +import { eq, sum } from "drizzle-orm"; import { apiError, apiJson } from "@/lib/api"; import { bearerUserId } from "@/lib/api-auth"; -import { prisma } from "@/lib/prisma"; +import { db, RadioListenerPoints } from "@/lib/db"; // The Bearer-authed user's total radio listener points: the sum of all // radio_listener_points.points rows for that user_id. @@ -11,12 +12,12 @@ export async function GET(req: Request) { if (!uid) return apiError("Unauthorized", 401); try { - const agg = await prisma.radioListenerPoints.aggregate({ - where: { userId: uid }, - _sum: { points: true }, - }); + const [agg] = await db + .select({ points: sum(RadioListenerPoints.points) }) + .from(RadioListenerPoints) + .where(eq(RadioListenerPoints.userId, BigInt(uid))); - return apiJson({ points: agg._sum.points ?? 0 }); + return apiJson({ points: Number(agg?.points ?? 0) }); } catch { // DB unavailable — report zero rather than a 500. return apiJson({ points: 0 }, { status: 200 }); diff --git a/src/app/api/radio/shouts/route.ts b/src/app/api/radio/shouts/route.ts index 1b2b6e43..cf59c0b8 100644 --- a/src/app/api/radio/shouts/route.ts +++ b/src/app/api/radio/shouts/route.ts @@ -1,6 +1,7 @@ +import { desc, inArray } from "drizzle-orm"; import { apiError, apiJson } from "@/lib/api"; import { bearerUserId } from "@/lib/api-auth"; -import { prisma } from "@/lib/prisma"; +import { db, RadioShouts, User } from "@/lib/db"; // Latest 50 radio shouts with their author's username/look resolved. Mirrors the // query behind the public /radio/shouts page (radio_shouts ordered by created_at @@ -12,19 +13,20 @@ const MAX_MESSAGE_LENGTH = 255; export async function GET(_req: Request) { try { - const shouts = await prisma.radioShouts.findMany({ - orderBy: { createdAt: "desc" }, - take: 50, - }); + const shouts = await db + .select() + .from(RadioShouts) + .orderBy(desc(RadioShouts.createdAt)) + .limit(50); // Resolve author usernames/looks. radio_shouts.user_id is an UnsignedBigInt // while users.id is an Int, so narrow to Number for the lookup. const authorIds = Array.from(new Set(shouts.map((s) => Number(s.userId)))); const authors = authorIds.length - ? await prisma.user.findMany({ - where: { id: { in: authorIds } }, - select: { id: true, username: true, look: true }, - }) + ? await db + .select({ id: User.id, username: User.username, look: User.look }) + .from(User) + .where(inArray(User.id, authorIds)) : []; const authorById = new Map(authors.map((a) => [a.id, a])); @@ -67,14 +69,11 @@ export async function POST(req: Request) { try { const now = new Date(); - await prisma.radioShouts.create({ - data: { - userId: BigInt(uid), - message, - createdAt: now, - updatedAt: now, - }, - select: { id: true }, + await db.insert(RadioShouts).values({ + userId: BigInt(uid), + message, + createdAt: now, + updatedAt: now, }); return apiJson({ ok: true }); diff --git a/src/app/api/shop/categories/route.ts b/src/app/api/shop/categories/route.ts index cfa9638c..230ce2fb 100644 --- a/src/app/api/shop/categories/route.ts +++ b/src/app/api/shop/categories/route.ts @@ -1,22 +1,26 @@ // Public REST: website store categories (website_shop_categories). // AtomCMS JSON API parity — read-only list ordered by `order` then name. +import { asc } from "drizzle-orm"; import { apiJson } from "@/lib/api"; -import { prisma } from "@/lib/prisma"; +import { db, WebsiteShopCategories } from "@/lib/db"; export const dynamic = "force-dynamic"; export async function GET(_req: Request) { try { - const data = await prisma.websiteShopCategories.findMany({ - orderBy: [{ order: "asc" }, { name: "asc" }], - select: { - id: true, - name: true, - description: true, - icon: true, - order: true, - }, - }); + const data = await db + .select({ + id: WebsiteShopCategories.id, + name: WebsiteShopCategories.name, + description: WebsiteShopCategories.description, + icon: WebsiteShopCategories.icon, + order: WebsiteShopCategories.order, + }) + .from(WebsiteShopCategories) + .orderBy( + asc(WebsiteShopCategories.order), + asc(WebsiteShopCategories.name), + ); return apiJson({ data }); } catch { diff --git a/src/app/api/shop/route.ts b/src/app/api/shop/route.ts index 8401fcb1..1a13a2c4 100644 --- a/src/app/api/shop/route.ts +++ b/src/app/api/shop/route.ts @@ -1,8 +1,9 @@ // Public REST: website store packages (website_shop_articles). // AtomCMS JSON API parity — read-only list of buyable packages, paginated and // ordered by `position` (then name), matching the admin /admin/shop query. +import { asc, count, eq } from "drizzle-orm"; import { apiError, apiJson, pagination, positiveBigInt } from "@/lib/api"; -import { prisma } from "@/lib/prisma"; +import { db, WebsiteShopArticles } from "@/lib/db"; export const dynamic = "force-dynamic"; @@ -12,40 +13,49 @@ export async function GET(req: Request) { // Optional ?category= filter (website_shop_category_id is a BigInt). const categoryRaw = sp.get("category"); - let where: { categoryId?: bigint } = {}; + let categoryId: bigint | undefined; if (categoryRaw !== null) { - const categoryId = positiveBigInt(categoryRaw); - if (!categoryId) return apiError("A valid category id is required", 422); - where = { categoryId }; + const parsed = positiveBigInt(categoryRaw); + if (!parsed) return apiError("A valid category id is required", 422); + categoryId = parsed; } try { - const [total, data] = await Promise.all([ - prisma.websiteShopArticles.count({ where }), - prisma.websiteShopArticles.findMany({ - where, - orderBy: [{ position: "asc" }, { name: "asc" }], - skip, - take, - select: { - id: true, - categoryId: true, - name: true, - info: true, - iconUrl: true, - color: true, - costs: true, - giveRank: true, - isGiftable: true, - credits: true, - duckets: true, - diamonds: true, - badges: true, - furniture: true, - position: true, - }, - }), + const where = + categoryId !== undefined + ? eq(WebsiteShopArticles.categoryId, categoryId) + : undefined; + + const [totalRows, data] = await Promise.all([ + db.select({ total: count() }).from(WebsiteShopArticles).where(where), + db + .select({ + id: WebsiteShopArticles.id, + categoryId: WebsiteShopArticles.categoryId, + name: WebsiteShopArticles.name, + info: WebsiteShopArticles.info, + iconUrl: WebsiteShopArticles.iconUrl, + color: WebsiteShopArticles.color, + costs: WebsiteShopArticles.costs, + giveRank: WebsiteShopArticles.giveRank, + isGiftable: WebsiteShopArticles.isGiftable, + credits: WebsiteShopArticles.credits, + duckets: WebsiteShopArticles.duckets, + diamonds: WebsiteShopArticles.diamonds, + badges: WebsiteShopArticles.badges, + furniture: WebsiteShopArticles.furniture, + position: WebsiteShopArticles.position, + }) + .from(WebsiteShopArticles) + .where(where) + .orderBy( + asc(WebsiteShopArticles.position), + asc(WebsiteShopArticles.name), + ) + .limit(take) + .offset(skip), ]); + const total = totalRows[0]?.total ?? 0; return apiJson({ data, diff --git a/src/app/api/staff/route.ts b/src/app/api/staff/route.ts index 3b7c0165..592fed91 100644 --- a/src/app/api/staff/route.ts +++ b/src/app/api/staff/route.ts @@ -1,5 +1,6 @@ +import { asc, desc, gte } from "drizzle-orm"; import { apiJson } from "@/lib/api"; -import { prisma } from "@/lib/prisma"; +import { db, User } from "@/lib/db"; import { siteSettings } from "@/lib/services/site-settings"; // Public REST API — staff list. Mirrors src/app/staff/page.tsx: users whose @@ -11,12 +12,17 @@ export async function GET(_req: Request) { const minStaffRank = Number(await siteSettings.get("min_staff_rank", "7")) || 7; - const staff = await prisma.user.findMany({ - where: { rank: { gte: minStaffRank } }, - select: { username: true, look: true, rank: true, motto: true }, - orderBy: [{ rank: "desc" }, { username: "asc" }], - take: 100, - }); + const staff = await db + .select({ + username: User.username, + look: User.look, + rank: User.rank, + motto: User.motto, + }) + .from(User) + .where(gte(User.rank, minStaffRank)) + .orderBy(desc(User.rank), asc(User.username)) + .limit(100); return apiJson({ data: staff }, { status: 200 }); } catch { diff --git a/src/app/api/teams/route.ts b/src/app/api/teams/route.ts index ddc54a8b..75f53d8e 100644 --- a/src/app/api/teams/route.ts +++ b/src/app/api/teams/route.ts @@ -1,5 +1,6 @@ +import { asc, eq } from "drizzle-orm"; import { apiJson } from "@/lib/api"; -import { prisma } from "@/lib/prisma"; +import { db, WebsiteTeams } from "@/lib/db"; // Public REST API — website teams (staff ranks). Mirrors src/app/staff/page.tsx: // visible ranks (hiddenRank=false) ordered by id. @@ -7,17 +8,17 @@ export const dynamic = "force-dynamic"; export async function GET(_req: Request) { try { - const rows = await prisma.websiteTeams.findMany({ - where: { hiddenRank: false }, - select: { - id: true, - rankName: true, - badge: true, - jobDescription: true, - staffColor: true, - }, - orderBy: { id: "asc" }, - }); + const rows = await db + .select({ + id: WebsiteTeams.id, + rankName: WebsiteTeams.rankName, + badge: WebsiteTeams.badge, + jobDescription: WebsiteTeams.jobDescription, + staffColor: WebsiteTeams.staffColor, + }) + .from(WebsiteTeams) + .where(eq(WebsiteTeams.hiddenRank, false)) + .orderBy(asc(WebsiteTeams.id)); // apiJson serialises BigInt ids → string automatically. return apiJson({ data: rows }, { status: 200 }); diff --git a/src/app/api/tickets/[id]/reply/route.ts b/src/app/api/tickets/[id]/reply/route.ts index 241d38f3..ada372cf 100644 --- a/src/app/api/tickets/[id]/reply/route.ts +++ b/src/app/api/tickets/[id]/reply/route.ts @@ -4,9 +4,14 @@ // into website_help_center_ticket_replies. The target ticket must exist and // belong to the authed user. Fail-soft: never a 500. +import { eq } from "drizzle-orm"; import { apiError, apiJson, positiveBigInt } from "@/lib/api"; import { bearerUserId } from "@/lib/api-auth"; -import { prisma } from "@/lib/prisma"; +import { + db, + WebsiteHelpCenterTicketReplies, + WebsiteHelpCenterTickets, +} from "@/lib/db"; import { rateLimit } from "@/lib/rate-limit"; import { createOwnedTicketReply } from "@/lib/services/ticket-replies"; @@ -36,30 +41,36 @@ export async function POST( try { // Ownership check — only the ticket owner may reply. - const reply = await prisma.$transaction((tx) => + const reply = await db.transaction(async (tx) => createOwnedTicketReply( { - findTicket: (ticketId) => - tx.websiteHelpCenterTickets.findUnique({ - where: { id: ticketId }, - select: { id: true, userId: true }, - }), - createReply: (data) => - tx.websiteHelpCenterTicketReplies.create({ - data, - select: { - id: true, - userId: true, - content: true, - createdAt: true, - }, - }), - touchTicket: (ticketId, updatedAt) => - tx.websiteHelpCenterTickets.update({ - where: { id: ticketId }, - data: { updatedAt }, - select: { id: true }, - }), + findTicket: async (id) => { + const [row] = await tx + .select({ + id: WebsiteHelpCenterTickets.id, + userId: WebsiteHelpCenterTickets.userId, + }) + .from(WebsiteHelpCenterTickets) + .where(eq(WebsiteHelpCenterTickets.id, id)) + .limit(1); + return row ?? null; + }, + createReply: async (data) => { + const [result] = await tx + .insert(WebsiteHelpCenterTicketReplies) + .values(data); + return { + id: BigInt(result.insertId), + userId: data.userId, + content: data.content, + createdAt: data.createdAt, + }; + }, + touchTicket: (id, updatedAt) => + tx + .update(WebsiteHelpCenterTickets) + .set({ updatedAt }) + .where(eq(WebsiteHelpCenterTickets.id, id)), }, { ticketId, userId: uid, content }, ), diff --git a/src/app/api/tickets/[id]/route.ts b/src/app/api/tickets/[id]/route.ts index 9c877e4b..c0481465 100644 --- a/src/app/api/tickets/[id]/route.ts +++ b/src/app/api/tickets/[id]/route.ts @@ -4,9 +4,15 @@ // together with its replies; reply author usernames are resolved in a single // users lookup. Fail-soft: never a 500. +import { asc, eq, inArray } from "drizzle-orm"; import { apiError, apiJson, positiveBigInt } from "@/lib/api"; import { bearerUserId } from "@/lib/api-auth"; -import { prisma } from "@/lib/prisma"; +import { + db, + User, + WebsiteHelpCenterTicketReplies, + WebsiteHelpCenterTickets, +} from "@/lib/db"; export const dynamic = "force-dynamic"; @@ -23,37 +29,43 @@ export async function GET( if (!ticketId) return apiError("Invalid ticket id", 422); try { - const ticket = await prisma.websiteHelpCenterTickets.findUnique({ - where: { id: ticketId }, - select: { - id: true, - userId: true, - categoryId: true, - title: true, - content: true, - open: true, - createdAt: true, - }, - }); + const [ticket] = await db + .select({ + id: WebsiteHelpCenterTickets.id, + userId: WebsiteHelpCenterTickets.userId, + categoryId: WebsiteHelpCenterTickets.categoryId, + title: WebsiteHelpCenterTickets.title, + content: WebsiteHelpCenterTickets.content, + open: WebsiteHelpCenterTickets.open, + createdAt: WebsiteHelpCenterTickets.createdAt, + }) + .from(WebsiteHelpCenterTickets) + .where(eq(WebsiteHelpCenterTickets.id, ticketId)) + .limit(1); // Ownership check — return 404 (not 403) so a foreign id is indistinguishable // from a missing one. if (!ticket || ticket.userId !== uid) return apiError("Ticket not found", 404); - const replies = await prisma.websiteHelpCenterTicketReplies.findMany({ - where: { ticketId }, - select: { id: true, userId: true, content: true, createdAt: true }, - orderBy: { id: "asc" }, - }); + const replies = await db + .select({ + id: WebsiteHelpCenterTicketReplies.id, + userId: WebsiteHelpCenterTicketReplies.userId, + content: WebsiteHelpCenterTicketReplies.content, + createdAt: WebsiteHelpCenterTicketReplies.createdAt, + }) + .from(WebsiteHelpCenterTicketReplies) + .where(eq(WebsiteHelpCenterTicketReplies.ticketId, ticketId)) + .orderBy(asc(WebsiteHelpCenterTicketReplies.id)); // Resolve author usernames in one query. const authorIds = [...new Set(replies.map((r) => r.userId))]; const authors = authorIds.length - ? await prisma.user.findMany({ - where: { id: { in: authorIds } }, - select: { id: true, username: true }, - }) + ? await db + .select({ id: User.id, username: User.username }) + .from(User) + .where(inArray(User.id, authorIds)) : []; const nameById = new Map(authors.map((a) => [a.id, a.username])); diff --git a/src/app/api/tickets/route.ts b/src/app/api/tickets/route.ts index 9fdd78bf..aaa7b3ec 100644 --- a/src/app/api/tickets/route.ts +++ b/src/app/api/tickets/route.ts @@ -4,9 +4,11 @@ // Backed by website_help_center_tickets (WebsiteHelpCenterTickets). Fail-soft: // DB errors return an apiError envelope, never a 500. +import { desc, eq } from "drizzle-orm"; +import type { ResultSetHeader } from "mysql2"; import { apiError, apiJson, positiveBigInt } from "@/lib/api"; import { bearerUserId } from "@/lib/api-auth"; -import { prisma } from "@/lib/prisma"; +import { db, WebsiteHelpCenterTickets } from "@/lib/db"; import { rateLimit } from "@/lib/rate-limit"; export const dynamic = "force-dynamic"; @@ -17,11 +19,16 @@ export async function GET(req: Request) { if (!uid) return apiError("Unauthorized", 401); try { - const tickets = await prisma.websiteHelpCenterTickets.findMany({ - where: { userId: uid }, - select: { id: true, title: true, open: true, createdAt: true }, - orderBy: { id: "desc" }, - }); + const tickets = await db + .select({ + id: WebsiteHelpCenterTickets.id, + title: WebsiteHelpCenterTickets.title, + open: WebsiteHelpCenterTickets.open, + createdAt: WebsiteHelpCenterTickets.createdAt, + }) + .from(WebsiteHelpCenterTickets) + .where(eq(WebsiteHelpCenterTickets.userId, uid)) + .orderBy(desc(WebsiteHelpCenterTickets.id)); return apiJson({ tickets: tickets.map((t) => ({ @@ -74,26 +81,23 @@ export async function POST(req: Request) { try { const now = new Date(); - const ticket = await prisma.websiteHelpCenterTickets.create({ - data: { - userId: uid, - categoryId, - title, - content, - open: true, - createdAt: now, - updatedAt: now, - }, - select: { id: true, title: true, open: true, createdAt: true }, - }); + const [result] = (await db.insert(WebsiteHelpCenterTickets).values({ + userId: uid, + categoryId, + title, + content, + open: true, + createdAt: now, + updatedAt: now, + })) as unknown as [ResultSetHeader]; return apiJson( { ticket: { - id: ticket.id, - title: ticket.title, - open: ticket.open, - createdAt: ticket.createdAt, + id: BigInt(result.insertId), + title, + open: true, + createdAt: now, }, }, { status: 201 }, diff --git a/src/app/api/users/[username]/route.ts b/src/app/api/users/[username]/route.ts index 2e0c4ff0..40161ee0 100644 --- a/src/app/api/users/[username]/route.ts +++ b/src/app/api/users/[username]/route.ts @@ -5,8 +5,9 @@ // page (src/app/u/[username]/page.tsx). Never exposes password / auth_ticket / // 2FA secrets / pincode / mail. +import { eq } from "drizzle-orm"; import { apiJson } from "@/lib/api"; -import { prisma } from "@/lib/prisma"; +import { db, User } from "@/lib/db"; export const dynamic = "force-dynamic"; @@ -17,18 +18,19 @@ export async function GET( const { username } = await params; try { - const user = await prisma.user.findUnique({ - where: { username }, - select: { - username: true, - look: true, - motto: true, - rank: true, - credits: true, - online: true, - accountCreated: true, - }, - }); + const [user] = await db + .select({ + username: User.username, + look: User.look, + motto: User.motto, + rank: User.rank, + credits: User.credits, + online: User.online, + accountCreated: User.accountCreated, + }) + .from(User) + .where(eq(User.username, username)) + .limit(1); if (!user) { return apiJson({ error: "User not found" }, { status: 404 }); diff --git a/src/app/api/values/[id]/route.ts b/src/app/api/values/[id]/route.ts index e4f3c87c..7540298c 100644 --- a/src/app/api/values/[id]/route.ts +++ b/src/app/api/values/[id]/route.ts @@ -1,8 +1,9 @@ // Public REST: a single rare furni trade value by id (website_rare_values), // together with its parent category. AtomCMS JSON API parity. Returns { error } // (404) when the id is unknown. +import { eq } from "drizzle-orm"; import { apiJson } from "@/lib/api"; -import { prisma } from "@/lib/prisma"; +import { db, WebsiteRareValueCategories, WebsiteRareValues } from "@/lib/db"; export const dynamic = "force-dynamic"; @@ -25,21 +26,22 @@ export async function GET( } try { - const value = await prisma.websiteRareValues.findUnique({ - where: { id: valueId }, - select: { - id: true, - categoryId: true, - itemId: true, - name: true, - creditValue: true, - currencyValue: true, - currencyType: true, - furnitureIcon: true, - createdAt: true, - updatedAt: true, - }, - }); + const [value] = await db + .select({ + id: WebsiteRareValues.id, + categoryId: WebsiteRareValues.categoryId, + itemId: WebsiteRareValues.itemId, + name: WebsiteRareValues.name, + creditValue: WebsiteRareValues.creditValue, + currencyValue: WebsiteRareValues.currencyValue, + currencyType: WebsiteRareValues.currencyType, + furnitureIcon: WebsiteRareValues.furnitureIcon, + createdAt: WebsiteRareValues.createdAt, + updatedAt: WebsiteRareValues.updatedAt, + }) + .from(WebsiteRareValues) + .where(eq(WebsiteRareValues.id, valueId)) + .limit(1); if (!value) { return apiJson({ error: "Rare value not found" }, { status: 404 }); @@ -54,10 +56,17 @@ export async function GET( priority: number; } | null = null; try { - category = await prisma.websiteRareValueCategories.findUnique({ - where: { id: value.categoryId }, - select: { id: true, name: true, badge: true, priority: true }, - }); + const [row] = await db + .select({ + id: WebsiteRareValueCategories.id, + name: WebsiteRareValueCategories.name, + badge: WebsiteRareValueCategories.badge, + priority: WebsiteRareValueCategories.priority, + }) + .from(WebsiteRareValueCategories) + .where(eq(WebsiteRareValueCategories.id, value.categoryId)) + .limit(1); + category = row ?? null; } catch { category = null; } diff --git a/src/app/api/values/categories/route.ts b/src/app/api/values/categories/route.ts index d3484525..ff250c72 100644 --- a/src/app/api/values/categories/route.ts +++ b/src/app/api/values/categories/route.ts @@ -1,22 +1,26 @@ // Public REST: rare value categories (website_rare_value_categories). // AtomCMS JSON API parity — read-only list ordered by priority then name, // matching the admin /admin/rare-values query. +import { asc } from "drizzle-orm"; import { apiJson } from "@/lib/api"; -import { prisma } from "@/lib/prisma"; +import { db, WebsiteRareValueCategories } from "@/lib/db"; export const dynamic = "force-dynamic"; export async function GET(_req: Request) { try { - const data = await prisma.websiteRareValueCategories.findMany({ - orderBy: [{ priority: "asc" }, { name: "asc" }], - select: { - id: true, - name: true, - badge: true, - priority: true, - }, - }); + const data = await db + .select({ + id: WebsiteRareValueCategories.id, + name: WebsiteRareValueCategories.name, + badge: WebsiteRareValueCategories.badge, + priority: WebsiteRareValueCategories.priority, + }) + .from(WebsiteRareValueCategories) + .orderBy( + asc(WebsiteRareValueCategories.priority), + asc(WebsiteRareValueCategories.name), + ); return apiJson({ data }); } catch { diff --git a/src/app/api/values/route.ts b/src/app/api/values/route.ts index 00c69307..0b38004e 100644 --- a/src/app/api/values/route.ts +++ b/src/app/api/values/route.ts @@ -1,8 +1,9 @@ // Public REST: rare furni trade values (website_rare_values). // AtomCMS JSON API parity — read-only catalog of rares with their credit / // currency values. Supports ?category= filter; paginated, ordered by name. +import { asc, count, eq } from "drizzle-orm"; import { apiError, apiJson, pagination, positiveBigInt } from "@/lib/api"; -import { prisma } from "@/lib/prisma"; +import { db, WebsiteRareValues } from "@/lib/db"; export const dynamic = "force-dynamic"; @@ -12,33 +13,39 @@ export async function GET(req: Request) { // Optional ?category= filter (category_id is a BigInt). const categoryRaw = sp.get("category"); - let where: { categoryId?: bigint } = {}; + let categoryId: bigint | undefined; if (categoryRaw !== null) { - const categoryId = positiveBigInt(categoryRaw); - if (!categoryId) return apiError("A valid category id is required", 422); - where = { categoryId }; + const parsed = positiveBigInt(categoryRaw); + if (!parsed) return apiError("A valid category id is required", 422); + categoryId = parsed; } try { - const [total, data] = await Promise.all([ - prisma.websiteRareValues.count({ where }), - prisma.websiteRareValues.findMany({ - where, - orderBy: { name: "asc" }, - skip, - take, - select: { - id: true, - categoryId: true, - itemId: true, - name: true, - creditValue: true, - currencyValue: true, - currencyType: true, - furnitureIcon: true, - }, - }), + const where = + categoryId !== undefined + ? eq(WebsiteRareValues.categoryId, categoryId) + : undefined; + + const [totalRows, data] = await Promise.all([ + db.select({ total: count() }).from(WebsiteRareValues).where(where), + db + .select({ + id: WebsiteRareValues.id, + categoryId: WebsiteRareValues.categoryId, + itemId: WebsiteRareValues.itemId, + name: WebsiteRareValues.name, + creditValue: WebsiteRareValues.creditValue, + currencyValue: WebsiteRareValues.currencyValue, + currencyType: WebsiteRareValues.currencyType, + furnitureIcon: WebsiteRareValues.furnitureIcon, + }) + .from(WebsiteRareValues) + .where(where) + .orderBy(asc(WebsiteRareValues.name)) + .limit(take) + .offset(skip), ]); + const total = totalRows[0]?.total ?? 0; return apiJson({ data, diff --git a/src/app/client/page.tsx b/src/app/client/page.tsx index 4452afae..7613e3ee 100644 --- a/src/app/client/page.tsx +++ b/src/app/client/page.tsx @@ -1,10 +1,11 @@ +import { count, eq } from "drizzle-orm"; import { headers } from "next/headers"; import { redirect } from "next/navigation"; import { auth } from "@/lib/auth"; import { issueSsoTicket } from "@/lib/auth/sso-ticket"; import { cached } from "@/lib/cache"; +import { db, User } from "@/lib/db"; import { resolveHotelName } from "@/lib/hotel-name"; -import { prisma } from "@/lib/prisma"; import { siteSettings } from "@/lib/services/site-settings"; import { ClientView } from "./client-view"; @@ -22,11 +23,15 @@ export default async function ClientPage() { ]); const ip = (await headers()).get("x-real-client-ip") ?? "0.0.0.0"; - const ticket = await issueSsoTicket(prisma, userId, hotelName, ip); + const ticket = await issueSsoTicket(userId, hotelName, ip); - const onlineCount = await cached("online_count", 10_000, () => - prisma.user.count({ where: { online: "1" } }), - ).catch(() => 0); + const onlineCount = await cached("online_count", 10_000, async () => { + const [row] = await db + .select({ total: count() }) + .from(User) + .where(eq(User.online, "1")); + return row?.total ?? 0; + }).catch(() => 0); return ( > = []; + let bans: BanRow[] = []; try { - bans = await prisma.ban.findMany({ - where: activeBanWhere(now), - orderBy: { timestamp: "desc" }, - take: 100, - }); + bans = await db + .select({ + id: Ban.id, + userId: Ban.userId, + type: Ban.type, + banReason: Ban.banReason, + banExpire: Ban.banExpire, + }) + .from(Ban) + .where(or(eq(Ban.banExpire, 0), gt(Ban.banExpire, now))) + .orderBy(desc(Ban.timestamp)) + .limit(100); } catch { bans = []; } diff --git a/src/app/mod/cfh/[id]/page.tsx b/src/app/mod/cfh/[id]/page.tsx index 6556e5de..20660e6b 100644 --- a/src/app/mod/cfh/[id]/page.tsx +++ b/src/app/mod/cfh/[id]/page.tsx @@ -1,8 +1,9 @@ +import { count, desc, eq, inArray } from "drizzle-orm"; import { notFound } from "next/navigation"; import { CfhDetail } from "@/app/admin/moderation/cfh/[id]/cfh-detail"; import { requireModPermission } from "@/lib/admin/guard"; +import { Ban, db, SupportTickets, User } from "@/lib/db"; import { canAccess, getAdminContext, PERMS } from "@/lib/permissions"; -import { prisma } from "@/lib/prisma"; export default async function ModCfhDetailPage({ params, @@ -16,9 +17,11 @@ export default async function ModCfhDetailPage({ const ticketId = Number(id); if (Number.isNaN(ticketId)) notFound(); - const ticket = await prisma.supportTickets.findUnique({ - where: { id: ticketId }, - }); + const [ticket] = await db + .select() + .from(SupportTickets) + .where(eq(SupportTickets.id, ticketId)) + .limit(1); if (!ticket) notFound(); const canEdit = @@ -30,32 +33,49 @@ export default async function ModCfhDetailPage({ ); const users = userIds.length > 0 - ? await prisma.user.findMany({ - where: { id: { in: userIds } }, - select: { - id: true, - username: true, - look: true, - rank: true, - online: true, - ipRegister: true, - mail: true, - }, - }) + ? await db + .select({ + id: User.id, + username: User.username, + look: User.look, + rank: User.rank, + online: User.online, + ipRegister: User.ipRegister, + mail: User.mail, + }) + .from(User) + .where(inArray(User.id, userIds)) : []; const userMap = Object.fromEntries(users.map((u) => [u.id, u])); const reportedId = ticket.reportedId; - const [banCount, recentBans] = reportedId - ? await Promise.all([ - prisma.ban.count({ where: { userId: reportedId } }), - prisma.ban.findMany({ - where: { userId: reportedId }, - orderBy: { id: "desc" }, - take: 5, - }), - ]) - : [0, []]; + let banCount = 0; + let recentBans: { + id: number; + banReason: string; + type: string; + userStaffId: number; + timestamp: number; + }[] = []; + if (reportedId) { + const [countRows, bans] = await Promise.all([ + db.select({ total: count() }).from(Ban).where(eq(Ban.userId, reportedId)), + db + .select({ + id: Ban.id, + banReason: Ban.banReason, + type: Ban.type, + userStaffId: Ban.userStaffId, + timestamp: Ban.timestamp, + }) + .from(Ban) + .where(eq(Ban.userId, reportedId)) + .orderBy(desc(Ban.id)) + .limit(5), + ]); + banCount = countRows[0]?.total ?? 0; + recentBans = bans; + } return ( = 0) { - conditions.push({ state: stateFilter }); + conditions.push(eq(SupportTickets.state, stateFilter)); } if (parsed.search.trim()) { @@ -29,63 +49,57 @@ export default async function ModCfhListPage({ const asId = Number(q); let userIds: number[] = []; try { - const users = await prisma.user.findMany({ - where: { username: { contains: q } }, - select: { id: true }, - take: 50, - }); + const users = await db + .select({ id: User.id }) + .from(User) + .where(like(User.username, `%${q}%`)) + .limit(50); userIds = users.map((u) => u.id); } catch { userIds = []; } - conditions.push({ - OR: [ - { issue: { contains: q } }, - ...(Number.isFinite(asId) && asId > 0 ? [{ id: asId }] : []), - ...(userIds.length - ? [ - { senderId: { in: userIds } }, - { reportedId: { in: userIds } }, - { modId: { in: userIds } }, - ] - : []), - ], - }); + const ors: SQL[] = [like(SupportTickets.issue, `%${q}%`)]; + if (Number.isFinite(asId) && asId > 0) { + ors.push(eq(SupportTickets.id, asId)); + } + if (userIds.length) { + ors.push(inArray(SupportTickets.senderId, userIds)); + ors.push(inArray(SupportTickets.reportedId, userIds)); + ors.push(inArray(SupportTickets.modId, userIds)); + } + conditions.push(or(...ors)!); } - const where: any = - conditions.length === 0 - ? {} - : conditions.length === 1 - ? conditions[0] - : { AND: conditions }; + const where = conditions.length > 0 ? and(...conditions) : undefined; - const SORT_MAP: Record = { - id: { id: "desc" }, - issue: { issue: "asc" }, - sender: { senderId: "asc" }, - reported: { reportedId: "asc" }, - mod: { modId: "asc" }, - state: { state: "asc" }, - }; + const sortKey = + parsed.sort && parsed.sort in SORT_COLS + ? (parsed.sort as keyof typeof SORT_COLS) + : "id"; + const sortCol = SORT_COLS[sortKey]; + const finalOrder = + parsed.sort && parsed.sort in SORT_COLS + ? parsed.order === "asc" + ? asc(sortCol) + : desc(sortCol) + : desc(SupportTickets.id); - const baseOrder = SORT_MAP[parsed.sort ?? "id"] ?? { id: "desc" }; - const orderField = Object.keys(baseOrder)[0] as keyof typeof baseOrder; - const orderBy = { - [orderField]: parsed.order, - } as any; - - const total = await prisma.supportTickets.count({ where }).catch(() => 0); + const total = await db + .select({ total: count() }) + .from(SupportTickets) + .where(where) + .then((rows) => rows[0]?.total ?? 0) + .catch(() => 0); const pagination = calcPagination(total, parsed.page, parsed.perPage); - const tickets = await prisma.supportTickets - .findMany({ - where, - orderBy, - skip: pagination.offset, - take: pagination.perPage, - }) + const tickets = await db + .select() + .from(SupportTickets) + .where(where) + .orderBy(finalOrder) + .offset(pagination.offset) + .limit(pagination.perPage) .catch(() => []); const userIds = [ @@ -99,10 +113,10 @@ export default async function ModCfhListPage({ let userMap = new Map(); if (userIds.length > 0) { try { - const users = await prisma.user.findMany({ - where: { id: { in: userIds } }, - select: { id: true, username: true }, - }); + const users = await db + .select({ id: User.id, username: User.username }) + .from(User) + .where(inArray(User.id, userIds)); userMap = new Map(users.map((u) => [u.id, u.username])); } catch { userMap = new Map(); diff --git a/src/app/mod/help-tickets/[id]/page.tsx b/src/app/mod/help-tickets/[id]/page.tsx index 170fd920..831d52d3 100644 --- a/src/app/mod/help-tickets/[id]/page.tsx +++ b/src/app/mod/help-tickets/[id]/page.tsx @@ -1,10 +1,16 @@ +import { asc, eq, inArray } from "drizzle-orm"; import { notFound } from "next/navigation"; import { AdminHelpTicketDetail } from "@/app/admin/help-tickets/[id]/admin-help-ticket-detail"; import { requireModPermission } from "@/lib/admin/guard"; import { getMinStaffRank } from "@/lib/admin/min-staff-rank"; import { positiveBigInt } from "@/lib/api"; +import { + db, + User, + WebsiteHelpCenterTicketReplies, + WebsiteHelpCenterTickets, +} from "@/lib/db"; import { canAccess, getAdminContext, PERMS } from "@/lib/permissions"; -import { prisma } from "@/lib/prisma"; export default async function ModHelpTicketDetailPage({ params, @@ -18,31 +24,32 @@ export default async function ModHelpTicketDetailPage({ const ticketId = positiveBigInt(id); if (!ticketId) notFound(); - const ticket = await prisma.websiteHelpCenterTickets.findUnique({ - where: { id: ticketId }, - select: { - id: true, - userId: true, - title: true, - content: true, - open: true, - createdAt: true, - updatedAt: true, - }, - }); + const [ticket] = await db + .select({ + id: WebsiteHelpCenterTickets.id, + userId: WebsiteHelpCenterTickets.userId, + title: WebsiteHelpCenterTickets.title, + content: WebsiteHelpCenterTickets.content, + open: WebsiteHelpCenterTickets.open, + createdAt: WebsiteHelpCenterTickets.createdAt, + updatedAt: WebsiteHelpCenterTickets.updatedAt, + }) + .from(WebsiteHelpCenterTickets) + .where(eq(WebsiteHelpCenterTickets.id, ticketId)) + .limit(1); if (!ticket) notFound(); - const replies = await prisma.websiteHelpCenterTicketReplies.findMany({ - where: { ticketId: ticket.id }, - orderBy: { id: "asc" }, - select: { - id: true, - userId: true, - content: true, - createdAt: true, - }, - }); + const replies = await db + .select({ + id: WebsiteHelpCenterTicketReplies.id, + userId: WebsiteHelpCenterTicketReplies.userId, + content: WebsiteHelpCenterTicketReplies.content, + createdAt: WebsiteHelpCenterTicketReplies.createdAt, + }) + .from(WebsiteHelpCenterTicketReplies) + .where(eq(WebsiteHelpCenterTicketReplies.ticketId, ticket.id)) + .orderBy(asc(WebsiteHelpCenterTicketReplies.id)); const authorIds = [ ...new Set([ @@ -53,15 +60,15 @@ export default async function ModHelpTicketDetailPage({ const users = authorIds.length > 0 - ? await prisma.user.findMany({ - where: { id: { in: authorIds } }, - select: { - id: true, - username: true, - rank: true, - mail: true, - }, - }) + ? await db + .select({ + id: User.id, + username: User.username, + rank: User.rank, + mail: User.mail, + }) + .from(User) + .where(inArray(User.id, authorIds)) : []; const userById = new Map(users.map((u) => [u.id, u])); diff --git a/src/app/mod/help-tickets/page.tsx b/src/app/mod/help-tickets/page.tsx index dd356116..d9c60130 100644 --- a/src/app/mod/help-tickets/page.tsx +++ b/src/app/mod/help-tickets/page.tsx @@ -1,3 +1,14 @@ +import { + and, + asc, + count, + desc, + eq, + inArray, + like, + or, + type SQL, +} from "drizzle-orm"; import { getTranslations } from "next-intl/server"; import { type HelpTicketRow, @@ -8,9 +19,23 @@ import { TicketQueueBanner } from "@/components/admin/ticket-queue-banner"; import { requireModPermission } from "@/lib/admin/guard"; import { fetchTicketQueueOpenCounts } from "@/lib/admin/ticket-queue-counts"; import { calcPagination, parseListParams } from "@/lib/admin-helpers"; +import { + db, + User, + WebsiteHelpCenterTicketReplies, + WebsiteHelpCenterTickets, +} from "@/lib/db"; import { formatDate } from "@/lib/format-date"; import { PERMS } from "@/lib/permissions"; -import { prisma } from "@/lib/prisma"; + +const SORT_COLS = { + title: WebsiteHelpCenterTickets.title, + open: WebsiteHelpCenterTickets.open, + user: WebsiteHelpCenterTickets.userId, + date: WebsiteHelpCenterTickets.createdAt, + updated: WebsiteHelpCenterTickets.updatedAt, + id: WebsiteHelpCenterTickets.id, +} as const; export default async function ModHelpTicketsPage({ searchParams, @@ -24,87 +49,94 @@ export default async function ModHelpTicketsPage({ const parsed = parseListParams(new URLSearchParams(raw)); const statusFilter = raw.filter_status || "open"; - const conditions: any[] = []; + const conditions: SQL[] = []; if (statusFilter === "open") { - conditions.push({ open: true }); + conditions.push(eq(WebsiteHelpCenterTickets.open, true)); } else if (statusFilter === "closed") { - conditions.push({ open: false }); + conditions.push(eq(WebsiteHelpCenterTickets.open, false)); } if (parsed.search.trim()) { const q = parsed.search.trim(); const asId = /^\d+$/.test(q) ? BigInt(q) : null; - const matchingUsers = await prisma.user - .findMany({ - where: { username: { contains: q } }, - select: { id: true }, - take: 50, - }) + const matchingUsers = await db + .select({ id: User.id }) + .from(User) + .where(like(User.username, `%${q}%`)) + .limit(50) .catch(() => []); - conditions.push({ - OR: [ - { title: { contains: q } }, - { content: { contains: q } }, - ...(matchingUsers.length > 0 - ? [{ userId: { in: matchingUsers.map((u) => u.id) } }] - : []), - ...(asId !== null ? [{ id: asId }] : []), - ], - }); + const ors: SQL[] = [ + like(WebsiteHelpCenterTickets.title, `%${q}%`), + like(WebsiteHelpCenterTickets.content, `%${q}%`), + ]; + if (matchingUsers.length > 0) { + ors.push( + inArray( + WebsiteHelpCenterTickets.userId, + matchingUsers.map((u) => u.id), + ), + ); + } + if (asId !== null) { + ors.push(eq(WebsiteHelpCenterTickets.id, asId)); + } + conditions.push(or(...ors)!); } - const where: any = - conditions.length === 0 - ? {} - : conditions.length === 1 - ? conditions[0] - : { AND: conditions }; + const where = conditions.length > 0 ? and(...conditions) : undefined; - const SORT_MAP: Record = { - title: { title: "asc" }, - open: { open: "desc" }, - user: { userId: "asc" }, - date: { createdAt: "desc" }, - updated: { updatedAt: "desc" }, - id: { id: "desc" }, - }; - - const baseOrder = SORT_MAP[parsed.sort ?? "updated"] ?? { updatedAt: "desc" }; - const orderField = Object.keys(baseOrder)[0] as keyof typeof baseOrder; - const orderBy = { - [orderField]: parsed.order, - } as any; + const sortKey = + parsed.sort && parsed.sort in SORT_COLS + ? (parsed.sort as keyof typeof SORT_COLS) + : "updated"; + const sortCol = SORT_COLS[sortKey]; + const finalOrder = + parsed.sort && parsed.sort in SORT_COLS + ? parsed.order === "asc" + ? asc(sortCol) + : desc(sortCol) + : desc(WebsiteHelpCenterTickets.updatedAt); const [total, openCount, closedCount, queues] = await Promise.all([ - prisma.websiteHelpCenterTickets.count({ where }).catch(() => 0), - prisma.websiteHelpCenterTickets - .count({ where: { open: true } }) + db + .select({ total: count() }) + .from(WebsiteHelpCenterTickets) + .where(where) + .then((rows) => rows[0]?.total ?? 0) .catch(() => 0), - prisma.websiteHelpCenterTickets - .count({ where: { open: false } }) + db + .select({ total: count() }) + .from(WebsiteHelpCenterTickets) + .where(eq(WebsiteHelpCenterTickets.open, true)) + .then((rows) => rows[0]?.total ?? 0) + .catch(() => 0), + db + .select({ total: count() }) + .from(WebsiteHelpCenterTickets) + .where(eq(WebsiteHelpCenterTickets.open, false)) + .then((rows) => rows[0]?.total ?? 0) .catch(() => 0), fetchTicketQueueOpenCounts(), ]); const pagination = calcPagination(total, parsed.page, parsed.perPage); - const tickets = await prisma.websiteHelpCenterTickets - .findMany({ - where, - orderBy, - skip: pagination.offset, - take: pagination.perPage, - select: { - id: true, - userId: true, - title: true, - open: true, - createdAt: true, - updatedAt: true, - }, + const tickets = await db + .select({ + id: WebsiteHelpCenterTickets.id, + userId: WebsiteHelpCenterTickets.userId, + title: WebsiteHelpCenterTickets.title, + open: WebsiteHelpCenterTickets.open, + createdAt: WebsiteHelpCenterTickets.createdAt, + updatedAt: WebsiteHelpCenterTickets.updatedAt, }) + .from(WebsiteHelpCenterTickets) + .where(where) + .orderBy(finalOrder) + .offset(pagination.offset) + .limit(pagination.perPage) .catch(() => []); const ticketIds = tickets.map((ticket) => ticket.id); @@ -118,26 +150,27 @@ export default async function ModHelpTicketsPage({ const [replyGroups, users] = await Promise.all([ ticketIds.length > 0 - ? prisma.websiteHelpCenterTicketReplies - .groupBy({ - by: ["ticketId"], - where: { ticketId: { in: ticketIds } }, - _count: true, + ? db + .select({ + ticketId: WebsiteHelpCenterTicketReplies.ticketId, + total: count(), }) + .from(WebsiteHelpCenterTicketReplies) + .where(inArray(WebsiteHelpCenterTicketReplies.ticketId, ticketIds)) + .groupBy(WebsiteHelpCenterTicketReplies.ticketId) .catch(() => []) : Promise.resolve([]), userIds.length > 0 - ? prisma.user - .findMany({ - where: { id: { in: userIds } }, - select: { id: true, username: true }, - }) + ? db + .select({ id: User.id, username: User.username }) + .from(User) + .where(inArray(User.id, userIds)) .catch(() => []) : Promise.resolve([]), ]); const replyCountByTicket = new Map( - replyGroups.map((g) => [String(g.ticketId), g._count]), + replyGroups.map((g) => [String(g.ticketId), g.total]), ); const usernameById = new Map(users.map((u) => [u.id, u.username])); diff --git a/src/app/mod/layout.tsx b/src/app/mod/layout.tsx index 08a109c2..07628de1 100644 --- a/src/app/mod/layout.tsx +++ b/src/app/mod/layout.tsx @@ -1,3 +1,4 @@ +import { eq } from "drizzle-orm"; import { Ban, Gavel, @@ -16,9 +17,9 @@ import type { ReactNode } from "react"; import { LanguageSwitcher } from "@/components/language-switcher"; import { ThemeSwitcher } from "@/components/theme-switcher"; import { requireMod } from "@/lib/admin/guard"; +import { db, User } from "@/lib/db"; import { setCsrfCookie } from "@/lib/foundation/security"; import { canAccess, getAdminContext, PERMS } from "@/lib/permissions"; -import { prisma } from "@/lib/prisma"; import { siteSettings } from "@/lib/services/site-settings"; export const dynamic = "force-dynamic"; @@ -32,12 +33,12 @@ export default async function ModLayout({ children }: { children: ReactNode }) { csrfToken = ""; } if (await siteSettings.getBool("force_staff_2fa", false)) { - const u = await prisma.user - .findUnique({ - where: { id: staff.id }, - select: { twoFactorConfirmedAt: true }, - }) - .catch(() => null); + const [u] = await db + .select({ twoFactorConfirmedAt: User.twoFactorConfirmedAt }) + .from(User) + .where(eq(User.id, staff.id)) + .limit(1) + .catch(() => [] as { twoFactorConfirmedAt: Date | null }[]); if (!u?.twoFactorConfirmedAt) redirect("/settings/2fa?error=staffrequired"); } diff --git a/src/app/mod/page.tsx b/src/app/mod/page.tsx index 43968be7..1b8c211b 100644 --- a/src/app/mod/page.tsx +++ b/src/app/mod/page.tsx @@ -1,3 +1,4 @@ +import { and, count, eq, gte, ne } from "drizzle-orm"; import { Ban, Gavel, @@ -11,8 +12,15 @@ import Link from "next/link"; import { getTranslations } from "next-intl/server"; import { StatsCard } from "@/components/admin/stats-card"; import { getMinStaffRank } from "@/lib/admin/min-staff-rank"; +import { + Ban as BanTable, + db, + SupportTickets, + User, + WebsiteHelpCenterTickets, + WebsiteTicket, +} from "@/lib/db"; import { canAccess, getAdminContext, PERMS } from "@/lib/permissions"; -import { prisma } from "@/lib/prisma"; export default async function ModDashboardPage() { const t = await getTranslations("pages.mod"); @@ -44,24 +52,41 @@ export default async function ModDashboardPage() { const [openCfh, todayBans, modsOnline, openTickets, openHelpTickets] = await Promise.all([ showCfh - ? prisma.supportTickets.count({ where: { state: 0 } }).catch(() => 0) + ? db + .select({ total: count() }) + .from(SupportTickets) + .where(eq(SupportTickets.state, 0)) + .then((rows) => rows[0]?.total ?? 0) + .catch(() => 0) : Promise.resolve(0), showBans - ? prisma.ban - .count({ where: { timestamp: { gte: todayStart } } }) + ? db + .select({ total: count() }) + .from(BanTable) + .where(gte(BanTable.timestamp, todayStart)) + .then((rows) => rows[0]?.total ?? 0) .catch(() => 0) : Promise.resolve(0), - prisma.user - .count({ where: { online: "1", rank: { gte: minStaffRank } } }) + db + .select({ total: count() }) + .from(User) + .where(and(eq(User.online, "1"), gte(User.rank, minStaffRank))) + .then((rows) => rows[0]?.total ?? 0) .catch(() => 0), showTickets - ? prisma.websiteTicket - .count({ where: { status: { not: "closed" } } }) + ? db + .select({ total: count() }) + .from(WebsiteTicket) + .where(ne(WebsiteTicket.status, "closed")) + .then((rows) => rows[0]?.total ?? 0) .catch(() => 0) : Promise.resolve(0), showTickets - ? prisma.websiteHelpCenterTickets - .count({ where: { open: true } }) + ? db + .select({ total: count() }) + .from(WebsiteHelpCenterTickets) + .where(eq(WebsiteHelpCenterTickets.open, true)) + .then((rows) => rows[0]?.total ?? 0) .catch(() => 0) : Promise.resolve(0), ]); diff --git a/src/app/mod/team/page.tsx b/src/app/mod/team/page.tsx index 0f585689..4d04c5ac 100644 --- a/src/app/mod/team/page.tsx +++ b/src/app/mod/team/page.tsx @@ -1,58 +1,77 @@ +import { and, count, desc, gte, inArray, like } from "drizzle-orm"; import Link from "next/link"; import { AvatarImage } from "@/components/shared/avatar-image"; import { Badge } from "@/components/ui/badge"; import { Card, CardContent } from "@/components/ui/card"; import { requireModPermission } from "@/lib/admin/guard"; import { getMinStaffRank } from "@/lib/admin/min-staff-rank"; +import { + AdminAuditLog, + db, + SupportTickets, + User, + WebsiteTicket, +} from "@/lib/db"; import { PERMS } from "@/lib/permissions"; -import { prisma } from "@/lib/prisma"; export default async function ModTeamLitePage() { await requireModPermission([PERMS.MOD_TEAM_VIEW, PERMS.MODERATION_VIEW]); const minStaffRank = await getMinStaffRank(); - const staff = await prisma.user.findMany({ - where: { rank: { gte: minStaffRank } }, - select: { - id: true, - username: true, - look: true, - rank: true, - online: true, - lastLogin: true, - lastOnline: true, - }, - orderBy: [{ online: "desc" }, { rank: "desc" }], - }); + const staff = await db + .select({ + id: User.id, + username: User.username, + look: User.look, + rank: User.rank, + online: User.online, + lastLogin: User.lastLogin, + lastOnline: User.lastOnline, + }) + .from(User) + .where(gte(User.rank, minStaffRank)) + .orderBy(desc(User.online), desc(User.rank)); - const [cfhCounts, webTicketCounts, modActionCounts] = await Promise.all([ - prisma.supportTickets.groupBy({ - by: ["modId"], - where: { modId: { in: staff.map((s) => s.id) } }, - _count: { _all: true }, - }), - prisma.websiteTicket.groupBy({ - by: ["assigneeId"], - where: { assigneeId: { in: staff.map((s) => s.id) } }, - _count: { _all: true }, - }), - prisma.adminAuditLog.groupBy({ - by: ["userId"], - where: { - userId: { in: staff.map((s) => s.id) }, - action: { startsWith: "mod_" }, - }, - _count: { _all: true }, - }), - ]); + const staffIds = staff.map((s) => s.id); - const cfhMap = new Map(cfhCounts.map((c) => [c.modId, c._count._all])); - const webMap = new Map( - webTicketCounts.map((c) => [c.assigneeId, c._count._all]), - ); - const actionMap = new Map( - modActionCounts.map((c) => [c.userId, c._count._all]), - ); + const [cfhCounts, webTicketCounts, modActionCounts] = + staffIds.length === 0 + ? [[], [], []] + : await Promise.all([ + db + .select({ + modId: SupportTickets.modId, + total: count(), + }) + .from(SupportTickets) + .where(inArray(SupportTickets.modId, staffIds)) + .groupBy(SupportTickets.modId), + db + .select({ + assigneeId: WebsiteTicket.assigneeId, + total: count(), + }) + .from(WebsiteTicket) + .where(inArray(WebsiteTicket.assigneeId, staffIds)) + .groupBy(WebsiteTicket.assigneeId), + db + .select({ + userId: AdminAuditLog.userId, + total: count(), + }) + .from(AdminAuditLog) + .where( + and( + inArray(AdminAuditLog.userId, staffIds), + like(AdminAuditLog.action, "mod_%"), + ), + ) + .groupBy(AdminAuditLog.userId), + ]); + + const cfhMap = new Map(cfhCounts.map((c) => [c.modId, c.total])); + const webMap = new Map(webTicketCounts.map((c) => [c.assigneeId, c.total])); + const actionMap = new Map(modActionCounts.map((c) => [c.userId, c.total])); return (
diff --git a/src/app/mod/tickets/[id]/page.tsx b/src/app/mod/tickets/[id]/page.tsx index bdb18c45..28a28d89 100644 --- a/src/app/mod/tickets/[id]/page.tsx +++ b/src/app/mod/tickets/[id]/page.tsx @@ -1,9 +1,15 @@ +import { asc, eq, gte } from "drizzle-orm"; +import { alias } from "drizzle-orm/mysql-core"; import { notFound } from "next/navigation"; import { AdminTicketDetail } from "@/app/admin/tickets/[id]/admin-ticket-detail"; import { requireModPermission } from "@/lib/admin/guard"; import { getMinStaffRank } from "@/lib/admin/min-staff-rank"; +import { db, User, WebsiteTicket, WebsiteTicketMessage } from "@/lib/db"; import { canAccess, getAdminContext, PERMS } from "@/lib/permissions"; -import { prisma } from "@/lib/prisma"; + +const Creator = alias(User, "ticket_creator"); +const Assignee = alias(User, "ticket_assignee"); +const MessageUser = alias(User, "ticket_message_user"); export default async function ModTicketDetailPage({ params, @@ -17,40 +23,55 @@ export default async function ModTicketDetailPage({ const ticketId = Number(id); if (Number.isNaN(ticketId)) notFound(); - const ticket = await prisma.websiteTicket.findUnique({ - where: { id: ticketId }, - include: { - creator: { - select: { - id: true, - username: true, - look: true, - rank: true, - ipRegister: true, - mail: true, - accountCreated: true, - }, - }, - assignee: { select: { id: true, username: true } }, - messages: { - orderBy: { createdAt: "asc" }, - include: { - user: { - select: { id: true, username: true, look: true, rank: true }, - }, - }, - }, - }, - }); + const [ticket] = await db + .select({ + id: WebsiteTicket.id, + subject: WebsiteTicket.subject, + category: WebsiteTicket.category, + priority: WebsiteTicket.priority, + status: WebsiteTicket.status, + createdAt: WebsiteTicket.createdAt, + closedAt: WebsiteTicket.closedAt, + creatorId: Creator.id, + creatorUsername: Creator.username, + creatorLook: Creator.look, + creatorRank: Creator.rank, + creatorIpRegister: Creator.ipRegister, + creatorMail: Creator.mail, + creatorAccountCreated: Creator.accountCreated, + assigneeId: Assignee.id, + assigneeUsername: Assignee.username, + }) + .from(WebsiteTicket) + .innerJoin(Creator, eq(WebsiteTicket.creatorId, Creator.id)) + .leftJoin(Assignee, eq(WebsiteTicket.assigneeId, Assignee.id)) + .where(eq(WebsiteTicket.id, ticketId)) + .limit(1); if (!ticket) notFound(); + const messages = await db + .select({ + id: WebsiteTicketMessage.id, + message: WebsiteTicketMessage.message, + isStaff: WebsiteTicketMessage.isStaff, + createdAt: WebsiteTicketMessage.createdAt, + userId: MessageUser.id, + username: MessageUser.username, + look: MessageUser.look, + rank: MessageUser.rank, + }) + .from(WebsiteTicketMessage) + .innerJoin(MessageUser, eq(WebsiteTicketMessage.userId, MessageUser.id)) + .where(eq(WebsiteTicketMessage.ticketId, ticketId)) + .orderBy(asc(WebsiteTicketMessage.createdAt)); + const minStaffRank = await getMinStaffRank(); - const staffMembers = await prisma.user.findMany({ - where: { rank: { gte: minStaffRank } }, - select: { id: true, username: true }, - orderBy: { username: "asc" }, - }); + const staffMembers = await db + .select({ id: User.id, username: User.username }) + .from(User) + .where(gte(User.rank, minStaffRank)) + .orderBy(asc(User.username)); const canEdit = canAccess(permissions, PERMS.MOD_TICKETS_EDIT, session.user.rank) || @@ -67,28 +88,29 @@ export default async function ModTicketDetailPage({ createdAt: ticket.createdAt.toISOString(), closedAt: ticket.closedAt?.toISOString() ?? null, creator: { - id: ticket.creator.id, - username: ticket.creator.username, - look: ticket.creator.look, - rank: ticket.creator.rank, - mail: ticket.creator.mail ?? "", - ipRegister: ticket.creator.ipRegister, - accountCreated: ticket.creator.accountCreated, + id: ticket.creatorId, + username: ticket.creatorUsername, + look: ticket.creatorLook, + rank: ticket.creatorRank, + mail: ticket.creatorMail ?? "", + ipRegister: ticket.creatorIpRegister, + accountCreated: ticket.creatorAccountCreated, }, - assignee: ticket.assignee - ? { id: ticket.assignee.id, username: ticket.assignee.username } - : null, + assignee: + ticket.assigneeId != null && ticket.assigneeUsername + ? { id: ticket.assigneeId, username: ticket.assigneeUsername } + : null, }} - messages={ticket.messages.map((m) => ({ + messages={messages.map((m) => ({ id: m.id, message: m.message, isStaff: m.isStaff === 1, createdAt: m.createdAt.toISOString(), user: { - id: m.user.id, - username: m.user.username, - look: m.user.look, - rank: m.user.rank, + id: m.userId, + username: m.username, + look: m.look, + rank: m.rank, }, }))} staffMembers={staffMembers} diff --git a/src/app/mod/tickets/page.tsx b/src/app/mod/tickets/page.tsx index d2e88b02..821b7c0a 100644 --- a/src/app/mod/tickets/page.tsx +++ b/src/app/mod/tickets/page.tsx @@ -1,3 +1,16 @@ +import { + and, + asc, + count, + desc, + eq, + inArray, + like, + ne, + or, + type SQL, +} from "drizzle-orm"; +import { alias } from "drizzle-orm/mysql-core"; import { getTranslations } from "next-intl/server"; import { type TicketRow, @@ -8,9 +21,21 @@ import { TicketQueueBanner } from "@/components/admin/ticket-queue-banner"; import { requireModPermission } from "@/lib/admin/guard"; import { fetchTicketQueueOpenCounts } from "@/lib/admin/ticket-queue-counts"; import { calcPagination, parseListParams } from "@/lib/admin-helpers"; +import { db, User, WebsiteTicket, WebsiteTicketMessage } from "@/lib/db"; import { formatDate } from "@/lib/format-date"; import { PERMS } from "@/lib/permissions"; -import { prisma } from "@/lib/prisma"; + +const Creator = alias(User, "ticket_creator"); +const Assignee = alias(User, "ticket_assignee"); + +const SORT_COLS = { + subject: WebsiteTicket.subject, + status: WebsiteTicket.status, + priority: WebsiteTicket.priority, + assignee: WebsiteTicket.assigneeId, + date: WebsiteTicket.createdAt, + id: WebsiteTicket.id, +} as const; export default async function ModTicketsPage({ searchParams, @@ -24,92 +49,128 @@ export default async function ModTicketsPage({ const parsed = parseListParams(new URLSearchParams(raw)); const statusFilter = raw.filter_status || raw.status || "active"; - const conditions: any[] = []; + const conditions: SQL[] = []; if (statusFilter === "active") { - conditions.push({ status: { not: "closed" } }); + conditions.push(ne(WebsiteTicket.status, "closed")); } else if (statusFilter && statusFilter !== "all") { - conditions.push({ status: statusFilter }); + conditions.push(eq(WebsiteTicket.status, statusFilter)); } if (parsed.search.trim()) { const q = parsed.search.trim(); const asId = Number(q); - conditions.push({ - OR: [ - { subject: { contains: q } }, - { category: { contains: q } }, - { creator: { username: { contains: q } } }, - { assignee: { username: { contains: q } } }, - ...(Number.isFinite(asId) && asId > 0 ? [{ id: asId }] : []), - ], - }); + const ors: SQL[] = [ + like(WebsiteTicket.subject, `%${q}%`), + like(WebsiteTicket.category, `%${q}%`), + like(Creator.username, `%${q}%`), + like(Assignee.username, `%${q}%`), + ]; + if (Number.isFinite(asId) && asId > 0) { + ors.push(eq(WebsiteTicket.id, asId)); + } + conditions.push(or(...ors)!); } - const where: any = - conditions.length === 0 - ? {} - : conditions.length === 1 - ? conditions[0] - : { AND: conditions }; + const where = conditions.length > 0 ? and(...conditions) : undefined; - const SORT_MAP: Record = { - subject: { subject: "asc" }, - status: { status: "asc" }, - priority: { priority: "asc" }, - assignee: { assigneeId: "asc" }, - date: { createdAt: "desc" }, - id: { id: "desc" }, - }; - - const baseOrder = SORT_MAP[parsed.sort ?? "date"] ?? { createdAt: "desc" }; - const orderField = Object.keys(baseOrder)[0] as keyof typeof baseOrder; - const orderBy = { - [orderField]: parsed.order, - } as any; + const sortKey = + parsed.sort && parsed.sort in SORT_COLS + ? (parsed.sort as keyof typeof SORT_COLS) + : "date"; + const sortCol = SORT_COLS[sortKey]; + const finalOrder = + parsed.sort && parsed.sort in SORT_COLS + ? parsed.order === "asc" + ? asc(sortCol) + : desc(sortCol) + : desc(WebsiteTicket.createdAt); const [total, openCount, inProgressCount, waitingCount, closedCount, queues] = await Promise.all([ - prisma.websiteTicket.count({ where }).catch(() => 0), - prisma.websiteTicket.count({ where: { status: "open" } }).catch(() => 0), - prisma.websiteTicket - .count({ where: { status: "in_progress" } }) + db + .select({ total: count() }) + .from(WebsiteTicket) + .leftJoin(Creator, eq(WebsiteTicket.creatorId, Creator.id)) + .leftJoin(Assignee, eq(WebsiteTicket.assigneeId, Assignee.id)) + .where(where) + .then((rows) => rows[0]?.total ?? 0) .catch(() => 0), - prisma.websiteTicket - .count({ where: { status: "waiting" } }) + db + .select({ total: count() }) + .from(WebsiteTicket) + .where(eq(WebsiteTicket.status, "open")) + .then((rows) => rows[0]?.total ?? 0) .catch(() => 0), - prisma.websiteTicket - .count({ where: { status: "closed" } }) + db + .select({ total: count() }) + .from(WebsiteTicket) + .where(eq(WebsiteTicket.status, "in_progress")) + .then((rows) => rows[0]?.total ?? 0) + .catch(() => 0), + db + .select({ total: count() }) + .from(WebsiteTicket) + .where(eq(WebsiteTicket.status, "waiting")) + .then((rows) => rows[0]?.total ?? 0) + .catch(() => 0), + db + .select({ total: count() }) + .from(WebsiteTicket) + .where(eq(WebsiteTicket.status, "closed")) + .then((rows) => rows[0]?.total ?? 0) .catch(() => 0), fetchTicketQueueOpenCounts(), ]); const pagination = calcPagination(total, parsed.page, parsed.perPage); - const tickets = await prisma.websiteTicket - .findMany({ - where, - orderBy, - skip: pagination.offset, - take: pagination.perPage, - include: { - creator: { select: { id: true, username: true } }, - assignee: { select: { id: true, username: true } }, - _count: { select: { messages: true } }, - }, + const tickets = await db + .select({ + id: WebsiteTicket.id, + subject: WebsiteTicket.subject, + category: WebsiteTicket.category, + status: WebsiteTicket.status, + priority: WebsiteTicket.priority, + creatorId: WebsiteTicket.creatorId, + createdAt: WebsiteTicket.createdAt, + creatorUsername: Creator.username, + assigneeUsername: Assignee.username, }) + .from(WebsiteTicket) + .leftJoin(Creator, eq(WebsiteTicket.creatorId, Creator.id)) + .leftJoin(Assignee, eq(WebsiteTicket.assigneeId, Assignee.id)) + .where(where) + .orderBy(finalOrder) + .offset(pagination.offset) + .limit(pagination.perPage) .catch(() => []); + const ticketIds = tickets.map((ticket) => ticket.id); + const msgRows = + ticketIds.length > 0 + ? await db + .select({ + ticketId: WebsiteTicketMessage.ticketId, + total: count(), + }) + .from(WebsiteTicketMessage) + .where(inArray(WebsiteTicketMessage.ticketId, ticketIds)) + .groupBy(WebsiteTicketMessage.ticketId) + .catch(() => []) + : []; + const msgMap = new Map(msgRows.map((m) => [m.ticketId, m.total])); + const rows: TicketRow[] = tickets.map((ticket) => ({ id: ticket.id, subject: ticket.subject, - creator: ticket.creator.username, + creator: ticket.creatorUsername ?? "—", creatorId: ticket.creatorId, category: ticket.category, - messages: ticket._count.messages, + messages: msgMap.get(ticket.id) ?? 0, status: ticket.status, priority: ticket.priority, - assignee: ticket.assignee?.username ?? "—", + assignee: ticket.assigneeUsername ?? "—", date: formatDate(ticket.createdAt, "date"), })); diff --git a/src/app/mod/users/[id]/page.tsx b/src/app/mod/users/[id]/page.tsx index a5c2aab8..3b383598 100644 --- a/src/app/mod/users/[id]/page.tsx +++ b/src/app/mod/users/[id]/page.tsx @@ -1,12 +1,13 @@ +import { eq } from "drizzle-orm"; import Link from "next/link"; import { notFound } from "next/navigation"; import { getTranslations } from "next-intl/server"; import { Badge } from "@/components/ui/badge"; import { requireModPermission } from "@/lib/admin/guard"; +import { db, User } from "@/lib/db"; import { formatDate } from "@/lib/format-date"; import { getAvatarUrl } from "@/lib/imager"; import { PERMS } from "@/lib/permissions"; -import { prisma } from "@/lib/prisma"; export default async function ModUserDetailPage({ params, @@ -19,20 +20,21 @@ export default async function ModUserDetailPage({ const id = Number(rawId); if (!Number.isFinite(id) || id <= 0) notFound(); - const user = await prisma.user.findUnique({ - where: { id }, - select: { - id: true, - username: true, - rank: true, - online: true, - motto: true, - look: true, - accountCreated: true, - lastLogin: true, - lastOnline: true, - }, - }); + const [user] = await db + .select({ + id: User.id, + username: User.username, + rank: User.rank, + online: User.online, + motto: User.motto, + look: User.look, + accountCreated: User.accountCreated, + lastLogin: User.lastLogin, + lastOnline: User.lastOnline, + }) + .from(User) + .where(eq(User.id, id)) + .limit(1); if (!user) notFound(); return ( diff --git a/src/app/mod/users/page.tsx b/src/app/mod/users/page.tsx index ea30bff0..3c72acc9 100644 --- a/src/app/mod/users/page.tsx +++ b/src/app/mod/users/page.tsx @@ -1,12 +1,13 @@ +import { and, asc, count, desc, eq, like, or, type SQL } from "drizzle-orm"; import Link from "next/link"; import { getTranslations } from "next-intl/server"; import { DataTable } from "@/components/admin/data-table"; import { Badge } from "@/components/ui/badge"; import { requireModPermission } from "@/lib/admin/guard"; import { calcPagination, parseListParams } from "@/lib/admin-helpers"; +import { db, User } from "@/lib/db"; import { getAvatarUrl } from "@/lib/imager"; import { PERMS } from "@/lib/permissions"; -import { prisma } from "@/lib/prisma"; import type { DataTableColumn } from "@/types/common"; type ModUserRow = { @@ -89,6 +90,15 @@ const columns: DataTableColumn[] = [ }, ]; +const SORT_COLS = { + id: User.id, + username: User.username, + rank: User.rank, + online: User.online, + motto: User.motto, + lastLogin: User.lastLogin, +} as const; + export default async function ModUsersPage({ searchParams, }: { @@ -99,44 +109,55 @@ export default async function ModUsersPage({ const raw = await searchParams; const parsed = parseListParams(new URLSearchParams(raw)); - const where = parsed.search.trim() - ? { - OR: [ - { username: { contains: parsed.search.trim() } }, - { motto: { contains: parsed.search.trim() } }, - ...(Number.isFinite(Number(parsed.search)) && - Number(parsed.search) > 0 - ? [{ id: Number(parsed.search) }] - : []), - ], - } - : {}; + const conditions: SQL[] = []; + if (parsed.search.trim()) { + const q = parsed.search.trim(); + const ors = [like(User.username, `%${q}%`), like(User.motto, `%${q}%`)]; + if (Number.isFinite(Number(q)) && Number(q) > 0) { + ors.push(eq(User.id, Number(q))); + } + conditions.push(or(...ors)!); + } + const where = conditions.length > 0 ? and(...conditions) : undefined; - const orderBy = parsed.sort - ? { [parsed.sort]: parsed.order } - : { id: "desc" as const }; + const sortKey = + parsed.sort && parsed.sort in SORT_COLS + ? (parsed.sort as keyof typeof SORT_COLS) + : "id"; + const sortCol = SORT_COLS[sortKey]; + const finalOrder = + parsed.sort && parsed.sort in SORT_COLS + ? parsed.order === "asc" + ? asc(sortCol) + : desc(sortCol) + : desc(User.id); - const [total, users] = await Promise.all([ - prisma.user.count({ where }).catch(() => 0), - prisma.user - .findMany({ - where, - orderBy, - skip: (parsed.page - 1) * parsed.perPage, - take: parsed.perPage, - select: { - id: true, - username: true, - rank: true, - online: true, - motto: true, - look: true, - lastLogin: true, - }, + const [totals, users] = await Promise.all([ + db + .select({ total: count() }) + .from(User) + .where(where) + .then((rows) => rows[0]?.total ?? 0) + .catch(() => 0), + db + .select({ + id: User.id, + username: User.username, + rank: User.rank, + online: User.online, + motto: User.motto, + look: User.look, + lastLogin: User.lastLogin, }) - .catch(() => []), + .from(User) + .where(where) + .orderBy(finalOrder) + .offset((parsed.page - 1) * parsed.perPage) + .limit(parsed.perPage) + .catch(() => [] as ModUserRow[]), ]); + const total = totals; const pagination = calcPagination(total, parsed.page, parsed.perPage); const rows: ModUserRow[] = users; diff --git a/src/app/sitemap.ts b/src/app/sitemap.ts index 206c1f37..06c160e2 100644 --- a/src/app/sitemap.ts +++ b/src/app/sitemap.ts @@ -1,5 +1,6 @@ +import { desc } from "drizzle-orm"; import type { MetadataRoute } from "next"; -import { prisma } from "@/lib/prisma"; +import { db, Guilds, WebsiteArticles } from "@/lib/db"; // Built at request time — avoids competing with SSG workers for scarce DB // connections during `next build` (pool timeouts killed deploy on sitemap). @@ -75,11 +76,14 @@ export default async function sitemap(): Promise { // News articles let articles: { slug: string; updatedAt: Date | null }[] = []; try { - articles = await prisma.websiteArticles.findMany({ - select: { slug: true, updatedAt: true }, - orderBy: { createdAt: "desc" }, - take: 200, - }); + articles = await db + .select({ + slug: WebsiteArticles.slug, + updatedAt: WebsiteArticles.updatedAt, + }) + .from(WebsiteArticles) + .orderBy(desc(WebsiteArticles.createdAt)) + .limit(200); } catch { /* empty */ } @@ -94,10 +98,7 @@ export default async function sitemap(): Promise { // Guilds let guildIds: number[] = []; try { - const rows = await prisma.guilds.findMany({ - select: { id: true }, - take: 200, - }); + const rows = await db.select({ id: Guilds.id }).from(Guilds).limit(200); guildIds = rows.map((r) => Number(r.id)); } catch { /* empty */ diff --git a/src/components/navigation.tsx b/src/components/navigation.tsx index 15b3f2c0..9da9d14f 100644 --- a/src/components/navigation.tsx +++ b/src/components/navigation.tsx @@ -1,3 +1,4 @@ +import { count, eq } from "drizzle-orm"; import Image from "next/image"; import Link from "next/link"; import type { Session } from "next-auth"; @@ -7,9 +8,9 @@ import { MobileNav } from "@/components/mobile-nav"; import { NavDropdown } from "@/components/nav-dropdown"; import { NavbarColorPicker } from "@/components/navbar-color-picker"; import { ThemeSwitcher } from "@/components/theme-switcher"; +import { db, MessengerFriendrequests, MessengerOffline } from "@/lib/db"; import { resolveHotelName } from "@/lib/hotel-name"; import { canAccess, getApiAdminContext, PERMS } from "@/lib/permissions"; -import { prisma } from "@/lib/prisma"; export async function Navigation({ session }: { session: Session | null }) { const t = await getTranslations("nav"); @@ -45,10 +46,18 @@ export async function Navigation({ session }: { session: Session | null }) { if (session?.user?.id) { const id = Number(session.user.id); try { - [unreadMessages, pendingFriendRequests] = await Promise.all([ - prisma.messengerOffline.count({ where: { userId: id } }), - prisma.messengerFriendrequests.count({ where: { userToId: id } }), + const [unreadRows, pendingRows] = await Promise.all([ + db + .select({ total: count() }) + .from(MessengerOffline) + .where(eq(MessengerOffline.userId, id)), + db + .select({ total: count() }) + .from(MessengerFriendrequests) + .where(eq(MessengerFriendrequests.userToId, id)), ]); + unreadMessages = unreadRows[0]?.total ?? 0; + pendingFriendRequests = pendingRows[0]?.total ?? 0; } catch {} } diff --git a/src/components/site-header.tsx b/src/components/site-header.tsx index ef020071..6ca95772 100644 --- a/src/components/site-header.tsx +++ b/src/components/site-header.tsx @@ -1,9 +1,10 @@ +import { count, eq } from "drizzle-orm"; import Image from "next/image"; import Link from "next/link"; import { getTranslations } from "next-intl/server"; import { cached } from "@/lib/cache"; +import { db, User } from "@/lib/db"; import { resolveHotelName } from "@/lib/hotel-name"; -import { prisma } from "@/lib/prisma"; import { siteSettings } from "@/lib/services/site-settings"; export async function SiteHeader() { @@ -16,9 +17,13 @@ export async function SiteHeader() { let online: number; try { - online = await cached("online_count", 10_000, () => - prisma.user.count({ where: { online: "1" } }), - ); + online = await cached("online_count", 10_000, async () => { + const [row] = await db + .select({ total: count() }) + .from(User) + .where(eq(User.online, "1")); + return row?.total ?? 0; + }); } catch { online = 0; } diff --git a/src/components/top-header.tsx b/src/components/top-header.tsx index 2268bcb6..b2451dac 100644 --- a/src/components/top-header.tsx +++ b/src/components/top-header.tsx @@ -1,3 +1,4 @@ +import { count, eq, inArray } from "drizzle-orm"; import Image from "next/image"; import Link from "next/link"; import type { Session } from "next-auth"; @@ -5,10 +6,16 @@ import { getTranslations } from "next-intl/server"; import { RoomQuickEntry } from "@/components/room-quick-entry"; import { signOut } from "@/lib/auth"; import { cached } from "@/lib/cache"; +import { + db, + MessengerFriendrequests, + MessengerOffline, + User, + UsersCurrency, +} from "@/lib/db"; import { avatarImageUrl } from "@/lib/format"; import { resolveHotelName } from "@/lib/hotel-name"; import { canAccess, getApiAdminContext, PERMS } from "@/lib/permissions"; -import { prisma } from "@/lib/prisma"; import { siteSettings } from "@/lib/services/site-settings"; function Currency({ @@ -52,12 +59,16 @@ export async function TopHeader({ session }: { session: Session | null }) { let diamonds = 0; let look = ""; try { - const [user, currencies] = await Promise.all([ - prisma.user.findUnique({ - where: { id }, - select: { credits: true, look: true }, - }), - prisma.usersCurrency.findMany({ where: { userId: id } }), + const [[user], currencies] = await Promise.all([ + db + .select({ credits: User.credits, look: User.look }) + .from(User) + .where(eq(User.id, id)) + .limit(1), + db + .select({ type: UsersCurrency.type, amount: UsersCurrency.amount }) + .from(UsersCurrency) + .where(eq(UsersCurrency.userId, id)), ]); credits = user?.credits ?? 0; look = user?.look ?? ""; @@ -100,9 +111,13 @@ export async function TopHeader({ session }: { session: Session | null }) { let online: number; try { - online = await cached("online_count", 10_000, () => - prisma.user.count({ where: { online: "1" } }), - ); + online = await cached("online_count", 10_000, async () => { + const [row] = await db + .select({ total: count() }) + .from(User) + .where(eq(User.online, "1")); + return row?.total ?? 0; + }); } catch { online = 0; } @@ -110,26 +125,41 @@ export async function TopHeader({ session }: { session: Session | null }) { let unreadMessages = 0; let pendingFriendRequests = 0; try { - [unreadMessages, pendingFriendRequests] = await Promise.all([ - prisma.messengerOffline.count({ where: { userId: id } }), - prisma.messengerFriendrequests.count({ where: { userToId: id } }), + const [unreadRows, pendingRows] = await Promise.all([ + db + .select({ total: count() }) + .from(MessengerOffline) + .where(eq(MessengerOffline.userId, id)), + db + .select({ total: count() }) + .from(MessengerFriendrequests) + .where(eq(MessengerFriendrequests.userToId, id)), ]); + unreadMessages = unreadRows[0]?.total ?? 0; + pendingFriendRequests = pendingRows[0]?.total ?? 0; } catch {} - const friendRequests = await prisma.messengerFriendrequests - .findMany({ - where: { userToId: id }, - select: { userFromId: true }, - }) - .catch(() => []); + const friendRequests = await db + .select({ userFromId: MessengerFriendrequests.userFromId }) + .from(MessengerFriendrequests) + .where(eq(MessengerFriendrequests.userToId, id)) + .catch(() => [] as { userFromId: number }[]); const friendRequestUsers = friendRequests.length - ? await prisma.user - .findMany({ - where: { id: { in: friendRequests.map((r) => r.userFromId) } }, - select: { id: true, username: true, look: true }, + ? await db + .select({ + id: User.id, + username: User.username, + look: User.look, }) - .catch(() => []) + .from(User) + .where( + inArray( + User.id, + friendRequests.map((r) => r.userFromId), + ), + ) + .catch(() => [] as { id: number; username: string; look: string }[]) : []; const totalNotifications = unreadMessages + pendingFriendRequests; diff --git a/src/lib/access-guard.ts b/src/lib/access-guard.ts index 09c1c329..ab4dc152 100644 --- a/src/lib/access-guard.ts +++ b/src/lib/access-guard.ts @@ -1,10 +1,11 @@ +import { and, eq, gt, or } from "drizzle-orm"; import { headers } from "next/headers"; import { redirect } from "next/navigation"; import { auth } from "@/lib/auth"; -import { activeBanWhere } from "@/lib/bans"; +import { unixNow } from "@/lib/bans"; +import { Ban, db } from "@/lib/db"; import { safeRedirect } from "@/lib/foundation/security"; import { logger } from "@/lib/logger"; -import { prisma } from "@/lib/prisma"; import { isIpBlacklisted, recordRequest } from "@/lib/services/abuse-guard"; import { siteSettings } from "@/lib/services/site-settings"; @@ -63,13 +64,17 @@ export async function enforceSiteAccess(): Promise { try { if (!target && session?.user?.id) { - const ban = await prisma.ban.findFirst({ - where: { - userId: Number(session.user.id), - ...activeBanWhere(), - }, - select: { id: true }, - }); + const now = unixNow(); + const [ban] = await db + .select({ id: Ban.id }) + .from(Ban) + .where( + and( + eq(Ban.userId, Number(session.user.id)), + or(eq(Ban.banExpire, 0), gt(Ban.banExpire, now)), + ), + ) + .limit(1); if (ban) target = "/banned"; } } catch { diff --git a/src/lib/api-auth.ts b/src/lib/api-auth.ts index 7ab4e4ce..31f198c0 100644 --- a/src/lib/api-auth.ts +++ b/src/lib/api-auth.ts @@ -1,7 +1,8 @@ import { createHash, randomBytes } from "node:crypto"; +import { and, eq, gt, isNull, or } from "drizzle-orm"; import { personalTokenScope } from "@/lib/auth/personal-token-scope"; import { databaseUserId } from "@/lib/auth/session-user"; -import { prisma } from "@/lib/prisma"; +import { db, PersonalAccessTokens } from "@/lib/db"; /** * Bearer-token auth for the public REST API, backed by personal_access_tokens @@ -24,21 +25,28 @@ export async function bearerUserId(req: Request): Promise { if (!raw) return null; try { - const row = await prisma.personalAccessTokens.findFirst({ - where: { - token: hashToken(raw), - OR: [{ expiresAt: null }, { expiresAt: { gt: new Date() } }], - }, - select: { id: true, tokenableId: true }, - }); + const [row] = await db + .select({ + id: PersonalAccessTokens.id, + tokenableId: PersonalAccessTokens.tokenableId, + }) + .from(PersonalAccessTokens) + .where( + and( + eq(PersonalAccessTokens.token, hashToken(raw)), + or( + isNull(PersonalAccessTokens.expiresAt), + gt(PersonalAccessTokens.expiresAt, new Date()), + ), + ), + ) + .limit(1); if (!row) return null; // Best-effort last-used stamp (don't fail the request if it errors). - prisma.personalAccessTokens - .update({ - where: { id: row.id }, - data: { lastUsedAt: new Date() }, - select: { id: true }, - }) + void db + .update(PersonalAccessTokens) + .set({ lastUsedAt: new Date() }) + .where(eq(PersonalAccessTokens.id, row.id)) .catch(() => {}); return databaseUserId(row.tokenableId); } catch { @@ -52,17 +60,15 @@ export async function issueToken( name = "api", ): Promise { const plaintext = randomBytes(32).toString("hex"); + const now = new Date(); try { - await prisma.personalAccessTokens.create({ - data: { - ...personalTokenScope(userId), - name: name.slice(0, 100), - token: hashToken(plaintext), - abilities: '["*"]', - createdAt: new Date(), - updatedAt: new Date(), - }, - select: { id: true }, + await db.insert(PersonalAccessTokens).values({ + ...personalTokenScope(userId), + name: name.slice(0, 100), + token: hashToken(plaintext), + abilities: '["*"]', + createdAt: now, + updatedAt: now, }); return plaintext; } catch { diff --git a/src/lib/auth.ts b/src/lib/auth.ts index 3fc1abd2..74a82713 100644 --- a/src/lib/auth.ts +++ b/src/lib/auth.ts @@ -1,4 +1,4 @@ -import { sql } from "drizzle-orm"; +import { eq, sql } from "drizzle-orm"; import NextAuth from "next-auth"; import Credentials from "next-auth/providers/credentials"; import { env } from "@/env"; @@ -7,9 +7,8 @@ import { LaravelEncrypter } from "@/lib/auth/laravel-encrypter"; import { checkLogin } from "@/lib/auth/password"; import { verifyTotp } from "@/lib/auth/totp"; import { cachedQuery, invalidateKey } from "@/lib/cached-db"; -import { db } from "@/lib/db"; +import { db, User, WebsiteLoginLogs } from "@/lib/db"; import { logger } from "@/lib/logger"; -import { prisma } from "@/lib/prisma"; import { clientIp, rateLimit } from "@/lib/rate-limit"; import { siteSettings } from "@/lib/services/site-settings"; @@ -88,10 +87,14 @@ export async function invalidateLoginCache(username: string): Promise { } async function verify2faCode(userId: number, code: string): Promise { - const user = await prisma.user.findUnique({ - where: { id: userId }, - select: { twoFactorSecret: true, twoFactorRecoveryCodes: true }, - }); + const [user] = await db + .select({ + twoFactorSecret: User.twoFactorSecret, + twoFactorRecoveryCodes: User.twoFactorRecoveryCodes, + }) + .from(User) + .where(eq(User.id, userId)) + .limit(1); if (!user?.twoFactorSecret) return false; // Try TOTP first @@ -119,10 +122,10 @@ async function verify2faCode(userId: number, code: string): Promise { if (idx !== -1) { codes.splice(idx, 1); const remaining = codes.length > 0 ? JSON.stringify(codes) : null; - await prisma.user.update({ - where: { id: userId }, - data: { twoFactorRecoveryCodes: remaining }, - }); + await db + .update(User) + .set({ twoFactorRecoveryCodes: remaining }) + .where(eq(User.id, userId)); return true; } } @@ -189,10 +192,10 @@ export const { handlers, signOut, auth } = NextAuth({ } if (res.upgradedHash) { - await prisma.user.update({ - where: { id: user.id }, - data: { password: res.upgradedHash }, - }); + await db + .update(User) + .set({ password: res.upgradedHash }) + .where(eq(User.id, user.id)); invalidateLoginCache(username); } @@ -213,13 +216,11 @@ export const { handlers, signOut, auth } = NextAuth({ try { const { headers } = await import("next/headers"); const ua = (await headers()).get("user-agent")?.slice(0, 512) ?? null; - await prisma.websiteLoginLogs.create({ - data: { - userId: user.id, - ip, - userAgent: ua, - createdAt: new Date(), - }, + await db.insert(WebsiteLoginLogs).values({ + userId: user.id, + ip, + userAgent: ua, + createdAt: new Date(), }); } catch { logger.warn("Failed to record login log for user", { diff --git a/src/lib/auth/jwt-version-cache.test.ts b/src/lib/auth/jwt-version-cache.test.ts index 2acc341a..ac02f6ee 100644 --- a/src/lib/auth/jwt-version-cache.test.ts +++ b/src/lib/auth/jwt-version-cache.test.ts @@ -1,14 +1,16 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; -const mockFindUnique = vi.hoisted(() => vi.fn()); +const mockLimit = vi.hoisted(() => vi.fn()); +const mockWhere = vi.hoisted(() => vi.fn(() => ({ limit: mockLimit }))); +const mockFrom = vi.hoisted(() => vi.fn(() => ({ where: mockWhere }))); +const mockSelect = vi.hoisted(() => vi.fn(() => ({ from: mockFrom }))); const mockRedisGet = vi.hoisted(() => vi.fn()); const mockRedisSetex = vi.hoisted(() => vi.fn()); const mockRedisDel = vi.hoisted(() => vi.fn()); -vi.mock("@/lib/prisma", () => ({ - prisma: { - user: { findUnique: mockFindUnique }, - }, +vi.mock("@/lib/db", () => ({ + db: { select: mockSelect }, + User: { id: "id", websiteJwtVersion: "websiteJwtVersion" }, })); vi.mock("@/lib/redis", () => ({ @@ -23,29 +25,32 @@ describe("jwt-version-cache", () => { beforeEach(() => { vi.resetModules(); vi.clearAllMocks(); + mockWhere.mockReturnValue({ limit: mockLimit }); + mockFrom.mockReturnValue({ where: mockWhere }); + mockSelect.mockReturnValue({ from: mockFrom }); mockRedisGet.mockResolvedValue(null); mockRedisSetex.mockResolvedValue("OK"); mockRedisDel.mockResolvedValue(1); }); it("returns DB version and caches it", async () => { - mockFindUnique.mockResolvedValue({ websiteJwtVersion: 3 }); + mockLimit.mockResolvedValue([{ websiteJwtVersion: 3 }]); const { getCachedJwtVersion } = await import("./jwt-version-cache"); await expect(getCachedJwtVersion(42)).resolves.toBe(3); - expect(mockFindUnique).toHaveBeenCalledTimes(1); + expect(mockSelect).toHaveBeenCalledTimes(1); await expect(getCachedJwtVersion(42)).resolves.toBe(3); - expect(mockFindUnique).toHaveBeenCalledTimes(1); + expect(mockSelect).toHaveBeenCalledTimes(1); }); it("invalidates memory and redis entries", async () => { - mockFindUnique.mockResolvedValue({ websiteJwtVersion: 1 }); + mockLimit.mockResolvedValue([{ websiteJwtVersion: 1 }]); const { getCachedJwtVersion, invalidateJwtVersionCache } = await import( "./jwt-version-cache" ); await getCachedJwtVersion(7); await invalidateJwtVersionCache(7); expect(mockRedisDel).toHaveBeenCalled(); - mockFindUnique.mockResolvedValue({ websiteJwtVersion: 2 }); + mockLimit.mockResolvedValue([{ websiteJwtVersion: 2 }]); await expect(getCachedJwtVersion(7)).resolves.toBe(2); }); }); diff --git a/src/lib/auth/jwt-version-cache.ts b/src/lib/auth/jwt-version-cache.ts index 3991e5e5..f5ce6a13 100644 --- a/src/lib/auth/jwt-version-cache.ts +++ b/src/lib/auth/jwt-version-cache.ts @@ -1,6 +1,7 @@ import "server-only"; -import { prisma } from "@/lib/prisma"; +import { eq } from "drizzle-orm"; +import { db, User } from "@/lib/db"; import { redis } from "@/lib/redis"; const MEMORY_TTL_MS = 60_000; @@ -40,10 +41,11 @@ export async function getCachedJwtVersion( } try { - const row = await prisma.user.findUnique({ - where: { id: userId }, - select: { websiteJwtVersion: true }, - }); + const [row] = await db + .select({ websiteJwtVersion: User.websiteJwtVersion }) + .from(User) + .where(eq(User.id, userId)) + .limit(1); if (!row) return null; const version = row.websiteJwtVersion; memory.set(userId, { version, expiresAt: now + MEMORY_TTL_MS }); diff --git a/src/lib/auth/sso-ticket.test.ts b/src/lib/auth/sso-ticket.test.ts index 2eba0ce4..33fd60c4 100644 --- a/src/lib/auth/sso-ticket.test.ts +++ b/src/lib/auth/sso-ticket.test.ts @@ -1,9 +1,18 @@ -import { describe, expect, it, vi } from "vitest"; -import { generateSsoTicket, issueSsoTicket } from "./sso-ticket"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { generateSsoTicket } from "./sso-ticket"; const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/; +const mockWhere = vi.hoisted(() => vi.fn().mockResolvedValue(undefined)); +const mockSet = vi.hoisted(() => vi.fn(() => ({ where: mockWhere }))); +const mockUpdate = vi.hoisted(() => vi.fn(() => ({ set: mockSet }))); + +vi.mock("@/lib/db", () => ({ + db: { update: mockUpdate }, + User: { id: "id" }, +})); + describe("generateSsoTicket", () => { it("uses '{hotelName-without-spaces}-{uuidv4}'", () => { const t = generateSsoTicket("Atom Hotel"); @@ -23,15 +32,23 @@ describe("generateSsoTicket", () => { }); describe("issueSsoTicket", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockSet.mockReturnValue({ where: mockWhere }); + mockUpdate.mockReturnValue({ set: mockSet }); + mockWhere.mockResolvedValue(undefined); + }); + it("writes auth_ticket AND ip_current and returns the ticket", async () => { - const update = vi.fn().mockResolvedValue(undefined); - const db = { user: { update } }; - const ticket = await issueSsoTicket(db, 42, "Atom Hotel", "1.2.3.4"); + const { issueSsoTicket } = await import("./sso-ticket"); + const ticket = await issueSsoTicket(42, "Atom Hotel", "1.2.3.4"); expect(ticket.startsWith("AtomHotel-")).toBe(true); - expect(update).toHaveBeenCalledWith({ - where: { id: 42 }, - data: { authTicket: ticket, ipCurrent: "1.2.3.4" }, + expect(mockUpdate).toHaveBeenCalled(); + expect(mockSet).toHaveBeenCalledWith({ + authTicket: ticket, + ipCurrent: "1.2.3.4", }); + expect(mockWhere).toHaveBeenCalled(); }); }); diff --git a/src/lib/auth/sso-ticket.ts b/src/lib/auth/sso-ticket.ts index 284b2e08..e86730bf 100644 --- a/src/lib/auth/sso-ticket.ts +++ b/src/lib/auth/sso-ticket.ts @@ -1,4 +1,6 @@ import { randomUUID } from "node:crypto"; +import { eq } from "drizzle-orm"; +import { db, User } from "@/lib/db"; /** * Build the SSO ticket exactly like AtomCMS's User::ssoTicket(): @@ -12,30 +14,19 @@ export function generateSsoTicket(hotelName: string): string { return `${normalized}-${randomUUID()}`; } -/** Minimal shape of the Prisma client this needs (keeps it unit-testable). */ -export interface SsoUserUpdater { - user: { - update(args: { - where: { id: number }; - data: { authTicket: string; ipCurrent: string }; - }): Promise; - }; -} - /** * Generate a ticket and persist it like AtomCMS: writes auth_ticket AND * ip_current on the user, then returns the ticket for the client launcher. */ export async function issueSsoTicket( - db: SsoUserUpdater, userId: number, hotelName: string, ip: string, ): Promise { const ticket = generateSsoTicket(hotelName); - await db.user.update({ - where: { id: userId }, - data: { authTicket: ticket, ipCurrent: ip }, - }); + await db + .update(User) + .set({ authTicket: ticket, ipCurrent: ip }) + .where(eq(User.id, userId)); return ticket; }