/** * iHeartJane Platform Normalizer * * Normalizes raw Jane/Algolia product responses to canonical format. * * Jane uses Algolia for product search. Key differences from Dutchie: * - Product ID is numeric (not MongoDB ObjectId) * - Prices are per-weight (price_gram, price_eighth_ounce, etc.) * - Category = strain type (hybrid, indica, sativa) * - Kind = product type (vape, flower, edible, etc.) */ import { BaseNormalizer } from './base'; import { NormalizedProduct, NormalizedPricing, NormalizedAvailability, NormalizedBrand, NormalizedCategory, } from '../types'; export class JaneNormalizer extends BaseNormalizer { readonly platform = 'jane'; readonly supportedVersions = [1]; // ============================================================ // EXTRACTION // ============================================================ extractProducts(rawJson: any): any[] { // Algolia response format: { hits: [...] } if (rawJson?.hits && Array.isArray(rawJson.hits)) { return rawJson.hits; } // Direct array of products if (Array.isArray(rawJson)) { return rawJson; } // Products array wrapper if (rawJson?.products && Array.isArray(rawJson.products)) { return rawJson.products; } // Try data.hits (nested response) if (rawJson?.data?.hits && Array.isArray(rawJson.data.hits)) { return rawJson.data.hits; } console.warn('[JaneNormalizer] Could not extract products from payload'); return []; } validatePayload(rawJson: any): { valid: boolean; errors: string[] } { const errors: string[] = []; if (!rawJson) { errors.push('Payload is null or undefined'); return { valid: false, errors }; } const products = this.extractProducts(rawJson); if (products.length === 0) { errors.push('No products found in payload'); } // Check for Algolia errors if (rawJson?.message) { errors.push(`Algolia error: ${rawJson.message}`); } return { valid: errors.length === 0, errors }; } // ============================================================ // NORMALIZATION // ============================================================ protected normalizeProduct(rawProduct: any, dispensaryId: number): NormalizedProduct | null { const externalId = rawProduct.product_id || rawProduct.objectID; if (!externalId) { console.warn('[JaneNormalizer] Product missing ID, skipping'); return null; } const name = rawProduct.name; if (!name) { console.warn(`[JaneNormalizer] Product ${externalId} missing name, skipping`); return null; } return { externalProductId: String(externalId), dispensaryId, platform: 'jane', platformDispensaryId: '', // Will be set by handler // Core fields name, brandName: rawProduct.brand || null, brandId: rawProduct.product_brand_id ? String(rawProduct.product_brand_id) : null, category: rawProduct.kind || null, // Jane's "kind" = product type (vape, flower, etc.) subcategory: rawProduct.kind_subtype || rawProduct.root_subtype || null, type: rawProduct.kind || null, strainType: rawProduct.category || null, // Jane's "category" = strain (hybrid, indica, sativa) // Potency thcPercent: rawProduct.percent_thc ?? null, cbdPercent: rawProduct.percent_cbd ?? null, thcContent: rawProduct.percent_thc ?? null, cbdContent: rawProduct.percent_cbd ?? null, // Status - Jane products in search are always active status: 'Active', isActive: true, medicalOnly: rawProduct.store_types?.includes('medical') && !rawProduct.store_types?.includes('recreational'), recOnly: rawProduct.store_types?.includes('recreational') && !rawProduct.store_types?.includes('medical'), // Images primaryImageUrl: rawProduct.image_urls?.[0] || null, images: (rawProduct.image_urls || []).map((url: string, i: number) => ({ url, position: i, })), // Raw reference rawProduct, }; } protected normalizePricing(rawProduct: any): NormalizedPricing | null { const externalId = rawProduct.product_id || rawProduct.objectID; if (!externalId) return null; // Jane has multiple price fields by weight const prices: number[] = []; const specialPrices: number[] = []; // Collect all regular prices if (rawProduct.price_gram) prices.push(rawProduct.price_gram); if (rawProduct.price_each) prices.push(rawProduct.price_each); if (rawProduct.price_half_gram) prices.push(rawProduct.price_half_gram); if (rawProduct.price_eighth_ounce) prices.push(rawProduct.price_eighth_ounce); if (rawProduct.price_quarter_ounce) prices.push(rawProduct.price_quarter_ounce); if (rawProduct.price_half_ounce) prices.push(rawProduct.price_half_ounce); if (rawProduct.price_ounce) prices.push(rawProduct.price_ounce); if (rawProduct.price_two_gram) prices.push(rawProduct.price_two_gram); // Collect special/discounted prices if (rawProduct.special_price_gram) specialPrices.push(rawProduct.special_price_gram); if (rawProduct.special_price_each) specialPrices.push(rawProduct.special_price_each); if (rawProduct.discounted_price_gram) specialPrices.push(rawProduct.discounted_price_gram); if (rawProduct.discounted_price_each) specialPrices.push(rawProduct.discounted_price_each); // Also check bucket_price and sort_price if (rawProduct.bucket_price && !prices.includes(rawProduct.bucket_price)) { prices.push(rawProduct.bucket_price); } // Determine if on special const isOnSpecial = specialPrices.length > 0 || rawProduct.has_brand_discount === true; // Calculate discount percent let discountPercent: number | null = null; if (isOnSpecial && prices.length > 0 && specialPrices.length > 0) { const regularMin = Math.min(...prices); const specialMin = Math.min(...specialPrices); if (regularMin > 0 && specialMin < regularMin) { discountPercent = Math.round(((regularMin - specialMin) / regularMin) * 100); } } // Get special name from brand_special_prices let specialName: string | null = null; if (rawProduct.brand_special_prices) { const firstSpecial = Object.values(rawProduct.brand_special_prices)[0] as any; if (firstSpecial?.title) { specialName = firstSpecial.title; } } return { externalProductId: String(externalId), // Use minimum price for display priceRec: this.toCents(this.getMin(prices)), priceRecMin: this.toCents(this.getMin(prices)), priceRecMax: this.toCents(this.getMax(prices)), priceRecSpecial: this.toCents(this.getMin(specialPrices)), // Jane doesn't distinguish med pricing in Algolia response priceMed: null, priceMedMin: null, priceMedMax: null, priceMedSpecial: null, isOnSpecial, specialName, discountPercent, }; } protected normalizeAvailability(rawProduct: any): NormalizedAvailability | null { const externalId = rawProduct.product_id || rawProduct.objectID; if (!externalId) return null; // Jane products in Algolia are in-stock (OOS products aren't returned) const availableForPickup = rawProduct.available_for_pickup ?? true; const availableForDelivery = rawProduct.available_for_delivery ?? false; const inStock = availableForPickup || availableForDelivery; // Jane doesn't expose quantity in Algolia const quantity = rawProduct.max_cart_quantity || null; return { externalProductId: String(externalId), inStock, stockStatus: inStock ? 'in_stock' : 'out_of_stock', quantity, quantityAvailable: quantity, isBelowThreshold: false, // Jane doesn't expose this optionsBelowThreshold: false, }; } protected extractBrand(rawProduct: any): NormalizedBrand | null { const brandName = rawProduct.brand; if (!brandName) return null; return { externalBrandId: rawProduct.product_brand_id ? String(rawProduct.product_brand_id) : null, name: brandName, slug: this.slugify(brandName), logoUrl: rawProduct.brand_logo_url || null, }; } protected extractCategory(rawProduct: any): NormalizedCategory | null { // Use "kind" as the primary category (vape, flower, edible, etc.) const categoryName = rawProduct.kind; if (!categoryName) return null; return { name: categoryName, slug: this.slugify(categoryName), parentCategory: null, }; } }