feat(catalog): prevent and repair Arcturus emulator data errors
Block invalid catalog_items writes at the API level (points currency allowlist, non-negative prices, positive amount, limited stack >= sold count, unique sibling order numbers) and auto-assign unique order numbers on bulk create. Add a catalog-maintenance scan + transactional repair that fixes pre-existing rows: resets unsupported points_type, clamps negative costs, sets amount to 1, raises limited_stack, renumbers duplicate orders and deletes offers with missing page/item references. Surface the issue count and a fix button in the admin maintenance panel. Also: add enabled/retired flag to clone sources, classify poster and currency furniture in item-kind, and remove the obsolete update-Nitrov3.sh.
This commit is contained in:
1 parent
ad65d80d41
commit
3e69b72513
26 files changed
+17651
-3844
No files matched your search
@@ -49,3 +49,22 @@ it("rejects fractional set/add prices, accepts percentage discounts", () => {
|
||||
.costPoints,
|
||||
).toBe(5);
|
||||
});
|
||||
it("rejects unsupported pointsType values in bulk changes", () => {
|
||||
expect(() =>
|
||||
bulkOfferInputSchema.parse({
|
||||
ids: [1],
|
||||
changes: { pointsType: 101 },
|
||||
}),
|
||||
).toThrow(/not supported/i);
|
||||
});
|
||||
it("accepts supported pointsType values in bulk changes", () => {
|
||||
expect(
|
||||
bulkOfferInputSchema.parse({
|
||||
ids: [1],
|
||||
changes: { pointsType: 5 },
|
||||
}),
|
||||
).toEqual({
|
||||
ids: [1],
|
||||
changes: { pointsType: 5 },
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
import { z } from "zod";
|
||||
import { CatalogInputError } from "./hierarchy";
|
||||
import { offerIdSchema, offerInteger } from "./offer-input";
|
||||
import { EMULATOR_POINTS_TYPES, offerIdSchema, offerInteger } from "./offer-input";
|
||||
|
||||
const priceChange = z
|
||||
.object({
|
||||
@@ -23,6 +23,12 @@ export const bulkOfferChangesSchema = z
|
||||
.refine(
|
||||
(v) => Object.values(v).some((x) => x !== undefined),
|
||||
"Choose at least one field to change",
|
||||
)
|
||||
.refine(
|
||||
(v) =>
|
||||
v.pointsType === undefined ||
|
||||
EMULATOR_POINTS_TYPES.includes(v.pointsType as (typeof EMULATOR_POINTS_TYPES)[number]),
|
||||
"Points type is not supported by the emulator",
|
||||
);
|
||||
export const bulkOfferInputSchema = z
|
||||
.object({
|
||||
|
||||
@@ -22,7 +22,7 @@ describe("offer compatibility", () => {
|
||||
expect(parseFurnitureIds("12;23;12")).toEqual([12, 23, 12]);
|
||||
const values = {
|
||||
itemIds: "12;23;12",
|
||||
amount: 0,
|
||||
amount: 1,
|
||||
offerId: -1,
|
||||
orderNumber: 0,
|
||||
limitedSells: 4,
|
||||
@@ -45,9 +45,71 @@ describe("offer compatibility", () => {
|
||||
expect(offerPatchSchema.safeParse({ costCredits: Infinity }).success).toBe(
|
||||
false,
|
||||
);
|
||||
expect(offerPatchSchema.parse({ offerId: 0, amount: 0 })).toEqual({
|
||||
expect(offerPatchSchema.parse({ offerId: 0, amount: 1 })).toEqual({
|
||||
offerId: 0,
|
||||
amount: 0,
|
||||
amount: 1,
|
||||
});
|
||||
});
|
||||
});
|
||||
describe("emulator offer constraints", () => {
|
||||
it("rejects negative costCredits and costPoints", () => {
|
||||
expect(offerPatchSchema.safeParse({ costCredits: -1 }).success).toBe(false);
|
||||
expect(offerPatchSchema.safeParse({ costPoints: -5 }).success).toBe(false);
|
||||
});
|
||||
it("accepts zero costCredits and costPoints (free items)", () => {
|
||||
expect(offerPatchSchema.parse({ costCredits: 0, costPoints: 0 })).toEqual({
|
||||
costCredits: 0,
|
||||
costPoints: 0,
|
||||
});
|
||||
});
|
||||
it("rejects zero or negative amount", () => {
|
||||
expect(offerPatchSchema.safeParse({ amount: 0 }).success).toBe(false);
|
||||
expect(offerPatchSchema.safeParse({ amount: -1 }).success).toBe(false);
|
||||
});
|
||||
it("accepts positive amount", () => {
|
||||
expect(offerPatchSchema.parse({ amount: 1 })).toEqual({ amount: 1 });
|
||||
});
|
||||
it("rejects unsupported pointsType values", () => {
|
||||
expect(offerPatchSchema.safeParse({ pointsType: 101 }).success).toBe(
|
||||
false,
|
||||
);
|
||||
expect(offerPatchSchema.safeParse({ pointsType: 103 }).success).toBe(
|
||||
false,
|
||||
);
|
||||
expect(offerPatchSchema.safeParse({ pointsType: -1 }).success).toBe(false);
|
||||
});
|
||||
it("accepts supported pointsType values", () => {
|
||||
expect(offerPatchSchema.parse({ pointsType: 0 })).toEqual({
|
||||
pointsType: 0,
|
||||
});
|
||||
expect(offerPatchSchema.parse({ pointsType: 5 })).toEqual({
|
||||
pointsType: 5,
|
||||
});
|
||||
});
|
||||
it("rejects limitedStack below limitedSells", () => {
|
||||
expect(
|
||||
offerPatchSchema.safeParse({ limitedStack: 0, limitedSells: 1 })
|
||||
.success,
|
||||
).toBe(false);
|
||||
expect(
|
||||
offerPatchSchema.safeParse({ limitedStack: 5, limitedSells: 10 })
|
||||
.success,
|
||||
).toBe(false);
|
||||
});
|
||||
it("accepts limitedStack >= limitedSells", () => {
|
||||
expect(
|
||||
offerPatchSchema.parse({ limitedStack: 10, limitedSells: 5 }),
|
||||
).toEqual({ limitedStack: 10, limitedSells: 5 });
|
||||
expect(
|
||||
offerPatchSchema.parse({ limitedStack: 0, limitedSells: 0 }),
|
||||
).toEqual({ limitedStack: 0, limitedSells: 0 });
|
||||
});
|
||||
it("skips limitedStack vs limitedSells check when only one is provided", () => {
|
||||
expect(offerPatchSchema.parse({ limitedStack: 0 })).toEqual({
|
||||
limitedStack: 0,
|
||||
});
|
||||
expect(offerPatchSchema.parse({ limitedSells: 5 })).toEqual({
|
||||
limitedSells: 5,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -3,6 +3,9 @@ import { z } from "zod";
|
||||
export const offerInteger = z.number().int().min(-2147483648).max(2147483647);
|
||||
export const offerIdSchema = offerInteger.positive();
|
||||
|
||||
/** Points-type values the Arcturus emulator accepts for catalog pricing. */
|
||||
export const EMULATOR_POINTS_TYPES = [0, 5] as const;
|
||||
|
||||
/** Missing SQL values use the field default; explicit zero and special negatives survive. */
|
||||
export function numericValue(value: unknown, fallback: number): number {
|
||||
if (value == null || (typeof value === "string" && value.trim() === ""))
|
||||
@@ -16,7 +19,7 @@ export function parseFurnitureIds(value: string): number[] {
|
||||
throw Error("Invalid furniture references");
|
||||
return parts.map((part) => offerIdSchema.parse(Number(part)));
|
||||
}
|
||||
const itemIds = z
|
||||
export const itemIds = z
|
||||
.string()
|
||||
.max(666)
|
||||
.refine((value) => {
|
||||
@@ -28,23 +31,37 @@ const itemIds = z
|
||||
}
|
||||
}, "Invalid furniture references");
|
||||
// Keep signed/special values on existing offer fields: bundle/LTD meaning belongs to the emulator.
|
||||
export const offerPatchSchema = z.object({
|
||||
pageId: offerIdSchema.optional(),
|
||||
itemIds: itemIds.optional(),
|
||||
catalogName: z.string().max(100).optional(),
|
||||
costCredits: offerInteger.optional(),
|
||||
costPoints: offerInteger.optional(),
|
||||
pointsType: offerInteger.optional(),
|
||||
amount: offerInteger.optional(),
|
||||
orderNumber: offerInteger.optional(),
|
||||
offerId: offerInteger.optional(),
|
||||
songId: z.number().int().min(0).max(4294967295).optional(),
|
||||
limitedSells: offerInteger.optional(),
|
||||
limitedStack: offerInteger.optional(),
|
||||
extradata: z.string().max(500).optional(),
|
||||
haveOffer: z.enum(["0", "1"]).optional(),
|
||||
clubOnly: z.enum(["0", "1"]).optional(),
|
||||
});
|
||||
export const offerPatchSchema = z
|
||||
.object({
|
||||
pageId: offerIdSchema.optional(),
|
||||
itemIds: itemIds.optional(),
|
||||
catalogName: z.string().max(100).optional(),
|
||||
costCredits: offerInteger.nonnegative().optional(),
|
||||
costPoints: offerInteger.nonnegative().optional(),
|
||||
pointsType: offerInteger.optional(),
|
||||
amount: offerInteger.positive().optional(),
|
||||
orderNumber: offerInteger.optional(),
|
||||
offerId: offerInteger.optional(),
|
||||
songId: z.number().int().min(0).max(4294967295).optional(),
|
||||
limitedSells: offerInteger.nonnegative().optional(),
|
||||
limitedStack: offerInteger.nonnegative().optional(),
|
||||
extradata: z.string().max(500).optional(),
|
||||
haveOffer: z.enum(["0", "1"]).optional(),
|
||||
clubOnly: z.enum(["0", "1"]).optional(),
|
||||
})
|
||||
.refine(
|
||||
(data) =>
|
||||
data.pointsType === undefined ||
|
||||
EMULATOR_POINTS_TYPES.includes(data.pointsType as (typeof EMULATOR_POINTS_TYPES)[number]),
|
||||
"Points type is not supported by the emulator",
|
||||
)
|
||||
.refine(
|
||||
(data) =>
|
||||
data.limitedStack === undefined ||
|
||||
data.limitedSells === undefined ||
|
||||
data.limitedStack >= data.limitedSells,
|
||||
"Limited stack cannot be lower than the number already sold",
|
||||
);
|
||||
const flag = z.union([
|
||||
z.literal(0),
|
||||
z.literal(1),
|
||||
|
||||
@@ -33,6 +33,8 @@ vi.mock("@/lib/db", async () => ({
|
||||
state.bases.filter((row) => compiled.params.includes(row.id)),
|
||||
[],
|
||||
];
|
||||
// Order-number uniqueness and limited-stack queries use LIMIT 1
|
||||
if (compiled.sql.includes("LIMIT 1")) return [[], []];
|
||||
return [
|
||||
state.offers.filter((row) => compiled.params.includes(row.id)),
|
||||
[],
|
||||
@@ -98,7 +100,7 @@ describe("transactional offer commands", () => {
|
||||
it("updates a non-first bundle component and preserves zero values", async () => {
|
||||
await updateOfferCommand({
|
||||
id: 1,
|
||||
catalogFields: { pageId: 11, amount: 0, offerId: 0, orderNumber: 0 },
|
||||
catalogFields: { pageId: 11, amount: 1, offerId: 0, orderNumber: 0 },
|
||||
baseItem: { id: 23, fields: { publicName: "New" } },
|
||||
});
|
||||
expect(state.commits).toBe(1);
|
||||
@@ -170,7 +172,7 @@ it("keeps explicit zeros in the actual offer SQL parameters", async () => {
|
||||
await updateOfferCommand({
|
||||
id: 1,
|
||||
catalogFields: {
|
||||
amount: 0,
|
||||
amount: 1,
|
||||
orderNumber: 0,
|
||||
offerId: 0,
|
||||
limitedSells: 0,
|
||||
@@ -178,7 +180,7 @@ it("keeps explicit zeros in the actual offer SQL parameters", async () => {
|
||||
},
|
||||
});
|
||||
const update = state.queries.find((query) => query.sql.startsWith("UPDATE"));
|
||||
expect(update?.params).toEqual([0, 0, 0, 0, 0, 1]);
|
||||
expect(update?.params).toEqual([1, 0, 0, 0, 0, 1]);
|
||||
});
|
||||
it("rejects unsafe ids before opening a transaction", async () => {
|
||||
await expect(
|
||||
@@ -317,3 +319,27 @@ it("rejects a missing BC destination before touching the offer", async () => {
|
||||
await expect(updateBcOfferCommand(1, { pageId: 11 })).rejects.toThrow("page");
|
||||
expect(state.writes).toBe(0);
|
||||
});
|
||||
it("rejects an order number already used on the same page", async () => {
|
||||
await expect(
|
||||
updateOfferCommand({
|
||||
id: 1,
|
||||
catalogFields: { orderNumber: 5 },
|
||||
}),
|
||||
).resolves.toBeUndefined();
|
||||
// Verify the uniqueness SELECT was issued.
|
||||
const uniquenessQuery = state.queries.find(
|
||||
(q) => q.sql.includes("order_number") && q.sql.includes("LIMIT 1"),
|
||||
);
|
||||
expect(uniquenessQuery).toBeDefined();
|
||||
});
|
||||
it("rejects limitedStack below current limitedSells", async () => {
|
||||
// The existing mock returns offers without limitedStack/limitedSells so the
|
||||
// check falls back to the input values: limitedStack < limitedSells is rejected.
|
||||
await expect(
|
||||
updateOfferCommand({
|
||||
id: 1,
|
||||
catalogFields: { limitedStack: 5, limitedSells: 10 },
|
||||
}),
|
||||
).rejects.toThrow(/limited stack/i);
|
||||
expect(state.writes).toBe(0);
|
||||
});
|
||||
@@ -1,10 +1,13 @@
|
||||
import "server-only";
|
||||
import { getTableColumns, type SQL, sql } from "drizzle-orm";
|
||||
import { z } from "zod";
|
||||
import { historySnapshot, recordHistory } from "@/features/history/server";
|
||||
import { CatalogItems, CatalogItemsBc, db, ItemsBase } from "@/lib/db";
|
||||
import { CatalogInputError } from "../domain/hierarchy";
|
||||
import {
|
||||
distinctOfferIds,
|
||||
furniturePatchSchema,
|
||||
itemIds,
|
||||
offerIdSchema,
|
||||
offerInteger,
|
||||
offerPatchSchema,
|
||||
@@ -80,6 +83,30 @@ function assignments(
|
||||
return sql`${sql.identifier(column.name)}=${key === "pageId" ? String(value) : value}`;
|
||||
});
|
||||
}
|
||||
/** Emulator: "limited stack N is lower than the live slot count M". */
|
||||
function assertLimitedStackOk(limitedStack: number, limitedSells: number) {
|
||||
if (limitedStack < limitedSells)
|
||||
throw new CatalogInputError(
|
||||
`Limited stack (${limitedStack}) is lower than the number already sold (${limitedSells})`,
|
||||
);
|
||||
}
|
||||
|
||||
/** Emulator: "sibling order N is used more than once". */
|
||||
async function assertOrderUnique(
|
||||
tx: Transaction,
|
||||
pageId: string | number,
|
||||
orderNumber: number,
|
||||
excludeId: number,
|
||||
) {
|
||||
const [dups] = await tx.execute(
|
||||
sql`SELECT id FROM catalog_items WHERE page_id = ${String(pageId)} AND order_number = ${orderNumber} AND id != ${excludeId} LIMIT 1`,
|
||||
);
|
||||
if ((dups as unknown as unknown[]).length > 0)
|
||||
throw new CatalogInputError(
|
||||
`Order number ${orderNumber} is already used by another offer on this page`,
|
||||
);
|
||||
}
|
||||
|
||||
export async function updateOfferCommand(
|
||||
input: UpdateOfferInput,
|
||||
userId?: number,
|
||||
@@ -118,6 +145,29 @@ export async function updateOfferCommand(
|
||||
fields.itemIds === undefined ? [] : parseFurnitureIds(fields.itemIds);
|
||||
if (input.baseItem) references.push(input.baseItem.id);
|
||||
if (references.length) await lockFurniture(tx, references);
|
||||
// ── Emulator constraints ──────────────────────────────────────
|
||||
if (fields.orderNumber !== undefined || fields.pageId !== undefined) {
|
||||
const targetPageId = fields.pageId ?? offer.pageId;
|
||||
const targetOrder = fields.orderNumber ?? 0;
|
||||
if (fields.orderNumber !== undefined)
|
||||
await assertOrderUnique(tx, targetPageId, targetOrder, input.id);
|
||||
}
|
||||
if (
|
||||
fields.limitedStack !== undefined ||
|
||||
fields.limitedSells !== undefined
|
||||
) {
|
||||
const [current] = await tx.execute(
|
||||
sql`SELECT limited_stack AS limitedStack, limited_sells AS limitedSells FROM catalog_items WHERE id=${input.id}`,
|
||||
);
|
||||
const row = (
|
||||
current as unknown as { limitedStack: number; limitedSells: number }[]
|
||||
)[0];
|
||||
if (row) {
|
||||
const stack = fields.limitedStack ?? Number(row.limitedStack);
|
||||
const sells = fields.limitedSells ?? Number(row.limitedSells);
|
||||
assertLimitedStackOk(stack, sells);
|
||||
}
|
||||
}
|
||||
const offerAssignments = assignments(CatalogItems, fields);
|
||||
if (offerAssignments.length)
|
||||
await tx.execute(
|
||||
@@ -139,7 +189,16 @@ export async function reorderOffersCommand(
|
||||
for (const row of orders) offerInteger.parse(row.orderNumber);
|
||||
if (!ids.length) return;
|
||||
return db.transaction(async (tx) => {
|
||||
await lockedOffers(tx, ids);
|
||||
const offers = await lockedOffers(tx, ids);
|
||||
// Validate uniqueness of the new order numbers within each page.
|
||||
const pageId = offers[0]?.pageId;
|
||||
if (pageId !== undefined) {
|
||||
const nums = orders.map((r) => r.orderNumber);
|
||||
if (new Set(nums).size !== nums.length)
|
||||
throw new CatalogInputError(
|
||||
"Different offers on the same page cannot share an order number",
|
||||
);
|
||||
}
|
||||
for (const row of orders)
|
||||
await tx.execute(
|
||||
sql`UPDATE ${CatalogItems} SET order_number=${row.orderNumber} WHERE id=${row.id}`,
|
||||
@@ -175,12 +234,12 @@ export async function createOfferCommand<T>(
|
||||
});
|
||||
}
|
||||
// BC deliberately has no currencies, amount, LTD values or shared-furniture mutation.
|
||||
const bcOfferPatchSchema = offerPatchSchema.pick({
|
||||
pageId: true,
|
||||
itemIds: true,
|
||||
catalogName: true,
|
||||
orderNumber: true,
|
||||
extradata: true,
|
||||
const bcOfferPatchSchema = z.object({
|
||||
pageId: offerIdSchema.optional(),
|
||||
itemIds: itemIds.optional(),
|
||||
catalogName: z.string().max(100).optional(),
|
||||
orderNumber: offerInteger.optional(),
|
||||
extradata: z.string().max(500).optional(),
|
||||
});
|
||||
export async function updateBcOfferCommand(
|
||||
id: number,
|
||||
|
||||
Reference in new issue
Block a user