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:
openhands committed 2026-09-10 11:29:28 +02:00
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 },
});
});
+7 -1
View File
@@ -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,
});
});
});
+35 -18
View File
@@ -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);
});
File diff suppressed because it is too large. Load diff