/** * Public API Routes for External Consumers (WordPress, etc.) * * These routes use the dutchie_az data pipeline and are protected by API key auth. * Supports two key types: * - 'internal': Full access to ALL dispensaries (for internal services, Hoodie Analytics) * - 'wordpress': Restricted to a single dispensary (for WordPress plugin integrations) */ import { Router, Request, Response, NextFunction } from 'express'; import { pool } from '../db/pool'; import ipaddr from 'ipaddr.js'; import { ApiScope, ApiKeyType, buildApiScope, getEffectiveDispensaryId, validateResourceAccess, buildDispensaryWhereClause, } from '../middleware/apiScope'; const router = Router(); // ============================================================ // TYPES // ============================================================ interface ApiKeyPermission { id: number; user_name: string; api_key: string; key_type: string; allowed_ips: string | null; allowed_domains: string | null; is_active: number; store_id: number; store_name: string; rate_limit: number; dutchie_az_store_id?: number; } interface PublicApiRequest extends Request { apiPermission?: ApiKeyPermission; scope?: ApiScope; } // ============================================================ // MIDDLEWARE // ============================================================ /** * Validates if an IP address matches any of the allowed IP patterns */ function isIpAllowed(clientIp: string, allowedIps: string[]): boolean { try { const clientAddr = ipaddr.process(clientIp); for (const allowedIp of allowedIps) { const trimmed = allowedIp.trim(); if (!trimmed) continue; if (trimmed.includes('/')) { try { const range = ipaddr.parseCIDR(trimmed); if (clientAddr.match(range)) { return true; } } catch (e) { console.warn(`Invalid CIDR notation: ${trimmed}`); continue; } } else { try { const allowedAddr = ipaddr.process(trimmed); if (clientAddr.toString() === allowedAddr.toString()) { return true; } } catch (e) { console.warn(`Invalid IP address: ${trimmed}`); continue; } } } return false; } catch (error) { console.error('Error processing client IP:', error); return false; } } /** * Validates if a domain matches any of the allowed domain patterns */ function isDomainAllowed(origin: string, allowedDomains: string[]): boolean { try { const url = new URL(origin); const domain = url.hostname; for (const allowedDomain of allowedDomains) { const trimmed = allowedDomain.trim(); if (!trimmed) continue; if (trimmed.startsWith('*.')) { const baseDomain = trimmed.substring(2); if (domain === baseDomain || domain.endsWith('.' + baseDomain)) { return true; } } else { if (domain === trimmed) { return true; } } } return false; } catch (error) { console.error('Error processing domain:', error); return false; } } // Trusted origins for consumer sites (bypass API key auth) const CONSUMER_TRUSTED_ORIGINS = [ 'https://findagram.co', 'https://www.findagram.co', 'https://findadispo.com', 'https://www.findadispo.com', 'http://localhost:3001', 'http://localhost:3002', ]; // Wildcard trusted origin patterns (*.domain.com) const CONSUMER_TRUSTED_PATTERNS = [ /^https:\/\/([a-z0-9-]+\.)?cannaiq\.co$/, /^https:\/\/([a-z0-9-]+\.)?cannabrands\.app$/, ]; // Trusted IPs for local development (bypass API key auth) const TRUSTED_IPS = ['127.0.0.1', '::1', '::ffff:127.0.0.1']; /** * Check if request is from localhost */ function isLocalhost(req: Request): boolean { const clientIp = req.ip || req.socket.remoteAddress || ''; return TRUSTED_IPS.includes(clientIp); } /** * Check if request is from a trusted consumer origin */ function isConsumerTrustedRequest(req: Request): boolean { // Localhost always bypasses if (isLocalhost(req)) { return true; } const origin = req.headers.origin; if (origin) { // Check exact matches if (CONSUMER_TRUSTED_ORIGINS.includes(origin)) { return true; } // Check wildcard patterns for (const pattern of CONSUMER_TRUSTED_PATTERNS) { if (pattern.test(origin)) { return true; } } } const referer = req.headers.referer; if (referer) { for (const trusted of CONSUMER_TRUSTED_ORIGINS) { if (referer.startsWith(trusted)) { return true; } } // Check wildcard patterns against referer origin try { const refererUrl = new URL(referer); const refererOrigin = refererUrl.origin; for (const pattern of CONSUMER_TRUSTED_PATTERNS) { if (pattern.test(refererOrigin)) { return true; } } } catch { // Invalid referer URL, ignore } } return false; } /** * Middleware to validate API key and build scope */ async function validatePublicApiKey( req: PublicApiRequest, res: Response, next: NextFunction ) { // Allow trusted consumer origins without API key (read-only access to all dispensaries) if (isConsumerTrustedRequest(req)) { // Create a synthetic internal permission for consumer sites req.scope = { type: 'internal', dispensaryIds: 'ALL', apiKeyId: 0, apiKeyName: 'consumer-site', rateLimit: 100, }; return next(); } const apiKey = req.headers['x-api-key'] as string; if (!apiKey) { return res.status(401).json({ error: 'Missing API key', message: 'Provide your API key in the X-API-Key header' }); } try { // Query WordPress permissions table with store info const result = await pool.query(` SELECT p.id, p.user_name, p.api_key, COALESCE(p.key_type, 'wordpress') as key_type, p.allowed_ips, p.allowed_domains, p.is_active, p.store_id, p.store_name, COALESCE(p.rate_limit, 100) as rate_limit FROM wp_dutchie_api_permissions p WHERE p.api_key = $1 AND p.is_active = 1 `, [apiKey]); if (result.rows.length === 0) { return res.status(401).json({ error: 'Invalid API key' }); } const permission = result.rows[0]; // Validate IP if configured (only for wordpress keys) const clientIp = (req.headers['x-forwarded-for'] as string)?.split(',')[0].trim() || (req.headers['x-real-ip'] as string) || req.ip || req.connection.remoteAddress || ''; if (permission.key_type === 'wordpress' && permission.allowed_ips) { const allowedIps = permission.allowed_ips.split('\n').filter((ip: string) => ip.trim()); if (allowedIps.length > 0 && !isIpAllowed(clientIp, allowedIps)) { return res.status(403).json({ error: 'IP address not allowed', client_ip: clientIp }); } } // Validate domain if configured (only for wordpress keys) const origin = req.get('origin') || req.get('referer') || ''; if (permission.key_type === 'wordpress' && permission.allowed_domains && origin) { const allowedDomains = permission.allowed_domains.split('\n').filter((d: string) => d.trim()); if (allowedDomains.length > 0 && !isDomainAllowed(origin, allowedDomains)) { return res.status(403).json({ error: 'Domain not allowed', origin: origin }); } } // Resolve the dutchie_az store for wordpress keys if (permission.key_type === 'wordpress' && permission.store_name) { const storeResult = await pool.query(` SELECT id FROM dispensaries WHERE LOWER(TRIM(name)) = LOWER(TRIM($1)) OR LOWER(TRIM(name)) LIKE LOWER(TRIM($1)) || '%' OR LOWER(TRIM($1)) LIKE LOWER(TRIM(name)) || '%' ORDER BY CASE WHEN LOWER(TRIM(name)) = LOWER(TRIM($1)) THEN 0 ELSE 1 END, id LIMIT 1 `, [permission.store_name]); if (storeResult.rows.length > 0) { permission.dutchie_az_store_id = storeResult.rows[0].id; } } // Update last_used_at timestamp (async, don't wait) pool.query(` UPDATE wp_dutchie_api_permissions SET last_used_at = CURRENT_TIMESTAMP WHERE id = $1 `, [permission.id]).catch((err: Error) => { console.error('Error updating last_used_at:', err); }); // Build the scope object req.apiPermission = permission; req.scope = buildApiScope({ id: permission.id, user_name: permission.user_name, key_type: permission.key_type, store_id: permission.store_id, rate_limit: permission.rate_limit, dutchie_az_store_id: permission.dutchie_az_store_id, }); next(); } catch (error: any) { console.error('Public API validation error:', error); const errorDetails: any = { error: 'Internal server error during API validation', message: 'An unexpected error occurred while validating your API key. Please try again or contact support.', }; if (error.code === 'ECONNREFUSED') { errorDetails.hint = 'Database connection failed'; } else if (error.code === '42P01') { errorDetails.hint = 'Database table not found - permissions table may not be initialized'; } else if (error.message?.includes('timeout')) { errorDetails.hint = 'Database query timeout'; } return res.status(500).json(errorDetails); } } // Apply middleware to all routes router.use(validatePublicApiKey); // ============================================================ // HELPER: Get dispensary ID for scoped queries // ============================================================ /** * Get the effective dispensary ID based on scope * Returns { dispensaryId, error } where error is set if wordpress key has no dispensary */ function getScopedDispensaryId(req: PublicApiRequest): { dispensaryId: number | null; error?: string } { const scope = req.scope!; if (scope.type === 'internal') { // Internal keys can optionally filter by dispensary_id param const requestedId = req.query.dispensary_id as string | undefined; if (requestedId) { const id = parseInt(requestedId, 10); return { dispensaryId: isNaN(id) ? null : id }; } return { dispensaryId: null }; // null = all dispensaries } // WordPress keys are locked to their dispensary if (scope.dispensaryIds === 'ALL' || scope.dispensaryIds.length === 0) { return { dispensaryId: null, error: `Menu data for ${req.apiPermission?.store_name || 'this dispensary'} is not yet available.` }; } return { dispensaryId: scope.dispensaryIds[0] }; } // ============================================================ // PRODUCT ENDPOINTS // ============================================================ /** * GET /api/v1/products * Get products for the authenticated dispensary * * Query params: * - category: Filter by product type (e.g., 'flower', 'edible') * - brand: Filter by brand name * - strain_type: Filter by strain type (indica, sativa, hybrid) * - min_price: Minimum price filter (in dollars) * - max_price: Maximum price filter (in dollars) * - min_thc: Minimum THC percentage filter * - max_thc: Maximum THC percentage filter * - on_special: Only return products on special (true/false) * - search: Search by name or brand * - in_stock_only: Only return in-stock products (default: false) * - limit: Max products to return (default: 100, max: 500) * - offset: Pagination offset (default: 0) * - dispensary_id: (internal keys only) Filter by specific dispensary * - sort_by: Sort field (name, price, thc, updated) (default: name) * - sort_dir: Sort direction (asc, desc) (default: asc) * - pricing_type: Price type to return (rec, med, all) (default: rec) * - include_variants: Include per-variant pricing/inventory (true/false) (default: false) */ router.get('/products', async (req: PublicApiRequest, res: Response) => { try { const scope = req.scope!; const { dispensaryId, error } = getScopedDispensaryId(req); if (error) { return res.status(503).json({ error: 'No menu data available', message: error, dispensary_name: req.apiPermission?.store_name }); } const { category, brand, strain_type, min_price, max_price, min_thc, max_thc, on_special, search, in_stock_only = 'false', limit = '100', offset = '0', sort_by = 'name', sort_dir = 'asc', pricing_type = 'rec', include_variants = 'false' } = req.query; // Build query let whereClause = 'WHERE 1=1'; const params: any[] = []; let paramIndex = 1; // Apply dispensary scope if (dispensaryId) { whereClause += ` AND p.dispensary_id = $${paramIndex}`; params.push(dispensaryId); paramIndex++; } else if (scope.dispensaryIds !== 'ALL') { // WordPress key but no dispensary resolved return res.status(503).json({ error: 'No menu data available', message: 'Menu data is not yet available for this dispensary.' }); } // Filter by stock status if requested if (in_stock_only === 'true' || in_stock_only === '1') { whereClause += ` AND p.stock_status = 'in_stock'`; } // Filter by category if (category) { whereClause += ` AND LOWER(p.category_raw) = LOWER($${paramIndex})`; params.push(category); paramIndex++; } // Filter by brand if (brand) { whereClause += ` AND LOWER(p.brand_name_raw) LIKE LOWER($${paramIndex})`; params.push(`%${brand}%`); paramIndex++; } // Filter by strain type (indica, sativa, hybrid) if (strain_type) { whereClause += ` AND LOWER(p.strain_type) = LOWER($${paramIndex})`; params.push(strain_type); paramIndex++; } // Filter by THC range if (min_thc) { whereClause += ` AND p.thc_percent >= $${paramIndex}`; params.push(parseFloat(min_thc as string)); paramIndex++; } if (max_thc) { whereClause += ` AND p.thc_percent <= $${paramIndex}`; params.push(parseFloat(max_thc as string)); paramIndex++; } // Filter by on special if (on_special === 'true' || on_special === '1') { whereClause += ` AND s.special = TRUE`; } // Search by name or brand if (search) { whereClause += ` AND (LOWER(p.name_raw) LIKE LOWER($${paramIndex}) OR LOWER(p.brand_name_raw) LIKE LOWER($${paramIndex}))`; params.push(`%${search}%`); paramIndex++; } // Enforce limits const limitNum = Math.min(parseInt(limit as string, 10) || 100, 500); const offsetNum = parseInt(offset as string, 10) || 0; // Build ORDER BY clause (use pricing_type for price sorting) const sortDirection = sort_dir === 'desc' ? 'DESC' : 'ASC'; let orderBy = 'p.name_raw ASC'; switch (sort_by) { case 'price': // View uses *_cents columns, but we SELECT as price_rec/price_med const sortPriceCol = pricing_type === 'med' ? 's.med_min_price_cents' : 's.rec_min_price_cents'; orderBy = `${sortPriceCol} ${sortDirection} NULLS LAST`; break; case 'thc': orderBy = `p.thc_percent ${sortDirection} NULLS LAST`; break; case 'updated': orderBy = `p.updated_at ${sortDirection}`; break; case 'name': default: orderBy = `p.name_raw ${sortDirection}`; } params.push(limitNum, offsetNum); // Determine which price column to use for filtering based on pricing_type // View uses *_cents columns, divide by 100 for dollar comparison const priceColumn = pricing_type === 'med' ? 's.med_min_price_cents / 100.0' : 's.rec_min_price_cents / 100.0'; // Query products with latest snapshot data // Uses store_products + v_product_snapshots (canonical tables with raw_data) const { rows: products } = await pool.query(` SELECT p.id, p.dispensary_id, p.provider_product_id as dutchie_id, p.name_raw as name, p.brand_name_raw as brand, p.category_raw as category, p.subcategory_raw as subcategory, p.strain_type, p.stock_status, p.thc_percent as thc, p.cbd_percent as cbd, p.image_url, p.created_at, p.updated_at, s.rec_min_price_cents / 100.0 as price_rec, s.med_min_price_cents / 100.0 as price_med, s.rec_min_special_price_cents / 100.0 as price_rec_special, s.med_min_special_price_cents / 100.0 as price_med_special, s.stock_quantity as total_quantity_available, s.special, s.crawled_at as snapshot_at, ${include_variants === 'true' || include_variants === '1' ? "s.raw_data->'POSMetaData'->'children' as variants_raw" : 'NULL as variants_raw'} FROM store_products p LEFT JOIN LATERAL ( SELECT * FROM v_product_snapshots WHERE store_product_id = p.id ORDER BY crawled_at DESC LIMIT 1 ) s ON true ${whereClause} ${min_price ? `AND ${priceColumn} >= ${parseFloat(min_price as string)}` : ''} ${max_price ? `AND ${priceColumn} <= ${parseFloat(max_price as string)}` : ''} ORDER BY ${orderBy} LIMIT $${paramIndex} OFFSET $${paramIndex + 1} `, params); // Get total count for pagination (include price filters if specified) const { rows: countRows } = await pool.query(` SELECT COUNT(*) as total FROM store_products p LEFT JOIN LATERAL ( SELECT rec_min_price_cents / 100.0 as price_rec, med_min_price_cents / 100.0 as price_med, special FROM v_product_snapshots WHERE store_product_id = p.id ORDER BY crawled_at DESC LIMIT 1 ) s ON true ${whereClause} ${min_price ? `AND ${priceColumn} >= ${parseFloat(min_price as string)}` : ''} ${max_price ? `AND ${priceColumn} <= ${parseFloat(max_price as string)}` : ''} `, params.slice(0, -2)); // Helper to format variants from raw Dutchie data const formatVariants = (variantsRaw: any[]) => { if (!variantsRaw || !Array.isArray(variantsRaw)) return []; return variantsRaw.map((v: any) => ({ option: v.option || v.key || '', price_rec: v.recPrice || v.price || null, price_med: v.medPrice || null, price_rec_special: v.recSpecialPrice || null, price_med_special: v.medSpecialPrice || null, quantity: v.quantityAvailable ?? v.quantity ?? null, in_stock: (v.quantityAvailable ?? v.quantity ?? 0) > 0, sku: v.canonicalSKU || null, canonical_id: v.canonicalID || null, })); }; // Transform products with pricing_type support const transformedProducts = products.map((p) => { // Select price based on pricing_type const useRecPricing = pricing_type !== 'med'; const regularPrice = useRecPricing ? (p.price_rec ? parseFloat(p.price_rec).toFixed(2) : null) : (p.price_med ? parseFloat(p.price_med).toFixed(2) : null); const salePrice = useRecPricing ? (p.price_rec_special ? parseFloat(p.price_rec_special).toFixed(2) : null) : (p.price_med_special ? parseFloat(p.price_med_special).toFixed(2) : null); const result: any = { id: p.id, dispensary_id: p.dispensary_id, dutchie_id: p.dutchie_id, name: p.name, brand: p.brand || null, category: p.category || null, subcategory: p.subcategory || null, strain_type: p.strain_type || null, description: null, regular_price: regularPrice, sale_price: salePrice, thc_percentage: p.thc ? parseFloat(p.thc) : null, cbd_percentage: p.cbd ? parseFloat(p.cbd) : null, image_url: p.image_url || null, in_stock: p.stock_status === 'in_stock', on_special: p.special || false, quantity_available: p.total_quantity_available || 0, created_at: p.created_at, updated_at: p.updated_at, snapshot_at: p.snapshot_at, pricing_type: pricing_type, }; // Include both pricing if pricing_type is 'all' if (pricing_type === 'all') { result.pricing = { rec: { price: p.price_rec ? parseFloat(p.price_rec).toFixed(2) : null, special_price: p.price_rec_special ? parseFloat(p.price_rec_special).toFixed(2) : null, }, med: { price: p.price_med ? parseFloat(p.price_med).toFixed(2) : null, special_price: p.price_med_special ? parseFloat(p.price_med_special).toFixed(2) : null, } }; } // Include variants if requested if (include_variants === 'true' || include_variants === '1') { result.variants = formatVariants(p.variants_raw); } return result; }); res.json({ success: true, scope: scope.type, dispensary: scope.type === 'wordpress' ? req.apiPermission?.store_name : undefined, products: transformedProducts, pagination: { total: parseInt(countRows[0]?.total || '0', 10), limit: limitNum, offset: offsetNum, has_more: offsetNum + products.length < parseInt(countRows[0]?.total || '0', 10) } }); } catch (error: any) { console.error('Public API products error:', error); res.status(500).json({ error: 'Failed to fetch products', message: error.message }); } }); /** * GET /api/v1/products/:id * Get a single product by ID */ router.get('/products/:id', async (req: PublicApiRequest, res: Response) => { try { const scope = req.scope!; const { id } = req.params; // Get product (without dispensary filter to check access afterward) const { rows: products } = await pool.query(` SELECT p.*, s.rec_min_price_cents, s.rec_max_price_cents, s.rec_min_special_price_cents, s.med_min_price_cents, s.med_max_price_cents, s.total_quantity_available, s.options, s.special, s.crawled_at as snapshot_at FROM v_products p LEFT JOIN LATERAL ( SELECT * FROM v_product_snapshots WHERE store_product_id = p.id ORDER BY crawled_at DESC LIMIT 1 ) s ON true WHERE p.id = $1 `, [id]); if (products.length === 0) { return res.status(404).json({ error: 'Product not found' }); } const p = products[0]; // Validate access based on scope const access = validateResourceAccess(scope, p.dispensary_id); if (!access.allowed) { return res.status(403).json({ error: 'Forbidden', message: access.reason }); } let imageUrl = p.primary_image_url; if (!imageUrl && p.images && Array.isArray(p.images) && p.images.length > 0) { const firstImage = p.images[0]; imageUrl = typeof firstImage === 'string' ? firstImage : firstImage?.url; } res.json({ success: true, product: { id: p.id, dispensary_id: p.dispensary_id, dutchie_id: p.external_product_id, name: p.name, brand: p.brand_name || null, category: p.type || null, subcategory: p.subcategory || null, strain_type: p.strain_type || null, regular_price: p.rec_min_price_cents ? (p.rec_min_price_cents / 100).toFixed(2) : null, sale_price: p.rec_min_special_price_cents ? (p.rec_min_special_price_cents / 100).toFixed(2) : null, thc_percentage: p.thc ? parseFloat(p.thc) : null, cbd_percentage: p.cbd ? parseFloat(p.cbd) : null, image_url: imageUrl || null, images: p.images || [], in_stock: p.stock_status === 'in_stock', on_special: p.special || false, effects: p.effects || [], options: p.options || [], quantity_available: p.total_quantity_available || 0, created_at: p.created_at, updated_at: p.updated_at, snapshot_at: p.snapshot_at } }); } catch (error: any) { console.error('Public API product detail error:', error); res.status(500).json({ error: 'Failed to fetch product', message: error.message }); } }); /** * GET /api/v1/categories * Get all categories for the authenticated dispensary */ router.get('/categories', async (req: PublicApiRequest, res: Response) => { try { const scope = req.scope!; const { dispensaryId, error } = getScopedDispensaryId(req); if (error) { return res.status(503).json({ error: 'No menu data available', message: error }); } let whereClause = 'WHERE type IS NOT NULL'; const params: any[] = []; if (dispensaryId) { whereClause += ' AND dispensary_id = $1'; params.push(dispensaryId); } else if (scope.dispensaryIds !== 'ALL') { return res.status(503).json({ error: 'No menu data available', message: 'Menu data is not yet available for this dispensary.' }); } const { rows: categories } = await pool.query(` SELECT type as category, subcategory, COUNT(*) as product_count, COUNT(*) FILTER (WHERE stock_status = 'in_stock') as in_stock_count FROM v_products ${whereClause} GROUP BY type, subcategory ORDER BY type, subcategory `, params); res.json({ success: true, scope: scope.type, dispensary: scope.type === 'wordpress' ? req.apiPermission?.store_name : undefined, categories }); } catch (error: any) { console.error('Public API categories error:', error); res.status(500).json({ error: 'Failed to fetch categories', message: error.message }); } }); /** * GET /api/v1/brands * Get all brands for the authenticated dispensary */ router.get('/brands', async (req: PublicApiRequest, res: Response) => { try { const scope = req.scope!; const { dispensaryId, error } = getScopedDispensaryId(req); if (error) { return res.status(503).json({ error: 'No menu data available', message: error }); } let whereClause = 'WHERE brand_name IS NOT NULL'; const params: any[] = []; if (dispensaryId) { whereClause += ' AND dispensary_id = $1'; params.push(dispensaryId); } else if (scope.dispensaryIds !== 'ALL') { return res.status(503).json({ error: 'No menu data available', message: 'Menu data is not yet available for this dispensary.' }); } const { rows: brands } = await pool.query(` SELECT brand_name as brand, COUNT(*) as product_count, COUNT(*) FILTER (WHERE stock_status = 'in_stock') as in_stock_count FROM v_products ${whereClause} GROUP BY brand_name ORDER BY product_count DESC `, params); res.json({ success: true, scope: scope.type, dispensary: scope.type === 'wordpress' ? req.apiPermission?.store_name : undefined, brands }); } catch (error: any) { console.error('Public API brands error:', error); res.status(500).json({ error: 'Failed to fetch brands', message: error.message }); } }); /** * GET /api/v1/specials * Get products on special/sale for the authenticated dispensary */ router.get('/specials', async (req: PublicApiRequest, res: Response) => { try { const scope = req.scope!; const { dispensaryId, error } = getScopedDispensaryId(req); if (error) { return res.status(503).json({ error: 'No menu data available', message: error }); } const { limit = '100', offset = '0' } = req.query; const limitNum = Math.min(parseInt(limit as string, 10) || 100, 500); const offsetNum = parseInt(offset as string, 10) || 0; let whereClause = 'WHERE s.special = true AND p.stock_status = \'in_stock\''; const params: any[] = []; let paramIndex = 1; if (dispensaryId) { whereClause += ` AND p.dispensary_id = $${paramIndex}`; params.push(dispensaryId); paramIndex++; } else if (scope.dispensaryIds !== 'ALL') { return res.status(503).json({ error: 'No menu data available', message: 'Menu data is not yet available for this dispensary.' }); } params.push(limitNum, offsetNum); const { rows: products } = await pool.query(` SELECT p.id, p.dispensary_id, p.external_product_id as dutchie_id, p.name, p.brand_name as brand, p.type as category, p.subcategory, p.strain_type, p.stock_status, p.primary_image_url as image_url, s.rec_min_price_cents, s.rec_min_special_price_cents, s.special, s.options, p.updated_at, s.crawled_at as snapshot_at FROM v_products p INNER JOIN LATERAL ( SELECT * FROM v_product_snapshots WHERE store_product_id = p.id ORDER BY crawled_at DESC LIMIT 1 ) s ON true ${whereClause} ORDER BY p.name ASC LIMIT $${paramIndex} OFFSET $${paramIndex + 1} `, params); // Get total count const countParams = params.slice(0, -2); const { rows: countRows } = await pool.query(` SELECT COUNT(*) as total FROM v_products p INNER JOIN LATERAL ( SELECT special FROM v_product_snapshots WHERE store_product_id = p.id ORDER BY crawled_at DESC LIMIT 1 ) s ON true ${whereClause} `, countParams); const transformedProducts = products.map((p) => ({ id: p.id, dispensary_id: p.dispensary_id, dutchie_id: p.dutchie_id, name: p.name, brand: p.brand || null, category: p.category || null, strain_type: p.strain_type || null, regular_price: p.rec_min_price_cents ? (p.rec_min_price_cents / 100).toFixed(2) : null, sale_price: p.rec_min_special_price_cents ? (p.rec_min_special_price_cents / 100).toFixed(2) : null, image_url: p.image_url || null, in_stock: p.stock_status === 'in_stock', options: p.options || [], updated_at: p.updated_at, snapshot_at: p.snapshot_at })); res.json({ success: true, scope: scope.type, dispensary: scope.type === 'wordpress' ? req.apiPermission?.store_name : undefined, specials: transformedProducts, pagination: { total: parseInt(countRows[0]?.total || '0', 10), limit: limitNum, offset: offsetNum, has_more: offsetNum + products.length < parseInt(countRows[0]?.total || '0', 10) } }); } catch (error: any) { console.error('Public API specials error:', error); res.status(500).json({ error: 'Failed to fetch specials', message: error.message }); } }); /** * GET /api/v1/dispensaries * Get all dispensaries with product data * * For internal keys: Returns all dispensaries (with optional filters) * For wordpress keys: Returns only their authorized dispensary */ router.get('/dispensaries', async (req: PublicApiRequest, res: Response) => { try { const scope = req.scope!; // WordPress keys only see their own dispensary if (scope.type === 'wordpress') { const { dispensaryId, error } = getScopedDispensaryId(req); if (error || !dispensaryId) { return res.json({ success: true, dispensaries: [{ id: req.apiPermission?.store_id, name: req.apiPermission?.store_name, data_available: false, message: 'Menu data not yet available for this dispensary' }] }); } // Get single dispensary for wordpress key const { rows: dispensaries } = await pool.query(` SELECT d.id, d.name, d.address1, d.address2, d.city, d.state, d.zipcode as zip, d.phone, d.email, d.website, d.latitude, d.longitude, d.menu_type as platform, d.menu_url, d.description, d.logo_image as image_url, d.google_rating, d.google_review_count, d.offer_pickup, d.offer_delivery, d.offer_curbside_pickup, d.is_medical, d.is_recreational, COALESCE(pc.product_count, 0) as product_count, COALESCE(pc.in_stock_count, 0) as in_stock_count, pc.last_updated FROM dispensaries d LEFT JOIN LATERAL ( SELECT COUNT(*) as product_count, COUNT(*) FILTER (WHERE stock_status = 'in_stock') as in_stock_count, MAX(updated_at) as last_updated FROM v_products WHERE dispensary_id = d.id ) pc ON true WHERE d.id = $1 `, [dispensaryId]); if (dispensaries.length === 0) { return res.json({ success: true, dispensaries: [{ id: dispensaryId, name: req.apiPermission?.store_name, data_available: false, message: 'Dispensary not found in data pipeline' }] }); } const d = dispensaries[0]; return res.json({ success: true, dispensaries: [{ id: d.id, name: d.name, address1: d.address1, address2: d.address2, city: d.city, state: d.state, zip: d.zip, phone: d.phone, email: d.email, website: d.website, menu_url: d.menu_url, location: d.latitude && d.longitude ? { latitude: parseFloat(d.latitude), longitude: parseFloat(d.longitude) } : null, platform: d.platform, description: d.description || null, image_url: d.image_url || null, services: { pickup: d.offer_pickup || false, delivery: d.offer_delivery || false, curbside: d.offer_curbside_pickup || false }, license_type: { medical: d.is_medical || false, recreational: d.is_recreational || false }, rating: d.google_rating ? parseFloat(d.google_rating) : null, review_count: d.google_review_count ? parseInt(d.google_review_count, 10) : null, product_count: parseInt(d.product_count || '0', 10), in_stock_count: parseInt(d.in_stock_count || '0', 10), last_updated: d.last_updated, data_available: parseInt(d.product_count || '0', 10) > 0 }] }); } // Internal keys: full list with filters const { state, city, has_products = 'false', limit = '100', offset = '0' } = req.query; let whereClause = 'WHERE 1=1'; const params: any[] = []; let paramIndex = 1; if (state) { whereClause += ` AND UPPER(d.state) = UPPER($${paramIndex})`; params.push(state); paramIndex++; } if (city) { whereClause += ` AND LOWER(d.city) LIKE LOWER($${paramIndex})`; params.push(`%${city}%`); paramIndex++; } const limitNum = Math.min(parseInt(limit as string, 10) || 100, 500); const offsetNum = parseInt(offset as string, 10) || 0; const { rows: dispensaries } = await pool.query(` SELECT d.id, d.name, d.slug, d.address1, d.address2, d.city, d.state, d.zipcode as zip, d.phone, d.email, d.website, d.latitude, d.longitude, d.menu_type as platform, d.menu_url, d.description, d.logo_image as image_url, d.google_rating, d.google_review_count, d.offer_pickup, d.offer_delivery, d.offer_curbside_pickup, d.is_medical, d.is_recreational, COALESCE(pc.product_count, 0) as product_count, COALESCE(pc.in_stock_count, 0) as in_stock_count, pc.last_updated FROM dispensaries d LEFT JOIN LATERAL ( SELECT COUNT(*) as product_count, COUNT(*) FILTER (WHERE stock_status = 'in_stock') as in_stock_count, MAX(updated_at) as last_updated FROM v_products WHERE dispensary_id = d.id ) pc ON true ${whereClause} ${has_products === 'true' || has_products === '1' ? 'AND COALESCE(pc.product_count, 0) > 0' : ''} ORDER BY d.name ASC LIMIT $${paramIndex} OFFSET $${paramIndex + 1} `, [...params, limitNum, offsetNum]); const { rows: countRows } = await pool.query(` SELECT COUNT(*) as total FROM dispensaries d LEFT JOIN LATERAL ( SELECT COUNT(*) as product_count FROM v_products WHERE dispensary_id = d.id ) pc ON true ${whereClause} ${has_products === 'true' || has_products === '1' ? 'AND COALESCE(pc.product_count, 0) > 0' : ''} `, params); const transformedDispensaries = dispensaries.map((d) => ({ id: d.id, name: d.name, slug: d.slug || null, address1: d.address1, address2: d.address2, city: d.city, state: d.state, zip: d.zip, phone: d.phone, email: d.email, website: d.website, menu_url: d.menu_url, location: d.latitude && d.longitude ? { latitude: parseFloat(d.latitude), longitude: parseFloat(d.longitude) } : null, platform: d.platform, description: d.description || null, image_url: d.image_url || null, services: { pickup: d.offer_pickup || false, delivery: d.offer_delivery || false, curbside: d.offer_curbside_pickup || false }, license_type: { medical: d.is_medical || false, recreational: d.is_recreational || false }, rating: d.google_rating ? parseFloat(d.google_rating) : null, review_count: d.google_review_count ? parseInt(d.google_review_count, 10) : null, product_count: parseInt(d.product_count || '0', 10), in_stock_count: parseInt(d.in_stock_count || '0', 10), last_updated: d.last_updated, data_available: parseInt(d.product_count || '0', 10) > 0 })); res.json({ success: true, scope: 'internal', dispensaries: transformedDispensaries, pagination: { total: parseInt(countRows[0]?.total || '0', 10), limit: limitNum, offset: offsetNum, has_more: offsetNum + dispensaries.length < parseInt(countRows[0]?.total || '0', 10) } }); } catch (error: any) { console.error('Public API dispensaries error:', error); res.status(500).json({ error: 'Failed to fetch dispensaries', message: error.message }); } }); /** * GET /api/v1/search * Full-text search across products */ router.get('/search', async (req: PublicApiRequest, res: Response) => { try { const scope = req.scope!; const { dispensaryId, error } = getScopedDispensaryId(req); if (error) { return res.status(503).json({ error: 'No menu data available', message: error }); } const { q, limit = '50', offset = '0', in_stock_only = 'false' } = req.query; if (!q || typeof q !== 'string' || q.trim().length < 2) { return res.status(400).json({ error: 'Invalid search query', message: 'Search query (q) must be at least 2 characters' }); } const searchQuery = q.trim(); const limitNum = Math.min(parseInt(limit as string, 10) || 50, 200); const offsetNum = parseInt(offset as string, 10) || 0; let whereClause = ` WHERE ( p.name ILIKE $1 OR p.brand_name ILIKE $1 OR p.type ILIKE $1 OR p.subcategory ILIKE $1 OR p.strain_type ILIKE $1 ) `; const params: any[] = [`%${searchQuery}%`]; let paramIndex = 2; if (dispensaryId) { whereClause += ` AND p.dispensary_id = $${paramIndex}`; params.push(dispensaryId); paramIndex++; } else if (scope.dispensaryIds !== 'ALL') { return res.status(503).json({ error: 'No menu data available', message: 'Menu data is not yet available for this dispensary.' }); } if (in_stock_only === 'true' || in_stock_only === '1') { whereClause += ` AND p.stock_status = 'in_stock'`; } // Add relevance scoring parameter params.push(searchQuery); const relevanceParamIndex = paramIndex; paramIndex++; params.push(limitNum, offsetNum); const { rows: products } = await pool.query(` SELECT p.id, p.dispensary_id, p.external_product_id as dutchie_id, p.name, p.brand_name as brand, p.type as category, p.subcategory, p.strain_type, p.stock_status, p.thc, p.cbd, p.primary_image_url as image_url, p.effects, p.updated_at, s.rec_min_price_cents, s.rec_min_special_price_cents, s.special, s.options, s.crawled_at as snapshot_at, CASE WHEN LOWER(p.name) = LOWER($${relevanceParamIndex}) THEN 100 WHEN LOWER(p.name) LIKE LOWER($${relevanceParamIndex}) || '%' THEN 90 WHEN LOWER(p.name) LIKE '%' || LOWER($${relevanceParamIndex}) || '%' THEN 80 WHEN LOWER(p.brand_name) = LOWER($${relevanceParamIndex}) THEN 70 WHEN LOWER(p.brand_name) LIKE '%' || LOWER($${relevanceParamIndex}) || '%' THEN 60 ELSE 50 END as relevance FROM v_products p LEFT JOIN LATERAL ( SELECT * FROM v_product_snapshots WHERE store_product_id = p.id ORDER BY crawled_at DESC LIMIT 1 ) s ON true ${whereClause} ORDER BY relevance DESC, p.name ASC LIMIT $${paramIndex} OFFSET $${paramIndex + 1} `, params); // Count query (without relevance param) const countParams = params.slice(0, paramIndex - 3); // Remove relevance, limit, offset const { rows: countRows } = await pool.query(` SELECT COUNT(*) as total FROM v_products p ${whereClause} `, countParams); const transformedProducts = products.map((p) => ({ id: p.id, dispensary_id: p.dispensary_id, dutchie_id: p.dutchie_id, name: p.name, brand: p.brand || null, category: p.category || null, subcategory: p.subcategory || null, strain_type: p.strain_type || null, regular_price: p.rec_min_price_cents ? (p.rec_min_price_cents / 100).toFixed(2) : null, sale_price: p.rec_min_special_price_cents ? (p.rec_min_special_price_cents / 100).toFixed(2) : null, thc_percentage: p.thc ? parseFloat(p.thc) : null, cbd_percentage: p.cbd ? parseFloat(p.cbd) : null, image_url: p.image_url || null, in_stock: p.stock_status === 'in_stock', on_special: p.special || false, effects: p.effects || [], options: p.options || [], relevance: p.relevance, updated_at: p.updated_at, snapshot_at: p.snapshot_at })); res.json({ success: true, scope: scope.type, dispensary: scope.type === 'wordpress' ? req.apiPermission?.store_name : undefined, query: searchQuery, results: transformedProducts, pagination: { total: parseInt(countRows[0]?.total || '0', 10), limit: limitNum, offset: offsetNum, has_more: offsetNum + products.length < parseInt(countRows[0]?.total || '0', 10) } }); } catch (error: any) { console.error('Public API search error:', error); res.status(500).json({ error: 'Failed to search products', message: error.message }); } }); // ============================================================ // STORE METRICS & INTELLIGENCE ENDPOINTS // ============================================================ /** * GET /api/v1/stores/:id/metrics * Get performance metrics for a specific store * * Returns: * - Product counts (total, in-stock, out-of-stock) * - Brand counts * - Category breakdown * - Price statistics (avg, min, max) * - Stock health metrics * - Crawl status */ router.get('/stores/:id/metrics', async (req: PublicApiRequest, res: Response) => { try { const scope = req.scope!; const storeId = parseInt(req.params.id, 10); if (isNaN(storeId)) { return res.status(400).json({ error: 'Invalid store ID' }); } // Validate access if (scope.type === 'wordpress' && scope.dispensaryIds !== 'ALL') { if (!scope.dispensaryIds.includes(storeId)) { return res.status(403).json({ error: 'Access denied to this store' }); } } // Get store info const { rows: storeRows } = await pool.query(` SELECT id, name, city, state, last_crawl_at, product_count, crawl_enabled FROM dispensaries WHERE id = $1 `, [storeId]); if (storeRows.length === 0) { return res.status(404).json({ error: 'Store not found' }); } const store = storeRows[0]; // Get product metrics const { rows: productMetrics } = await pool.query(` SELECT COUNT(*) as total_products, COUNT(*) FILTER (WHERE stock_status = 'in_stock') as in_stock, COUNT(*) FILTER (WHERE stock_status = 'out_of_stock') as out_of_stock, COUNT(DISTINCT brand_name_raw) FILTER (WHERE brand_name_raw IS NOT NULL) as unique_brands, COUNT(DISTINCT category_raw) FILTER (WHERE category_raw IS NOT NULL) as unique_categories FROM store_products WHERE dispensary_id = $1 `, [storeId]); // Get price statistics from latest snapshots const { rows: priceStats } = await pool.query(` SELECT ROUND(AVG(price_rec)::numeric, 2) as avg_price, MIN(price_rec) as min_price, MAX(price_rec) as max_price, COUNT(*) FILTER (WHERE is_on_special = true) as on_special_count FROM store_product_snapshots sps INNER JOIN ( SELECT store_product_id, MAX(captured_at) as latest FROM store_product_snapshots WHERE dispensary_id = $1 GROUP BY store_product_id ) latest ON sps.store_product_id = latest.store_product_id AND sps.captured_at = latest.latest WHERE sps.dispensary_id = $1 AND sps.price_rec > 0 `, [storeId]); // Get category breakdown const { rows: categoryBreakdown } = await pool.query(` SELECT COALESCE(category_raw, 'Uncategorized') as category, COUNT(*) as count, COUNT(*) FILTER (WHERE stock_status = 'in_stock') as in_stock FROM store_products WHERE dispensary_id = $1 GROUP BY category_raw ORDER BY count DESC LIMIT 10 `, [storeId]); // Calculate stock health const metrics = productMetrics[0] || {}; const totalProducts = parseInt(metrics.total_products || '0', 10); const inStock = parseInt(metrics.in_stock || '0', 10); const stockHealthPercent = totalProducts > 0 ? Math.round((inStock / totalProducts) * 100) : 0; const prices = priceStats[0] || {}; res.json({ success: true, store_id: storeId, store_name: store.name, location: { city: store.city, state: store.state }, metrics: { products: { total: totalProducts, in_stock: inStock, out_of_stock: parseInt(metrics.out_of_stock || '0', 10), stock_health_percent: stockHealthPercent }, brands: { unique_count: parseInt(metrics.unique_brands || '0', 10) }, categories: { unique_count: parseInt(metrics.unique_categories || '0', 10), breakdown: categoryBreakdown.map(c => ({ name: c.category, total: parseInt(c.count, 10), in_stock: parseInt(c.in_stock, 10) })) }, pricing: { average: prices.avg_price ? parseFloat(prices.avg_price) : null, min: prices.min_price ? parseFloat(prices.min_price) : null, max: prices.max_price ? parseFloat(prices.max_price) : null, on_special_count: parseInt(prices.on_special_count || '0', 10) }, crawl: { enabled: store.crawl_enabled, last_crawl_at: store.last_crawl_at, product_count_from_crawl: store.product_count } }, generated_at: new Date().toISOString() }); } catch (error: any) { console.error('Store metrics error:', error); res.status(500).json({ error: 'Failed to fetch store metrics', message: error.message }); } }); /** * GET /api/v1/stores/:id/product-metrics * Get detailed product-level metrics for a store * * Query params: * - category: Filter by category * - brand: Filter by brand * - sort_by: price_change, stock_status, price (default: price_change) * - limit: Max results (default: 50, max: 200) * * Returns per-product: * - Current price and stock * - Price change from last crawl * - Days in stock / out of stock * - Special/discount status */ router.get('/stores/:id/product-metrics', async (req: PublicApiRequest, res: Response) => { try { const scope = req.scope!; const storeId = parseInt(req.params.id, 10); if (isNaN(storeId)) { return res.status(400).json({ error: 'Invalid store ID' }); } // Validate access if (scope.type === 'wordpress' && scope.dispensaryIds !== 'ALL') { if (!scope.dispensaryIds.includes(storeId)) { return res.status(403).json({ error: 'Access denied to this store' }); } } const { category, brand, sort_by = 'price_change', limit = '50' } = req.query; const limitNum = Math.min(parseInt(limit as string, 10) || 50, 200); let whereClause = 'WHERE sp.dispensary_id = $1'; const params: any[] = [storeId]; let paramIndex = 2; if (category) { whereClause += ` AND LOWER(sp.category) = LOWER($${paramIndex})`; params.push(category); paramIndex++; } if (brand) { whereClause += ` AND LOWER(sp.brand_name) LIKE LOWER($${paramIndex})`; params.push(`%${brand}%`); paramIndex++; } params.push(limitNum); // Get products with their latest and previous snapshots for price comparison const { rows: products } = await pool.query(` WITH latest_snapshots AS ( SELECT DISTINCT ON (store_product_id) store_product_id, price_rec as current_price, price_rec_special as current_special_price, is_on_special, stock_quantity, captured_at as last_seen FROM store_product_snapshots WHERE dispensary_id = $1 ORDER BY store_product_id, captured_at DESC ), previous_snapshots AS ( SELECT DISTINCT ON (store_product_id) store_product_id, price_rec as previous_price, captured_at as previous_seen FROM store_product_snapshots sps WHERE dispensary_id = $1 AND captured_at < (SELECT MIN(last_seen) FROM latest_snapshots ls WHERE ls.store_product_id = sps.store_product_id) ORDER BY store_product_id, captured_at DESC ) SELECT sp.id, sp.name_raw as name, sp.brand_name_raw as brand_name, sp.category_raw as category, sp.stock_status, ls.current_price, ls.current_special_price, ls.is_on_special, ls.stock_quantity, ls.last_seen, ps.previous_price, ps.previous_seen, CASE WHEN ls.current_price IS NOT NULL AND ps.previous_price IS NOT NULL THEN ROUND(((ls.current_price - ps.previous_price) / ps.previous_price * 100)::numeric, 2) ELSE NULL END as price_change_percent FROM store_products sp LEFT JOIN latest_snapshots ls ON sp.id = ls.store_product_id LEFT JOIN previous_snapshots ps ON sp.id = ps.store_product_id ${whereClause} ORDER BY ${sort_by === 'price' ? 'ls.current_price DESC NULLS LAST' : sort_by === 'stock_status' ? "CASE sp.stock_status WHEN 'out_of_stock' THEN 0 ELSE 1 END, sp.name_raw" : 'ABS(COALESCE(price_change_percent, 0)) DESC'} LIMIT $${paramIndex} `, params); res.json({ success: true, store_id: storeId, products: products.map(p => ({ id: p.id, name: p.name, brand: p.brand_name, category: p.category, stock_status: p.stock_status, pricing: { current: p.current_price ? parseFloat(p.current_price) : null, special: p.current_special_price ? parseFloat(p.current_special_price) : null, previous: p.previous_price ? parseFloat(p.previous_price) : null, change_percent: p.price_change_percent ? parseFloat(p.price_change_percent) : null, is_on_special: p.is_on_special || false }, inventory: { quantity: p.stock_quantity || 0, last_seen: p.last_seen } })), filters: { category: category || null, brand: brand || null, sort_by }, count: products.length, generated_at: new Date().toISOString() }); } catch (error: any) { console.error('Product metrics error:', error); res.status(500).json({ error: 'Failed to fetch product metrics', message: error.message }); } }); /** * GET /api/v1/stores/:id/competitor-snapshot * Get competitive intelligence for a store * * Returns: * - Nearby competitor stores (same city/state) * - Price comparisons by category * - Brand overlap analysis * - Market position indicators */ router.get('/stores/:id/competitor-snapshot', async (req: PublicApiRequest, res: Response) => { try { const scope = req.scope!; const storeId = parseInt(req.params.id, 10); if (isNaN(storeId)) { return res.status(400).json({ error: 'Invalid store ID' }); } // Validate access if (scope.type === 'wordpress' && scope.dispensaryIds !== 'ALL') { if (!scope.dispensaryIds.includes(storeId)) { return res.status(403).json({ error: 'Access denied to this store' }); } } // Get store info const { rows: storeRows } = await pool.query(` SELECT id, name, city, state, latitude, longitude FROM dispensaries WHERE id = $1 `, [storeId]); if (storeRows.length === 0) { return res.status(404).json({ error: 'Store not found' }); } const store = storeRows[0]; // Get competitor stores in same city (or nearby if coordinates available) const { rows: competitors } = await pool.query(` SELECT d.id, d.name, d.city, d.state, d.product_count, d.last_crawl_at, CASE WHEN d.latitude IS NOT NULL AND d.longitude IS NOT NULL AND $2::numeric IS NOT NULL AND $3::numeric IS NOT NULL THEN ROUND(( 6371 * acos( cos(radians($2::numeric)) * cos(radians(d.latitude::numeric)) * cos(radians(d.longitude::numeric) - radians($3::numeric)) + sin(radians($2::numeric)) * sin(radians(d.latitude::numeric)) ) )::numeric, 2) ELSE NULL END as distance_km FROM dispensaries d WHERE d.id != $1 AND d.state = $4 AND d.crawl_enabled = true AND d.product_count > 0 AND (d.city = $5 OR d.latitude IS NOT NULL) ORDER BY distance_km NULLS LAST, d.name LIMIT 10 `, [storeId, store.latitude, store.longitude, store.state, store.city]); // Get this store's average prices by category const { rows: storePrices } = await pool.query(` SELECT sp.category_raw as category, ROUND(AVG(sps.price_rec)::numeric, 2) as avg_price, COUNT(*) as product_count FROM store_products sp INNER JOIN ( SELECT DISTINCT ON (store_product_id) store_product_id, price_rec FROM store_product_snapshots WHERE dispensary_id = $1 ORDER BY store_product_id, captured_at DESC ) sps ON sp.id = sps.store_product_id WHERE sp.dispensary_id = $1 AND sp.category_raw IS NOT NULL AND sps.price_rec > 0 GROUP BY sp.category_raw `, [storeId]); // Get market average prices by category (all competitors) const competitorIds = competitors.map(c => c.id); let marketPrices: any[] = []; if (competitorIds.length > 0) { const { rows } = await pool.query(` SELECT sp.category_raw as category, ROUND(AVG(sps.price_rec)::numeric, 2) as market_avg_price, COUNT(DISTINCT sp.dispensary_id) as store_count FROM store_products sp INNER JOIN ( SELECT DISTINCT ON (store_product_id) store_product_id, price_rec FROM store_product_snapshots WHERE dispensary_id = ANY($1) ORDER BY store_product_id, captured_at DESC ) sps ON sp.id = sps.store_product_id WHERE sp.dispensary_id = ANY($1) AND sp.category_raw IS NOT NULL AND sps.price_rec > 0 GROUP BY sp.category_raw `, [competitorIds]); marketPrices = rows; } // Get this store's brands const { rows: storeBrands } = await pool.query(` SELECT DISTINCT brand_name_raw as brand_name FROM store_products WHERE dispensary_id = $1 AND brand_name_raw IS NOT NULL `, [storeId]); const storeBrandSet = new Set(storeBrands.map(b => b.brand_name.toLowerCase())); // Get brand overlap with competitors let brandOverlap: any[] = []; if (competitorIds.length > 0) { const { rows } = await pool.query(` SELECT d.id as competitor_id, d.name as competitor_name, COUNT(DISTINCT sp.brand_name_raw) as total_brands, COUNT(DISTINCT sp.brand_name_raw) FILTER ( WHERE LOWER(sp.brand_name_raw) = ANY($2) ) as shared_brands FROM dispensaries d INNER JOIN store_products sp ON sp.dispensary_id = d.id WHERE d.id = ANY($1) AND sp.brand_name_raw IS NOT NULL GROUP BY d.id, d.name `, [competitorIds, Array.from(storeBrandSet)]); brandOverlap = rows; } // Build price comparison const priceComparison = storePrices.map(sp => { const marketPrice = marketPrices.find(mp => mp.category === sp.category); const diff = marketPrice ? parseFloat(((parseFloat(sp.avg_price) - parseFloat(marketPrice.market_avg_price)) / parseFloat(marketPrice.market_avg_price) * 100).toFixed(2)) : null; return { category: sp.category, your_avg_price: parseFloat(sp.avg_price), market_avg_price: marketPrice ? parseFloat(marketPrice.market_avg_price) : null, diff_percent: diff, position: diff === null ? 'unknown' : diff < -5 ? 'below_market' : diff > 5 ? 'above_market' : 'at_market' }; }); res.json({ success: true, store: { id: storeId, name: store.name, city: store.city, state: store.state }, competitors: competitors.map(c => ({ id: c.id, name: c.name, city: c.city, distance_km: c.distance_km ? parseFloat(c.distance_km) : null, product_count: c.product_count, last_crawl: c.last_crawl_at })), price_comparison: priceComparison, brand_analysis: { your_brand_count: storeBrandSet.size, overlap_with_competitors: brandOverlap.map(bo => ({ competitor_id: bo.competitor_id, competitor_name: bo.competitor_name, shared_brands: parseInt(bo.shared_brands, 10), their_total_brands: parseInt(bo.total_brands, 10), overlap_percent: Math.round((parseInt(bo.shared_brands, 10) / storeBrandSet.size) * 100) })) }, generated_at: new Date().toISOString() }); } catch (error: any) { console.error('Competitor snapshot error:', error); res.status(500).json({ error: 'Failed to fetch competitor snapshot', message: error.message }); } }); /** * GET /api/v1/stats * Get aggregate stats for consumer sites (product count, brand count, dispensary count) */ router.get('/stats', async (req: PublicApiRequest, res: Response) => { try { // Get aggregate stats across all data const { rows: stats } = await pool.query(` SELECT (SELECT COUNT(*) FROM store_products) as product_count, (SELECT COUNT(DISTINCT brand_name_raw) FROM store_products WHERE brand_name_raw IS NOT NULL) as brand_count, (SELECT COUNT(DISTINCT dispensary_id) FROM store_products) as dispensary_count `); const s = stats[0] || {}; res.json({ success: true, stats: { products: parseInt(s.product_count || '0', 10), brands: parseInt(s.brand_count || '0', 10), dispensaries: parseInt(s.dispensary_count || '0', 10) } }); } catch (error: any) { console.error('Public API stats error:', error); res.status(500).json({ error: 'Failed to fetch stats', message: error.message }); } }); /** * GET /api/v1/menu * Get complete menu summary for the authenticated dispensary */ router.get('/menu', async (req: PublicApiRequest, res: Response) => { try { const scope = req.scope!; const { dispensaryId, error } = getScopedDispensaryId(req); if (error) { return res.status(503).json({ error: 'No menu data available', message: error }); } let whereClause = 'WHERE 1=1'; const params: any[] = []; if (dispensaryId) { whereClause += ' AND dispensary_id = $1'; params.push(dispensaryId); } else if (scope.dispensaryIds !== 'ALL') { return res.status(503).json({ error: 'No menu data available', message: 'Menu data is not yet available for this dispensary.' }); } // Get counts by category const { rows: categoryCounts } = await pool.query(` SELECT type as category, COUNT(*) as total, COUNT(*) FILTER (WHERE stock_status = 'in_stock') as in_stock FROM v_products ${whereClause} AND type IS NOT NULL GROUP BY type ORDER BY total DESC `, params); // Get overall stats const { rows: stats } = await pool.query(` SELECT COUNT(*) as total_products, COUNT(*) FILTER (WHERE stock_status = 'in_stock') as in_stock_count, COUNT(DISTINCT brand_name) as brand_count, COUNT(DISTINCT type) as category_count, MAX(updated_at) as last_updated FROM v_products ${whereClause} `, params); // Get specials count const { rows: specialsCount } = await pool.query(` SELECT COUNT(*) as count FROM v_products p INNER JOIN LATERAL ( SELECT special FROM v_product_snapshots WHERE store_product_id = p.id ORDER BY crawled_at DESC LIMIT 1 ) s ON true ${whereClause.replace('WHERE 1=1', 'WHERE 1=1')} AND s.special = true AND p.stock_status = 'in_stock' ${dispensaryId ? `AND p.dispensary_id = $1` : ''} `, params); const summary = stats[0] || {}; res.json({ success: true, scope: scope.type, dispensary: scope.type === 'wordpress' ? req.apiPermission?.store_name : undefined, menu: { total_products: parseInt(summary.total_products || '0', 10), in_stock_count: parseInt(summary.in_stock_count || '0', 10), brand_count: parseInt(summary.brand_count || '0', 10), category_count: parseInt(summary.category_count || '0', 10), specials_count: parseInt(specialsCount[0]?.count || '0', 10), last_updated: summary.last_updated, categories: categoryCounts.map((c) => ({ name: c.category, total: parseInt(c.total, 10), in_stock: parseInt(c.in_stock, 10) })) } }); } catch (error: any) { console.error('Public API menu error:', error); res.status(500).json({ error: 'Failed to fetch menu summary', message: error.message }); } }); // ============================================================ // VISITOR TRACKING & GEOLOCATION // ============================================================ import crypto from 'crypto'; import { GeoLocation, lookupIP } from '../services/ip2location'; /** * Get location from IP using local IP2Location database */ function getLocationFromIP(ip: string): GeoLocation | null { return lookupIP(ip); } /** * Hash IP for privacy (we don't store raw IPs) */ function hashIP(ip: string): string { return crypto.createHash('sha256').update(ip).digest('hex').substring(0, 16); } /** * POST /api/v1/visitor/track * Track visitor location for analytics * * Body: * - domain: string (required) - 'findagram.co', 'findadispo.com', etc. * - page_path: string (optional) - current page path * - session_id: string (optional) - client-generated session ID * - referrer: string (optional) - document.referrer * * Returns: * - location: { city, state, lat, lng } for client use */ router.post('/visitor/track', async (req: Request, res: Response) => { try { const { domain, page_path, session_id, referrer } = req.body; if (!domain) { return res.status(400).json({ error: 'domain is required' }); } // Get client IP const clientIp = (req.headers['x-forwarded-for'] as string)?.split(',')[0].trim() || req.headers['x-real-ip'] as string || req.ip || req.socket.remoteAddress || ''; // Get location from IP (local database lookup) const location = getLocationFromIP(clientIp); // Store visit (with hashed IP for privacy) await pool.query(` INSERT INTO visitor_locations ( ip_hash, city, state, state_code, country, country_code, latitude, longitude, domain, page_path, referrer, user_agent, session_id ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13) `, [ hashIP(clientIp), location?.city || null, location?.state || null, location?.stateCode || null, location?.country || null, location?.countryCode || null, location?.lat || null, location?.lng || null, domain, page_path || null, referrer || null, req.headers['user-agent'] || null, session_id || null ]); // Return location to client (for nearby dispensary feature) res.json({ success: true, location: location ? { city: location.city, state: location.state, stateCode: location.stateCode, lat: location.lat, lng: location.lng } : null }); } catch (error: any) { console.error('Visitor tracking error:', error); // Don't fail the request - tracking is non-critical res.json({ success: false, location: null }); } }); /** * GET /api/v1/visitor/location * Get visitor location without tracking (just IP lookup) */ router.get('/visitor/location', (req: Request, res: Response) => { try { const clientIp = (req.headers['x-forwarded-for'] as string)?.split(',')[0].trim() || req.headers['x-real-ip'] as string || req.ip || req.socket.remoteAddress || ''; const location = getLocationFromIP(clientIp); res.json({ success: true, location: location ? { city: location.city, state: location.state, stateCode: location.stateCode, lat: location.lat, lng: location.lng } : null }); } catch (error: any) { console.error('Location lookup error:', error); res.json({ success: false, location: null }); } }); /** * GET /api/v1/analytics/visitors * Get visitor analytics (admin only - requires auth) * * Query params: * - domain: filter by domain * - days: number of days to look back (default: 30) * - limit: max results (default: 50) */ router.get('/analytics/visitors', async (req: PublicApiRequest, res: Response) => { try { const scope = req.scope; // Only allow internal keys if (!scope || scope.type !== 'internal') { return res.status(403).json({ error: 'Access denied - internal key required' }); } const { domain, days = '30', limit = '50' } = req.query; const daysNum = Math.min(parseInt(days as string, 10) || 30, 90); const limitNum = Math.min(parseInt(limit as string, 10) || 50, 200); let whereClause = 'WHERE created_at > NOW() - $1::interval'; const params: any[] = [`${daysNum} days`]; let paramIndex = 2; if (domain) { whereClause += ` AND domain = $${paramIndex}`; params.push(domain); paramIndex++; } // Get top locations const { rows: topLocations } = await pool.query(` SELECT city, state, state_code, country_code, COUNT(*) as visit_count, COUNT(DISTINCT session_id) as unique_sessions, MAX(created_at) as last_visit FROM visitor_locations ${whereClause} GROUP BY city, state, state_code, country_code ORDER BY visit_count DESC LIMIT $${paramIndex} `, [...params, limitNum]); // Get daily totals const { rows: dailyStats } = await pool.query(` SELECT DATE(created_at) as date, COUNT(*) as visits, COUNT(DISTINCT session_id) as unique_sessions FROM visitor_locations ${whereClause} GROUP BY DATE(created_at) ORDER BY date DESC LIMIT 30 `, params); // Get totals const { rows: totals } = await pool.query(` SELECT COUNT(*) as total_visits, COUNT(DISTINCT session_id) as total_sessions, COUNT(DISTINCT city || state_code) as unique_locations FROM visitor_locations ${whereClause} `, params); res.json({ success: true, period: { days: daysNum, domain: domain || 'all' }, totals: totals[0], top_locations: topLocations.map(l => ({ city: l.city, state: l.state, state_code: l.state_code, country_code: l.country_code, visits: parseInt(l.visit_count, 10), unique_sessions: parseInt(l.unique_sessions, 10), last_visit: l.last_visit })), daily_stats: dailyStats.map(d => ({ date: d.date, visits: parseInt(d.visits, 10), unique_sessions: parseInt(d.unique_sessions, 10) })) }); } catch (error: any) { console.error('Visitor analytics error:', error); res.status(500).json({ error: 'Failed to fetch visitor analytics', message: error.message }); } }); export default router;