Files
Epicnabbo-Catalogus-Updated…/src/features/housekeeping/domains/economy/services/mutation-runtime-catalog.ts
T

1068 lines
32 KiB
TypeScript

import "server-only";
import { eq, inArray, like, or, sql } from "drizzle-orm";
import type { ResultSetHeader } from "mysql2";
import {
CatalogItems,
CatalogItemsBc,
CatalogPages,
CatalogPagesBc,
db,
ItemsBase,
} from "@/lib/db";
import { allocateCatalogItemId } from "@/lib/services/furni-import";
import { rcon } from "@/lib/services/rcon";
import {
action,
flag,
hasOwn,
integer,
integerArray,
notFound,
positiveInteger,
rawText,
record,
requirePatch,
safeOutput,
text,
validation,
} from "./mutation-runtime-input";
import type {
EconomyMutationContext,
EconomyMutationOperation,
EconomyMutationSnapshot,
} from "./mutations";
import { EconomyCommittedExternalFailure } from "./mutations-production";
type EconomyDatabase = typeof db;
async function synchronizeCatalog(
snapshot: EconomyMutationSnapshot,
): Promise<EconomyMutationSnapshot> {
try {
const delivered = await rcon.updateCatalog();
if (!delivered) throw new Error("catalog sync rejected");
return snapshot;
} catch {
throw new EconomyCommittedExternalFailure(snapshot);
}
}
function pageValues(data: Record<string, unknown>, builderClub: boolean) {
const values: Record<string, unknown> = {};
if (hasOwn(data, "caption")) values.caption = text(data.caption, 128, true);
if (hasOwn(data, "parentId")) values.parentId = integer(data.parentId, -1);
if (hasOwn(data, "pageLayout"))
values.pageLayout = text(data.pageLayout, 26, true);
if (hasOwn(data, "enabled")) values.enabled = flag(data.enabled);
if (hasOwn(data, "visible")) values.visible = flag(data.visible);
if (hasOwn(data, "orderNum")) values.orderNum = integer(data.orderNum);
if (hasOwn(data, "iconImage")) values.iconImage = integer(data.iconImage);
if (hasOwn(data, "iconColor")) values.iconColor = integer(data.iconColor);
if (hasOwn(data, "pageHeadline"))
values.pageHeadline = text(data.pageHeadline, 1024);
if (hasOwn(data, "pageTeaser")) values.pageTeaser = text(data.pageTeaser, 64);
if (hasOwn(data, "pageSpecial"))
values.pageSpecial = text(data.pageSpecial, 2048);
if (hasOwn(data, "pageText1")) values.pageText1 = rawText(data.pageText1);
if (hasOwn(data, "pageText2")) values.pageText2 = rawText(data.pageText2);
if (hasOwn(data, "pageTextDetails"))
values.pageTextDetails = rawText(data.pageTextDetails);
if (hasOwn(data, "pageTextTeaser"))
values.pageTextTeaser = rawText(data.pageTextTeaser);
if (!builderClub) {
if (hasOwn(data, "minRank")) values.minRank = integer(data.minRank, 1);
if (hasOwn(data, "clubOnly")) values.clubOnly = flag(data.clubOnly);
if (hasOwn(data, "vipOnly")) values.vipOnly = flag(data.vipOnly);
if (hasOwn(data, "includes")) values.includes = text(data.includes, 128);
if (hasOwn(data, "captionSave"))
values.captionSave = text(data.captionSave, 25);
}
return values;
}
async function catalogPageChange(
input: unknown,
): Promise<EconomyMutationSnapshot> {
const data = record(input);
const selectedAction = action(data, [
"create",
"update",
"delete",
"toggle-enabled",
"toggle-visible",
]);
const snapshot = await db.transaction(async (transaction) => {
if (selectedAction === "create") {
const caption = text(data.caption, 128, true);
const values = pageValues(data, false);
const [result] = await transaction.insert(CatalogPages).values({
caption,
captionSave: text(data.captionSave ?? caption, 25, true),
parentId: integer(data.parentId ?? -1, -1),
pageLayout: text(data.pageLayout ?? "default_3x3", 26, true),
iconColor: integer(data.iconColor ?? 0),
iconImage: integer(data.iconImage ?? 0),
minRank: integer(data.minRank ?? 1, 1),
orderNum: integer(data.orderNum ?? 0),
visible: hasOwn(data, "visible") ? flag(data.visible) : "1",
enabled: hasOwn(data, "enabled") ? flag(data.enabled) : "1",
clubOnly: hasOwn(data, "clubOnly") ? flag(data.clubOnly) : "0",
vipOnly: hasOwn(data, "vipOnly") ? flag(data.vipOnly) : "0",
pageHeadline: text(data.pageHeadline, 1024),
pageTeaser: text(data.pageTeaser, 64),
includes: text(data.includes, 128),
...(values as Partial<typeof CatalogPages.$inferInsert>),
});
const id = String((result as ResultSetHeader).insertId);
return { before: null, after: { id, caption }, output: { id } };
}
const id = positiveInteger(data.id);
const [existing] = await transaction
.select({
id: CatalogPages.id,
caption: CatalogPages.caption,
parentId: CatalogPages.parentId,
enabled: CatalogPages.enabled,
visible: CatalogPages.visible,
orderNum: CatalogPages.orderNum,
})
.from(CatalogPages)
.where(eq(CatalogPages.id, id))
.limit(1);
if (!existing) throw notFound();
const before = {
id: String(id),
caption: existing.caption,
parentId: existing.parentId,
enabled: existing.enabled,
visible: existing.visible,
orderNum: existing.orderNum,
};
if (selectedAction === "delete") {
await deleteCatalogTree(
transaction as unknown as EconomyDatabase,
id,
"reparent",
);
return { before, after: null };
}
if (
selectedAction === "toggle-enabled" ||
selectedAction === "toggle-visible"
) {
const field = selectedAction === "toggle-enabled" ? "enabled" : "visible";
const next = existing[field] === "1" ? "0" : "1";
await transaction
.update(CatalogPages)
.set({ [field]: next })
.where(eq(CatalogPages.id, id));
return { before, after: { ...before, [field]: next } };
}
const values = pageValues(data, false) as Partial<
typeof CatalogPages.$inferInsert
>;
if (values.caption && !values.captionSave)
values.captionSave = values.caption.slice(0, 25);
requirePatch(values as Record<string, unknown>);
await transaction
.update(CatalogPages)
.set(values)
.where(eq(CatalogPages.id, id));
return { before, after: { ...before, ...values } };
});
return synchronizeCatalog(snapshot);
}
async function moveCatalogPage(
connection: EconomyDatabase,
pageId: number,
newParentId: number,
): Promise<void> {
if (newParentId > 0) {
let currentId = newParentId;
for (let index = 0; index < 50; index += 1) {
if (currentId === pageId)
throw validation({ newParentId: ["circular hierarchy"] });
const [parent] = await connection
.select({ parentId: CatalogPages.parentId })
.from(CatalogPages)
.where(eq(CatalogPages.id, currentId))
.limit(1);
if (!parent || parent.parentId <= 0) break;
currentId = parent.parentId;
}
}
await connection
.update(CatalogPages)
.set({ parentId: newParentId })
.where(eq(CatalogPages.id, pageId));
}
async function catalogPageReorder(
input: unknown,
): Promise<EconomyMutationSnapshot> {
const data = record(input);
const pageId = positiveInteger(data.pageId ?? data.id);
const newOrderNum = integer(data.newOrderNum ?? data.orderNum);
const snapshot = await db.transaction(async (transaction) => {
const [existing] = await transaction
.select({
parentId: CatalogPages.parentId,
orderNum: CatalogPages.orderNum,
})
.from(CatalogPages)
.where(eq(CatalogPages.id, pageId))
.limit(1);
if (!existing) throw notFound();
const newParentId = hasOwn(data, "newParentId")
? integer(data.newParentId, -1)
: existing.parentId;
if (newParentId !== existing.parentId)
await moveCatalogPage(
transaction as unknown as EconomyDatabase,
pageId,
newParentId,
);
await transaction
.update(CatalogPages)
.set({ orderNum: newOrderNum })
.where(eq(CatalogPages.id, pageId));
return {
before: {
id: String(pageId),
parentId: existing.parentId,
orderNum: existing.orderNum,
},
after: {
id: String(pageId),
parentId: newParentId,
orderNum: newOrderNum,
},
};
});
return synchronizeCatalog(snapshot);
}
async function deleteCatalogTree(
connection: EconomyDatabase,
pageId: number,
mode: "cascade" | "reparent",
): Promise<{ deletedPages: number; movedChildren: number }> {
const [page] = await connection
.select({ parentId: CatalogPages.parentId })
.from(CatalogPages)
.where(eq(CatalogPages.id, pageId))
.limit(1);
if (!page) throw notFound();
if (mode === "reparent") {
const [updated] = await connection
.update(CatalogPages)
.set({ parentId: page.parentId })
.where(eq(CatalogPages.parentId, pageId));
await connection.execute(
sql`DELETE FROM catalog_items WHERE page_id = ${String(pageId)}`,
);
await connection.delete(CatalogPages).where(eq(CatalogPages.id, pageId));
return { deletedPages: 1, movedChildren: updated.affectedRows };
}
const toDelete = [pageId];
let queue = [pageId];
while (queue.length > 0) {
const children = await connection
.select({ id: CatalogPages.id })
.from(CatalogPages)
.where(inArray(CatalogPages.parentId, queue));
queue = children.map((child) => child.id);
toDelete.push(...queue);
}
await connection.execute(
sql`DELETE FROM catalog_items WHERE page_id IN (${sql.join(toDelete.map(String), sql`, `)})`,
);
await connection
.delete(CatalogPages)
.where(inArray(CatalogPages.id, toDelete));
return { deletedPages: toDelete.length, movedChildren: 0 };
}
async function catalogPageDeleteTree(
input: unknown,
): Promise<EconomyMutationSnapshot> {
const data = record(input);
const pageId = positiveInteger(data.pageId ?? data.id);
const mode = text(data.mode ?? "reparent", 16, true);
if (mode !== "reparent" && mode !== "cascade") throw validation();
const result = await db.transaction((transaction) =>
deleteCatalogTree(transaction as unknown as EconomyDatabase, pageId, mode),
);
return synchronizeCatalog({
before: { id: String(pageId) },
after: null,
output: result,
});
}
async function bcPageChange(input: unknown): Promise<EconomyMutationSnapshot> {
const data = record(input);
const selectedAction = action(data, [
"create",
"update",
"delete",
"toggle-enabled",
"toggle-visible",
]);
const snapshot = await db.transaction(async (transaction) => {
if (selectedAction === "create") {
const caption = text(data.caption, 128, true);
const [result] = await transaction.insert(CatalogPagesBc).values({
caption,
parentId: integer(data.parentId ?? -1, -1),
pageLayout: text(data.pageLayout ?? "default_3x3", 26, true),
iconColor: integer(data.iconColor ?? 0),
iconImage: integer(data.iconImage ?? 0),
orderNum: integer(data.orderNum ?? 0),
visible: hasOwn(data, "visible") ? flag(data.visible) : "1",
enabled: hasOwn(data, "enabled") ? flag(data.enabled) : "1",
pageHeadline: text(data.pageHeadline, 1024),
pageTeaser: text(data.pageTeaser, 64),
});
const id = String((result as ResultSetHeader).insertId);
return { before: null, after: { id, caption }, output: { id } };
}
const id = positiveInteger(data.id);
const [existing] = await transaction
.select({
caption: CatalogPagesBc.caption,
parentId: CatalogPagesBc.parentId,
enabled: CatalogPagesBc.enabled,
visible: CatalogPagesBc.visible,
orderNum: CatalogPagesBc.orderNum,
})
.from(CatalogPagesBc)
.where(eq(CatalogPagesBc.id, id))
.limit(1);
if (!existing) throw notFound();
const before = { id: String(id), ...existing };
if (selectedAction === "delete") {
await deleteBcTree(
transaction as unknown as EconomyDatabase,
id,
"reparent",
);
return { before, after: null };
}
if (
selectedAction === "toggle-enabled" ||
selectedAction === "toggle-visible"
) {
const field = selectedAction === "toggle-enabled" ? "enabled" : "visible";
const next = existing[field] === "1" ? "0" : "1";
await transaction
.update(CatalogPagesBc)
.set({ [field]: next })
.where(eq(CatalogPagesBc.id, id));
return { before, after: { ...before, [field]: next } };
}
const values = pageValues(data, true) as Partial<
typeof CatalogPagesBc.$inferInsert
>;
requirePatch(values as Record<string, unknown>);
await transaction
.update(CatalogPagesBc)
.set(values)
.where(eq(CatalogPagesBc.id, id));
return { before, after: { ...before, ...values } };
});
return synchronizeCatalog(snapshot);
}
async function moveBcPage(
connection: EconomyDatabase,
pageId: number,
newParentId: number,
): Promise<void> {
if (newParentId > 0) {
let currentId = newParentId;
for (let index = 0; index < 50; index += 1) {
if (currentId === pageId)
throw validation({ newParentId: ["circular hierarchy"] });
const [parent] = await connection
.select({ parentId: CatalogPagesBc.parentId })
.from(CatalogPagesBc)
.where(eq(CatalogPagesBc.id, currentId))
.limit(1);
if (!parent || parent.parentId <= 0) break;
currentId = parent.parentId;
}
}
await connection
.update(CatalogPagesBc)
.set({ parentId: newParentId })
.where(eq(CatalogPagesBc.id, pageId));
}
async function bcPageReorder(input: unknown): Promise<EconomyMutationSnapshot> {
const data = record(input);
const pageId = positiveInteger(data.pageId ?? data.id);
const newOrderNum = integer(data.newOrderNum ?? data.orderNum);
const snapshot = await db.transaction(async (transaction) => {
const [existing] = await transaction
.select({
parentId: CatalogPagesBc.parentId,
orderNum: CatalogPagesBc.orderNum,
})
.from(CatalogPagesBc)
.where(eq(CatalogPagesBc.id, pageId))
.limit(1);
if (!existing) throw notFound();
const newParentId = hasOwn(data, "newParentId")
? integer(data.newParentId, -1)
: existing.parentId;
if (newParentId !== existing.parentId)
await moveBcPage(
transaction as unknown as EconomyDatabase,
pageId,
newParentId,
);
await transaction
.update(CatalogPagesBc)
.set({ orderNum: newOrderNum })
.where(eq(CatalogPagesBc.id, pageId));
return {
before: { id: String(pageId), ...existing },
after: {
id: String(pageId),
parentId: newParentId,
orderNum: newOrderNum,
},
};
});
return synchronizeCatalog(snapshot);
}
async function deleteBcTree(
connection: EconomyDatabase,
pageId: number,
mode: "cascade" | "reparent",
) {
const [page] = await connection
.select({ parentId: CatalogPagesBc.parentId })
.from(CatalogPagesBc)
.where(eq(CatalogPagesBc.id, pageId))
.limit(1);
if (!page) throw notFound();
if (mode === "reparent") {
const [updated] = await connection
.update(CatalogPagesBc)
.set({ parentId: page.parentId })
.where(eq(CatalogPagesBc.parentId, pageId));
await connection
.delete(CatalogItemsBc)
.where(eq(CatalogItemsBc.pageId, pageId));
await connection
.delete(CatalogPagesBc)
.where(eq(CatalogPagesBc.id, pageId));
return { deletedPages: 1, movedChildren: updated.affectedRows };
}
const toDelete = [pageId];
let queue = [pageId];
while (queue.length > 0) {
const children = await connection
.select({ id: CatalogPagesBc.id })
.from(CatalogPagesBc)
.where(inArray(CatalogPagesBc.parentId, queue));
queue = children.map((child) => child.id);
toDelete.push(...queue);
}
await connection
.delete(CatalogItemsBc)
.where(inArray(CatalogItemsBc.pageId, toDelete));
await connection
.delete(CatalogPagesBc)
.where(inArray(CatalogPagesBc.id, toDelete));
return { deletedPages: toDelete.length, movedChildren: 0 };
}
async function bcPageDeleteTree(
input: unknown,
): Promise<EconomyMutationSnapshot> {
const data = record(input);
const pageId = positiveInteger(data.pageId ?? data.id);
const mode = text(data.mode ?? "reparent", 16, true);
if (mode !== "reparent" && mode !== "cascade") throw validation();
const result = await db.transaction((transaction) =>
deleteBcTree(transaction as unknown as EconomyDatabase, pageId, mode),
);
return synchronizeCatalog({
before: { id: String(pageId) },
after: null,
output: result,
});
}
async function bcItemChange(input: unknown): Promise<EconomyMutationSnapshot> {
const data = record(input);
const selectedAction = action(data, ["create", "update", "delete"]);
const snapshot = await db.transaction(async (transaction) => {
if (selectedAction === "create") {
const values = {
pageId: positiveInteger(data.pageId),
itemIds: text(data.itemIds, 666, true),
catalogName: text(data.catalogName, 100, true),
orderNumber: integer(data.orderNumber ?? 1),
extradata: text(data.extradata, 500),
};
const [result] = await transaction.insert(CatalogItemsBc).values(values);
const id = String((result as ResultSetHeader).insertId);
return { before: null, after: { id, ...values }, output: { id } };
}
const id = positiveInteger(data.id);
const [existing] = await transaction
.select()
.from(CatalogItemsBc)
.where(eq(CatalogItemsBc.id, id))
.limit(1);
if (!existing) throw notFound();
const before = {
id: String(id),
pageId: existing.pageId,
itemIds: existing.itemIds,
catalogName: existing.catalogName,
orderNumber: existing.orderNumber,
};
if (selectedAction === "delete") {
await transaction.delete(CatalogItemsBc).where(eq(CatalogItemsBc.id, id));
return { before, after: null };
}
const values: Partial<typeof CatalogItemsBc.$inferInsert> = {};
if (hasOwn(data, "pageId")) values.pageId = positiveInteger(data.pageId);
if (hasOwn(data, "itemIds")) values.itemIds = text(data.itemIds, 666, true);
if (hasOwn(data, "catalogName"))
values.catalogName = text(data.catalogName, 100, true);
if (hasOwn(data, "orderNumber"))
values.orderNumber = integer(data.orderNumber);
if (hasOwn(data, "extradata")) values.extradata = text(data.extradata, 500);
requirePatch(values as Record<string, unknown>);
await transaction
.update(CatalogItemsBc)
.set(values)
.where(eq(CatalogItemsBc.id, id));
return { before, after: { ...before, ...values } };
});
return synchronizeCatalog(snapshot);
}
const CATALOG_ITEM_TEXT_FIELDS = new Map([
["itemIds", 666],
["catalogName", 100],
["extradata", 500],
] as const);
const CATALOG_ITEM_NUMBER_FIELDS = [
"costCredits",
"costPoints",
"pointsType",
"amount",
"orderNumber",
"offerId",
"songId",
"limitedSells",
"limitedStack",
] as const;
function catalogItemValues(data: Record<string, unknown>) {
const values: Record<string, unknown> = {};
for (const [key, maximum] of CATALOG_ITEM_TEXT_FIELDS)
if (hasOwn(data, key))
values[key] = text(data[key], maximum, key !== "extradata");
for (const key of CATALOG_ITEM_NUMBER_FIELDS)
if (hasOwn(data, key))
values[key] = integer(data[key], key === "offerId" ? -1 : 0);
if (hasOwn(data, "haveOffer")) values.haveOffer = flag(data.haveOffer);
if (hasOwn(data, "clubOnly")) values.clubOnly = flag(data.clubOnly);
return values;
}
async function insertCatalogItem(
data: Record<string, unknown>,
): Promise<number> {
const pageId = positiveInteger(data.pageId);
let catalogName = text(data.catalogName, 100);
const itemIds = text(data.itemIds, 666, true);
if (!catalogName) {
const firstId = Number.parseInt(itemIds.split(";")[0] ?? "", 10);
const [base] =
firstId > 0
? 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);
}
return allocateCatalogItemId(async (nextId) => {
await db.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}, ${String(pageId)}, ${itemIds}, ${catalogName}, ${integer(data.costCredits ?? 0)}, ${integer(data.costPoints ?? 0)}, ${integer(data.pointsType ?? 0)}, ${integer(data.amount ?? 1, 1)}, ${integer(data.orderNumber ?? 1)}, ${integer(data.offerId ?? -1, -1)}, ${integer(data.songId ?? 0)}, ${integer(data.limitedSells ?? 0)}, ${integer(data.limitedStack ?? 0)}, ${text(data.extradata, 500)}, ${hasOwn(data, "haveOffer") ? flag(data.haveOffer) : "1"}, ${hasOwn(data, "clubOnly") ? flag(data.clubOnly) : "0"})`,
);
return nextId;
});
}
async function catalogItemChange(
input: unknown,
): Promise<EconomyMutationSnapshot> {
const data = record(input);
const selectedAction = action(data, ["create", "update", "delete"]);
if (selectedAction === "create") {
const id = await insertCatalogItem(data);
return synchronizeCatalog({
before: null,
after: {
id: String(id),
pageId: positiveInteger(data.pageId),
itemIds: text(data.itemIds, 666, true),
},
output: { id: String(id) },
});
}
if (selectedAction === "delete" && Array.isArray(data.ids)) {
const ids = integerArray(data.ids);
if (ids.length === 0) throw validation();
await db.delete(CatalogItems).where(inArray(CatalogItems.id, ids));
return synchronizeCatalog({
before: { ids: ids.map(String) },
after: null,
output: { deleted: ids.length },
});
}
const id = positiveInteger(data.id);
const snapshot = await db.transaction(async (transaction) => {
const [existing] = await transaction
.select({
id: CatalogItems.id,
pageId: CatalogItems.pageId,
itemIds: CatalogItems.itemIds,
catalogName: CatalogItems.catalogName,
costCredits: CatalogItems.costCredits,
costPoints: CatalogItems.costPoints,
amount: CatalogItems.amount,
orderNumber: CatalogItems.orderNumber,
})
.from(CatalogItems)
.where(eq(CatalogItems.id, id))
.limit(1);
if (!existing) throw notFound();
const before = { ...existing, id: String(id) };
if (selectedAction === "delete") {
await transaction.delete(CatalogItems).where(eq(CatalogItems.id, id));
return { before, after: null };
}
const catalogFields = record(data.catalogFields ?? data);
const values = catalogItemValues(catalogFields) as Partial<
typeof CatalogItems.$inferInsert
>;
if (hasOwn(catalogFields, "pageId"))
values.pageId = positiveInteger(catalogFields.pageId);
const baseItem =
data.baseItem && typeof data.baseItem === "object"
? record(data.baseItem)
: null;
if (Object.keys(values).length === 0 && !baseItem) throw validation();
if (Object.keys(values).length > 0)
await transaction
.update(CatalogItems)
.set(values)
.where(eq(CatalogItems.id, id));
if (baseItem)
await updateItemsBaseRecord(
transaction as unknown as EconomyDatabase,
positiveInteger(baseItem.id),
record(baseItem.fields),
);
return { before, after: { ...before, ...values } };
});
return synchronizeCatalog(snapshot);
}
async function catalogItemBulkCreate(
input: unknown,
): Promise<EconomyMutationSnapshot> {
const data = record(input);
const pageId = positiveInteger(data.pageId);
if (!Array.isArray(data.rows) || data.rows.length > 500) throw validation();
let created = 0;
let failed = 0;
for (const candidate of data.rows) {
try {
const row = record(candidate);
const baseId = positiveInteger(row.baseId);
const [base] = await db
.select({
publicName: ItemsBase.publicName,
itemName: ItemsBase.itemName,
})
.from(ItemsBase)
.where(eq(ItemsBase.id, baseId))
.limit(1);
if (!base) {
failed += 1;
continue;
}
await insertCatalogItem({
pageId,
itemIds: String(baseId),
catalogName: base.publicName || base.itemName || String(baseId),
costCredits: row.credits ?? 0,
costPoints: row.points ?? 0,
pointsType: row.pointsType ?? 0,
amount: 1,
orderNumber: 1,
offerId: -1,
songId: 0,
limitedSells: 0,
limitedStack: 0,
extradata: "",
haveOffer: "1",
clubOnly: "0",
});
created += 1;
} catch {
failed += 1;
}
}
return synchronizeCatalog({
before: null,
after: { pageId: String(pageId), created, failed },
output: { created, failed },
});
}
async function catalogItemMove(
input: unknown,
): Promise<EconomyMutationSnapshot> {
const data = record(input);
const ids = integerArray(data.ids);
const targetPageId = positiveInteger(data.targetPageId);
if (ids.length === 0) throw validation();
await db.execute(
sql`UPDATE catalog_items SET page_id = ${String(targetPageId)} WHERE id IN (${sql.join(
ids.map((id) => sql`${id}`),
sql`, `,
)})`,
);
return synchronizeCatalog({
before: { ids: ids.map(String) },
after: { ids: ids.map(String), pageId: String(targetPageId) },
});
}
async function catalogItemReorder(
input: unknown,
): Promise<EconomyMutationSnapshot> {
const data = record(input);
if (
!Array.isArray(data.orders) ||
data.orders.length === 0 ||
data.orders.length > 500
)
throw validation();
const orders = data.orders.map((candidate) => {
const row = record(candidate);
return {
id: positiveInteger(row.id),
orderNumber: integer(row.orderNumber),
};
});
await db.transaction(async (transaction) => {
for (const row of orders)
await transaction
.update(CatalogItems)
.set({ orderNumber: row.orderNumber })
.where(eq(CatalogItems.id, row.id));
});
return synchronizeCatalog({
before: { ids: orders.map((row) => String(row.id)) },
after: { orders },
});
}
async function catalogItemTranslate(
input: unknown,
): Promise<EconomyMutationSnapshot> {
const data = record(input);
if (
!Array.isArray(data.items) ||
data.items.length === 0 ||
data.items.length > 500
)
throw validation();
const furniPatches: Array<{
classname: string;
itemType: string;
name?: string;
description?: string;
spriteId?: number;
createIfMissing?: boolean;
}> = [];
let namesUpdated = 0;
let descriptionsUpdated = 0;
for (const candidate of data.items) {
const item = record(candidate);
const id = positiveInteger(item.id);
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, id))
.limit(1);
if (!base) continue;
const nextName = text(item.publicName, 56);
const nextDescription = text(item.description, 500);
if (nextName && nextName !== base.publicName) {
await db
.update(ItemsBase)
.set({ publicName: nextName })
.where(eq(ItemsBase.id, id));
const idText = String(id);
const related = await db
.select({ id: CatalogItems.id })
.from(CatalogItems)
.where(
or(
eq(CatalogItems.itemIds, idText),
like(CatalogItems.itemIds, `${idText};%`),
like(CatalogItems.itemIds, `%;${idText};%`),
like(CatalogItems.itemIds, `%;${idText}`),
),
);
for (const row of related)
await db
.update(CatalogItems)
.set({ catalogName: nextName })
.where(eq(CatalogItems.id, row.id));
namesUpdated += 1;
}
if (nextDescription || nextName) {
if (nextDescription) descriptionsUpdated += 1;
furniPatches.push({
classname: base.itemName,
itemType: base.type || "s",
name: nextName || base.publicName || base.itemName,
description: nextDescription,
spriteId: base.spriteId,
createIfMissing: true,
});
}
}
const snapshot: EconomyMutationSnapshot = {
before: null,
after: { namesUpdated, descriptionsUpdated },
output: { namesUpdated, descriptionsUpdated },
};
try {
let furniDataUpdated = 0;
let furniDataInserted = 0;
if (furniPatches.length > 0) {
const [{ patchFurniEntryNames }, { invalidateFurniDataCache }] =
await Promise.all([
import("@/lib/services/furni-data"),
import("@/lib/services/catalog-items-loader"),
]);
const result = await patchFurniEntryNames(furniPatches);
if (result.updated > 0 || result.inserted > 0) invalidateFurniDataCache();
furniDataUpdated = result.updated;
furniDataInserted = result.inserted;
}
return synchronizeCatalog({
...snapshot,
output: {
namesUpdated,
descriptionsUpdated,
furniDataUpdated,
furniDataInserted,
},
});
} catch (error) {
if (error instanceof EconomyCommittedExternalFailure) throw error;
throw new EconomyCommittedExternalFailure(snapshot);
}
}
const ITEMS_BASE_TEXT_FIELDS = new Map([
["publicName", 56],
["itemName", 70],
["type", 3],
["interactionType", 500],
["vendingIds", 255],
["multiheight", 50],
["customparams", 25_600],
["clothingOnWalk", 255],
] as const);
const ITEMS_BASE_NUMBER_FIELDS = [
"spriteId",
"width",
"length",
"allowStack",
"allowSit",
"allowLay",
"allowWalk",
"allowGift",
"allowTrade",
"allowRecycle",
"allowMarketplaceSell",
"allowInventoryStack",
"interactionModesCount",
"effectIdMale",
"effectIdFemale",
] as const;
async function updateItemsBaseRecord(
connection: EconomyDatabase,
id: number,
fields: Record<string, unknown>,
) {
const values: Record<string, unknown> = {};
for (const [key, maximum] of ITEMS_BASE_TEXT_FIELDS)
if (hasOwn(fields, key))
values[key] = text(fields[key], maximum, key === "itemName");
for (const key of ITEMS_BASE_NUMBER_FIELDS)
if (hasOwn(fields, key)) values[key] = integer(fields[key]);
if (hasOwn(fields, "stackHeight")) {
const height = Number(fields.stackHeight);
if (!Number.isFinite(height) || height < 0) throw validation();
values.stackHeight = height;
}
requirePatch(values);
await connection
.update(ItemsBase)
.set(values as Partial<typeof ItemsBase.$inferInsert>)
.where(eq(ItemsBase.id, id));
return values;
}
async function itemsBaseUpdate(
input: unknown,
): Promise<EconomyMutationSnapshot> {
const data = record(input);
const id = positiveInteger(data.id);
const snapshot = await db.transaction(async (transaction) => {
const [existing] = await transaction
.select({
id: ItemsBase.id,
publicName: ItemsBase.publicName,
itemName: ItemsBase.itemName,
interactionType: ItemsBase.interactionType,
})
.from(ItemsBase)
.where(eq(ItemsBase.id, id))
.limit(1);
if (!existing) throw notFound();
const fields =
data.fields && typeof data.fields === "object"
? record(data.fields)
: data;
const values = await updateItemsBaseRecord(
transaction as unknown as EconomyDatabase,
id,
fields,
);
return {
before: { ...existing, id: String(id) },
after: { ...existing, id: String(id), ...values },
};
});
return synchronizeCatalog(snapshot);
}
async function maintenance(
operation: EconomyMutationOperation,
input: unknown,
): Promise<EconomyMutationSnapshot> {
const data = record(input);
const service = await import("@/lib/services/furni-maintenance");
let result: unknown;
switch (operation) {
case "maintenance.fix-offers":
result = await service.fixCatalogOffers();
break;
case "maintenance.fix-everything":
result = await service.fixEverything({
dedupePages: data.dedupePages !== false,
});
break;
case "maintenance.fix-sprite-ids":
result = await service.fixSpriteIds();
break;
case "maintenance.reconcile-ids":
result = await service.reconcileIds();
break;
case "maintenance.align-ids":
result = await service.forceItemsBaseIdsToFurnidata(data.apply !== false);
break;
case "maintenance.remove-duplicates":
result = await service.removeDuplicates(data.dedupePages !== false);
break;
default:
throw validation();
}
const output = safeOutput(result);
return synchronizeCatalog({
before: null,
after: { operation, completed: true },
output,
});
}
const HANDLERS: Partial<
Record<
EconomyMutationOperation,
(
input: unknown,
context: EconomyMutationContext,
) => Promise<EconomyMutationSnapshot>
>
> = {
"catalog-page.change": (input) => catalogPageChange(input),
"catalog-page.reorder": (input) => catalogPageReorder(input),
"catalog-page.delete-tree": (input) => catalogPageDeleteTree(input),
"bc-page.change": (input) => bcPageChange(input),
"bc-page.reorder": (input) => bcPageReorder(input),
"bc-page.delete-tree": (input) => bcPageDeleteTree(input),
"bc-item.change": (input) => bcItemChange(input),
"catalog-item.change": (input) => catalogItemChange(input),
"catalog-item.bulk-create": (input) => catalogItemBulkCreate(input),
"catalog-item.move": (input) => catalogItemMove(input),
"catalog-item.reorder": (input) => catalogItemReorder(input),
"catalog-item.translate": (input) => catalogItemTranslate(input),
"items-base.update": (input) => itemsBaseUpdate(input),
"maintenance.fix-offers": (input) =>
maintenance("maintenance.fix-offers", input),
"maintenance.fix-everything": (input) =>
maintenance("maintenance.fix-everything", input),
"maintenance.fix-sprite-ids": (input) =>
maintenance("maintenance.fix-sprite-ids", input),
"maintenance.reconcile-ids": (input) =>
maintenance("maintenance.reconcile-ids", input),
"maintenance.align-ids": (input) =>
maintenance("maintenance.align-ids", input),
"maintenance.remove-duplicates": (input) =>
maintenance("maintenance.remove-duplicates", input),
};
export async function executeEconomyCatalogMutation(
operation: EconomyMutationOperation,
input: unknown,
context: EconomyMutationContext,
): Promise<EconomyMutationSnapshot | null> {
const handler = HANDLERS[operation];
return handler ? handler(input, context) : null;
}