Files
cannaiq/backend/src/hydration/normalizers/jane.ts
Kelly c215d11a84 feat: Platform isolation, Evomi geo-targeting, proxy management
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>
2025-12-13 15:16:48 -07:00

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,
};
}
}