import { withAdmin } from "@/lib/api-handler"; import { apiError } from "@/lib/api-response"; import { PERMS } from "@/lib/permissions"; import { logAudit } from "@/lib/services/audit"; import { appendFurniEntriesBatch } from "@/lib/services/furni-data"; import { ensureDirectories, importSingleFurni, } from "@/lib/services/furni-import"; import { rcon } from "@/lib/services/rcon"; import type { ImportSingleResult } from "@/types/furni"; interface BatchItem { id: number; classname: string; name: string; description: string; type: string; revision: number; category: string; } /** * SSE batch import endpoint. * Processes multiple furniture items with configurable concurrency, * streaming real-time progress events to the client. */ export const POST = withAdmin( { permission: PERMS.ASSETS_IMPORT }, async (request, ctx) => { const body = await request.json(); const rawItems: BatchItem[] = body.items || []; const concurrency = Math.min(Math.max(body.concurrency || 3, 1), 5); if (rawItems.length === 0) return apiError("No items to import", 400); // Deduplicate by (spriteId, classname) to avoid concurrent INSERT races on // items_base.id when the same item is listed twice in the request body. const seen = new Set(); const items: BatchItem[] = []; for (const it of rawItems) { const key = `${it.id}:${it.classname}`; if (seen.has(key)) continue; seen.add(key); items.push(it); } await ensureDirectories(); const encoder = new TextEncoder(); const stream = new ReadableStream({ async start(controller) { const send = (data: unknown) => { try { controller.enqueue( encoder.encode(`data: ${JSON.stringify(data)}\n\n`), ); } catch { /* stream closed by client */ } }; const startTime = Date.now(); send({ type: "batch_start", total: items.length, concurrency }); let succeeded = 0; let failed = 0; let withWarnings = 0; const furniDataEntries: Array<{ entry: Record; itemType: string; }> = []; // Process in chunks of `concurrency` for (let i = 0; i < items.length; i += concurrency) { const chunk = items.slice(i, i + concurrency); const promises = chunk.map(async (item, chunkIdx) => { const index = i + chunkIdx; send({ type: "item_progress", classname: item.classname, status: "started", index, }); try { const result: ImportSingleResult = await importSingleFurni({ id: item.id ?? 0, classname: item.classname, name: item.name, description: item.description ?? "", type: item.type ?? "flooritem", revision: item.revision ?? 0, category: item.category ?? "unknown", skipFurniDataWrite: true, onProgress: (status: string) => { send({ type: "item_progress", classname: item.classname, status, index, }); }, }); if (result.ok) { succeeded++; if (result.warnings.length > 0) withWarnings++; if (result.furniDataEntry) furniDataEntries.push(result.furniDataEntry); send({ type: "item_progress", classname: item.classname, status: "done", index, itemId: result.itemId, warnings: result.warnings.length > 0 ? result.warnings : undefined, }); logAudit({ userId: ctx.session.user.id, action: "furni_import", target: "ItemsBase", targetId: result.itemId!, after: { classname: item.classname, name: item.name, type: item.type === "wallitem" ? "i" : "s", catalogItemId: result.catalogItemId, dimensions: result.dimensions, batch: true, }, }); } else { failed++; send({ type: "item_progress", classname: item.classname, status: "failed", index, message: result.error, }); } } catch (err) { failed++; send({ type: "item_progress", classname: item.classname, status: "failed", index, message: (err as Error).message, }); } }); await Promise.allSettled(promises); } // Batch write FurnitureData.json once at the end — BEFORE RCON so the // emulator never sees items that the Nitro client can't render. let furniDataWriteOk = true; if (furniDataEntries.length > 0) { try { await appendFurniEntriesBatch(furniDataEntries); } catch (err) { furniDataWriteOk = false; send({ type: "item_progress", classname: "_batch_", status: "failed", index: -1, message: `FurnitureData.json batch write failed: ${(err as Error).message}`, }); } } // Refresh game server caches once — skip if FurnitureData write failed // to avoid an inconsistent state (emulator sees items, client doesn't). if (furniDataWriteOk) { try { const okCat = await rcon.updateCatalog(); const okItems = await rcon.updateItems(); if (!okCat || !okItems) { send({ type: "item_progress", classname: "_rcon_", status: "failed", index: -1, message: "RCON refresh failed — emulator cache may be stale", }); } } catch (err) { console.warn( "[import-furni] RCON update failed after batch:", (err as Error).message, ); } } send({ type: "batch_complete", succeeded, failed, warnings: withWarnings, duration: Date.now() - startTime, }); controller.close(); }, }); return new Response(stream, { headers: { "Content-Type": "text/event-stream", "Cache-Control": "no-cache", Connection: "keep-alive", }, }); }, );