fix(catalog): stream single imports and preserve response errors
CI / check (push) Successful in 1m31s
CI / deploy (push) Successful in 1m13s
CI / e2e (push) Successful in 21s

This commit is contained in:
Simo committed 2026-09-05 15:16:42 +02:00
1 parent f0dcbee162
commit 33b6d1520e
5 files changed
+351 -142

No files matched your search

+142 -135
View File
@@ -39,6 +39,7 @@ import {
} from "@/lib/services/furni-import";
import { getOfficialHabboFurnidata } from "@/lib/services/habbo-furnidata-cache";
import { listFurniture } from "@/lib/services/habbofurni";
import { streamImportResponse } from "@/lib/services/import/core/single-response";
import { rcon } from "@/lib/services/rcon";
import { convertSwfToNitro } from "@/lib/services/swf-to-nitro";
@@ -682,144 +683,150 @@ export const POST = withAdmin(
});
}
// ── Single import ────────────────────────────────────────────────
const {
id,
classname,
name,
description,
type,
revision,
category,
sourceId,
updateExisting,
} = body;
if (!classname || !name) return apiError("Missing classname or name", 400);
let source: Awaited<ReturnType<typeof getSource>> | null = null;
if (sourceId) source = await getSource(sourceId);
const result = await importSingleFurni({
id: id ?? 0,
classname,
name,
description: description ?? "",
type: type ?? "flooritem",
revision: revision ?? 0,
category: category ?? "unknown",
updateExisting: updateExisting === true,
repairExisting: body.repairExisting === true,
sourceSwfBaseUrl: source?.sourceSwfBaseUrl,
nitroBaseUrl: source?.nitroBaseUrl || undefined,
iconBaseUrl: source?.iconBaseUrl || undefined,
});
if (!result.ok) return apiError(result.error ?? "Import failed", 409);
// Refresh game server caches
try {
await rcon.updateCatalog();
await rcon.updateItems();
} catch (err) {
console.warn(
"[import-furni] RCON update failed:",
(err as Error).message,
);
result.warnings.push("RCON cache refresh failed");
}
// Force offer_id to mirror its catalog row id across the tree
const reconciled = await reconcileImportedOfferIds();
// Keep asset + furnidata folders readable by the web server
const ownership = await ensureFurniOwnership();
// Backfill any missing nitros and icons into the emulator's Gamedata bundle
const assetsSynced = await syncAssetsToGamedataBundle();
// Verify the furnidata matches the DB ids
const furniReconcile = await reconcileFurniDataWithItemsBase();
// Fix database consistency (catalog_name + have_offer + cost_credits) after import
const { catalogNameFixed, haveOfferFixed, costCreditsFixed } =
await fixDatabaseConsistencyAfterImport();
// Force every catalog offer_id to equal its own row id
const offerRebuild = await rebuildCatalogOfferIds();
// Repair any sprite_id drift so the client renders the right item
const spriteVerify = await verifyAndFixSpriteIds();
// Verify + fix interaction_modes_count (single consolidated pass)
const verification = await verifyAndFixInteractionModesCount();
// Patch localized furnidata files for this single furni (best effort).
// Same `translate` / `langs` contract as the batch path above.
let languagesPatch: Awaited<
ReturnType<typeof patchLocalizedFurniDataEntries>
> = [];
try {
if (body.translate !== false) {
const langs = Array.isArray(body.langs)
? (body.langs as unknown[]).filter(
(l): l is string => typeof l === "string",
)
: undefined;
languagesPatch = await patchLocalizedFurniDataEntries(
[{ classname, name, description: description ?? "" }],
body.translate === true,
langs,
);
}
} catch (err) {
console.warn(
"[import-furni] Localized patch failed:",
(err as Error).message,
);
}
logAudit({
userId: ctx.session.user.id,
action: "furni_import",
target: "ItemsBase",
targetId: result.itemId ?? 0,
after: {
const runSingle = async () => {
// ── Single import ────────────────────────────────────────────────
const {
id,
classname,
name,
spriteId: id ?? 0,
type: type === "wallitem" ? "i" : "s",
catalogItemId: result.catalogItemId,
dimensions: result.dimensions,
spriteCount: result.spriteCount,
warnings: result.warnings.length > 0 ? result.warnings : undefined,
},
});
description,
type,
revision,
category,
sourceId,
updateExisting,
} = body;
return apiOk({
id: result.itemId,
catalogItemId: result.catalogItemId,
offerIdsFixed: reconciled.fixed,
ownershipFixed: ownership.fixed,
nitrosSynced: assetsSynced.copiedNitros,
iconsSynced: assetsSynced.copiedIcons,
furniDataFixedIds: furniReconcile.fixedIds,
furniDataFixedOfferIds: furniReconcile.fixedOfferIds,
furniDataMissing: furniReconcile.missing,
furniDataFixedConflicts: furniReconcile.fixedConflicts,
furniDataConflicts: furniReconcile.conflicts,
offerRebuildChecked: offerRebuild.checked,
offerRebuildFixed: offerRebuild.fixed,
spriteChecked: spriteVerify.checked,
spriteFixed: spriteVerify.fixed,
interactionFixed: verification.fixed,
catalogNameFixed,
haveOfferFixed,
costCreditsFixed,
languages: languagesPatch,
spriteCount: result.spriteCount,
warnings: result.warnings,
});
if (!classname || !name)
return apiError("Missing classname or name", 400);
let source: Awaited<ReturnType<typeof getSource>> | null = null;
if (sourceId) source = await getSource(sourceId);
const result = await importSingleFurni({
id: id ?? 0,
classname,
name,
description: description ?? "",
type: type ?? "flooritem",
revision: revision ?? 0,
category: category ?? "unknown",
updateExisting: updateExisting === true,
repairExisting: body.repairExisting === true,
sourceSwfBaseUrl: source?.sourceSwfBaseUrl,
nitroBaseUrl: source?.nitroBaseUrl || undefined,
iconBaseUrl: source?.iconBaseUrl || undefined,
});
if (!result.ok) return apiError(result.error ?? "Import failed", 409);
// Refresh game server caches
try {
await rcon.updateCatalog();
await rcon.updateItems();
} catch (err) {
console.warn(
"[import-furni] RCON update failed:",
(err as Error).message,
);
result.warnings.push("RCON cache refresh failed");
}
// Force offer_id to mirror its catalog row id across the tree
const reconciled = await reconcileImportedOfferIds();
// Keep asset + furnidata folders readable by the web server
const ownership = await ensureFurniOwnership();
// Backfill any missing nitros and icons into the emulator's Gamedata bundle
const assetsSynced = await syncAssetsToGamedataBundle();
// Verify the furnidata matches the DB ids
const furniReconcile = await reconcileFurniDataWithItemsBase();
// Fix database consistency (catalog_name + have_offer + cost_credits) after import
const { catalogNameFixed, haveOfferFixed, costCreditsFixed } =
await fixDatabaseConsistencyAfterImport();
// Force every catalog offer_id to equal its own row id
const offerRebuild = await rebuildCatalogOfferIds();
// Repair any sprite_id drift so the client renders the right item
const spriteVerify = await verifyAndFixSpriteIds();
// Verify + fix interaction_modes_count (single consolidated pass)
const verification = await verifyAndFixInteractionModesCount();
// Patch localized furnidata files for this single furni (best effort).
// Same `translate` / `langs` contract as the batch path above.
let languagesPatch: Awaited<
ReturnType<typeof patchLocalizedFurniDataEntries>
> = [];
try {
if (body.translate !== false) {
const langs = Array.isArray(body.langs)
? (body.langs as unknown[]).filter(
(l): l is string => typeof l === "string",
)
: undefined;
languagesPatch = await patchLocalizedFurniDataEntries(
[{ classname, name, description: description ?? "" }],
body.translate === true,
langs,
);
}
} catch (err) {
console.warn(
"[import-furni] Localized patch failed:",
(err as Error).message,
);
}
logAudit({
userId: ctx.session.user.id,
action: "furni_import",
target: "ItemsBase",
targetId: result.itemId ?? 0,
after: {
classname,
name,
spriteId: id ?? 0,
type: type === "wallitem" ? "i" : "s",
catalogItemId: result.catalogItemId,
dimensions: result.dimensions,
spriteCount: result.spriteCount,
warnings: result.warnings.length > 0 ? result.warnings : undefined,
},
});
return apiOk({
id: result.itemId,
catalogItemId: result.catalogItemId,
offerIdsFixed: reconciled.fixed,
ownershipFixed: ownership.fixed,
nitrosSynced: assetsSynced.copiedNitros,
iconsSynced: assetsSynced.copiedIcons,
furniDataFixedIds: furniReconcile.fixedIds,
furniDataFixedOfferIds: furniReconcile.fixedOfferIds,
furniDataMissing: furniReconcile.missing,
furniDataFixedConflicts: furniReconcile.fixedConflicts,
furniDataConflicts: furniReconcile.conflicts,
offerRebuildChecked: offerRebuild.checked,
offerRebuildFixed: offerRebuild.fixed,
spriteChecked: spriteVerify.checked,
spriteFixed: spriteVerify.fixed,
interactionFixed: verification.fixed,
catalogNameFixed,
haveOfferFixed,
costCreditsFixed,
languages: languagesPatch,
spriteCount: result.spriteCount,
warnings: result.warnings,
});
};
return request.headers.get("accept")?.includes("text/event-stream")
? streamImportResponse(runSingle)
: runSingle();
},
);