Gitea Actions Runner Test / test-job (push) Successful in 1s
CI / check (push) Successful in 36s
CI / tests-integration (push) Successful in 1m52s
CI / tests-unit (push) Successful in 1m56s
CI / tests-ui (push) Successful in 2m48s
CI / preflight (push) Skipped
CI / deploy (push) Successful in 2m27s
Furniture was not always loading completely because the same file was cached twice and nobody could reach the client. The catalog items loader kept its own 30s TTL copy of FurnitureData.json next to the mtime-validated cache in `furni-data.ts`. An import cleared only the second one, so the catalog table kept serving pre-import furnidata — empty descriptions and revisions — until the TTL ran out. The loader now reads through `readFurniData`, which revalidates on mtime+size and is reset by every write, so there is exactly one cache and it cannot go stale on its own. `invalidateFurniDataCache` and its single call site are gone with it. The client was worse: nginx served all of /gamedata/ with `max-age=604800`, and the `cms-gamedata` purge that would have fixed it hung off the catalog Git export, which is disabled in production. A freshly imported item was invisible in the client for up to seven days no matter how often you imported. - `writeFurniData` now purges the gamedata edge tag itself. One place covers import, batch, resync, regen, nitro-editor, translate and dedupe. It is fire-and-forget and swallowed at every level: a stale edge copy is bounded by the edge TTL, so a failed purge must never fail an import. - nginx splits /gamedata/ by how mutable the content is: config/ gets `max-age=300, must-revalidate`, bundled/ `max-age=3600, must-revalidate`, and the content-addressed trees (c_images, album*, clothes) keep the long TTL. `must-revalidate` is the point — the client now revalidates instead of replaying the old body. All three keep `Cache-Tag: cms-gamedata` so the purge still reaches them. - A 30-minute safety-net purge in the jobs worker covers the case where Cloudflare was unreachable at write time.
547 lines
15 KiB
TypeScript
547 lines
15 KiB
TypeScript
"use server";
|
|
|
|
import { eq, inArray, like, or, sql } from "drizzle-orm";
|
|
import { revalidatePath } from "next/cache";
|
|
import { offerPatchSchema } from "@/features/catalog/domain/offer-input";
|
|
import { catalogFailure } from "@/features/catalog/server/errors";
|
|
import {
|
|
deleteCatalogItemsCommand,
|
|
listRestorableDeletionsCommand,
|
|
restoreDeletedCatalogItemsCommand,
|
|
} from "@/features/catalog/server/item-deletes";
|
|
import {
|
|
createOfferCommand,
|
|
moveBcOffersCommand,
|
|
moveOffersCommand,
|
|
reorderBcOffersCommand,
|
|
reorderOffersCommand,
|
|
updateOfferCommand,
|
|
} from "@/features/catalog/server/offer-commands";
|
|
import { sendCatalogUpdate } from "@/features/catalog/server/sync-status";
|
|
import { requirePermission } from "@/lib/admin/guard";
|
|
import { CatalogItems, db, ItemsBase, queryRows } from "@/lib/db";
|
|
import { PERMS } from "@/lib/permissions";
|
|
import { logAudit } from "@/lib/services/audit";
|
|
import { withCatalogExport } from "@/lib/services/catalog-git-queue";
|
|
import { allocateCatalogItemId } from "@/lib/services/furni-import";
|
|
import { logStaffActivity } from "@/lib/services/staff-activity";
|
|
import { translateItemsSchema } from "@/lib/validators/catalog";
|
|
|
|
/** Raw INSERT — catalog_items.id has no AUTO_INCREMENT on real Habbo DBs; page_id is often VARCHAR. */
|
|
export 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);
|
|
offerPatchSchema.required().parse(data);
|
|
return allocateCatalogItemId((nextId) =>
|
|
createOfferCommand("normal", data, async (tx) => {
|
|
await tx.execute(sql`
|
|
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";
|
|
},
|
|
// The table is shared between both catalogs, and a BC offer has no price or
|
|
// currency columns at all. The catalog decides which command runs rather than
|
|
// letting the table send normal-only fields at a BC row.
|
|
catalog: "normal" | "bc" = "normal",
|
|
) {
|
|
const staff = await requirePermission(PERMS.CATALOG_EDIT);
|
|
if (catalog === "bc") {
|
|
const { createBcItem } = await import("@/actions/catalog-bc");
|
|
return createBcItem({
|
|
pageId: data.pageId,
|
|
itemIds: data.itemIds,
|
|
catalogName: data.catalogName,
|
|
orderNumber: data.orderNumber,
|
|
extradata: data.extradata,
|
|
});
|
|
}
|
|
return await withCatalogExport(async () => {
|
|
let catalogName = data.catalogName.trim();
|
|
if (!catalogName) {
|
|
const firstId = Number.parseInt(data.itemIds.split(";")[0] || "", 10);
|
|
if (firstId > 0) {
|
|
const [base] = await db
|
|
.select({
|
|
publicName: ItemsBase.publicName,
|
|
itemName: ItemsBase.itemName,
|
|
})
|
|
.from(ItemsBase)
|
|
.where(eq(ItemsBase.id, firstId))
|
|
.limit(1);
|
|
catalogName = base?.publicName || base?.itemName || String(firstId);
|
|
}
|
|
}
|
|
const id = await insertCatalogItemRow({ ...data, catalogName });
|
|
await sendCatalogUpdate();
|
|
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);
|
|
return await withCatalogExport(async () => {
|
|
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 db
|
|
.select({
|
|
id: ItemsBase.id,
|
|
publicName: ItemsBase.publicName,
|
|
itemName: ItemsBase.itemName,
|
|
})
|
|
.from(ItemsBase)
|
|
.where(inArray(ItemsBase.id, baseIds));
|
|
const baseMap = new Map(bases.map((b) => [b.id, b]));
|
|
|
|
let created = 0;
|
|
let failed = 0;
|
|
|
|
// Find the next order number on the target page to avoid collisions.
|
|
const maxOrder = await queryRows<{ maxOrder: number }>(sql`
|
|
SELECT COALESCE(MAX(order_number), 0) AS maxOrder FROM catalog_items WHERE page_id = ${String(pageId)}
|
|
`);
|
|
let nextOrder = Number(maxOrder[0]?.maxOrder ?? 0) + 1;
|
|
|
|
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: nextOrder++,
|
|
offerId: -1,
|
|
songId: 0,
|
|
haveOffer: "1",
|
|
clubOnly: "0",
|
|
extradata: "",
|
|
});
|
|
created++;
|
|
} catch {
|
|
failed++;
|
|
}
|
|
}
|
|
|
|
if (created > 0) {
|
|
await sendCatalogUpdate();
|
|
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 } };
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Deleting offers is the one catalog mutation with no natural inverse, so the
|
|
* full rows are kept at delete time and `restoreId` is handed back. The UI shows
|
|
* that as an undo affordance; without it a mis-click is unrecoverable.
|
|
*/
|
|
export async function deleteCatalogItems({
|
|
ids,
|
|
requestKey,
|
|
catalog = "normal",
|
|
}: {
|
|
ids: number[];
|
|
requestKey?: string;
|
|
catalog?: "normal" | "bc";
|
|
}) {
|
|
const staff = await requirePermission(PERMS.CATALOG_EDIT);
|
|
try {
|
|
return await withCatalogExport(async () => {
|
|
const data: {
|
|
deleted: number;
|
|
restoreId: number;
|
|
} = await deleteCatalogItemsCommand(
|
|
ids,
|
|
staff.id,
|
|
requestKey,
|
|
catalog === "bc" ? "bc" : "normal",
|
|
);
|
|
await sendCatalogUpdate();
|
|
await logStaffActivity({
|
|
staffId: staff.id,
|
|
action: "catalog_items_delete",
|
|
description: `Deleted ${data.deleted} ${catalog === "bc" ? "BC " : ""}catalog offer(s): ${ids.join(", ")}`,
|
|
targetType: catalog === "bc" ? "catalog_item_bc" : "catalog_item",
|
|
});
|
|
revalidatePath("/admin/catalog");
|
|
if (catalog === "bc") revalidatePath("/admin/catalog/builder-club");
|
|
return { ok: true as const, data };
|
|
});
|
|
} catch (error) {
|
|
const failure = catalogFailure(error);
|
|
return { ok: false as const, error: failure.message };
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Recent deletions that are still restorable. The undo toast covers the common
|
|
* case; this is the fallback for a delete noticed after that toast is gone.
|
|
*/
|
|
export async function listRestorableCatalogItemDeletions() {
|
|
await requirePermission(PERMS.CATALOG_EDIT);
|
|
try {
|
|
return {
|
|
ok: true as const,
|
|
data: await listRestorableDeletionsCommand(),
|
|
};
|
|
} catch (error) {
|
|
const failure = catalogFailure(error);
|
|
return { ok: false as const, error: failure.message };
|
|
}
|
|
}
|
|
|
|
export async function restoreDeletedCatalogItems({
|
|
restoreId,
|
|
requestKey,
|
|
}: {
|
|
restoreId: number;
|
|
requestKey?: string;
|
|
}) {
|
|
const staff = await requirePermission(PERMS.CATALOG_EDIT);
|
|
try {
|
|
return await withCatalogExport(async () => {
|
|
const data: { restored: number } =
|
|
await restoreDeletedCatalogItemsCommand(
|
|
restoreId,
|
|
staff.id,
|
|
requestKey,
|
|
);
|
|
await sendCatalogUpdate();
|
|
await logStaffActivity({
|
|
staffId: staff.id,
|
|
action: "catalog_items_restore",
|
|
description: `Restored ${data.restored} deleted catalog offer(s)`,
|
|
targetType: "catalog_item",
|
|
});
|
|
revalidatePath("/admin/catalog");
|
|
return { ok: true as const, data };
|
|
});
|
|
} catch (error) {
|
|
const failure = catalogFailure(error);
|
|
return { ok: false as const, error: failure.message };
|
|
}
|
|
}
|
|
|
|
export async function moveCatalogItems({
|
|
ids,
|
|
targetPageId,
|
|
catalog = "normal",
|
|
}: {
|
|
ids: number[];
|
|
targetPageId: number;
|
|
catalog?: "normal" | "bc";
|
|
}) {
|
|
await requirePermission(PERMS.CATALOG_EDIT);
|
|
return await withCatalogExport(async () => {
|
|
if (ids.length === 0) {
|
|
return { ok: true as const, data: {} };
|
|
}
|
|
// Moving BC offers has to lock a BC category and write the BC table; the
|
|
// normal command would move a row in the wrong catalog.
|
|
if (catalog === "bc") await moveBcOffersCommand(ids, targetPageId);
|
|
else await moveOffersCommand(ids, targetPageId);
|
|
await sendCatalogUpdate();
|
|
revalidatePath("/admin/catalog");
|
|
if (catalog === "bc") revalidatePath("/admin/catalog/builder-club");
|
|
return { ok: true as const, data: {} };
|
|
});
|
|
}
|
|
|
|
export async function reorderCatalogItems({
|
|
orders,
|
|
catalog = "normal",
|
|
}: {
|
|
orders: Array<{ id: number; orderNumber: number }>;
|
|
catalog?: "normal" | "bc";
|
|
}) {
|
|
await requirePermission(PERMS.CATALOG_EDIT);
|
|
return await withCatalogExport(async () => {
|
|
if (catalog === "bc") await reorderBcOffersCommand(orders);
|
|
else await reorderOffersCommand(orders);
|
|
await sendCatalogUpdate();
|
|
revalidatePath("/admin/catalog");
|
|
if (catalog === "bc") revalidatePath("/admin/catalog/builder-club");
|
|
return { ok: true as const, data: {} };
|
|
});
|
|
}
|
|
|
|
export async function updateCatalogItem({
|
|
id,
|
|
catalogFields,
|
|
baseItem,
|
|
catalog = "normal",
|
|
}: {
|
|
id: number;
|
|
catalogFields: Record<string, unknown>;
|
|
baseItem?: { id: number; fields: Record<string, unknown> };
|
|
catalog?: "normal" | "bc";
|
|
}) {
|
|
const staff = await requirePermission(PERMS.CATALOG_EDIT);
|
|
// BC rows have no price, points or currency column. The table is shared, so
|
|
// the catalog is resolved here rather than trusting the caller to strip
|
|
// fields the BC command would reject anyway.
|
|
if (catalog === "bc") {
|
|
const { updateBcItem } = await import("@/actions/catalog-bc");
|
|
return updateBcItem({ id, ...catalogFields });
|
|
}
|
|
return await withCatalogExport(async () => {
|
|
try {
|
|
await updateOfferCommand({ id, catalogFields, baseItem }, staff.id);
|
|
} catch (error) {
|
|
return {
|
|
ok: false as const,
|
|
error:
|
|
error instanceof Error
|
|
? error.message
|
|
: "Unable to update catalog item",
|
|
};
|
|
}
|
|
await sendCatalogUpdate();
|
|
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);
|
|
return await withCatalogExport(async () => {
|
|
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 { patchFurniEntryNames } = await import("@/lib/services/furni-data");
|
|
const { patchLocalizedFurniDataEntries } = await import(
|
|
"@/lib/services/furni-data-i18n"
|
|
);
|
|
|
|
let namesUpdated = 0;
|
|
let descriptionsUpdated = 0;
|
|
const furniPatches: Array<{
|
|
classname: string;
|
|
itemType: string;
|
|
name?: string;
|
|
description?: string;
|
|
spriteId?: number;
|
|
createIfMissing?: boolean;
|
|
}> = [];
|
|
const localizedEntries: Array<{
|
|
classname: string;
|
|
name?: string;
|
|
description?: string;
|
|
}> = [];
|
|
|
|
for (const item of items) {
|
|
const [base] = await db
|
|
.select({
|
|
id: ItemsBase.id,
|
|
publicName: ItemsBase.publicName,
|
|
itemName: ItemsBase.itemName,
|
|
type: ItemsBase.type,
|
|
spriteId: ItemsBase.spriteId,
|
|
})
|
|
.from(ItemsBase)
|
|
.where(eq(ItemsBase.id, item.id))
|
|
.limit(1);
|
|
if (!base) continue;
|
|
|
|
const nextName = item.publicName?.trim() ?? "";
|
|
const nextDesc = item.description ?? "";
|
|
const nameChanged =
|
|
nextName !== "" && nextName !== (base.publicName ?? "");
|
|
|
|
if (nameChanged) {
|
|
await db
|
|
.update(ItemsBase)
|
|
.set({ publicName: nextName })
|
|
.where(eq(ItemsBase.id, base.id));
|
|
const idStr = String(base.id);
|
|
const related = await db
|
|
.select({
|
|
id: CatalogItems.id,
|
|
catalogName: CatalogItems.catalogName,
|
|
})
|
|
.from(CatalogItems)
|
|
.where(
|
|
or(
|
|
eq(CatalogItems.itemIds, idStr),
|
|
like(CatalogItems.itemIds, `${idStr};%`),
|
|
like(CatalogItems.itemIds, `%;${idStr};%`),
|
|
like(CatalogItems.itemIds, `%;${idStr}`),
|
|
),
|
|
);
|
|
for (const row of related) {
|
|
if (row.catalogName !== nextName) {
|
|
await db
|
|
.update(CatalogItems)
|
|
.set({ catalogName: nextName })
|
|
.where(eq(CatalogItems.id, row.id));
|
|
}
|
|
}
|
|
namesUpdated++;
|
|
}
|
|
|
|
if (nextDesc !== "" || nameChanged) {
|
|
descriptionsUpdated += nextDesc !== "" ? 1 : 0;
|
|
const englishName = base.publicName?.trim() || base.itemName || "";
|
|
furniPatches.push({
|
|
classname: base.itemName,
|
|
itemType: base.type || "s",
|
|
name: nextName || base.publicName || base.itemName,
|
|
description: nextDesc,
|
|
spriteId: base.spriteId,
|
|
createIfMissing: true,
|
|
});
|
|
localizedEntries.push({
|
|
classname: base.itemName,
|
|
name: englishName,
|
|
description: nextDesc || undefined,
|
|
});
|
|
}
|
|
}
|
|
|
|
const furniResult =
|
|
furniPatches.length > 0
|
|
? await patchFurniEntryNames(furniPatches)
|
|
: { updated: 0, inserted: 0 };
|
|
// `furniResult` needs no follow-up: `patchFurniEntryNames` writes through
|
|
// `writeFurniData`, which resets the shared in-process cache and purges
|
|
// the gamedata edge tag itself.
|
|
|
|
if (localizedEntries.length > 0) {
|
|
try {
|
|
await patchLocalizedFurniDataEntries(localizedEntries, true);
|
|
} catch {
|
|
// Best-effort: localized files update is non-critical
|
|
}
|
|
}
|
|
|
|
await sendCatalogUpdate();
|
|
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,
|
|
},
|
|
};
|
|
});
|
|
}
|