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

Co-authored-by: Cursor <[email protected]>
This commit is contained in:
SimoandCursor committed 2026-08-01 14:15:36 +02:00
1 parent 580972c0a0
commit 9aa4f331bf
40 files changed
+1259 -927

No files matched your search

@@ -1,6 +1,7 @@
import { inArray } from "drizzle-orm";
import { withAdmin } from "@/lib/api-handler";
import { db, ItemsBase } from "@/lib/db";
import { PERMS } from "@/lib/permissions";
import { prisma } from "@/lib/prisma";
import { logAudit } from "@/lib/services/audit";
import {
cloneSingleFurni,
@@ -51,11 +52,10 @@ export const POST = withAdmin(
const classnames = entries.map((e) => e.classname);
if (classnames.length === 0) continue;
const placeholders = classnames.map(() => "?").join(",");
const rows = await prisma.$queryRawUnsafe<Array<{ item_name: string }>>(
`SELECT item_name FROM items_base WHERE item_name IN (${placeholders})`,
...classnames,
);
const rows = await db
.select({ item_name: ItemsBase.itemName })
.from(ItemsBase)
.where(inArray(ItemsBase.itemName, classnames));
const have = new Set(rows.map((r) => r.item_name));
for (const entry of entries) {
@@ -1,9 +1,10 @@
import { existsSync, promises as fs } from "node:fs";
import path from "node:path";
import { eq } from "drizzle-orm";
import { withAdmin } from "@/lib/api-handler";
import { apiError } from "@/lib/api-response";
import { db, ItemsBase } from "@/lib/db";
import { PERMS } from "@/lib/permissions";
import { prisma } from "@/lib/prisma";
import { getFurniAssetDirs } from "@/lib/services/furni-asset-dirs";
import {
appendFurniEntriesBatch,
@@ -120,15 +121,16 @@ export const POST = withAdmin(
// Try spriteId as fallback revision
const revsToTry: number[] = [];
if (rev > 0) revsToTry.push(rev);
const dbItem = await prisma.itemsBase.findFirst({
where: { itemName: item.classname },
select: {
id: true,
spriteId: true,
publicName: true,
type: true,
},
});
const [dbItem] = await db
.select({
id: ItemsBase.id,
spriteId: ItemsBase.spriteId,
publicName: ItemsBase.publicName,
type: ItemsBase.type,
})
.from(ItemsBase)
.where(eq(ItemsBase.itemName, item.classname))
.limit(1);
if (dbItem?.spriteId && !revsToTry.includes(dbItem.spriteId)) {
revsToTry.push(dbItem.spriteId);
}
@@ -1,10 +1,11 @@
import { existsSync, promises as fs } from "node:fs";
import path from "node:path";
import { eq, sql } from "drizzle-orm";
import type { NextRequest } from "next/server";
import { withAdmin } from "@/lib/api-handler";
import { apiError, apiOk } from "@/lib/api-response";
import { db, ItemsBase } from "@/lib/db";
import { PERMS } from "@/lib/permissions";
import { prisma } from "@/lib/prisma";
import { logAudit } from "@/lib/services/audit";
import { getFurniAssetDirs } from "@/lib/services/furni-asset-dirs";
import {
@@ -47,16 +48,17 @@ export const GET = withAdmin(
const { json } = parseNitroBundle(buffer);
// Fetch interaction flags from DB
const items = await prisma.$queryRaw<
const [items] = (await db.execute(sql`
SELECT allow_sit, allow_lay, allow_walk
FROM items_base WHERE item_name = ${rawClassname} LIMIT 1
`)) as unknown as [
Array<{
allow_sit: string;
allow_lay: string;
allow_walk: string;
}>
>`
SELECT allow_sit, allow_lay, allow_walk
FROM items_base WHERE item_name = ${rawClassname} LIMIT 1
`;
}>,
unknown,
];
const dbRow = items[0];
const flags = {
@@ -153,14 +155,14 @@ export const PUT = withAdmin(
// 5. Update items_base (best-effort)
try {
await prisma.$executeRaw`
await db.execute(sql`
UPDATE items_base
SET width = ${dims.x}, length = ${dims.y}, stack_height = ${dims.z},
allow_sit = ${cansiton ? "1" : "0"},
allow_lay = ${canlayon ? "1" : "0"},
allow_walk = ${canstandon ? "1" : "0"}
WHERE item_name = ${rawClassname}
`;
`);
} catch (err) {
console.warn(
"[nitro-editor] items_base update failed:",
@@ -180,10 +182,11 @@ export const PUT = withAdmin(
// 7. Audit log
try {
const dbItem = await prisma.itemsBase.findFirst({
where: { itemName: rawClassname },
select: { id: true },
});
const [dbItem] = await db
.select({ id: ItemsBase.id })
.from(ItemsBase)
.where(eq(ItemsBase.itemName, rawClassname))
.limit(1);
await logAudit({
userId: ctx.session.user.id,
action: "furni_edit_nitro",
+35 -34
View File
@@ -1,8 +1,9 @@
import { existsSync } from "node:fs";
import { asc, inArray, sql } from "drizzle-orm";
import { withAdmin } from "@/lib/api-handler";
import { apiOk } from "@/lib/api-response";
import { db, ItemsBase } from "@/lib/db";
import { PERMS } from "@/lib/permissions";
import { prisma } from "@/lib/prisma";
import {
appendFurniEntriesBatch,
buildFurniEntry,
@@ -64,19 +65,19 @@ export const POST = withAdmin(
for (let i = 0; i < targetIds.length; i += CHUNK) {
const slice = targetIds.slice(i, i + CHUNK);
const rows = await prisma.itemsBase.findMany({
where: { id: { in: slice } },
select: {
id: true,
spriteId: true,
itemName: true,
publicName: true,
type: true,
width: true,
length: true,
stackHeight: true,
},
});
const rows = await db
.select({
id: ItemsBase.id,
spriteId: ItemsBase.spriteId,
itemName: ItemsBase.itemName,
publicName: ItemsBase.publicName,
type: ItemsBase.type,
width: ItemsBase.width,
length: ItemsBase.length,
stackHeight: ItemsBase.stackHeight,
})
.from(ItemsBase)
.where(inArray(ItemsBase.id, slice));
const entries: Array<{
entry: Record<string, unknown>;
@@ -158,20 +159,20 @@ async function resolveTargetIds(
days: number,
): Promise<number[]> {
if (mode === "all") {
const rows = await prisma.itemsBase.findMany({
select: { id: true },
orderBy: { id: "asc" },
});
const rows = await db
.select({ id: ItemsBase.id })
.from(ItemsBase)
.orderBy(asc(ItemsBase.id));
return rows.map((r) => r.id);
}
if (mode === "missing" || mode === "broken") {
const furniDataPath = await getFurnitureDataPath();
if (!existsSync(furniDataPath)) {
const rows = await prisma.itemsBase.findMany({
select: { id: true },
orderBy: { id: "asc" },
});
const rows = await db
.select({ id: ItemsBase.id })
.from(ItemsBase)
.orderBy(asc(ItemsBase.id));
return rows.map((r) => r.id);
}
const furniData = (await readFurniData()) as {
@@ -184,16 +185,16 @@ async function resolveTargetIds(
if (typeof e?.classname === "string") byClassname.set(e.classname, e);
}
}
const rows = await prisma.itemsBase.findMany({
select: {
id: true,
itemName: true,
spriteId: true,
width: true,
length: true,
},
orderBy: { id: "asc" },
});
const rows = await db
.select({
id: ItemsBase.id,
itemName: ItemsBase.itemName,
spriteId: ItemsBase.spriteId,
width: ItemsBase.width,
length: ItemsBase.length,
})
.from(ItemsBase)
.orderBy(asc(ItemsBase.id));
if (mode === "missing") {
return rows.filter((r) => !byClassname.has(r.itemName)).map((r) => r.id);
@@ -220,7 +221,7 @@ async function resolveTargetIds(
Date.now() - days * 24 * 60 * 60 * 1000,
).toISOString();
const sinceMysql = sinceIso.slice(0, 19).replace("T", " ");
const auditRows = await prisma.$queryRaw<Array<{ target_id: number }>>`
const [auditRows] = (await db.execute(sql`
SELECT DISTINCT target_id
FROM admin_audit_log
WHERE action = 'furni_import'
@@ -228,6 +229,6 @@ async function resolveTargetIds(
AND target_id IS NOT NULL
AND created_at >= ${sinceMysql}
ORDER BY target_id ASC
`;
`)) as unknown as [Array<{ target_id: number }>, unknown];
return auditRows.map((r) => r.target_id).filter(Boolean);
}
+106 -80
View File
@@ -1,9 +1,10 @@
import { existsSync, promises as fs } from "node:fs";
import path from "node:path";
import { and, eq, inArray, sql } from "drizzle-orm";
import { withAdmin } from "@/lib/api-handler";
import { apiError, apiOk } from "@/lib/api-response";
import { CatalogPages, db, ItemsBase } from "@/lib/db";
import { PERMS } from "@/lib/permissions";
import { prisma } from "@/lib/prisma";
import { logAudit } from "@/lib/services/audit";
import { getFurniAssetDirs } from "@/lib/services/furni-asset-dirs";
import {
@@ -97,29 +98,32 @@ export const GET = withAdmin(
if (action === "stats") {
const [totalResult, catalogResult, nitroMissingResult] =
await Promise.all([
prisma.$queryRaw<
[{ cnt: bigint }]
>`SELECT COUNT(*) as cnt FROM items_base`,
prisma.$queryRaw<
[{ cnt: bigint }]
>`SELECT COUNT(DISTINCT item_ids) as cnt FROM catalog_items`,
prisma.$queryRaw<
[{ cnt: bigint }]
>`SELECT COUNT(*) as cnt FROM items_base WHERE item_name NOT IN (SELECT catalog_name FROM catalog_items)`,
db.execute(sql`SELECT COUNT(*) as cnt FROM items_base`),
db.execute(
sql`SELECT COUNT(DISTINCT item_ids) as cnt FROM catalog_items`,
),
db.execute(
sql`SELECT COUNT(*) as cnt FROM items_base WHERE item_name NOT IN (SELECT catalog_name FROM catalog_items)`,
),
]);
const totalInDb = Number(totalResult[0].cnt);
const inCatalog = Number(catalogResult[0].cnt);
const notInCatalog = Number(nitroMissingResult[0].cnt);
const totalInDb = Number(
(totalResult as unknown as [[{ cnt: bigint }], unknown])[0][0].cnt,
);
const inCatalog = Number(
(catalogResult as unknown as [[{ cnt: bigint }], unknown])[0][0].cnt,
);
const notInCatalog = Number(
(nitroMissingResult as unknown as [[{ cnt: bigint }], unknown])[0][0]
.cnt,
);
// Count missing .nitro files by checking filesystem
const importedItems = await prisma.$queryRaw<
Array<{ item_name: string }>
>`
const [importedItems] = (await db.execute(sql`
SELECT ib.item_name FROM items_base ib
INNER JOIN catalog_items ci ON ci.catalog_name = ib.item_name
LIMIT 10000
`;
`)) as unknown as [Array<{ item_name: string }>, unknown];
let missingNitro = 0;
for (const item of importedItems) {
@@ -146,17 +150,18 @@ export const GET = withAdmin(
);
const perPage = 50;
const importedItems = await prisma.$queryRaw<
const [importedItems] = (await db.execute(sql`
SELECT ib.item_name, ib.public_name, ib.sprite_id, ib.type FROM items_base ib
INNER JOIN catalog_items ci ON ci.catalog_name = ib.item_name
`)) as unknown as [
Array<{
item_name: string;
public_name: string;
sprite_id: number;
type: string;
}>
>`
SELECT ib.item_name, ib.public_name, ib.sprite_id, ib.type FROM items_base ib
INNER JOIN catalog_items ci ON ci.catalog_name = ib.item_name
`;
}>,
unknown,
];
// Filter to only those missing .nitro on filesystem
const missing = importedItems.filter((item) => {
@@ -209,9 +214,9 @@ export const GET = withAdmin(
// Pull every imported classname once so we can both (a) sort non-imported
// items to the top *before* pagination and (b) skip the per-page lookup
// below. Single-column indexed query — fast even at 10k+ rows.
const allImported = await prisma.itemsBase.findMany({
select: { itemName: true },
});
const allImported = await db
.select({ itemName: ItemsBase.itemName })
.from(ItemsBase);
const importedSet = new Set(
allImported.map((e) => e.itemName).filter((n): n is string => !!n),
);
@@ -442,10 +447,11 @@ export const PATCH = withAdmin(
// Download SWF if not present — try multiple revision candidates
if (!existsSync(swfPath)) {
// Build list of revisions to try: known rev, spriteId, common fallbacks
const spriteId = await prisma.itemsBase.findFirst({
where: { itemName: item.classname },
select: { spriteId: true },
});
const [spriteId] = await db
.select({ spriteId: ItemsBase.spriteId })
.from(ItemsBase)
.where(eq(ItemsBase.itemName, item.classname))
.limit(1);
const revsToTry = [
...new Set([rev, spriteId?.spriteId || 0].filter((r) => r > 0)),
];
@@ -500,15 +506,16 @@ export const PATCH = withAdmin(
);
if (!alreadyInFD) {
const dbItem = await prisma.itemsBase.findFirst({
where: { itemName: item.classname },
select: {
id: true,
spriteId: true,
publicName: true,
type: true,
},
});
const [dbItem] = await db
.select({
id: ItemsBase.id,
spriteId: ItemsBase.spriteId,
publicName: ItemsBase.publicName,
type: ItemsBase.type,
})
.from(ItemsBase)
.where(eq(ItemsBase.itemName, item.classname))
.limit(1);
const furnidata = await getHabboItFurnidata();
const fd =
furnidata.get(item.classname) || furnidata.get(baseClassname);
@@ -560,10 +567,11 @@ export const PUT = withAdmin(
const isDryRun = request.nextUrl.searchParams.get("dryrun") === "1";
// 1. Find the root "imported_furni" page
const parentPage = await prisma.catalogPages.findFirst({
where: { captionSave: IMPORTED_PAGE_CAPTION_SAVE },
select: { id: true },
});
const [parentPage] = await db
.select({ id: CatalogPages.id })
.from(CatalogPages)
.where(eq(CatalogPages.captionSave, IMPORTED_PAGE_CAPTION_SAVE))
.limit(1);
if (!parentPage) {
return apiOk({
dryRun: isDryRun,
@@ -574,10 +582,13 @@ export const PUT = withAdmin(
}
// 2. Collect ALL imported page IDs (parent + every imp_* sub-page)
const subPages = await prisma.catalogPages.findMany({
where: { parentId: parentPage.id },
select: { id: true, captionSave: true },
});
const subPages = await db
.select({
id: CatalogPages.id,
captionSave: CatalogPages.captionSave,
})
.from(CatalogPages)
.where(eq(CatalogPages.parentId, parentPage.id));
const allImportedPageIds = [
String(parentPage.id),
...subPages.map((p) => String(p.id)),
@@ -585,19 +596,23 @@ export const PUT = withAdmin(
// 3. Find ALL catalog_items across all imported pages
// page_id is VARCHAR in DB so we use string placeholders
const inPlaceholders = allImportedPageIds.map(() => "?").join(",");
const allItems = await prisma.$queryRawUnsafe<
const [allItems] = (await db.execute(sql`
SELECT id, item_ids, catalog_name, page_id, cost_credits
FROM catalog_items
WHERE CAST(page_id AS CHAR) IN (${sql.join(
allImportedPageIds.map((id) => sql`${id}`),
sql`, `,
)})
`)) as unknown as [
Array<{
id: number;
item_ids: string;
catalog_name: string;
page_id: string;
cost_credits: number;
}>
>(
`SELECT id, item_ids, catalog_name, page_id, cost_credits FROM catalog_items WHERE page_id IN (${inPlaceholders})`,
...allImportedPageIds,
);
}>,
unknown,
];
if (allItems.length === 0) {
return apiOk({
@@ -614,10 +629,14 @@ export const PUT = withAdmin(
].filter((n) => !Number.isNaN(n));
const dbItems =
uniqueItemIds.length > 0
? await prisma.itemsBase.findMany({
where: { id: { in: uniqueItemIds } },
select: { id: true, itemName: true, type: true },
})
? await db
.select({
id: ItemsBase.id,
itemName: ItemsBase.itemName,
type: ItemsBase.type,
})
.from(ItemsBase)
.where(inArray(ItemsBase.id, uniqueItemIds))
: [];
const typeMap = new Map<string, string>();
for (const di of dbItems) {
@@ -641,14 +660,20 @@ export const PUT = withAdmin(
// Create the page (only happens once per new category)
const catInfo = CATEGORY_PAGE[catKey] || CATEGORY_PAGE.other;
const parentId = parentPage?.id;
await prisma.$executeRaw`
await db.execute(sql`
INSERT INTO catalog_pages (caption_save, caption, page_layout, parent_id, min_rank, order_num, icon_image, enabled, visible, includes, page_headline, page_teaser, page_special, page_text1, page_text2, page_text_details, page_text_teaser)
VALUES (${captionSave}, ${catInfo.label}, 'default_3x3', ${parentId}, '1', ${catInfo.order}, ${catInfo.icon}, '1', '1', '', '', '', '', '', '', '', '')
`;
const created = await prisma.catalogPages.findFirst({
where: { captionSave, parentId },
select: { id: true },
});
`);
const [created] = await db
.select({ id: CatalogPages.id })
.from(CatalogPages)
.where(
and(
eq(CatalogPages.captionSave, captionSave),
eq(CatalogPages.parentId, parentId!),
),
)
.limit(1);
categoryPageMap.set(captionSave, created?.id);
return String(created?.id);
}
@@ -719,15 +744,17 @@ export const PUT = withAdmin(
for (const [batchKey, ids] of updateBatches) {
const [pageId, credits, points, pointsType] = batchKey.split("|");
try {
const idPlaceholders = ids.map(() => "?").join(",");
await prisma.$queryRawUnsafe(
`UPDATE catalog_items SET page_id = ?, cost_credits = ?, cost_points = ?, points_type = ? WHERE id IN (${idPlaceholders})`,
pageId,
Number(credits),
Number(points),
Number(pointsType),
...ids,
);
await db.execute(sql`
UPDATE catalog_items
SET page_id = ${pageId},
cost_credits = ${Number(credits)},
cost_points = ${Number(points)},
points_type = ${Number(pointsType)}
WHERE id IN (${sql.join(
ids.map((id) => sql`${id}`),
sql`, `,
)})
`);
} catch (err) {
errors.push(`batch update: ${(err as Error).message}`);
}
@@ -741,15 +768,14 @@ export const PUT = withAdmin(
const subPageIdStr = String(subPage.id);
if (!pagesWithItems.has(subPageIdStr)) {
try {
const remaining = await prisma.$queryRawUnsafe<
Array<{ cnt: bigint }>
>(
`SELECT COUNT(*) as cnt FROM catalog_items WHERE page_id = ?`,
subPageIdStr,
);
const [remaining] = (await db.execute(
sql`SELECT COUNT(*) as cnt FROM catalog_items WHERE CAST(page_id AS CHAR) = ${subPageIdStr}`,
)) as unknown as [Array<{ cnt: bigint }>, unknown];
const count = Number(remaining[0]?.cnt ?? 0);
if (count === 0) {
await prisma.$executeRaw`DELETE FROM catalog_pages WHERE id = ${subPage.id}`;
await db.execute(
sql`DELETE FROM catalog_pages WHERE id = ${subPage.id}`,
);
pagesDeleted++;
}
} catch (err) {