feat: Treez SSR support, task improvements, worker geo display
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
- Add SSR config extraction for Treez sites (BEST Dispensary) - Increase MAX_RETRIES from 3 to 5 for task failures - Update task list ordering: active > pending > failed > completed - Show detected proxy location in worker dashboard (from fingerprint) - Hardcode 'dutchie' menu_type in promotion.ts (remove deriveMenuType) - Update provider display to show actual provider names 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -151,18 +151,6 @@ function generateSlug(name: string, city: string, state: string): string {
|
|||||||
return base;
|
return base;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Derive menu_type from platform_menu_url pattern
|
|
||||||
*/
|
|
||||||
function deriveMenuType(url: string | null): string {
|
|
||||||
if (!url) return 'unknown';
|
|
||||||
if (url.includes('/dispensary/')) return 'standalone';
|
|
||||||
if (url.includes('/embedded-menu/')) return 'embedded';
|
|
||||||
if (url.includes('/stores/')) return 'standalone';
|
|
||||||
// Custom domain = embedded widget on store's site
|
|
||||||
if (!url.includes('dutchie.com')) return 'embedded';
|
|
||||||
return 'unknown';
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Log a promotion action to dutchie_promotion_log
|
* Log a promotion action to dutchie_promotion_log
|
||||||
@@ -415,7 +403,7 @@ async function promoteLocation(
|
|||||||
loc.timezone, // $15 timezone
|
loc.timezone, // $15 timezone
|
||||||
loc.platform_location_id, // $16 platform_dispensary_id
|
loc.platform_location_id, // $16 platform_dispensary_id
|
||||||
loc.platform_menu_url, // $17 menu_url
|
loc.platform_menu_url, // $17 menu_url
|
||||||
deriveMenuType(loc.platform_menu_url), // $18 menu_type
|
'dutchie', // $18 menu_type
|
||||||
loc.description, // $19 description
|
loc.description, // $19 description
|
||||||
loc.logo_image, // $20 logo_image
|
loc.logo_image, // $20 logo_image
|
||||||
loc.banner_image, // $21 banner_image
|
loc.banner_image, // $21 banner_image
|
||||||
|
|||||||
@@ -289,6 +289,102 @@ export function getStoreConfig(): TreezStoreConfig | null {
|
|||||||
return currentStoreConfig;
|
return currentStoreConfig;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract store config from page HTML for SSR sites.
|
||||||
|
*
|
||||||
|
* SSR sites (like BEST Dispensary) pre-render data and don't make client-side
|
||||||
|
* API requests. The config is embedded in __NEXT_DATA__ or window variables.
|
||||||
|
*
|
||||||
|
* Looks for:
|
||||||
|
* - __NEXT_DATA__.props.pageProps.msoStoreConfig.orgId / entityId
|
||||||
|
* - window.__SETTINGS__.msoOrgId / msoStoreEntityId
|
||||||
|
* - treezStores config in page data
|
||||||
|
*/
|
||||||
|
async function extractConfigFromPage(page: Page): Promise<TreezStoreConfig | null> {
|
||||||
|
console.log('[Treez Client] Attempting to extract config from page HTML (SSR fallback)...');
|
||||||
|
|
||||||
|
const config = await page.evaluate(() => {
|
||||||
|
// Try __NEXT_DATA__ first (Next.js SSR)
|
||||||
|
const nextDataEl = document.getElementById('__NEXT_DATA__');
|
||||||
|
if (nextDataEl) {
|
||||||
|
try {
|
||||||
|
const nextData = JSON.parse(nextDataEl.textContent || '{}');
|
||||||
|
const pageProps = nextData?.props?.pageProps;
|
||||||
|
|
||||||
|
// Look for MSO config in various locations
|
||||||
|
const msoConfig = pageProps?.msoStoreConfig || pageProps?.storeConfig || {};
|
||||||
|
const settings = pageProps?.settings || {};
|
||||||
|
|
||||||
|
// Extract org-id and entity-id
|
||||||
|
let orgId = msoConfig.orgId || msoConfig.msoOrgId || settings.msoOrgId;
|
||||||
|
let entityId = msoConfig.entityId || msoConfig.msoStoreEntityId || settings.msoStoreEntityId;
|
||||||
|
|
||||||
|
// Also check treezStores array
|
||||||
|
if (!orgId || !entityId) {
|
||||||
|
const treezStores = pageProps?.treezStores || nextData?.props?.treezStores;
|
||||||
|
if (treezStores && Array.isArray(treezStores) && treezStores.length > 0) {
|
||||||
|
const store = treezStores[0];
|
||||||
|
orgId = orgId || store.orgId || store.organization_id;
|
||||||
|
entityId = entityId || store.entityId || store.entity_id || store.storeId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for API settings
|
||||||
|
const apiSettings = pageProps?.apiSettings || settings.api || {};
|
||||||
|
|
||||||
|
if (orgId && entityId) {
|
||||||
|
return {
|
||||||
|
orgId,
|
||||||
|
entityId,
|
||||||
|
esUrl: apiSettings.esUrl || null,
|
||||||
|
apiKey: apiSettings.apiKey || null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Error parsing __NEXT_DATA__:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try window variables
|
||||||
|
const win = window as any;
|
||||||
|
if (win.__SETTINGS__) {
|
||||||
|
const s = win.__SETTINGS__;
|
||||||
|
if (s.msoOrgId && s.msoStoreEntityId) {
|
||||||
|
return {
|
||||||
|
orgId: s.msoOrgId,
|
||||||
|
entityId: s.msoStoreEntityId,
|
||||||
|
esUrl: s.esUrl || null,
|
||||||
|
apiKey: s.apiKey || null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!config || !config.orgId || !config.entityId) {
|
||||||
|
console.log('[Treez Client] Could not extract config from page');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build full config with defaults for missing values
|
||||||
|
const fullConfig: TreezStoreConfig = {
|
||||||
|
orgId: config.orgId,
|
||||||
|
entityId: config.entityId,
|
||||||
|
// Default ES URL pattern - gapcommerce is the common tenant
|
||||||
|
esUrl: config.esUrl || 'https://search-gapcommerce.gapcommerceapi.com/product/search',
|
||||||
|
// Use default API key from config
|
||||||
|
apiKey: config.apiKey || TREEZ_CONFIG.esApiKey,
|
||||||
|
};
|
||||||
|
|
||||||
|
console.log('[Treez Client] Extracted config from page (SSR):');
|
||||||
|
console.log(` ES URL: ${fullConfig.esUrl}`);
|
||||||
|
console.log(` Org ID: ${fullConfig.orgId}`);
|
||||||
|
console.log(` Entity ID: ${fullConfig.entityId}`);
|
||||||
|
|
||||||
|
return fullConfig;
|
||||||
|
}
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
// PRODUCT FETCHING (Direct API Approach)
|
// PRODUCT FETCHING (Direct API Approach)
|
||||||
// ============================================================
|
// ============================================================
|
||||||
@@ -343,9 +439,15 @@ export async function fetchAllProducts(
|
|||||||
// Wait for initial page load to trigger first API request
|
// Wait for initial page load to trigger first API request
|
||||||
await sleep(3000);
|
await sleep(3000);
|
||||||
|
|
||||||
// Check if we captured the store config
|
// Check if we captured the store config from network requests
|
||||||
if (!currentStoreConfig) {
|
if (!currentStoreConfig) {
|
||||||
console.error('[Treez Client] Failed to capture store config from browser requests');
|
console.log('[Treez Client] No API requests captured - trying SSR fallback...');
|
||||||
|
// For SSR sites, extract config from page HTML
|
||||||
|
currentStoreConfig = await extractConfigFromPage(page);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!currentStoreConfig) {
|
||||||
|
console.error('[Treez Client] Failed to capture store config from browser requests or page HTML');
|
||||||
throw new Error('Failed to capture Treez store config');
|
throw new Error('Failed to capture Treez store config');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -267,7 +267,7 @@ class TaskService {
|
|||||||
async completeTask(taskId: number, result?: Record<string, unknown>): Promise<boolean> {
|
async completeTask(taskId: number, result?: Record<string, unknown>): Promise<boolean> {
|
||||||
await pool.query(
|
await pool.query(
|
||||||
`UPDATE worker_tasks
|
`UPDATE worker_tasks
|
||||||
SET status = 'completed', completed_at = NOW(), result = $2
|
SET status = 'completed', completed_at = NOW(), result = $2, error_message = NULL
|
||||||
WHERE id = $1`,
|
WHERE id = $1`,
|
||||||
[taskId, result ? JSON.stringify(result) : null]
|
[taskId, result ? JSON.stringify(result) : null]
|
||||||
);
|
);
|
||||||
@@ -351,7 +351,7 @@ class TaskService {
|
|||||||
* Hard failures: Auto-retry up to MAX_RETRIES with exponential backoff
|
* Hard failures: Auto-retry up to MAX_RETRIES with exponential backoff
|
||||||
*/
|
*/
|
||||||
async failTask(taskId: number, errorMessage: string): Promise<boolean> {
|
async failTask(taskId: number, errorMessage: string): Promise<boolean> {
|
||||||
const MAX_RETRIES = 3;
|
const MAX_RETRIES = 5;
|
||||||
const isSoft = this.isSoftFailure(errorMessage);
|
const isSoft = this.isSoftFailure(errorMessage);
|
||||||
|
|
||||||
// Get current retry count
|
// Get current retry count
|
||||||
@@ -490,7 +490,15 @@ class TaskService {
|
|||||||
${poolJoin}
|
${poolJoin}
|
||||||
LEFT JOIN worker_registry w ON w.worker_id = t.worker_id
|
LEFT JOIN worker_registry w ON w.worker_id = t.worker_id
|
||||||
${whereClause}
|
${whereClause}
|
||||||
ORDER BY t.created_at DESC
|
ORDER BY
|
||||||
|
CASE t.status
|
||||||
|
WHEN 'active' THEN 1
|
||||||
|
WHEN 'pending' THEN 2
|
||||||
|
WHEN 'failed' THEN 3
|
||||||
|
WHEN 'completed' THEN 4
|
||||||
|
ELSE 5
|
||||||
|
END,
|
||||||
|
t.created_at DESC
|
||||||
LIMIT ${limit} OFFSET ${offset}`,
|
LIMIT ${limit} OFFSET ${offset}`,
|
||||||
params
|
params
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,29 +1,36 @@
|
|||||||
/**
|
/**
|
||||||
* Provider Display Names
|
* Provider Display Names
|
||||||
*
|
*
|
||||||
* Maps internal provider identifiers to safe display labels.
|
* Maps internal menu_type values to display labels.
|
||||||
* Internal identifiers (menu_type, product_provider, crawler_type) remain unchanged.
|
* - standalone/embedded → dutchie (both are Dutchie platform)
|
||||||
* Only the display label shown to users is transformed.
|
* - treez → treez
|
||||||
|
* - jane/iheartjane → jane
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export const ProviderDisplayNames: Record<string, string> = {
|
export const ProviderDisplayNames: Record<string, string> = {
|
||||||
// All menu providers map to anonymous "Menu Feed" label
|
// Dutchie (standalone and embedded are both Dutchie)
|
||||||
dutchie: 'Menu Feed',
|
dutchie: 'dutchie',
|
||||||
treez: 'Menu Feed',
|
standalone: 'dutchie',
|
||||||
jane: 'Menu Feed',
|
embedded: 'dutchie',
|
||||||
iheartjane: 'Menu Feed',
|
|
||||||
blaze: 'Menu Feed',
|
// Other platforms
|
||||||
flowhub: 'Menu Feed',
|
treez: 'treez',
|
||||||
weedmaps: 'Menu Feed',
|
jane: 'jane',
|
||||||
leafly: 'Menu Feed',
|
iheartjane: 'jane',
|
||||||
leaflogix: 'Menu Feed',
|
|
||||||
tymber: 'Menu Feed',
|
// Future platforms
|
||||||
dispense: 'Menu Feed',
|
blaze: 'blaze',
|
||||||
|
flowhub: 'flowhub',
|
||||||
|
weedmaps: 'weedmaps',
|
||||||
|
leafly: 'leafly',
|
||||||
|
leaflogix: 'leaflogix',
|
||||||
|
tymber: 'tymber',
|
||||||
|
dispense: 'dispense',
|
||||||
|
|
||||||
// Catch-all
|
// Catch-all
|
||||||
unknown: 'Menu Feed',
|
unknown: 'unknown',
|
||||||
default: 'Menu Feed',
|
default: 'unknown',
|
||||||
'': 'Menu Feed',
|
'': 'unknown',
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -1,32 +1,36 @@
|
|||||||
/**
|
/**
|
||||||
* Provider Display Names
|
* Provider Display Names
|
||||||
*
|
*
|
||||||
* Maps internal provider identifiers to safe display labels.
|
* Maps internal menu_type values to display labels.
|
||||||
* Internal identifiers (menu_type, product_provider, crawler_type) remain unchanged.
|
* - standalone/embedded → Dutchie (both are Dutchie platform)
|
||||||
* Only the display label shown to users is transformed.
|
* - treez → Treez
|
||||||
*
|
* - jane/iheartjane → Jane
|
||||||
* IMPORTANT: Raw provider names (dutchie, treez, jane, etc.) must NEVER
|
|
||||||
* be displayed directly in the UI. Always use this utility.
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export const ProviderDisplayNames: Record<string, string> = {
|
export const ProviderDisplayNames: Record<string, string> = {
|
||||||
// All menu providers map to anonymous "Menu Feed" label
|
// Dutchie (standalone and embedded are both Dutchie)
|
||||||
dutchie: 'Menu Feed',
|
dutchie: 'dutchie',
|
||||||
treez: 'Menu Feed',
|
standalone: 'dutchie',
|
||||||
jane: 'Menu Feed',
|
embedded: 'dutchie',
|
||||||
iheartjane: 'Menu Feed',
|
|
||||||
blaze: 'Menu Feed',
|
// Other platforms
|
||||||
flowhub: 'Menu Feed',
|
treez: 'treez',
|
||||||
weedmaps: 'Menu Feed',
|
jane: 'jane',
|
||||||
leafly: 'Menu Feed',
|
iheartjane: 'jane',
|
||||||
leaflogix: 'Menu Feed',
|
|
||||||
tymber: 'Menu Feed',
|
// Future platforms
|
||||||
dispense: 'Menu Feed',
|
blaze: 'blaze',
|
||||||
|
flowhub: 'flowhub',
|
||||||
|
weedmaps: 'weedmaps',
|
||||||
|
leafly: 'leafly',
|
||||||
|
leaflogix: 'leaflogix',
|
||||||
|
tymber: 'tymber',
|
||||||
|
dispense: 'dispense',
|
||||||
|
|
||||||
// Catch-all
|
// Catch-all
|
||||||
unknown: 'Menu Feed',
|
unknown: 'unknown',
|
||||||
default: 'Menu Feed',
|
default: 'unknown',
|
||||||
'': 'Menu Feed',
|
'': 'unknown',
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -383,9 +383,10 @@ function PreflightSummary({ worker, poolOpen = true }: { worker: Worker; poolOpe
|
|||||||
const fingerprint = worker.fingerprint_data;
|
const fingerprint = worker.fingerprint_data;
|
||||||
const httpError = worker.preflight_http_error;
|
const httpError = worker.preflight_http_error;
|
||||||
const httpMs = worker.preflight_http_ms;
|
const httpMs = worker.preflight_http_ms;
|
||||||
// Geo from current_city/state columns, or fallback to fingerprint detected location
|
// Show DETECTED proxy location (from fingerprint), not assigned state
|
||||||
const geoState = worker.current_state || fingerprint?.detectedLocation?.region;
|
// This lets us verify the proxy is geo-targeted correctly
|
||||||
const geoCity = worker.current_city || fingerprint?.detectedLocation?.city;
|
const geoState = fingerprint?.detectedLocation?.region || worker.current_state;
|
||||||
|
const geoCity = fingerprint?.detectedLocation?.city || worker.current_city;
|
||||||
// Worker is ONLY qualified if http preflight passed AND has geo assigned
|
// Worker is ONLY qualified if http preflight passed AND has geo assigned
|
||||||
const hasGeo = Boolean(geoState);
|
const hasGeo = Boolean(geoState);
|
||||||
const isQualified = (worker.is_qualified || httpStatus === 'passed') && hasGeo;
|
const isQualified = (worker.is_qualified || httpStatus === 'passed') && hasGeo;
|
||||||
@@ -702,8 +703,9 @@ function WorkerSlot({
|
|||||||
|
|
||||||
const httpIp = worker?.http_ip;
|
const httpIp = worker?.http_ip;
|
||||||
const fingerprint = worker?.fingerprint_data;
|
const fingerprint = worker?.fingerprint_data;
|
||||||
const geoState = worker?.current_state || (fingerprint as any)?.detectedLocation?.region;
|
// Show DETECTED proxy location (from fingerprint), not assigned state
|
||||||
const geoCity = worker?.current_city || (fingerprint as any)?.detectedLocation?.city;
|
const geoState = (fingerprint as any)?.detectedLocation?.region || worker?.current_state;
|
||||||
|
const geoCity = (fingerprint as any)?.detectedLocation?.city || worker?.current_city;
|
||||||
const isQualified = worker?.is_qualified;
|
const isQualified = worker?.is_qualified;
|
||||||
|
|
||||||
// Build fingerprint tooltip
|
// Build fingerprint tooltip
|
||||||
|
|||||||
Reference in New Issue
Block a user