fix(studio): harden organize-imports against long-running queries and hangs
The original GET route used a correlated NOT EXISTS / FIND_IN_SET subquery over the entire catalog_items table for every recent import audit entry, causing server timeouts when the audit log or catalog grew large. The per-item host-page validation inside the create action also issued one SELECT + one UPDATE per moved offer. Changes: - GET /api/admin/import/organize: replace the correlated subquery with a bounded candidate list and a JS-side placed-set check, then resolve all needed base items in a single indexed SELECT. This bounds the query cost regardless of catalog or audit log size. - organizeImportFurni action: validate mover ids in one SELECT, then batch every move per group into a single UPDATE with a CASE expression instead of one UPDATE per item. - OrganizeImportsDialog: add a 45-second abort timeout on the fetch and a distinct load-error state so the UI never silently hangs. - Add 'loadError' translation key (en + nl).
This commit is contained in:
1 parent
01a85ebcd0
commit
19dc0347df
5 files changed
+181
-66
No files matched your search
+54
-15
@@ -451,9 +451,37 @@ export async function organizeImportFurni(input: {
|
|||||||
.where(inArray(ItemsBase.id, baseIds));
|
.where(inArray(ItemsBase.id, baseIds));
|
||||||
const baseMap = new Map(bases.map((b) => [b.id, b]));
|
const baseMap = new Map(bases.map((b) => [b.id, b]));
|
||||||
|
|
||||||
|
// Verify every offer we're about to move still sits inside the import
|
||||||
|
// tree in ONE query, so we never hijack an unrelated catalog offer.
|
||||||
|
const moverIds = [
|
||||||
|
...new Set(
|
||||||
|
group.items
|
||||||
|
.map((r) => Number(r.catalogItemId))
|
||||||
|
.filter((n) => Number.isFinite(n) && n > 0),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
const validMoveIds = new Set<number>();
|
||||||
|
if (moverIds.length > 0) {
|
||||||
|
const [hostRows] = (await db.execute(sql`
|
||||||
|
SELECT id, page_id FROM catalog_items WHERE id IN (${sql.join(
|
||||||
|
moverIds.map((id) => sql`${id}`),
|
||||||
|
sql`, `,
|
||||||
|
)})
|
||||||
|
`)) as unknown as [Array<{ id: number; page_id: number }>, unknown];
|
||||||
|
for (const row of hostRows) {
|
||||||
|
if (importPageIds.has(Number(row.page_id))) {
|
||||||
|
validMoveIds.add(Number(row.id));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let nextOrder = 1;
|
let nextOrder = 1;
|
||||||
let moved = 0;
|
let moved = 0;
|
||||||
let added = 0;
|
let added = 0;
|
||||||
|
|
||||||
|
const moveCaseOrder: string[] = [];
|
||||||
|
const moveCaseName: string[] = [];
|
||||||
|
const moveIds: number[] = [];
|
||||||
for (const row of group.items) {
|
for (const row of group.items) {
|
||||||
const base = baseMap.get(row.itemId);
|
const base = baseMap.get(row.itemId);
|
||||||
if (!base) continue;
|
if (!base) continue;
|
||||||
@@ -461,23 +489,21 @@ export async function organizeImportFurni(input: {
|
|||||||
base.publicName || base.itemName || String(row.itemId);
|
base.publicName || base.itemName || String(row.itemId);
|
||||||
|
|
||||||
if (row.catalogItemId) {
|
if (row.catalogItemId) {
|
||||||
// Verify the offer currently sits inside the import tree before
|
if (!validMoveIds.has(Number(row.catalogItemId))) {
|
||||||
// moving it, so we never hijack an unrelated catalog offer.
|
// Offer no longer lives in the import tree (moved elsewhere or
|
||||||
const [host] = (await db.execute(sql`
|
// deleted) — skip it instead of duplicating it.
|
||||||
SELECT page_id FROM catalog_items WHERE id = ${row.catalogItemId}
|
|
||||||
`)) as unknown as [Array<{ page_id: number }>, unknown];
|
|
||||||
const hostPageId = Number(host[0]?.page_id ?? -1);
|
|
||||||
if (!importPageIds.has(hostPageId)) {
|
|
||||||
console.error(
|
|
||||||
`[organizeImportFurni] skipping offer #${row.catalogItemId} on non-import page #${hostPageId}`,
|
|
||||||
);
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
await db.execute(sql`
|
moveCaseOrder.push(
|
||||||
UPDATE catalog_items
|
`WHEN ${Number(row.catalogItemId)} THEN ${nextOrder}`,
|
||||||
SET page_id = ${pageId}, order_number = ${nextOrder}, catalog_name = ${catalogName}
|
);
|
||||||
WHERE id = ${row.catalogItemId}
|
moveCaseName.push(
|
||||||
`);
|
`WHEN ${Number(row.catalogItemId)} THEN '${catalogName.replace(
|
||||||
|
/['\\]/g,
|
||||||
|
"\\$&",
|
||||||
|
)}'`,
|
||||||
|
);
|
||||||
|
moveIds.push(Number(row.catalogItemId));
|
||||||
moved++;
|
moved++;
|
||||||
} else {
|
} else {
|
||||||
await insertCatalogItemRow({
|
await insertCatalogItemRow({
|
||||||
@@ -502,6 +528,19 @@ export async function organizeImportFurni(input: {
|
|||||||
nextOrder++;
|
nextOrder++;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (moveIds.length > 0) {
|
||||||
|
await db.execute(sql`
|
||||||
|
UPDATE catalog_items
|
||||||
|
SET page_id = ${pageId},
|
||||||
|
order_number = CASE id ${sql.raw(moveCaseOrder.join(" "))} END,
|
||||||
|
catalog_name = CASE id ${sql.raw(moveCaseName.join(" "))} END
|
||||||
|
WHERE id IN (${sql.join(
|
||||||
|
moveIds.map((id) => sql`${id}`),
|
||||||
|
sql`, `,
|
||||||
|
)})
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
await logStaffActivity({
|
await logStaffActivity({
|
||||||
staffId: staff.id,
|
staffId: staff.id,
|
||||||
action: "catalog_page_create",
|
action: "catalog_page_create",
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import {
|
|||||||
} from "@/features/catalog/server/import-pages";
|
} from "@/features/catalog/server/import-pages";
|
||||||
import { apiOk } from "@/lib/api";
|
import { apiOk } from "@/lib/api";
|
||||||
import { withAdmin } from "@/lib/api-handler";
|
import { withAdmin } from "@/lib/api-handler";
|
||||||
import { db } from "@/lib/db";
|
import { db, ItemsBase } from "@/lib/db";
|
||||||
import { PERMS } from "@/lib/permissions";
|
import { PERMS } from "@/lib/permissions";
|
||||||
|
|
||||||
interface OrganizeItem {
|
interface OrganizeItem {
|
||||||
@@ -21,14 +21,13 @@ interface OrganizeItem {
|
|||||||
alreadyPlaced: boolean;
|
alreadyPlaced: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const columns = sql`
|
/** Parse the base-item ids stored in an offer's item_ids column. */
|
||||||
ib.id AS itemId,
|
function parseItemIds(raw: string): number[] {
|
||||||
ib.item_name AS itemName,
|
return String(raw ?? "")
|
||||||
ib.public_name AS publicName,
|
.split(/[;,]/)
|
||||||
ib.type AS type,
|
.map((s) => Number(s.trim()))
|
||||||
ib.sprite_id AS spriteId,
|
.filter((n) => Number.isFinite(n) && n > 0);
|
||||||
ib.interaction_type AS interactionType
|
}
|
||||||
`;
|
|
||||||
|
|
||||||
export const GET = withAdmin(
|
export const GET = withAdmin(
|
||||||
{ permission: PERMS.CATALOG_VIEW },
|
{ permission: PERMS.CATALOG_VIEW },
|
||||||
@@ -45,32 +44,38 @@ export const GET = withAdmin(
|
|||||||
const since = sinceIso.slice(0, 19).replace("T", " ");
|
const since = sinceIso.slice(0, 19).replace("T", " ");
|
||||||
|
|
||||||
const pageIds = await getImportedCategoryPageIds();
|
const pageIds = await getImportedCategoryPageIds();
|
||||||
const byItemId = new Map<number, OrganizeItem>();
|
|
||||||
|
|
||||||
// 1) Offers already living in the imported-furniture tree.
|
// 1) Offers already living in the imported-furniture tree. No join, so
|
||||||
|
// this stays bounded by the (small) page-id set instead of scanning
|
||||||
|
// the whole catalog or forcing function-based lookups.
|
||||||
|
const placedById = new Map<number, OrganizeItem>();
|
||||||
|
const itemIdsToResolve = new Set<number>();
|
||||||
if (pageIds.length > 0) {
|
if (pageIds.length > 0) {
|
||||||
const [rows] = (await db.execute(sql`
|
const [rows] = (await db.execute(sql`
|
||||||
SELECT ${columns},
|
SELECT ci.id AS catalogItemId,
|
||||||
ci.id AS catalogItemId,
|
|
||||||
ci.page_id AS sourcePageId,
|
ci.page_id AS sourcePageId,
|
||||||
p.caption AS sourcePageCaption
|
ci.item_ids AS itemIds,
|
||||||
|
p.caption AS sourcePageCaption,
|
||||||
|
ci.order_number AS orderNumber
|
||||||
FROM catalog_items ci
|
FROM catalog_items ci
|
||||||
INNER JOIN items_base ib ON ib.id = CAST(ci.item_ids AS UNSIGNED)
|
|
||||||
INNER JOIN catalog_pages p ON p.id = ci.page_id
|
INNER JOIN catalog_pages p ON p.id = ci.page_id
|
||||||
WHERE ci.page_id IN (${sql.join(pageIds, sql`, `)})
|
WHERE ci.page_id IN (${sql.join(pageIds, sql`, `)})
|
||||||
ORDER BY ci.id
|
ORDER BY ci.id
|
||||||
LIMIT ${limit}
|
LIMIT ${limit}
|
||||||
`)) as unknown as [Record<string, unknown>[], unknown];
|
`)) as unknown as [Record<string, unknown>[], unknown];
|
||||||
|
|
||||||
for (const r of rows) {
|
for (const r of rows) {
|
||||||
const itemId = Number(r.itemId);
|
const parsed = parseItemIds(String(r.itemIds ?? ""));
|
||||||
if (!Number.isFinite(itemId)) continue;
|
if (parsed.length === 0) continue;
|
||||||
byItemId.set(itemId, {
|
const id = parsed[0];
|
||||||
itemId,
|
itemIdsToResolve.add(id);
|
||||||
itemName: String(r.itemName ?? ""),
|
placedById.set(id, {
|
||||||
publicName: String(r.publicName ?? ""),
|
itemId: id,
|
||||||
type: String(r.type ?? "s"),
|
itemName: "",
|
||||||
spriteId: Number(r.spriteId) || 0,
|
publicName: String(r.sourcePageCaption ?? ""),
|
||||||
interactionType: String(r.interactionType ?? "default"),
|
type: "s",
|
||||||
|
spriteId: 0,
|
||||||
|
interactionType: "default",
|
||||||
catalogItemId: Number(r.catalogItemId),
|
catalogItemId: Number(r.catalogItemId),
|
||||||
sourcePageId: Number(r.sourcePageId) || null,
|
sourcePageId: Number(r.sourcePageId) || null,
|
||||||
sourcePageCaption: r.sourcePageCaption
|
sourcePageCaption: r.sourcePageCaption
|
||||||
@@ -81,45 +86,104 @@ export const GET = withAdmin(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2) Recently imported furniture that isn't in any catalog page yet.
|
// 2) Recently imported furniture. Bounded candidate list first, matched
|
||||||
const [auditRows] = (await db.execute(sql`
|
// against the import tree in JS — never a correlated FIND_IN_SET scan.
|
||||||
SELECT DISTINCT ${columns},
|
const [candidateRows] = (await db.execute(sql`
|
||||||
NULL AS catalogItemId,
|
SELECT DISTINCT alog.target_id AS itemId
|
||||||
NULL AS sourcePageId,
|
|
||||||
NULL AS sourcePageCaption
|
|
||||||
FROM admin_audit_log alog
|
FROM admin_audit_log alog
|
||||||
INNER JOIN items_base ib ON ib.id = alog.target_id
|
|
||||||
WHERE alog.action = 'furni_import'
|
WHERE alog.action = 'furni_import'
|
||||||
AND alog.target = 'ItemsBase'
|
AND alog.target = 'ItemsBase'
|
||||||
AND alog.target_id IS NOT NULL
|
AND alog.target_id IS NOT NULL
|
||||||
AND alog.created_at >= ${since}
|
AND alog.created_at >= ${since}
|
||||||
AND NOT EXISTS (
|
|
||||||
SELECT 1 FROM catalog_items ci
|
|
||||||
WHERE FIND_IN_SET(ib.id, REPLACE(ci.item_ids, ';', ','))
|
|
||||||
)
|
|
||||||
ORDER BY itemId DESC
|
ORDER BY itemId DESC
|
||||||
LIMIT ${limit}
|
LIMIT 2000
|
||||||
`)) as unknown as [Record<string, unknown>[], unknown];
|
`)) as unknown as [Array<{ itemId: number }>, unknown];
|
||||||
for (const r of auditRows) {
|
|
||||||
|
for (const r of candidateRows) {
|
||||||
const itemId = Number(r.itemId);
|
const itemId = Number(r.itemId);
|
||||||
if (!Number.isFinite(itemId)) continue;
|
if (!Number.isFinite(itemId)) continue;
|
||||||
if (byItemId.has(itemId)) continue;
|
if (placedById.has(itemId)) continue;
|
||||||
byItemId.set(itemId, {
|
if (itemIdsToResolve.size >= limit) break;
|
||||||
|
itemIdsToResolve.add(itemId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3) Resolve base-item metadata in ONE indexed query.
|
||||||
|
const baseMap = new Map<
|
||||||
|
number,
|
||||||
|
{
|
||||||
|
itemName: string;
|
||||||
|
publicName: string;
|
||||||
|
type: string;
|
||||||
|
spriteId: number;
|
||||||
|
interactionType: string;
|
||||||
|
}
|
||||||
|
>();
|
||||||
|
if (itemIdsToResolve.size > 0) {
|
||||||
|
const bases = await db
|
||||||
|
.select({
|
||||||
|
id: ItemsBase.id,
|
||||||
|
itemName: ItemsBase.itemName,
|
||||||
|
publicName: ItemsBase.publicName,
|
||||||
|
type: ItemsBase.type,
|
||||||
|
spriteId: ItemsBase.spriteId,
|
||||||
|
interactionType: ItemsBase.interactionType,
|
||||||
|
})
|
||||||
|
.from(ItemsBase)
|
||||||
|
.where(
|
||||||
|
sql`${ItemsBase.id} IN (${sql.join(
|
||||||
|
[...itemIdsToResolve].map((id) => sql`${id}`),
|
||||||
|
sql`, `,
|
||||||
|
)})`,
|
||||||
|
);
|
||||||
|
for (const b of bases) {
|
||||||
|
baseMap.set(b.id, {
|
||||||
|
itemName: String(b.itemName ?? ""),
|
||||||
|
publicName: String(b.publicName ?? ""),
|
||||||
|
type: String(b.type ?? "s"),
|
||||||
|
spriteId: Number(b.spriteId) || 0,
|
||||||
|
interactionType: String(b.interactionType ?? "default"),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4) Merge: placed offers (with metadata backfilled) first, then new.
|
||||||
|
const items: OrganizeItem[] = [];
|
||||||
|
for (const [id, entry] of placedById) {
|
||||||
|
const meta = baseMap.get(id);
|
||||||
|
items.push({
|
||||||
|
...entry,
|
||||||
|
itemName: meta?.itemName ?? entry.itemName,
|
||||||
|
publicName: meta?.publicName ?? entry.publicName,
|
||||||
|
type: meta?.type ?? entry.type,
|
||||||
|
spriteId: meta?.spriteId ?? entry.spriteId,
|
||||||
|
interactionType: meta?.interactionType ?? entry.interactionType,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let claimed = items.length;
|
||||||
|
for (const r of candidateRows) {
|
||||||
|
if (claimed >= limit) break;
|
||||||
|
const itemId = Number(r.itemId);
|
||||||
|
if (!Number.isFinite(itemId)) continue;
|
||||||
|
if (placedById.has(itemId)) continue;
|
||||||
|
const meta = baseMap.get(itemId);
|
||||||
|
if (!meta) continue;
|
||||||
|
items.push({
|
||||||
itemId,
|
itemId,
|
||||||
itemName: String(r.itemName ?? ""),
|
itemName: meta.itemName,
|
||||||
publicName: String(r.publicName ?? ""),
|
publicName: meta.publicName,
|
||||||
type: String(r.type ?? "s"),
|
type: meta.type,
|
||||||
spriteId: Number(r.spriteId) || 0,
|
spriteId: meta.spriteId,
|
||||||
interactionType: String(r.interactionType ?? "default"),
|
interactionType: meta.interactionType,
|
||||||
catalogItemId: null,
|
catalogItemId: null,
|
||||||
sourcePageId: null,
|
sourcePageId: null,
|
||||||
sourcePageCaption: null,
|
sourcePageCaption: null,
|
||||||
alreadyPlaced: false,
|
alreadyPlaced: false,
|
||||||
});
|
});
|
||||||
|
claimed++;
|
||||||
}
|
}
|
||||||
|
|
||||||
const items = [...byItemId.values()].sort((a, b) => {
|
items.sort((a, b) => {
|
||||||
// Already-placed furniture first, newest item id first within each set.
|
|
||||||
return (
|
return (
|
||||||
Number(b.alreadyPlaced) - Number(a.alreadyPlaced) || b.itemId - a.itemId
|
Number(b.alreadyPlaced) - Number(a.alreadyPlaced) || b.itemId - a.itemId
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -75,6 +75,7 @@ export function OrganizeImportsDialog({
|
|||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [loadError, setLoadError] = useState(false);
|
||||||
const [items, setItems] = useState<OrganizeItem[]>([]);
|
const [items, setItems] = useState<OrganizeItem[]>([]);
|
||||||
const [rootPages, setRootPages] = useState<RootPage[]>([]);
|
const [rootPages, setRootPages] = useState<RootPage[]>([]);
|
||||||
const [importRootPageId, setImportRootPageId] = useState<number | null>(null);
|
const [importRootPageId, setImportRootPageId] = useState<number | null>(null);
|
||||||
@@ -96,10 +97,17 @@ export function OrganizeImportsDialog({
|
|||||||
|
|
||||||
const load = useCallback(async () => {
|
const load = useCallback(async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
setLoadError(false);
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timeout = setTimeout(() => controller.abort(), 45000);
|
||||||
try {
|
try {
|
||||||
const [data, tree] = await Promise.all([
|
const [data, tree] = await Promise.all([
|
||||||
fetch("/api/admin/import/organize").then((r) => r.json()),
|
fetch("/api/admin/import/organize", { signal: controller.signal }).then(
|
||||||
fetch("/api/admin/catalog/tree?parentId=-1").then((r) => r.json()),
|
(r) => r.json(),
|
||||||
|
),
|
||||||
|
fetch("/api/admin/catalog/tree?parentId=-1", {
|
||||||
|
signal: controller.signal,
|
||||||
|
}).then((r) => r.json()),
|
||||||
]);
|
]);
|
||||||
setItems(Array.isArray(data?.items) ? data.items : []);
|
setItems(Array.isArray(data?.items) ? data.items : []);
|
||||||
setImportRootPageId(data?.importRootPageId ?? null);
|
setImportRootPageId(data?.importRootPageId ?? null);
|
||||||
@@ -113,8 +121,10 @@ export function OrganizeImportsDialog({
|
|||||||
);
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("[OrganizeImports] load failed:", error);
|
console.error("[OrganizeImports] load failed:", error);
|
||||||
toast.error("Failed to load imports");
|
setLoadError(true);
|
||||||
|
setItems([]);
|
||||||
} finally {
|
} finally {
|
||||||
|
clearTimeout(timeout);
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
@@ -347,7 +357,7 @@ export function OrganizeImportsDialog({
|
|||||||
</div>
|
</div>
|
||||||
) : items.length === 0 ? (
|
) : items.length === 0 ? (
|
||||||
<div className="flex flex-1 flex-col items-center justify-center gap-3 text-sm text-muted-foreground">
|
<div className="flex flex-1 flex-col items-center justify-center gap-3 text-sm text-muted-foreground">
|
||||||
{t("empty")}
|
{loadError ? t("loadError") : t("empty")}
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
|
|||||||
@@ -2204,6 +2204,7 @@
|
|||||||
"itemsBadge": "{count} items",
|
"itemsBadge": "{count} items",
|
||||||
"loading": "Loading imported furniture…",
|
"loading": "Loading imported furniture…",
|
||||||
"empty": "No imported furniture found. Import furniture first.",
|
"empty": "No imported furniture found. Import furniture first.",
|
||||||
|
"loadError": "Could not load imports. Try again.",
|
||||||
"refresh": "Refresh",
|
"refresh": "Refresh",
|
||||||
"desc": "Imported furniture is grouped into suggested pages. Review each group, then create the pages you approve.",
|
"desc": "Imported furniture is grouped into suggested pages. Review each group, then create the pages you approve.",
|
||||||
"selectAll": "Select all",
|
"selectAll": "Select all",
|
||||||
|
|||||||
@@ -1972,6 +1972,7 @@
|
|||||||
"itemsBadge": "{count} items",
|
"itemsBadge": "{count} items",
|
||||||
"loading": "Geïmporteerde meubels laden…",
|
"loading": "Geïmporteerde meubels laden…",
|
||||||
"empty": "Geen geïmporteerde meubels gevonden. Importeer eerst meubels.",
|
"empty": "Geen geïmporteerde meubels gevonden. Importeer eerst meubels.",
|
||||||
|
"loadError": "Kon imports niet laden. Probeer het opnieuw.",
|
||||||
"refresh": "Vernieuwen",
|
"refresh": "Vernieuwen",
|
||||||
"desc": "Geïmporteerde meubels worden gegroepeerd in voorgestelde pagina's. Bekijk elke groep en maak alleen de pagina's aan die je goedkeurt.",
|
"desc": "Geïmporteerde meubels worden gegroepeerd in voorgestelde pagina's. Bekijk elke groep en maak alleen de pagina's aan die je goedkeurt.",
|
||||||
"selectAll": "Alles selecteren",
|
"selectAll": "Alles selecteren",
|
||||||
|
|||||||
Reference in new issue
Block a user