## Worker System - Role-agnostic workers that can handle any task type - Pod-based architecture with StatefulSet (5-15 pods, 5 workers each) - Custom pod names (Aethelgard, Xylos, Kryll, etc.) - Worker registry with friendly names and resource monitoring - Hub-and-spoke visualization on JobQueue page ## Stealth & Anti-Detection (REQUIRED) - Proxies are MANDATORY - workers fail to start without active proxies - CrawlRotator initializes on worker startup - Loads proxies from `proxies` table - Auto-rotates proxy + fingerprint on 403 errors - 12 browser fingerprints (Chrome, Firefox, Safari, Edge) - Locale/timezone matching for geographic consistency ## Task System - Renamed product_resync → product_refresh - Task chaining: store_discovery → entry_point → product_discovery - Priority-based claiming with FOR UPDATE SKIP LOCKED - Heartbeat and stale task recovery ## UI Updates - JobQueue: Pod visualization, resource monitoring on hover - WorkersDashboard: Simplified worker list - Removed unused filters from task list ## Other - IP2Location service for visitor analytics - Findagram consumer features scaffolding - Documentation updates 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
377 lines
12 KiB
JavaScript
377 lines
12 KiB
JavaScript
// Findadispo API Client
|
|
// Connects to /api/v1/* endpoints with X-API-Key authentication
|
|
|
|
import { API_CONFIG } from '../lib/utils';
|
|
|
|
const API_BASE_URL = API_CONFIG.DATA_API_URL;
|
|
const API_KEY = API_CONFIG.DATA_API_KEY;
|
|
|
|
// Helper function to make authenticated API requests
|
|
async function apiRequest(endpoint, options = {}) {
|
|
const url = `${API_BASE_URL}${endpoint}`;
|
|
|
|
const headers = {
|
|
'Content-Type': 'application/json',
|
|
...(API_KEY && { 'X-API-Key': API_KEY }),
|
|
...options.headers,
|
|
};
|
|
|
|
const response = await fetch(url, {
|
|
...options,
|
|
headers,
|
|
});
|
|
|
|
if (!response.ok) {
|
|
const error = await response.json().catch(() => ({ message: 'Request failed' }));
|
|
throw new Error(error.message || `API request failed: ${response.status}`);
|
|
}
|
|
|
|
return response.json();
|
|
}
|
|
|
|
/**
|
|
* Fetch dispensaries with optional filters
|
|
* @param {Object} params - Query parameters
|
|
* @param {string} params.search - Search query (name, city, zip)
|
|
* @param {string} params.state - State filter
|
|
* @param {string} params.city - City filter
|
|
* @param {number} params.limit - Results per page
|
|
* @param {number} params.offset - Pagination offset
|
|
* @returns {Promise<{dispensaries: Array, total: number, limit: number, offset: number}>}
|
|
*/
|
|
export async function getDispensaries(params = {}) {
|
|
const queryParams = new URLSearchParams();
|
|
|
|
if (params.search) queryParams.append('search', params.search);
|
|
if (params.state) queryParams.append('state', params.state);
|
|
if (params.city) queryParams.append('city', params.city);
|
|
if (params.limit) queryParams.append('limit', params.limit);
|
|
if (params.offset) queryParams.append('offset', params.offset);
|
|
|
|
const queryString = queryParams.toString();
|
|
const endpoint = `/api/v1/dispensaries${queryString ? `?${queryString}` : ''}`;
|
|
|
|
return apiRequest(endpoint);
|
|
}
|
|
|
|
/**
|
|
* Fetch a single dispensary by slug or ID
|
|
* @param {string} slugOrId - Dispensary slug or ID
|
|
* @returns {Promise<Object>}
|
|
*/
|
|
export async function getDispensaryBySlug(slugOrId) {
|
|
return apiRequest(`/api/v1/dispensaries/${slugOrId}`);
|
|
}
|
|
|
|
/**
|
|
* Fetch dispensary by ID
|
|
* @param {number} id - Dispensary ID
|
|
* @returns {Promise<Object>}
|
|
*/
|
|
export async function getDispensaryById(id) {
|
|
return apiRequest(`/api/v1/dispensaries/${id}`);
|
|
}
|
|
|
|
/**
|
|
* Map API dispensary response to UI format
|
|
* Converts snake_case API fields to camelCase UI fields
|
|
* and adds any default values for missing data
|
|
* @param {Object} apiDispensary - Dispensary from API
|
|
* @returns {Object} - Dispensary formatted for UI
|
|
*/
|
|
export function mapDispensaryForUI(apiDispensary) {
|
|
// Build full address from components
|
|
const addressParts = [
|
|
apiDispensary.address,
|
|
apiDispensary.city,
|
|
apiDispensary.state,
|
|
apiDispensary.zip
|
|
].filter(Boolean);
|
|
const fullAddress = addressParts.join(', ');
|
|
|
|
// Format hours for display
|
|
let hoursDisplay = 'Hours not available';
|
|
if (apiDispensary.hours) {
|
|
if (typeof apiDispensary.hours === 'string') {
|
|
hoursDisplay = apiDispensary.hours;
|
|
} else if (apiDispensary.hours.formatted) {
|
|
hoursDisplay = apiDispensary.hours.formatted;
|
|
} else {
|
|
// Try to format from day-by-day structure
|
|
hoursDisplay = formatHoursFromObject(apiDispensary.hours);
|
|
}
|
|
}
|
|
|
|
// Check if currently open based on hours
|
|
const isOpen = checkIfOpen(apiDispensary.hours);
|
|
|
|
// Handle location from nested object or flat fields
|
|
const lat = apiDispensary.location?.latitude || apiDispensary.latitude;
|
|
const lng = apiDispensary.location?.longitude || apiDispensary.longitude;
|
|
|
|
return {
|
|
id: apiDispensary.id,
|
|
name: apiDispensary.dba_name || apiDispensary.name,
|
|
slug: apiDispensary.slug || generateSlug(apiDispensary.name),
|
|
address: fullAddress,
|
|
city: apiDispensary.city,
|
|
state: apiDispensary.state,
|
|
zip: apiDispensary.zip,
|
|
phone: apiDispensary.phone || null,
|
|
hours: hoursDisplay,
|
|
hoursData: apiDispensary.hours || null, // Keep raw hours data for open/close logic
|
|
rating: apiDispensary.rating || 0,
|
|
reviews: apiDispensary.review_count || 0,
|
|
distance: apiDispensary.distance || null,
|
|
lat: lat,
|
|
lng: lng,
|
|
image: apiDispensary.image_url || 'https://images.unsplash.com/photo-1587854692152-cbe660dbde88?w=400&h=300&fit=crop',
|
|
isOpen: isOpen,
|
|
amenities: apiDispensary.amenities || [],
|
|
description: apiDispensary.description || 'Cannabis dispensary',
|
|
website: apiDispensary.website,
|
|
menuUrl: apiDispensary.menu_url,
|
|
menuType: apiDispensary.menu_type || apiDispensary.platform,
|
|
productCount: apiDispensary.product_count || 0,
|
|
inStockCount: apiDispensary.in_stock_count || 0,
|
|
lastUpdated: apiDispensary.last_updated,
|
|
dataAvailable: apiDispensary.data_available ?? true,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Generate a URL-friendly slug from dispensary name
|
|
*/
|
|
function generateSlug(name) {
|
|
if (!name) return '';
|
|
return name
|
|
.toLowerCase()
|
|
.replace(/[^a-z0-9]+/g, '-')
|
|
.replace(/^-|-$/g, '');
|
|
}
|
|
|
|
/**
|
|
* Format hours from day-by-day object to readable string
|
|
*/
|
|
function formatHoursFromObject(hours) {
|
|
if (!hours || typeof hours !== 'object') return 'Hours not available';
|
|
|
|
// Try to create a simple string like "Mon-Sat 9am-9pm"
|
|
const days = ['monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday', 'sunday'];
|
|
const shortDays = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'];
|
|
|
|
let result = [];
|
|
for (let i = 0; i < days.length; i++) {
|
|
const dayData = hours[days[i]];
|
|
if (dayData && dayData.open && dayData.close) {
|
|
result.push(`${shortDays[i]}: ${formatTime(dayData.open)}-${formatTime(dayData.close)}`);
|
|
}
|
|
}
|
|
|
|
return result.length > 0 ? result.join(', ') : 'Hours not available';
|
|
}
|
|
|
|
/**
|
|
* Format 24hr time to 12hr format
|
|
*/
|
|
function formatTime(time) {
|
|
if (!time) return '';
|
|
const [hours, minutes] = time.split(':');
|
|
const hour = parseInt(hours, 10);
|
|
const suffix = hour >= 12 ? 'pm' : 'am';
|
|
const displayHour = hour > 12 ? hour - 12 : hour === 0 ? 12 : hour;
|
|
return minutes === '00' ? `${displayHour}${suffix}` : `${displayHour}:${minutes}${suffix}`;
|
|
}
|
|
|
|
/**
|
|
* Check if dispensary is currently open based on hours data
|
|
*/
|
|
function checkIfOpen(hours) {
|
|
if (!hours || typeof hours !== 'object') return true; // Default to open if no data
|
|
|
|
const now = new Date();
|
|
const dayNames = ['sunday', 'monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday'];
|
|
const today = dayNames[now.getDay()];
|
|
const todayHours = hours[today];
|
|
|
|
if (!todayHours || !todayHours.open || !todayHours.close) return true;
|
|
|
|
const currentTime = now.getHours() * 60 + now.getMinutes();
|
|
const [openHour, openMin] = todayHours.open.split(':').map(Number);
|
|
const [closeHour, closeMin] = todayHours.close.split(':').map(Number);
|
|
|
|
const openTime = openHour * 60 + (openMin || 0);
|
|
const closeTime = closeHour * 60 + (closeMin || 0);
|
|
|
|
return currentTime >= openTime && currentTime <= closeTime;
|
|
}
|
|
|
|
/**
|
|
* Search dispensaries by query and filters
|
|
* This function provides a mockData-compatible interface
|
|
* @param {string} query - Search query
|
|
* @param {Object} filters - Filters (openNow, minRating, maxDistance, amenities)
|
|
* @returns {Promise<Array>} - Array of dispensaries
|
|
*/
|
|
export async function searchDispensaries(query, filters = {}) {
|
|
try {
|
|
const params = {
|
|
search: query || undefined,
|
|
limit: 100,
|
|
};
|
|
|
|
const response = await getDispensaries(params);
|
|
let dispensaries = (response.dispensaries || []).map(mapDispensaryForUI);
|
|
|
|
// Apply client-side filters that aren't supported by API
|
|
if (filters.openNow) {
|
|
dispensaries = dispensaries.filter(d => d.isOpen);
|
|
}
|
|
|
|
if (filters.minRating) {
|
|
dispensaries = dispensaries.filter(d => d.rating >= filters.minRating);
|
|
}
|
|
|
|
if (filters.maxDistance && filters.maxDistance < 100) {
|
|
dispensaries = dispensaries.filter(d => !d.distance || d.distance <= filters.maxDistance);
|
|
}
|
|
|
|
if (filters.amenities && filters.amenities.length > 0) {
|
|
dispensaries = dispensaries.filter(d =>
|
|
filters.amenities.every(amenity => d.amenities.includes(amenity))
|
|
);
|
|
}
|
|
|
|
return dispensaries;
|
|
} catch (error) {
|
|
console.error('Error searching dispensaries:', error);
|
|
return [];
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get list of unique cities for filter dropdown
|
|
* @returns {Promise<Array<string>>}
|
|
*/
|
|
export async function getCities() {
|
|
try {
|
|
const response = await getDispensaries({ limit: 500 });
|
|
const cities = [...new Set(
|
|
(response.dispensaries || [])
|
|
.map(d => d.city)
|
|
.filter(Boolean)
|
|
.sort()
|
|
)];
|
|
return cities;
|
|
} catch (error) {
|
|
console.error('Error fetching cities:', error);
|
|
return [];
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get list of unique states for filter dropdown
|
|
* @returns {Promise<Array<string>>}
|
|
*/
|
|
export async function getStates() {
|
|
try {
|
|
const response = await getDispensaries({ limit: 500 });
|
|
const states = [...new Set(
|
|
(response.dispensaries || [])
|
|
.map(d => d.state)
|
|
.filter(Boolean)
|
|
.sort()
|
|
)];
|
|
return states;
|
|
} catch (error) {
|
|
console.error('Error fetching states:', error);
|
|
return [];
|
|
}
|
|
}
|
|
|
|
// ============================================================
|
|
// PRODUCTS
|
|
// ============================================================
|
|
|
|
/**
|
|
* Fetch products for a specific dispensary
|
|
* @param {number} dispensaryId - Dispensary ID
|
|
* @param {Object} params - Query parameters
|
|
* @returns {Promise<{products: Array, pagination: Object}>}
|
|
*/
|
|
export async function getDispensaryProducts(dispensaryId, params = {}) {
|
|
const queryParams = new URLSearchParams();
|
|
|
|
if (params.category) queryParams.append('category', params.category);
|
|
if (params.brand) queryParams.append('brand', params.brand);
|
|
if (params.search) queryParams.append('search', params.search);
|
|
if (params.inStockOnly) queryParams.append('in_stock_only', 'true');
|
|
if (params.limit) queryParams.append('limit', params.limit);
|
|
if (params.offset) queryParams.append('offset', params.offset);
|
|
|
|
const queryString = queryParams.toString();
|
|
const endpoint = `/api/v1/dispensaries/${dispensaryId}/products${queryString ? `?${queryString}` : ''}`;
|
|
|
|
return apiRequest(endpoint);
|
|
}
|
|
|
|
/**
|
|
* Get categories available at a dispensary
|
|
* @param {number} dispensaryId - Dispensary ID
|
|
* @returns {Promise<Array>}
|
|
*/
|
|
export async function getDispensaryCategories(dispensaryId) {
|
|
return apiRequest(`/api/v1/dispensaries/${dispensaryId}/categories`);
|
|
}
|
|
|
|
/**
|
|
* Get brands available at a dispensary
|
|
* @param {number} dispensaryId - Dispensary ID
|
|
* @returns {Promise<Array>}
|
|
*/
|
|
export async function getDispensaryBrands(dispensaryId) {
|
|
return apiRequest(`/api/v1/dispensaries/${dispensaryId}/brands`);
|
|
}
|
|
|
|
/**
|
|
* Map API product to UI format
|
|
* @param {Object} apiProduct - Product from API
|
|
* @returns {Object} - Product formatted for UI
|
|
*/
|
|
export function mapProductForUI(apiProduct) {
|
|
const p = apiProduct;
|
|
|
|
// Parse price from string or number
|
|
const parsePrice = (val) => {
|
|
if (val === null || val === undefined) return null;
|
|
const num = typeof val === 'string' ? parseFloat(val) : val;
|
|
return isNaN(num) ? null : num;
|
|
};
|
|
|
|
return {
|
|
id: p.id,
|
|
name: p.name || p.name_raw,
|
|
brand: p.brand || p.brand_name || p.brand_name_raw,
|
|
category: p.category || p.type || p.category_raw,
|
|
subcategory: p.subcategory || p.subcategory_raw,
|
|
strainType: p.strain_type,
|
|
image: p.image_url || p.primary_image_url,
|
|
thc: p.thc || p.thc_percent || p.thc_percentage,
|
|
cbd: p.cbd || p.cbd_percent || p.cbd_percentage,
|
|
price: parsePrice(p.price_rec) || parsePrice(p.regular_price) || parsePrice(p.price),
|
|
salePrice: parsePrice(p.price_rec_special) || parsePrice(p.sale_price),
|
|
inStock: p.in_stock !== undefined ? p.in_stock : p.stock_status === 'in_stock',
|
|
stockStatus: p.stock_status,
|
|
onSale: p.on_special || p.special || false,
|
|
updatedAt: p.updated_at || p.snapshot_at,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Get aggregate stats (product count, brand count, dispensary count)
|
|
* @returns {Promise<Object>}
|
|
*/
|
|
export async function getStats() {
|
|
return apiRequest('/api/v1/stats');
|
|
}
|