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

Co-authored-by: Cursor <[email protected]>
This commit is contained in:
SimoandCursor committed 2026-08-01 14:15:39 +02:00
1 parent 9aa4f331bf
commit 30b54e99e7
40 files changed
+1183 -784

No files matched your search

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