- Replace Prisma client runtime with Drizzle ORM (zero Prisma engine/query engine in production) - Add Prisma-compatible facade (@/lib/prisma-facade.ts) backed by Drizzle for backwards compatibility - Runtime queries route through Drizzle ORM; @prisma/client is now devDependency (types only) - Remove @prisma/adapter-mariadb dependency; delete prisma-pool.ts and types/prisma.ts - New Drizzle schema layer: src/db/schema.ts (176 tables) and src/lib/db.ts (connection) - Update README documenting the dual-layer ORM architecture - Restore src/generated/ gitignore (build artifact for local type generation) - 0 TypeScript errors, 583 tests passing The facade intentionally uses `any` types to match the Prisma Client API surface, allowing existing code to run unmodified while routing queries through Drizzle at runtime.
447 lines
11 KiB
TypeScript
447 lines
11 KiB
TypeScript
"use server";
|
|
|
|
import { sql } from "drizzle-orm";
|
|
import { revalidatePath } from "next/cache";
|
|
import { requirePermission } from "@/lib/admin/guard";
|
|
import { PERMS } from "@/lib/permissions";
|
|
import { prisma } from "@/lib/prisma";
|
|
import { logAudit } from "@/lib/services/audit";
|
|
import { allocateCatalogItemId } from "@/lib/services/furni-import";
|
|
import { rcon } from "@/lib/services/rcon";
|
|
import { logStaffActivity } from "@/lib/services/staff-activity";
|
|
import { translateItemsSchema } from "@/lib/validators/catalog";
|
|
|
|
const CATALOG_ITEM_FIELDS = [
|
|
"pageId",
|
|
"itemIds",
|
|
"catalogName",
|
|
"costCredits",
|
|
"costPoints",
|
|
"pointsType",
|
|
"amount",
|
|
"orderNumber",
|
|
"offerId",
|
|
"songId",
|
|
"limitedSells",
|
|
"limitedStack",
|
|
"extradata",
|
|
"haveOffer",
|
|
"clubOnly",
|
|
] as const;
|
|
|
|
const ITEMS_BASE_FIELDS = [
|
|
"publicName",
|
|
"itemName",
|
|
"type",
|
|
"width",
|
|
"length",
|
|
"stackHeight",
|
|
"allowStack",
|
|
"allowSit",
|
|
"allowLay",
|
|
"allowWalk",
|
|
"allowGift",
|
|
"allowTrade",
|
|
"allowRecycle",
|
|
"allowMarketplaceSell",
|
|
"allowInventoryStack",
|
|
"interactionType",
|
|
"interactionModesCount",
|
|
"vendingIds",
|
|
"customparams",
|
|
"effectIdMale",
|
|
"effectIdFemale",
|
|
"clothingOnWalk",
|
|
] as const;
|
|
|
|
function pickAllowed(
|
|
fields: Record<string, unknown>,
|
|
allowed: readonly string[],
|
|
): Record<string, unknown> {
|
|
const out: Record<string, unknown> = {};
|
|
for (const key of allowed) {
|
|
if (Object.hasOwn(fields, key) && fields[key] !== undefined) {
|
|
out[key] = fields[key];
|
|
}
|
|
}
|
|
return out;
|
|
}
|
|
|
|
/** Raw INSERT — catalog_items.id has no AUTO_INCREMENT on real Habbo DBs; page_id is often VARCHAR. */
|
|
async function insertCatalogItemRow(data: {
|
|
pageId: number;
|
|
itemIds: string;
|
|
catalogName: string;
|
|
costCredits: number;
|
|
costPoints: number;
|
|
pointsType: number;
|
|
amount: number;
|
|
orderNumber: number;
|
|
offerId: number;
|
|
songId: number;
|
|
limitedSells: number;
|
|
limitedStack: number;
|
|
extradata: string;
|
|
haveOffer: string;
|
|
clubOnly: string;
|
|
}): Promise<number> {
|
|
const pageIdStr = String(data.pageId);
|
|
return allocateCatalogItemId(async (nextId) => {
|
|
await prisma.$executeRaw`
|
|
INSERT INTO catalog_items (
|
|
id, page_id, item_ids, catalog_name,
|
|
cost_credits, cost_points, points_type, amount,
|
|
order_number, offer_id, song_id,
|
|
limited_sells, limited_stack, extradata, have_offer, club_only
|
|
) VALUES (
|
|
${nextId}, ${pageIdStr}, ${data.itemIds}, ${data.catalogName},
|
|
${data.costCredits}, ${data.costPoints}, ${data.pointsType}, ${data.amount},
|
|
${data.orderNumber}, ${data.offerId}, ${data.songId},
|
|
${data.limitedSells}, ${data.limitedStack}, ${data.extradata},
|
|
${data.haveOffer}, ${data.clubOnly}
|
|
)
|
|
`;
|
|
return nextId;
|
|
});
|
|
}
|
|
|
|
export async function createCatalogItem(data: {
|
|
pageId: number;
|
|
itemIds: string;
|
|
catalogName: string;
|
|
costCredits: number;
|
|
costPoints: number;
|
|
pointsType: number;
|
|
amount: number;
|
|
orderNumber: number;
|
|
offerId: number;
|
|
limitedSells: number;
|
|
limitedStack: number;
|
|
extradata: string;
|
|
songId: number;
|
|
haveOffer: "0" | "1";
|
|
clubOnly: "0" | "1";
|
|
}) {
|
|
const staff = await requirePermission(PERMS.CATALOG_EDIT);
|
|
let catalogName = data.catalogName.trim();
|
|
if (!catalogName) {
|
|
const firstId = Number.parseInt(data.itemIds.split(";")[0] || "", 10);
|
|
if (firstId > 0) {
|
|
const base = await prisma.itemsBase.findUnique({
|
|
where: { id: firstId },
|
|
select: { publicName: true, itemName: true },
|
|
});
|
|
catalogName = base?.publicName || base?.itemName || String(firstId);
|
|
}
|
|
}
|
|
const id = await insertCatalogItemRow({ ...data, catalogName });
|
|
await rcon.updateCatalog();
|
|
await logStaffActivity({
|
|
staffId: staff.id,
|
|
action: "catalog_item_create",
|
|
description: `Created catalog item #${id}`,
|
|
targetType: "catalog_item",
|
|
targetId: id,
|
|
});
|
|
revalidatePath("/admin/catalog");
|
|
return { ok: true as const, data: { id } };
|
|
}
|
|
|
|
/** Bulk create with one RCON refresh at the end. */
|
|
export async function bulkCreateCatalogItems({
|
|
pageId,
|
|
rows,
|
|
}: {
|
|
pageId: number;
|
|
rows: Array<{
|
|
baseId: number;
|
|
credits?: number;
|
|
points?: number;
|
|
pointsType?: number;
|
|
}>;
|
|
}) {
|
|
const staff = await requirePermission(PERMS.CATALOG_EDIT);
|
|
if (rows.length === 0) {
|
|
return { ok: true as const, data: { created: 0, failed: 0 } };
|
|
}
|
|
if (rows.length > 500) {
|
|
return { ok: false as const, error: "Max 500 items per bulk import" };
|
|
}
|
|
|
|
const baseIds = [...new Set(rows.map((r) => r.baseId))];
|
|
const bases = await prisma.itemsBase.findMany({
|
|
where: { id: { in: baseIds } },
|
|
select: { id: true, publicName: true, itemName: true },
|
|
});
|
|
const baseMap = new Map(bases.map((b) => [b.id, b]));
|
|
|
|
let created = 0;
|
|
let failed = 0;
|
|
|
|
for (const row of rows) {
|
|
const base = baseMap.get(row.baseId);
|
|
if (!base) {
|
|
failed++;
|
|
continue;
|
|
}
|
|
try {
|
|
await insertCatalogItemRow({
|
|
pageId,
|
|
itemIds: String(row.baseId),
|
|
catalogName: base.publicName || base.itemName || String(row.baseId),
|
|
costCredits: row.credits ?? 0,
|
|
costPoints: row.points ?? 0,
|
|
pointsType: row.pointsType ?? 0,
|
|
amount: 1,
|
|
limitedSells: 0,
|
|
limitedStack: 0,
|
|
orderNumber: 1,
|
|
offerId: -1,
|
|
songId: 0,
|
|
haveOffer: "1",
|
|
clubOnly: "0",
|
|
extradata: "",
|
|
});
|
|
created++;
|
|
} catch {
|
|
failed++;
|
|
}
|
|
}
|
|
|
|
if (created > 0) {
|
|
await rcon.updateCatalog();
|
|
await logStaffActivity({
|
|
staffId: staff.id,
|
|
action: "catalog_items_bulk_create",
|
|
description: `Bulk imported ${created} catalog item(s) on page #${pageId}`,
|
|
targetType: "catalog_page",
|
|
targetId: pageId,
|
|
});
|
|
revalidatePath("/admin/catalog");
|
|
}
|
|
|
|
return { ok: true as const, data: { created, failed } };
|
|
}
|
|
|
|
export async function deleteCatalogItems({ ids }: { ids: number[] }) {
|
|
const staff = await requirePermission(PERMS.CATALOG_EDIT);
|
|
await prisma.catalogItems.deleteMany({ where: { id: { in: ids } } });
|
|
await rcon.updateCatalog();
|
|
await logStaffActivity({
|
|
staffId: staff.id,
|
|
action: "catalog_items_delete",
|
|
description: `Deleted catalog items: ${ids.join(", ")}`,
|
|
targetType: "catalog_item",
|
|
});
|
|
revalidatePath("/admin/catalog");
|
|
return { ok: true as const, data: {} };
|
|
}
|
|
|
|
export async function moveCatalogItems({
|
|
ids,
|
|
targetPageId,
|
|
}: {
|
|
ids: number[];
|
|
targetPageId: number;
|
|
}) {
|
|
await requirePermission(PERMS.CATALOG_EDIT);
|
|
if (ids.length === 0) {
|
|
return { ok: true as const, data: {} };
|
|
}
|
|
const pageIdStr = String(targetPageId);
|
|
await prisma.$executeRaw`
|
|
UPDATE catalog_items
|
|
SET page_id = ${pageIdStr}
|
|
WHERE id IN (${sql.join(ids, sql`, `)})
|
|
`;
|
|
await rcon.updateCatalog();
|
|
revalidatePath("/admin/catalog");
|
|
return { ok: true as const, data: {} };
|
|
}
|
|
|
|
export async function reorderCatalogItems({
|
|
orders,
|
|
}: {
|
|
orders: Array<{ id: number; orderNumber: number }>;
|
|
}) {
|
|
await requirePermission(PERMS.CATALOG_EDIT);
|
|
for (const { id, orderNumber } of orders) {
|
|
await prisma.catalogItems.update({ where: { id }, data: { orderNumber } });
|
|
}
|
|
await rcon.updateCatalog();
|
|
revalidatePath("/admin/catalog");
|
|
return { ok: true as const, data: {} };
|
|
}
|
|
|
|
export async function updateCatalogItem({
|
|
id,
|
|
catalogFields,
|
|
baseItem,
|
|
}: {
|
|
id: number;
|
|
catalogFields: Record<string, unknown>;
|
|
baseItem?: { id: number; fields: Record<string, unknown> };
|
|
}) {
|
|
const staff = await requirePermission(PERMS.CATALOG_EDIT);
|
|
const safeCatalog = pickAllowed(catalogFields, CATALOG_ITEM_FIELDS);
|
|
if (Object.keys(safeCatalog).length === 0 && !baseItem) {
|
|
return { ok: false as const, error: "No valid fields to update" };
|
|
}
|
|
|
|
// page_id is often VARCHAR — update it via raw SQL when present.
|
|
const pageIdRaw = safeCatalog.pageId;
|
|
if (pageIdRaw !== undefined) {
|
|
const pageIdStr = String(pageIdRaw);
|
|
await prisma.$executeRaw`
|
|
UPDATE catalog_items SET page_id = ${pageIdStr} WHERE id = ${id}
|
|
`;
|
|
delete safeCatalog.pageId;
|
|
}
|
|
|
|
if (Object.keys(safeCatalog).length > 0) {
|
|
await prisma.catalogItems.update({
|
|
where: { id },
|
|
data: safeCatalog as any,
|
|
});
|
|
}
|
|
if (baseItem) {
|
|
const safeBase = pickAllowed(baseItem.fields, ITEMS_BASE_FIELDS);
|
|
if (Object.keys(safeBase).length > 0) {
|
|
await prisma.itemsBase.update({
|
|
where: { id: baseItem.id },
|
|
data: safeBase as any,
|
|
});
|
|
}
|
|
}
|
|
await rcon.updateCatalog();
|
|
await logStaffActivity({
|
|
staffId: staff.id,
|
|
action: "catalog_item_update",
|
|
description: `Updated catalog item #${id}`,
|
|
targetType: "catalog_item",
|
|
targetId: id,
|
|
});
|
|
revalidatePath("/admin/catalog");
|
|
return { ok: true as const, data: {} };
|
|
}
|
|
|
|
export async function translateCatalogItems(input: {
|
|
/** `id` is items_base.id (not catalog_items.id) */
|
|
items: Array<{ id: number; publicName: string; description?: string }>;
|
|
}) {
|
|
const staff = await requirePermission(PERMS.CATALOG_EDIT);
|
|
const parsed = translateItemsSchema.safeParse(input);
|
|
if (!parsed.success) {
|
|
return {
|
|
ok: false as const,
|
|
error: parsed.error.issues[0]?.message ?? "Invalid translate payload",
|
|
};
|
|
}
|
|
const { items } = parsed.data;
|
|
const { invalidateFurniDataCache } = await import(
|
|
"@/lib/services/catalog-items-loader"
|
|
);
|
|
const { patchFurniEntryNames } = await import("@/lib/services/furni-data");
|
|
|
|
let namesUpdated = 0;
|
|
let descriptionsUpdated = 0;
|
|
const furniPatches: Array<{
|
|
classname: string;
|
|
itemType: string;
|
|
name?: string;
|
|
description?: string;
|
|
spriteId?: number;
|
|
createIfMissing?: boolean;
|
|
}> = [];
|
|
|
|
for (const item of items) {
|
|
const base = await prisma.itemsBase.findUnique({
|
|
where: { id: item.id },
|
|
select: {
|
|
id: true,
|
|
publicName: true,
|
|
itemName: true,
|
|
type: true,
|
|
spriteId: true,
|
|
},
|
|
});
|
|
if (!base) continue;
|
|
|
|
const nextName = item.publicName?.trim() ?? "";
|
|
const nextDesc = item.description ?? "";
|
|
const nameChanged = nextName !== "" && nextName !== (base.publicName ?? "");
|
|
|
|
if (nameChanged) {
|
|
await prisma.itemsBase.update({
|
|
where: { id: base.id },
|
|
data: { publicName: nextName },
|
|
});
|
|
const idStr = String(base.id);
|
|
const related = await prisma.catalogItems.findMany({
|
|
where: {
|
|
OR: [
|
|
{ itemIds: idStr },
|
|
{ itemIds: { startsWith: `${idStr};` } },
|
|
{ itemIds: { contains: `;${idStr};` } },
|
|
{ itemIds: { endsWith: `;${idStr}` } },
|
|
],
|
|
},
|
|
select: { id: true, catalogName: true },
|
|
});
|
|
for (const row of related) {
|
|
if (row.catalogName !== nextName) {
|
|
await prisma.catalogItems.update({
|
|
where: { id: row.id },
|
|
data: { catalogName: nextName },
|
|
});
|
|
}
|
|
}
|
|
namesUpdated++;
|
|
}
|
|
|
|
if (nextDesc !== "" || nameChanged) {
|
|
descriptionsUpdated += nextDesc !== "" ? 1 : 0;
|
|
furniPatches.push({
|
|
classname: base.itemName,
|
|
itemType: base.type || "s",
|
|
name: nextName || base.publicName || base.itemName,
|
|
description: nextDesc,
|
|
spriteId: base.spriteId,
|
|
createIfMissing: true,
|
|
});
|
|
}
|
|
}
|
|
|
|
const furniResult =
|
|
furniPatches.length > 0
|
|
? await patchFurniEntryNames(furniPatches)
|
|
: { updated: 0, inserted: 0 };
|
|
if (furniResult.updated > 0 || furniResult.inserted > 0) {
|
|
invalidateFurniDataCache();
|
|
}
|
|
|
|
await rcon.updateCatalog();
|
|
await logAudit({
|
|
userId: staff.id,
|
|
action: "items_base_translate",
|
|
target: "ItemsBase",
|
|
after: {
|
|
namesUpdated,
|
|
descriptionsUpdated,
|
|
furniDataUpdated: furniResult.updated > 0,
|
|
furniDataInserted: furniResult.inserted,
|
|
},
|
|
});
|
|
revalidatePath("/admin/catalog");
|
|
return {
|
|
ok: true as const,
|
|
data: {
|
|
namesUpdated,
|
|
descriptionsUpdated,
|
|
furniDataUpdated: furniResult.updated,
|
|
furniDataInserted: furniResult.inserted,
|
|
updated: items.length,
|
|
},
|
|
};
|
|
}
|