1068 lines
32 KiB
TypeScript
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;
|
|
}
|