Platform isolation:
- Rename handlers to {task}-{platform}.ts convention
- Deprecate -curl variants (now _deprecated-*)
- Platform-based routing in task-worker.ts
- Add Jane platform handlers and client
Evomi geo-targeting:
- Add dynamic proxy URL builder with state/city targeting
- Session stickiness per worker per state (30 min)
- Fallback to static proxy table when API unavailable
- Add proxy tracking columns to worker_tasks
Proxy management:
- New /proxies admin page for visibility
- Track proxy_ip, proxy_geo, proxy_source per task
- Show active sessions and task history
Validation filtering:
- Filter by validated stores (platform_dispensary_id + menu_url)
- Mark incomplete stores as deprecated
- Update all dashboard/stats queries
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
251 lines
8.4 KiB
TypeScript
251 lines
8.4 KiB
TypeScript
/**
|
|
* 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,
|
|
};
|
|
}
|
|
}
|