Database: - Add missing indexes (users.credits, users_currency(type,amount), users_settings.respects_received, camera_web.timestamp, messenger_offline.user_id) via migrations 0020/0021 - Use partial .select() everywhere instead of SELECT * (tickets, users, rooms, audit logs, catalog tree, polls, radio, password reset) - Add queryPrepared/queryPreparedOne (server-side prepared statements) and switch the login check to a prepared statement; drop dead cache options from the pool config - Raise total_users/total_rooms COUNT(*) cache TTL to 5m Caching: - Consolidate the three cache helpers (cached, redisCache, cachedQuery) into a single memory-first implementation backed by Redis - invalidateKey now clears the in-process cache as well as Redis - Cache homepage sections, news list, and leaderboard tabs; share one news_list cache key between homepage and news archive - siteSettings: in-process cache with TTL so repeated getters no longer pay a Redis round-trip per call - Share a 10s poll cache across all radio SSE connections - Normalize timestamps after cache reads (Redis JSON round-trip) Assets: - Enable AVIF/WebP via images.formats and remove unoptimized from news covers and the homepage hero (149KB jpg) with proper sizes/priority - Support ?format=webp|avif|png in the /imaging proxy via sharp Other: - Fix pnpm supply-chain minimumReleaseAge failures by excluding the freshly-published packages (next 16.3.1, hookform resolvers 5.8.0, resend 6.20.0) - Remove unused before/after fields from housekeeping AuditEntry
324 lines
8.5 KiB
TypeScript
324 lines
8.5 KiB
TypeScript
import { asc, eq, inArray, sql } from "drizzle-orm";
|
|
import { CatalogPages, db } from "@/lib/db";
|
|
|
|
export type { NestedTreeNode, TreeNode } from "@/types/catalog";
|
|
|
|
import type { NestedTreeNode, TreeNode } from "@/types/catalog";
|
|
|
|
function toInt(value: unknown, fallback = 0): number {
|
|
const n = typeof value === "number" ? value : Number(value);
|
|
return Number.isFinite(n) ? n : fallback;
|
|
}
|
|
|
|
/**
|
|
* Count catalog_items per page via raw SQL.
|
|
* Real Habbo DBs often store page_id as VARCHAR. String literals match both
|
|
* INT and VARCHAR columns while keeping the page_id index usable (CAST(... AS CHAR)
|
|
* would force a full index scan).
|
|
*/
|
|
export async function getCatalogItemCounts(
|
|
pageIds?: number[],
|
|
): Promise<Map<number, number>> {
|
|
const map = new Map<number, number>();
|
|
try {
|
|
if (pageIds && pageIds.length === 0) return map;
|
|
|
|
if (pageIds) {
|
|
const idStrs = pageIds.map(String);
|
|
const [rows] = (await db.execute(sql`
|
|
SELECT page_id, COUNT(*) as cnt
|
|
FROM catalog_items
|
|
WHERE page_id IN (${sql.join(idStrs, sql`, `)})
|
|
GROUP BY page_id
|
|
`)) as unknown as [
|
|
{ page_id: string | number; cnt: number | bigint }[],
|
|
unknown[],
|
|
];
|
|
for (const r of rows) {
|
|
map.set(toInt(r.page_id), toInt(r.cnt));
|
|
}
|
|
return map;
|
|
}
|
|
|
|
const [rows] = (await db.execute(sql`
|
|
SELECT page_id, COUNT(*) as cnt FROM catalog_items GROUP BY page_id
|
|
`)) as unknown as [
|
|
{ page_id: string | number; cnt: number | bigint }[],
|
|
unknown[],
|
|
];
|
|
for (const r of rows) {
|
|
map.set(toInt(r.page_id), toInt(r.cnt));
|
|
}
|
|
} catch {
|
|
// Best-effort — never break the tree for count failures.
|
|
}
|
|
return map;
|
|
}
|
|
|
|
/**
|
|
* Get the full catalog tree as a flat list with depth info.
|
|
*/
|
|
export async function getTreeFlat(): Promise<TreeNode[]> {
|
|
const allPages = await db
|
|
.select({
|
|
id: CatalogPages.id,
|
|
parentId: CatalogPages.parentId,
|
|
caption: CatalogPages.caption,
|
|
pageLayout: CatalogPages.pageLayout,
|
|
iconColor: CatalogPages.iconColor,
|
|
iconImage: CatalogPages.iconImage,
|
|
orderNum: CatalogPages.orderNum,
|
|
visible: CatalogPages.visible,
|
|
enabled: CatalogPages.enabled,
|
|
})
|
|
.from(CatalogPages)
|
|
.orderBy(asc(CatalogPages.orderNum));
|
|
const itemCountMap = await getCatalogItemCounts();
|
|
|
|
const childCountMap = new Map<number, number>();
|
|
for (const p of allPages) {
|
|
const parentId = toInt(p.parentId, -1);
|
|
childCountMap.set(parentId, (childCountMap.get(parentId) ?? 0) + 1);
|
|
}
|
|
|
|
const pageMap = new Map(allPages.map((p) => [p.id, p]));
|
|
const depthCache = new Map<number, number>();
|
|
|
|
function computeDepth(pageId: number, visiting = new Set<number>()): number {
|
|
if (depthCache.has(pageId)) return depthCache.get(pageId) ?? 0;
|
|
// Cycle guard: parent chains may loop in messy DBs — stop at 0 instead of overflowing.
|
|
if (visiting.has(pageId)) {
|
|
depthCache.set(pageId, 0);
|
|
return 0;
|
|
}
|
|
visiting.add(pageId);
|
|
const page = pageMap.get(pageId);
|
|
const parentId = page ? toInt(page.parentId, -1) : -1;
|
|
if (!page || parentId <= 0) {
|
|
visiting.delete(pageId);
|
|
depthCache.set(pageId, 0);
|
|
return 0;
|
|
}
|
|
const d = computeDepth(parentId, visiting) + 1;
|
|
visiting.delete(pageId);
|
|
depthCache.set(pageId, d);
|
|
return d;
|
|
}
|
|
|
|
return allPages.map((p) => {
|
|
const id = toInt(p.id);
|
|
const parentId = toInt(p.parentId, -1);
|
|
return {
|
|
id,
|
|
caption: p.caption,
|
|
parentId,
|
|
depth: computeDepth(id),
|
|
orderNum: toInt(p.orderNum),
|
|
enabled: String(p.enabled ?? "0"),
|
|
visible: String(p.visible ?? "0"),
|
|
iconImage: toInt(p.iconImage),
|
|
iconColor: toInt(p.iconColor),
|
|
pageLayout: p.pageLayout,
|
|
childCount: childCountMap.get(id) ?? 0,
|
|
itemCount: itemCountMap.get(id) ?? 0,
|
|
};
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Build a nested tree structure from flat nodes.
|
|
*/
|
|
export function buildNestedTree(
|
|
flat: TreeNode[],
|
|
rootParentId = -1,
|
|
): NestedTreeNode[] {
|
|
const childrenMap = new Map<number, NestedTreeNode[]>();
|
|
const nodeMap = new Map<number, NestedTreeNode>();
|
|
|
|
for (const node of flat) {
|
|
const nested: NestedTreeNode = { ...node, children: [] };
|
|
nodeMap.set(node.id, nested);
|
|
if (!childrenMap.has(node.parentId)) childrenMap.set(node.parentId, []);
|
|
childrenMap.get(node.parentId)?.push(nested);
|
|
}
|
|
|
|
for (const node of nodeMap.values()) {
|
|
node.children = childrenMap.get(node.id) ?? [];
|
|
}
|
|
|
|
return childrenMap.get(rootParentId) ?? [];
|
|
}
|
|
|
|
/**
|
|
* Create a new catalog page.
|
|
*/
|
|
export async function createPage(data: {
|
|
parentId: number;
|
|
caption: string;
|
|
captionSave?: string;
|
|
pageLayout?: string;
|
|
iconImage?: number;
|
|
iconColor?: number;
|
|
minRank?: number;
|
|
orderNum?: number;
|
|
visible?: string | boolean;
|
|
enabled?: string | boolean;
|
|
}) {
|
|
const visible =
|
|
typeof data.visible === "boolean"
|
|
? data.visible
|
|
? "1"
|
|
: "0"
|
|
: (data.visible ?? "1");
|
|
const enabled =
|
|
typeof data.enabled === "boolean"
|
|
? data.enabled
|
|
? "1"
|
|
: "0"
|
|
: (data.enabled ?? "1");
|
|
|
|
return db.insert(CatalogPages).values({
|
|
caption: data.caption,
|
|
parentId: data.parentId,
|
|
pageLayout: data.pageLayout ?? "default_3x3",
|
|
captionSave: (data.captionSave ?? data.caption).slice(0, 25),
|
|
iconColor: data.iconColor ?? 0,
|
|
iconImage: data.iconImage ?? 0,
|
|
minRank: data.minRank ?? 1,
|
|
orderNum: data.orderNum ?? 0,
|
|
visible,
|
|
enabled,
|
|
clubOnly: "0",
|
|
vipOnly: "0",
|
|
pageHeadline: "",
|
|
pageTeaser: "",
|
|
includes: "",
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Move a page to a new parent.
|
|
* Validates against circular hierarchy (A → B → C → A).
|
|
*/
|
|
export async function movePage(
|
|
pageId: number,
|
|
newParentId: number,
|
|
): Promise<void> {
|
|
if (newParentId > 0) {
|
|
let currentId = newParentId;
|
|
for (let i = 0; i < 50; i++) {
|
|
if (currentId === pageId) {
|
|
throw new Error("Cannot move page: would create a circular hierarchy");
|
|
}
|
|
const [parent] = await db
|
|
.select({ parentId: CatalogPages.parentId })
|
|
.from(CatalogPages)
|
|
.where(eq(CatalogPages.id, currentId))
|
|
.limit(1);
|
|
if (!parent || parent.parentId <= 0) break;
|
|
currentId = parent.parentId;
|
|
}
|
|
}
|
|
|
|
await db
|
|
.update(CatalogPages)
|
|
.set({ parentId: newParentId })
|
|
.where(eq(CatalogPages.id, pageId));
|
|
}
|
|
|
|
/**
|
|
* Delete catalog_items for the given page ids.
|
|
* Habbo DBs often store page_id as VARCHAR; text delete on INT or VARCHAR
|
|
* columns keeps the page_id index usable (CAST would force a full scan).
|
|
*/
|
|
async function deleteCatalogItemsByPageIds(pageIds: number[]): Promise<void> {
|
|
if (pageIds.length === 0) return;
|
|
const idStrs = pageIds.map(String);
|
|
await db.execute(sql`
|
|
DELETE FROM catalog_items
|
|
WHERE page_id IN (${sql.join(idStrs, sql`, `)})
|
|
`);
|
|
}
|
|
|
|
/**
|
|
* Delete a page with cascade or reparent mode.
|
|
*/
|
|
export async function deletePage(
|
|
pageId: number,
|
|
mode: "cascade" | "reparent" = "reparent",
|
|
): Promise<{ deletedPages: number; movedChildren: number }> {
|
|
const [page] = await db
|
|
.select({ parentId: CatalogPages.parentId })
|
|
.from(CatalogPages)
|
|
.where(eq(CatalogPages.id, pageId))
|
|
.limit(1);
|
|
if (!page) return { deletedPages: 0, movedChildren: 0 };
|
|
|
|
if (mode === "reparent") {
|
|
const [result] = await db
|
|
.update(CatalogPages)
|
|
.set({ parentId: page.parentId })
|
|
.where(eq(CatalogPages.parentId, pageId));
|
|
|
|
await deleteCatalogItemsByPageIds([pageId]);
|
|
await db.delete(CatalogPages).where(eq(CatalogPages.id, pageId));
|
|
|
|
return { deletedPages: 1, movedChildren: result.affectedRows };
|
|
}
|
|
|
|
const deleted = await cascadeDelete(pageId);
|
|
return { deletedPages: deleted, movedChildren: 0 };
|
|
}
|
|
|
|
async function cascadeDelete(pageId: number): Promise<number> {
|
|
const toDelete: number[] = [pageId];
|
|
const queue: number[] = [pageId];
|
|
|
|
while (queue.length > 0) {
|
|
const children = await db
|
|
.select({ id: CatalogPages.id })
|
|
.from(CatalogPages)
|
|
.where(inArray(CatalogPages.parentId, queue));
|
|
queue.length = 0;
|
|
for (const child of children) {
|
|
toDelete.push(child.id);
|
|
queue.push(child.id);
|
|
}
|
|
}
|
|
|
|
await deleteCatalogItemsByPageIds(toDelete);
|
|
for (let i = toDelete.length - 1; i >= 0; i--) {
|
|
await db.delete(CatalogPages).where(eq(CatalogPages.id, toDelete[i]));
|
|
}
|
|
|
|
return toDelete.length;
|
|
}
|
|
|
|
/**
|
|
* Get ancestors (breadcrumb) from root to the given page.
|
|
*/
|
|
export async function getAncestors(
|
|
pageId: number,
|
|
): Promise<Array<{ id: number; caption: string }>> {
|
|
const ancestors: Array<{ id: number; caption: string }> = [];
|
|
let currentId = pageId;
|
|
for (let i = 0; i < 50; i++) {
|
|
const [page] = await db
|
|
.select({
|
|
id: CatalogPages.id,
|
|
caption: CatalogPages.caption,
|
|
parentId: CatalogPages.parentId,
|
|
})
|
|
.from(CatalogPages)
|
|
.where(eq(CatalogPages.id, currentId))
|
|
.limit(1);
|
|
if (!page) break;
|
|
ancestors.unshift({ id: page.id, caption: page.caption });
|
|
if (page.parentId <= 0) break;
|
|
currentId = page.parentId;
|
|
}
|
|
return ancestors;
|
|
}
|
|
|
|
export { toInt };
|