refactor(db): migrate app pages and APIs from Prisma facade to Drizzle (5)
Co-authored-by: Cursor <[email protected]>
This commit is contained in:
1 parent
9aa4f331bf
commit
30b54e99e7
40 files changed
+1183
-784
No files matched your search
+18
-16
@@ -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,
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
|||||||
@@ -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) => {
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
|||||||
@@ -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
@@ -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,
|
||||||
|
|||||||
@@ -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
@@ -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 });
|
||||||
|
|||||||
@@ -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 },
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -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]));
|
||||||
|
|
||||||
|
|||||||
@@ -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 },
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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 = [];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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();
|
||||||
|
|||||||
@@ -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]));
|
||||||
|
|||||||
@@ -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]));
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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">
|
||||||
|
|||||||
@@ -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
@@ -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"),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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 */
|
||||||
|
|||||||
@@ -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 {}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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", {
|
||||||
|
|||||||
@@ -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);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -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 });
|
||||||
|
|||||||
@@ -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();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -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;
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user