Harden catalog writes: bulk import batch + field allowlists.
Local Build and Deploy / deploy (push) Successful in 1m16s

Bulk import resolves names from items_base and refreshes RCON once. Page/item updates only accept an allowlisted field set.

Co-authored-by: Cursor <[email protected]>
This commit is contained in:
SimoandCursor committed 2026-07-17 21:14:35 +02:00
1 parent b52e25578d
commit 2ff5b47104
4 files changed
+288 -50

No files matched your search

+49 -2
View File
@@ -7,12 +7,55 @@ import { prisma } from "@/lib/prisma";
import { rcon } from "@/lib/services/rcon";
import { logStaffActivity } from "@/lib/services/staff-activity";
const BC_PAGE_FIELDS = [
"caption",
"parentId",
"pageLayout",
"enabled",
"visible",
"orderNum",
"iconImage",
"iconColor",
"pageHeadline",
"pageTeaser",
"pageSpecial",
"pageText1",
"pageText2",
"pageTextDetails",
"pageTextTeaser",
] as const;
const BC_ITEM_FIELDS = [
"itemIds",
"catalogName",
"orderNumber",
"extradata",
"pageId",
] as const;
function pickAllowed(
fields: Record<string, unknown>,
allowed: readonly string[],
) {
const out: Record<string, unknown> = {};
for (const key of allowed) {
if (Object.hasOwn(fields, key) && fields[key] !== undefined) {
out[key] = fields[key];
}
}
return out;
}
export async function updateBcPage({
id,
...fields
}: { id: number } & Record<string, unknown>) {
const staff = await requirePermission(PERMS.CATALOG_EDIT);
await prisma.catalogPagesBc.update({ where: { id }, data: fields as any });
const data = pickAllowed(fields, BC_PAGE_FIELDS);
if (Object.keys(data).length === 0) {
return { ok: false as const, error: "No valid fields to update" };
}
await prisma.catalogPagesBc.update({ where: { id }, data: data as any });
await rcon.updateCatalog();
await logStaffActivity({
staffId: staff.id,
@@ -51,7 +94,11 @@ export async function updateBcItem({
extradata?: string;
}) {
const staff = await requirePermission(PERMS.CATALOG_EDIT);
await prisma.catalogItemsBc.update({ where: { id }, data: data as any });
const safe = pickAllowed(data as Record<string, unknown>, BC_ITEM_FIELDS);
if (Object.keys(safe).length === 0) {
return { ok: false as const, error: "No valid fields to update" };
}
await prisma.catalogItemsBc.update({ where: { id }, data: safe as any });
await rcon.updateCatalog();
await logStaffActivity({
staffId: staff.id,
+165 -10
View File
@@ -7,6 +7,62 @@ import { prisma } from "@/lib/prisma";
import { rcon } from "@/lib/services/rcon";
import { logStaffActivity } from "@/lib/services/staff-activity";
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;
}
export async function createCatalogItem(data: {
pageId: number;
itemIds: string;
@@ -25,7 +81,20 @@ export async function createCatalogItem(data: {
clubOnly: "0" | "1";
}) {
const staff = await requirePermission(PERMS.CATALOG_EDIT);
const created = await prisma.catalogItems.create({ data });
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 created = await prisma.catalogItems.create({
data: { ...data, catalogName },
});
await rcon.updateCatalog();
await logStaffActivity({
staffId: staff.id,
@@ -38,6 +107,84 @@ export async function createCatalogItem(data: {
return { ok: true as const, data: { id: created.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 prisma.catalogItems.create({
data: {
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 } } });
@@ -93,16 +240,25 @@ export async function updateCatalogItem({
baseItem?: { id: number; fields: Record<string, unknown> };
}) {
const staff = await requirePermission(PERMS.CATALOG_EDIT);
await prisma.catalogItems.update({
where: { id },
data: catalogFields as any,
});
if (baseItem) {
await prisma.itemsBase.update({
where: { id: baseItem.id },
data: baseItem.fields as any,
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" };
}
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,
@@ -160,7 +316,6 @@ export async function translateCatalogItems({
where: { id: base.id },
data: { publicName: nextName },
});
// Sync catalogName for catalog rows that reference this base item
const idStr = String(base.id);
const related = await prisma.catalogItems.findMany({
where: {
+41 -1
View File
@@ -9,12 +9,52 @@ import { deletePage, movePage } from "@/lib/services/catalog-tree";
import { rcon } from "@/lib/services/rcon";
import { logStaffActivity } from "@/lib/services/staff-activity";
const CATALOG_PAGE_FIELDS = [
"caption",
"parentId",
"pageLayout",
"enabled",
"visible",
"minRank",
"clubOnly",
"vipOnly",
"orderNum",
"iconImage",
"iconColor",
"pageHeadline",
"pageTeaser",
"pageSpecial",
"pageText1",
"pageText2",
"pageTextDetails",
"pageTextTeaser",
"includes",
"captionSave",
] as const;
function pickPageFields(fields: Record<string, unknown>) {
const out: Record<string, unknown> = {};
for (const key of CATALOG_PAGE_FIELDS) {
if (Object.hasOwn(fields, key) && fields[key] !== undefined) {
out[key] = fields[key];
}
}
return out;
}
export async function updateCatalogPage({
id,
...fields
}: { id: number } & Record<string, unknown>): Promise<ActionResult> {
const staff = await requirePermission(PERMS.CATALOG_EDIT);
await prisma.catalogPages.update({ where: { id }, data: fields as any });
const data = pickPageFields(fields);
if (Object.keys(data).length === 0) {
return { ok: false as const, error: "No valid fields to update" };
}
if (typeof data.caption === "string" && !data.captionSave) {
data.captionSave = data.caption.slice(0, 25);
}
await prisma.catalogPages.update({ where: { id }, data: data as any });
await rcon.updateCatalog();
await logStaffActivity({
staffId: staff.id,
@@ -3,7 +3,7 @@
import { AlertTriangle, CheckCircle2, Loader2, Upload } from "lucide-react";
import { useMemo, useState } from "react";
import { toast } from "sonner";
import { createCatalogItem } from "@/actions/catalog-items";
import { bulkCreateCatalogItems } from "@/actions/catalog-items";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
@@ -91,43 +91,39 @@ export function BulkImportItems({ pageId, onImported }: BulkImportItemsProps) {
return;
}
setImporting(true);
let success = 0;
let failed = 0;
for (const row of valid) {
try {
const res = await createCatalogItem({
pageId,
itemIds: String(row.baseId!),
catalogName: "",
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: "",
});
if (res.ok) success++;
else failed++;
} catch {
failed++;
try {
const res = await bulkCreateCatalogItems({
pageId,
rows: valid.map((row) => ({
baseId: row.baseId!,
credits: row.credits,
points: row.points,
pointsType: row.pointsType,
})),
});
if (!res.ok) {
toast.error(res.error || "Bulk import failed.");
return;
}
}
setImporting(false);
if (success > 0) {
toast.success(
`Imported ${success} item(s)${failed > 0 ? `, ${failed} failed` : ""}.`,
);
setInput("");
setOpen(false);
onImported?.();
} else {
toast.error(`All ${failed} imports failed.`);
const { created, failed } = res.data;
if (created > 0) {
toast.success(
`Imported ${created} item(s)${failed > 0 ? `, ${failed} failed` : ""}.`,
);
setInput("");
setOpen(false);
onImported?.();
} else {
toast.error(
failed > 0
? `All ${failed} imports failed (unknown base IDs?).`
: "Nothing imported.",
);
}
} catch {
toast.error("Bulk import failed.");
} finally {
setImporting(false);
}
}