diff --git a/mcp/resources.ts b/mcp/resources.ts index 375b310d..61ed6d89 100644 --- a/mcp/resources.ts +++ b/mcp/resources.ts @@ -1,5 +1,9 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { fetchAllProductsFromDb } from "@/utils/db/db-service"; +import { + getEffectiveShippingCost, + parseShippingFromTags, +} from "@/utils/parsers/product-tag-helpers"; import { NostrEvent } from "@/utils/types/types"; function getTagValue(tags: string[][], key: string): string | undefined { @@ -17,21 +21,17 @@ function getAllTagValues(tags: string[][], key: string): string[] { function buildCatalogEntry(event: NostrEvent) { const tags = event.tags || []; const priceTag = tags.find((t) => t[0] === "price"); - const shippingTag = tags.find((t) => t[0] === "shipping"); + const parsedShipping = parseShippingFromTags(tags); const price = priceTag ? Number(priceTag[1]) : 0; const currency = priceTag ? priceTag[2] || "" : ""; - const shippingType = shippingTag ? shippingTag[1] || "" : ""; - const shippingCost = - shippingTag && shippingTag[2] ? Number(shippingTag[2]) : 0; - - const effectiveShippingCost = - shippingType === "Free" || - shippingType === "Free/Pickup" || - shippingType === "Pickup" || - shippingType === "N/A" - ? 0 - : shippingCost; + const shippingType = parsedShipping?.shippingType; + const shippingCost = parsedShipping?.shippingCost; + const effectiveShippingCost = getEffectiveShippingCost( + shippingType, + shippingCost + ); + const shippingCostForTotal = effectiveShippingCost ?? 0; return { id: event.id, @@ -48,7 +48,7 @@ function buildCatalogEntry(event: NostrEvent) { unit: "per item", shippingCost: effectiveShippingCost, shippingType: shippingType || "N/A", - totalEstimate: price + effectiveShippingCost, + totalEstimate: price + shippingCostForTotal, paymentMethods: ["lightning", "cashu"], }, }; diff --git a/mcp/tools/read-tools.ts b/mcp/tools/read-tools.ts index 769c1014..99123976 100644 --- a/mcp/tools/read-tools.ts +++ b/mcp/tools/read-tools.ts @@ -7,6 +7,10 @@ import { validateDiscountCode, getDbPool, } from "@/utils/db/db-service"; +import { + getEffectiveShippingCost, + parseShippingFromTags, +} from "@/utils/parsers/product-tag-helpers"; import { NostrEvent } from "@/utils/types/types"; import { registerTool } from "./register-tool"; @@ -36,25 +40,23 @@ function determinePaymentMethods( function buildPricingBlock( price: number, currency: string, - shippingType: string, - shippingCost: number, + shippingType?: string, + shippingCost?: number, quantity: number = 1, paymentMethods?: string[] ) { - const effectiveShippingCost = - shippingType === "Free" || - shippingType === "Free/Pickup" || - shippingType === "Pickup" || - shippingType === "N/A" - ? 0 - : shippingCost; + const effectiveShippingCost = getEffectiveShippingCost( + shippingType, + shippingCost + ); + const shippingCostForTotal = effectiveShippingCost ?? 0; return { amount: price, currency: currency || "sats", unit: "per item", shippingCost: effectiveShippingCost, shippingType: shippingType || "N/A", - totalEstimate: price * quantity + effectiveShippingCost, + totalEstimate: price * quantity + shippingCostForTotal, paymentMethods: paymentMethods || ["lightning", "cashu"], }; } @@ -62,13 +64,12 @@ function buildPricingBlock( function parseProductEvent(event: NostrEvent) { const tags = event.tags || []; const priceTag = tags.find((t) => t[0] === "price"); - const shippingTag = tags.find((t) => t[0] === "shipping"); + const parsedShipping = parseShippingFromTags(tags); const price = priceTag ? Number(priceTag[1]) : 0; const currency = priceTag ? priceTag[2] || "" : ""; - const shippingType = shippingTag ? shippingTag[1] || "" : ""; - const shippingCost = - shippingTag && shippingTag[2] ? Number(shippingTag[2]) : 0; + const shippingType = parsedShipping?.shippingType; + const shippingCost = parsedShipping?.shippingCost; const sizes = tags .filter((t) => t[0] === "size" && t[1]) diff --git a/utils/parsers/__tests__/product-parser-functions.test.ts b/utils/parsers/__tests__/product-parser-functions.test.ts index b01bc586..6a9e5b29 100644 --- a/utils/parsers/__tests__/product-parser-functions.test.ts +++ b/utils/parsers/__tests__/product-parser-functions.test.ts @@ -7,6 +7,13 @@ jest.mock("@/components/utility-components/display-monetary-info", () => ({ })); const mockedCalculateTotalCost = calculateTotalCost as jest.Mock; +const totalCostWithoutShipping = ({ + price, + shippingCost, +}: { + price: number; + shippingCost?: number; +}) => price + (shippingCost ?? 0); describe("parseTags", () => { const baseEvent: NostrEvent = { @@ -78,6 +85,76 @@ describe("parseTags", () => { expect(result.shippingCost).toBe(10); }); + it("should ignore legacy 2-value shipping tags", () => { + mockedCalculateTotalCost.mockImplementation(totalCostWithoutShipping); + + const event = { + ...baseEvent, + tags: [ + ["price", "50", "USD"], + ["shipping", "5", "USD"], + ], + }; + const result = parseTags(event)!; + + expect(result.shippingType).toBeUndefined(); + expect(result.shippingCost).toBeUndefined(); + expect(result.totalCost).toBe(50); + }); + + it("should ignore legacy 1-value shipping tags", () => { + mockedCalculateTotalCost.mockImplementation(totalCostWithoutShipping); + + const event = { + ...baseEvent, + tags: [ + ["price", "50", "USD"], + ["shipping", "Free"], + ], + }; + const result = parseTags(event)!; + + expect(result.shippingType).toBeUndefined(); + expect(result.shippingCost).toBeUndefined(); + expect(result.totalCost).toBe(50); + }); + + it("should ignore malformed modern shipping tags with non-numeric cost", () => { + mockedCalculateTotalCost.mockImplementation(totalCostWithoutShipping); + + const event = { + ...baseEvent, + tags: [ + ["price", "50", "USD"], + ["shipping", "Added Cost", "not-a-number", "USD"], + ], + }; + const result = parseTags(event)!; + + expect(result.shippingType).toBeUndefined(); + expect(result.shippingCost).toBeUndefined(); + expect(result.totalCost).toBe(50); + }); + + it("should ignore malformed modern shipping tags with negative cost", () => { + mockedCalculateTotalCost.mockImplementation(totalCostWithoutShipping); + + const event = { + ...baseEvent, + tags: [ + ["price", "50", "USD"], + ["shipping", "Added Cost", "-10", "USD"], + ], + }; + const result = parseTags(event)!; + + expect(result.shippingType).toBeUndefined(); + expect(result.shippingCost).toBeUndefined(); + expect(result.totalCost).toBe(50); + }); + + + it("should parse various content-warning tags as true", () => { const event1 = { ...baseEvent, tags: [["content-warning"]] }; expect(parseTags(event1)!.contentWarning).toBe(true); diff --git a/utils/parsers/__tests__/product-tag-helpers.test.ts b/utils/parsers/__tests__/product-tag-helpers.test.ts new file mode 100644 index 00000000..52465c0e --- /dev/null +++ b/utils/parsers/__tests__/product-tag-helpers.test.ts @@ -0,0 +1,72 @@ +import { + getEffectiveShippingCost, + parseShippingFromTags, + parseShippingTag, +} from "../product-tag-helpers"; + +describe("parseShippingTag", () => { + it("parses the modern 3-value shipping tag format", () => { + expect( + parseShippingTag(["shipping", "Added Cost", "10", "USD"]) + ).toEqual({ + shippingType: "Added Cost", + shippingCost: 10, + }); + }); + + it("ignores legacy 2-value shipping tags", () => { + expect(parseShippingTag(["shipping", "5", "USD"])).toBeUndefined(); + }); + + it("ignores legacy 1-value shipping tags", () => { + expect(parseShippingTag(["shipping", "Free"])).toBeUndefined(); + }); + + it("ignores malformed shipping tags with non-numeric cost", () => { + expect( + parseShippingTag(["shipping", "Added Cost", "not-a-number", "USD"]) + ).toBeUndefined(); + }); + + it("ignores malformed shipping tags with negative cost", () => { + expect( + parseShippingTag(["shipping", "Added Cost", "-10", "USD"]) + ).toBeUndefined(); + }); +}); + +describe("getEffectiveShippingCost", () => { + it("returns null when shipping metadata is missing", () => { + expect(getEffectiveShippingCost(undefined, undefined)).toBeNull(); + }); + + it("returns zero for non-paid shipping types", () => { + expect(getEffectiveShippingCost("Free", 15)).toBe(0); + expect(getEffectiveShippingCost("Free/Pickup", 15)).toBe(0); + expect(getEffectiveShippingCost("Pickup", 15)).toBe(0); + expect(getEffectiveShippingCost("N/A", 15)).toBe(0); + }); + + it("returns null when a paid shipping cost is invalid", () => { + expect(getEffectiveShippingCost("Added Cost", Number.NaN)).toBeNull(); + expect(getEffectiveShippingCost("Added Cost", -15)).toBeNull(); + }); + + it("returns the parsed cost for paid shipping", () => { + expect(getEffectiveShippingCost("Added Cost", 15)).toBe(15); + }); +}); + +describe("parseShippingFromTags", () => { + it("accepts a later valid modern shipping tag after legacy tags", () => { + expect( + parseShippingFromTags([ + ["shipping", "5", "USD"], + ["shipping", "Added Cost", "12", "USD"], + ]) + ).toEqual({ + shippingType: "Added Cost", + shippingCost: 12, + }); + }); +}); diff --git a/utils/parsers/product-parser-functions.ts b/utils/parsers/product-parser-functions.ts index 0be1b959..d308e3f0 100644 --- a/utils/parsers/product-parser-functions.ts +++ b/utils/parsers/product-parser-functions.ts @@ -1,5 +1,6 @@ import { ShippingOptionsType } from "@/utils/STATIC-VARIABLES"; import { calculateTotalCost } from "@/components/utility-components/display-monetary-info"; +import { parseShippingTag } from "@/utils/parsers/product-tag-helpers"; import { NostrEvent } from "@/utils/types/types"; export type ProductData = { @@ -94,13 +95,11 @@ export const parseTags = (productEvent: NostrEvent) => { parsedData.currency = currency!; break; case "shipping": - if (values.length === 3) { - const [shippingType, cost, _currency] = values; - parsedData.shippingType = shippingType as ShippingOptionsType; - parsedData.shippingCost = Number(cost); - break; + const parsedShipping = parseShippingTag(tag); + if (parsedShipping) { + parsedData.shippingType = parsedShipping.shippingType; + parsedData.shippingCost = parsedShipping.shippingCost; } - break; case "d": parsedData.d = values[0]; diff --git a/utils/parsers/product-tag-helpers.ts b/utils/parsers/product-tag-helpers.ts new file mode 100644 index 00000000..2c5b1688 --- /dev/null +++ b/utils/parsers/product-tag-helpers.ts @@ -0,0 +1,85 @@ +import { + SHIPPING_OPTIONS, + ShippingOptionsType, +} from "@/utils/STATIC-VARIABLES"; + +export type ParsedShippingTag = { + shippingType: ShippingOptionsType; + shippingCost: number; +}; + +export function parseShippingTag( + tag?: string[] +): ParsedShippingTag | undefined { + if (!tag || tag[0] !== "shipping" || tag.length !== 4) { + return; + } + + const [, shippingType, rawShippingCost, shippingCurrency] = tag; + + if ( + !shippingType || + !shippingCurrency || + !SHIPPING_OPTIONS.includes(shippingType as ShippingOptionsType) + ) { + return; + } + + if (!rawShippingCost?.trim()) { + return; + } + + const shippingCost = Number(rawShippingCost); + if (!Number.isFinite(shippingCost) || shippingCost < 0) { + return; + } + + return { + shippingType: shippingType as ShippingOptionsType, + shippingCost, + }; +} + +export function parseShippingFromTags( + tags: string[][] +): ParsedShippingTag | undefined { + let parsedShipping: ParsedShippingTag | undefined; + + for (const tag of tags) { + if (tag[0] !== "shipping") continue; + + const parsed = parseShippingTag(tag); + if (parsed) { + parsedShipping = parsed; + } + } + + return parsedShipping; +} + +export function getEffectiveShippingCost( + shippingType?: string, + shippingCost?: number +): number | null { + if (!shippingType) { + return null; + } + if ( + shippingType === "Free" || + shippingType === "Free/Pickup" || + shippingType === "Pickup" || + shippingType === "N/A" + ) { + return 0; + } + + if ( + typeof shippingCost !== "number" || + !Number.isFinite(shippingCost) || + shippingCost < 0 + ) { + return null; + } + + return shippingCost; +}