Organising imports, the Studio furni batch, the catalog totals and the "import from a source" stats all used to need a full page reload, or at best a router.refresh() that re-rendered the whole admin route, before anything on screen reflected what the import had just written. - live-catalog-merge.ts (new): pure tree and total arithmetic. Applies a delta of created pages, added offers and moved offers, recomputes depth for the touched subtree, bumps parent child counts and the item totals. Returns the input untouched when a delta is empty, so subscribers can bail out instead of re-rendering. Depth resolution tolerates a parent cycle in a dirty DB and still terminates, matching getTreeFlat. - use-live-catalog.ts (new): one module-level store exposed through useSyncExternalStore, so every consumer shares a single instance without threading a provider through the admin layout. Deltas only apply to the "normal" catalog, so public and public_handlers trees stay separate. seedCatalogTotals() takes the first server value per mode and never overwrites it afterwards, so a later hard render cannot make the header totals jump backwards. - actions/catalog.ts: organizeImportFurni now reports each group through the new OrganizedPageChange, carrying parentId, pageLayout, the icon, isNew and the per-source movedFrom counts, so the client can fold the result into the tree without reading the page back. - organize-imports-dialog.tsx: drops useRouter and router.refresh(); the response is applied as a delta the moment the run finishes. - studio-client.tsx: reads the tree from the store instead of freezing it with useState(initialTree), loads it on mount when empty, and refreshes it once a batch import settles. The batch is server-side and derives its import pages from furnidata, so that one path re-reads the tree via GET /api/admin/catalog/tree?mode=full rather than trusting the delta. - studio/furni/page.tsx: stops calling getTreeFlat() and no longer passes initialTree; the store is the single source of truth for the rail. - import-clone-client.tsx: tracks which items are already present, so present and clonable update per cloned row instead of only at the end. - catalog-manager-dialog.tsx: seeds the totals once and renders the live values, so the header reflects an import that just ran. - e2e/ui/fixtures/entry.tsx: drops the removed initialTree prop.
650 lines
18 KiB
TypeScript
650 lines
18 KiB
TypeScript
"use server";
|
|
|
|
import { inArray, sql } from "drizzle-orm";
|
|
import { revalidatePath } from "next/cache";
|
|
import { insertCatalogItemRow } from "@/actions/catalog-items";
|
|
import { catalogFailure } from "@/features/catalog/server/errors";
|
|
import {
|
|
createPageCommand,
|
|
deletePageCommand,
|
|
reorderPagesCommand,
|
|
togglePageCommand,
|
|
updatePageCommand,
|
|
} from "@/features/catalog/server/page-commands";
|
|
import { sendCatalogUpdate } from "@/features/catalog/server/sync-status";
|
|
import { requirePermission } from "@/lib/admin/guard";
|
|
import { db, ItemsBase, queryRows } from "@/lib/db";
|
|
import { PERMS } from "@/lib/permissions";
|
|
import type { ActionResult } from "@/lib/safe-action-shared";
|
|
import { withCatalogExport } from "@/lib/services/catalog-git-queue";
|
|
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,
|
|
expected,
|
|
...fields
|
|
}: { id: number; expected?: Record<string, unknown> } & Record<
|
|
string,
|
|
unknown
|
|
>): Promise<ActionResult> {
|
|
const staff = await requirePermission(PERMS.CATALOG_EDIT);
|
|
return await withCatalogExport(async () => {
|
|
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);
|
|
}
|
|
try {
|
|
await updatePageCommand("normal", id, data, expected, staff.id);
|
|
} catch (error) {
|
|
return { ok: false as const, error: catalogFailure(error).message };
|
|
}
|
|
await sendCatalogUpdate();
|
|
await logStaffActivity({
|
|
staffId: staff.id,
|
|
action: "catalog_page_update",
|
|
description: `Updated catalog page #${id}`,
|
|
targetType: "catalog_page",
|
|
targetId: id,
|
|
});
|
|
revalidatePath("/admin/catalog");
|
|
return { ok: true as const, data: {} };
|
|
});
|
|
}
|
|
|
|
export async function deleteCatalogPage({ id }: { id: number }) {
|
|
const staff = await requirePermission(PERMS.CATALOG_EDIT);
|
|
return await withCatalogExport(async () => {
|
|
await deletePageCommand("normal", id, "reparent");
|
|
await sendCatalogUpdate();
|
|
await logStaffActivity({
|
|
staffId: staff.id,
|
|
action: "catalog_page_delete",
|
|
description: `Deleted catalog page #${id}`,
|
|
targetType: "catalog_page",
|
|
targetId: id,
|
|
});
|
|
revalidatePath("/admin/catalog");
|
|
return { ok: true as const, data: {} };
|
|
});
|
|
}
|
|
|
|
export async function toggleCatalogPage({
|
|
id,
|
|
action,
|
|
}: {
|
|
id: number;
|
|
action: "toggleEnabled" | "toggleVisible";
|
|
}) {
|
|
const staff = await requirePermission(PERMS.CATALOG_EDIT);
|
|
return await withCatalogExport(async () => {
|
|
await togglePageCommand(
|
|
"normal",
|
|
id,
|
|
action === "toggleEnabled" ? "enabled" : "visible",
|
|
staff.id,
|
|
);
|
|
await sendCatalogUpdate();
|
|
revalidatePath("/admin/catalog");
|
|
return { ok: true as const, data: {} };
|
|
});
|
|
}
|
|
|
|
export async function createCatalogPage(input: {
|
|
caption: string;
|
|
parentId: number;
|
|
pageLayout?: string;
|
|
iconImage?: number;
|
|
iconColor?: number;
|
|
enabled?: "0" | "1";
|
|
visible?: "0" | "1";
|
|
minRank?: number;
|
|
orderNum?: number;
|
|
}): Promise<ActionResult<{ id: number }>> {
|
|
const staff = await requirePermission(PERMS.CATALOG_EDIT);
|
|
return await withCatalogExport(async () => {
|
|
const createdId = await createPageCommand("normal", {
|
|
caption: input.caption,
|
|
parentId: input.parentId,
|
|
pageLayout: input.pageLayout ?? "default_3x3",
|
|
captionSave: input.caption.slice(0, 25),
|
|
iconColor: input.iconColor ?? 0,
|
|
iconImage: input.iconImage ?? 0,
|
|
minRank: input.minRank ?? 1,
|
|
orderNum: input.orderNum ?? 0,
|
|
visible: input.visible ?? "1",
|
|
enabled: input.enabled ?? "1",
|
|
clubOnly: "0",
|
|
vipOnly: "0",
|
|
pageHeadline: "",
|
|
pageTeaser: "",
|
|
includes: "",
|
|
});
|
|
await sendCatalogUpdate();
|
|
await logStaffActivity({
|
|
staffId: staff.id,
|
|
action: "catalog_page_create",
|
|
description: `Created catalog page "${input.caption}"`,
|
|
targetType: "catalog_page",
|
|
targetId: createdId,
|
|
});
|
|
revalidatePath("/admin/catalog");
|
|
return { ok: true as const, data: { id: createdId } };
|
|
});
|
|
}
|
|
|
|
export async function reorderTreePage(input: {
|
|
pageId: number;
|
|
newParentId?: number;
|
|
newOrderNum: number;
|
|
}) {
|
|
await requirePermission(PERMS.CATALOG_EDIT);
|
|
return await withCatalogExport(async () => {
|
|
await updatePageCommand("normal", input.pageId, {
|
|
parentId: input.newParentId,
|
|
orderNum: input.newOrderNum,
|
|
});
|
|
await sendCatalogUpdate();
|
|
revalidatePath("/admin/catalog");
|
|
return { ok: true as const, data: {} };
|
|
});
|
|
}
|
|
|
|
export async function deleteTreePage(input: {
|
|
pageId: number;
|
|
mode: "reparent" | "cascade";
|
|
}) {
|
|
await requirePermission(PERMS.CATALOG_EDIT);
|
|
return await withCatalogExport(async () => {
|
|
await deletePageCommand("normal", input.pageId, input.mode);
|
|
await sendCatalogUpdate();
|
|
revalidatePath("/admin/catalog");
|
|
return { ok: true as const, data: {} };
|
|
});
|
|
}
|
|
|
|
export async function reorderCatalogPages(input: {
|
|
parentId: number;
|
|
ids: number[];
|
|
expectedIds: number[];
|
|
}) {
|
|
await requirePermission(PERMS.CATALOG_EDIT);
|
|
return withCatalogExport(async () => {
|
|
await reorderPagesCommand("normal", input);
|
|
await sendCatalogUpdate();
|
|
revalidatePath("/admin/catalog");
|
|
return { ok: true as const, data: {} };
|
|
});
|
|
}
|
|
|
|
export type CatalogKind = "normal" | "bc";
|
|
|
|
export async function createAutoCategory(input: {
|
|
kind: CatalogKind;
|
|
parentId: number;
|
|
caption: string;
|
|
pageLayout?: string;
|
|
iconImage?: number;
|
|
enabled?: "0" | "1";
|
|
visible?: "0" | "1";
|
|
minRank?: number;
|
|
offerRows?: Array<{
|
|
baseId: number;
|
|
credits?: number;
|
|
points?: number;
|
|
pointsType?: number;
|
|
amount?: number;
|
|
}>;
|
|
}): Promise<ActionResult<{ pageId: number }>> {
|
|
const staff = await requirePermission(PERMS.CATALOG_EDIT);
|
|
return await withCatalogExport(async () => {
|
|
const createResult =
|
|
input.kind === "bc"
|
|
? await createPageCommand("bc", {
|
|
caption: input.caption,
|
|
parentId: input.parentId,
|
|
pageLayout: input.pageLayout ?? "default_3x3",
|
|
captionSave: input.caption.slice(0, 25),
|
|
iconImage: input.iconImage ?? 0,
|
|
iconColor: 0,
|
|
minRank: input.minRank ?? 1,
|
|
orderNum: 0,
|
|
visible: input.visible ?? "1",
|
|
enabled: input.enabled ?? "1",
|
|
clubOnly: "0",
|
|
vipOnly: "0",
|
|
pageHeadline: "",
|
|
pageTeaser: "",
|
|
includes: "",
|
|
})
|
|
: await createPageCommand("normal", {
|
|
caption: input.caption,
|
|
parentId: input.parentId,
|
|
pageLayout: input.pageLayout ?? "default_3x3",
|
|
captionSave: input.caption.slice(0, 25),
|
|
iconImage: input.iconImage ?? 0,
|
|
iconColor: 0,
|
|
minRank: input.minRank ?? 1,
|
|
orderNum: 0,
|
|
visible: input.visible ?? "1",
|
|
enabled: input.enabled ?? "1",
|
|
clubOnly: "0",
|
|
vipOnly: "0",
|
|
pageHeadline: "",
|
|
pageTeaser: "",
|
|
includes: "",
|
|
});
|
|
|
|
const pageId = Number(createResult);
|
|
|
|
// Create the new page's offers in the same export run (single snapshot).
|
|
if (input.kind === "normal" && (input.offerRows?.length ?? 0) > 0) {
|
|
if ((input.offerRows?.length ?? 0) > 500) {
|
|
return { ok: false as const, error: "Max 500 items per auto category" };
|
|
}
|
|
|
|
const rows = input.offerRows ?? [];
|
|
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]));
|
|
|
|
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;
|
|
|
|
let created = 0;
|
|
for (const row of rows) {
|
|
const base = baseMap.get(row.baseId);
|
|
if (!base) 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: row.amount ?? 1,
|
|
limitedSells: 0,
|
|
limitedStack: 0,
|
|
orderNumber: nextOrder++,
|
|
offerId: -1,
|
|
songId: 0,
|
|
haveOffer: "1",
|
|
clubOnly: "0",
|
|
extradata: "",
|
|
});
|
|
created++;
|
|
} catch (error) {
|
|
console.error(
|
|
`[createAutoCategory] failed to insert item #${row.baseId}:`,
|
|
error,
|
|
);
|
|
}
|
|
}
|
|
|
|
await logStaffActivity({
|
|
staffId: staff.id,
|
|
action: "catalog_items_bulk_create",
|
|
description: `Auto-created ${created} catalog item(s) on page #${pageId}`,
|
|
targetType: "catalog_page",
|
|
targetId: pageId,
|
|
});
|
|
}
|
|
|
|
await sendCatalogUpdate();
|
|
await logStaffActivity({
|
|
staffId: staff.id,
|
|
action: "catalog_page_create",
|
|
description: `Created catalog page #${pageId} "${input.caption}" (${input.kind})`,
|
|
targetType: "catalog_page",
|
|
targetId: pageId,
|
|
});
|
|
revalidatePath("/admin/catalog");
|
|
return { ok: true as const, data: { pageId } };
|
|
});
|
|
}
|
|
|
|
const MAX_ORGANIZE_GROUPS = 50;
|
|
const MAX_ORGANIZE_ITEMS = 500;
|
|
|
|
/**
|
|
* One page the run wrote to, described well enough for the client to fold the
|
|
* result into its live catalog tree. `isNew` is false when the group reused an
|
|
* existing destination, in which case the page already exists and every field
|
|
* that describes a *new* page (`parentId`, `pageLayout`, the icon) is only the
|
|
* requested value, not a verified read-back of the stored row.
|
|
*/
|
|
export interface OrganizedPageChange {
|
|
pageId: number;
|
|
caption: string;
|
|
parentId: number;
|
|
pageLayout: string;
|
|
iconImage: number;
|
|
iconColor: number;
|
|
orderNum: number;
|
|
visible: string;
|
|
enabled: string;
|
|
isNew: boolean;
|
|
moved: number;
|
|
added: number;
|
|
/** Import pages the offers were taken from, so counts can be rebalanced. */
|
|
movedFrom: Array<{ fromPageId: number; count: number }>;
|
|
}
|
|
|
|
export interface OrganizeImportGroup {
|
|
caption: string;
|
|
pageLayout: string;
|
|
iconImage: number;
|
|
/** When set, items are moved into this existing page instead of creating one. */
|
|
destinationPageId?: number | null;
|
|
items: Array<{
|
|
itemId: number;
|
|
catalogItemId: number | null;
|
|
credits?: number;
|
|
points?: number;
|
|
pointsType?: number;
|
|
amount?: number;
|
|
}>;
|
|
}
|
|
|
|
/**
|
|
* Turn approved import suggestions into real catalog pages in one export
|
|
* snapshot. Offers that already exist inside the imported-furniture tree are
|
|
* moved to the freshly created page; brand-new furniture gets a new offer.
|
|
* Returns per-page move/add counts so the UI can summarize what happened.
|
|
*/
|
|
export async function organizeImportFurni(input: {
|
|
parentId: number;
|
|
groups: OrganizeImportGroup[];
|
|
}): Promise<
|
|
ActionResult<{
|
|
created: OrganizedPageChange[];
|
|
}>
|
|
> {
|
|
const staff = await requirePermission(PERMS.CATALOG_EDIT);
|
|
|
|
const groups = input.groups.filter((g) => (g.items?.length ?? 0) > 0);
|
|
if (groups.length === 0) {
|
|
return { ok: false as const, error: "No groups selected" };
|
|
}
|
|
if (groups.length > MAX_ORGANIZE_GROUPS) {
|
|
return {
|
|
ok: false as const,
|
|
error: `Max ${MAX_ORGANIZE_GROUPS} groups per run`,
|
|
};
|
|
}
|
|
const totalItems = groups.reduce((sum, g) => sum + g.items.length, 0);
|
|
if (totalItems > MAX_ORGANIZE_ITEMS) {
|
|
return {
|
|
ok: false as const,
|
|
error: `Max ${MAX_ORGANIZE_ITEMS} items per run`,
|
|
};
|
|
}
|
|
|
|
return await withCatalogExport(async () => {
|
|
const { getImportedCategoryPageIds } = await import(
|
|
"@/features/catalog/server/import-pages"
|
|
);
|
|
const importPageIds = new Set(await getImportedCategoryPageIds());
|
|
|
|
const created: OrganizedPageChange[] = [];
|
|
let totalMoved = 0;
|
|
let totalAdded = 0;
|
|
|
|
for (const group of groups) {
|
|
// Either create a fresh page or reuse an existing destination.
|
|
const destinationPageId = group.destinationPageId
|
|
? Number(group.destinationPageId)
|
|
: null;
|
|
if (destinationPageId != null && !Number.isFinite(destinationPageId)) {
|
|
return {
|
|
ok: false as const,
|
|
error: "Invalid destination page id",
|
|
};
|
|
}
|
|
const isNew = destinationPageId == null;
|
|
const pageLayout = group.pageLayout || "default_3x3";
|
|
const iconImage = group.iconImage ?? 0;
|
|
const pageId =
|
|
destinationPageId ??
|
|
Number(
|
|
await createPageCommand("normal", {
|
|
caption: group.caption,
|
|
parentId: input.parentId,
|
|
pageLayout,
|
|
captionSave: group.caption.slice(0, 25),
|
|
iconImage,
|
|
iconColor: 0,
|
|
minRank: 1,
|
|
orderNum: 0,
|
|
visible: "1",
|
|
enabled: "1",
|
|
clubOnly: "0",
|
|
vipOnly: "0",
|
|
pageHeadline: "",
|
|
pageTeaser: "",
|
|
includes: "",
|
|
}),
|
|
);
|
|
let destinationCaption: string | null = null;
|
|
if (destinationPageId) {
|
|
const captionRows = await queryRows<{ caption: string }>(sql`
|
|
SELECT caption FROM catalog_pages WHERE id = ${destinationPageId}
|
|
`);
|
|
destinationCaption = captionRows[0]?.caption ?? null;
|
|
}
|
|
const caption =
|
|
destinationCaption ?? group.caption ?? `Category ${pageId}`;
|
|
|
|
// Existing destinations append after their current offers; freshly
|
|
// created pages start at order 1.
|
|
const maxOrder = await queryRows<{ maxOrder: number }>(sql`
|
|
SELECT COALESCE(MAX(order_number), 0) AS maxOrder
|
|
FROM catalog_items WHERE page_id = ${pageId}
|
|
`);
|
|
let nextOrder = Number(maxOrder[0]?.maxOrder ?? 0) + 1;
|
|
|
|
const baseIds = [...new Set(group.items.map((r) => r.itemId))];
|
|
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]));
|
|
|
|
// 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>();
|
|
const hostPageByItemId = new Map<number, number>();
|
|
if (moverIds.length > 0) {
|
|
const hostRows = await queryRows<{ id: number; page_id: number }>(sql`
|
|
SELECT id, page_id FROM catalog_items WHERE id IN (${sql.join(
|
|
moverIds.map((id) => sql`${id}`),
|
|
sql`, `,
|
|
)})
|
|
`);
|
|
for (const row of hostRows) {
|
|
const pageId = Number(row.page_id);
|
|
if (importPageIds.has(pageId)) {
|
|
validMoveIds.add(Number(row.id));
|
|
hostPageByItemId.set(Number(row.id), pageId);
|
|
}
|
|
}
|
|
}
|
|
|
|
let moved = 0;
|
|
let added = 0;
|
|
const movedFromCounts = new Map<number, number>();
|
|
|
|
const moveCaseOrder: string[] = [];
|
|
const moveCaseName: string[] = [];
|
|
const moveIds: number[] = [];
|
|
for (const row of group.items) {
|
|
const base = baseMap.get(row.itemId);
|
|
if (!base) continue;
|
|
const catalogName =
|
|
base.publicName || base.itemName || String(row.itemId);
|
|
|
|
if (row.catalogItemId) {
|
|
if (!validMoveIds.has(Number(row.catalogItemId))) {
|
|
// Offer no longer lives in the import tree (moved elsewhere or
|
|
// deleted) — skip it instead of duplicating it.
|
|
continue;
|
|
}
|
|
moveCaseOrder.push(
|
|
`WHEN ${Number(row.catalogItemId)} THEN ${nextOrder}`,
|
|
);
|
|
moveCaseName.push(
|
|
`WHEN ${Number(row.catalogItemId)} THEN '${catalogName.replace(
|
|
/['\\]/g,
|
|
"\\$&",
|
|
)}'`,
|
|
);
|
|
moveIds.push(Number(row.catalogItemId));
|
|
const hostPageId = hostPageByItemId.get(Number(row.catalogItemId));
|
|
if (hostPageId != null) {
|
|
movedFromCounts.set(
|
|
hostPageId,
|
|
(movedFromCounts.get(hostPageId) ?? 0) + 1,
|
|
);
|
|
}
|
|
moved++;
|
|
} else {
|
|
await insertCatalogItemRow({
|
|
pageId,
|
|
itemIds: String(row.itemId),
|
|
catalogName,
|
|
costCredits: row.credits ?? 0,
|
|
costPoints: row.points ?? 0,
|
|
pointsType: row.pointsType ?? 0,
|
|
amount: row.amount ?? 1,
|
|
limitedSells: 0,
|
|
limitedStack: 0,
|
|
orderNumber: nextOrder,
|
|
offerId: -1,
|
|
songId: 0,
|
|
haveOffer: "1",
|
|
clubOnly: "0",
|
|
extradata: "",
|
|
});
|
|
added++;
|
|
}
|
|
nextOrder++;
|
|
}
|
|
|
|
if (moveIds.length > 0) {
|
|
// Preserve existing offer names when moving into an existing
|
|
// destination; freshly created pages get the base item's name.
|
|
const nameUpdate =
|
|
destinationPageId == null
|
|
? sql`, catalog_name = CASE id ${sql.raw(moveCaseName.join(" "))} END`
|
|
: sql``;
|
|
await db.execute(sql`
|
|
UPDATE catalog_items
|
|
SET page_id = ${pageId},
|
|
order_number = CASE id ${sql.raw(moveCaseOrder.join(" "))} END
|
|
${nameUpdate}
|
|
WHERE id IN (${sql.join(
|
|
moveIds.map((id) => sql`${id}`),
|
|
sql`, `,
|
|
)})
|
|
`);
|
|
}
|
|
|
|
await logStaffActivity({
|
|
staffId: staff.id,
|
|
action: destinationPageId
|
|
? "catalog_items_bulk_update"
|
|
: "catalog_page_create",
|
|
description: `Organized import into page #${pageId} "${caption}" (${moved} moved, ${added} added)`,
|
|
targetType: "catalog_page",
|
|
targetId: pageId,
|
|
});
|
|
created.push({
|
|
pageId,
|
|
caption,
|
|
parentId: input.parentId,
|
|
pageLayout,
|
|
iconImage,
|
|
iconColor: 0,
|
|
orderNum: 0,
|
|
visible: "1",
|
|
enabled: "1",
|
|
isNew,
|
|
moved,
|
|
added,
|
|
movedFrom: [...movedFromCounts].map(([fromPageId, count]) => ({
|
|
fromPageId,
|
|
count,
|
|
})),
|
|
});
|
|
totalMoved += moved;
|
|
totalAdded += added;
|
|
}
|
|
|
|
await sendCatalogUpdate();
|
|
await logStaffActivity({
|
|
staffId: staff.id,
|
|
action: "catalog_import_organize",
|
|
description: `Organized ${created.length} import page(s): ${totalMoved} offer(s) moved, ${totalAdded} offer(s) added`,
|
|
targetType: "catalog",
|
|
});
|
|
revalidatePath("/admin/catalog");
|
|
return { ok: true as const, data: { created } };
|
|
});
|
|
}
|