"use strict"; /** * Public API Routes for External Consumers (WordPress, etc.) * * These routes use the dutchie_az data pipeline and are protected by API key auth. * Designed for Deeply Rooted and other WordPress sites consuming menu data. */ var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); const express_1 = require("express"); const migrate_1 = require("../db/migrate"); const connection_1 = require("../dutchie-az/db/connection"); const ipaddr_js_1 = __importDefault(require("ipaddr.js")); const router = (0, express_1.Router)(); // ============================================================ // MIDDLEWARE // ============================================================ /** * Validates if an IP address matches any of the allowed IP patterns */ function isIpAllowed(clientIp, allowedIps) { try { const clientAddr = ipaddr_js_1.default.process(clientIp); for (const allowedIp of allowedIps) { const trimmed = allowedIp.trim(); if (!trimmed) continue; if (trimmed.includes('/')) { try { const range = ipaddr_js_1.default.parseCIDR(trimmed); if (clientAddr.match(range)) { return true; } } catch (e) { console.warn(`Invalid CIDR notation: ${trimmed}`); continue; } } else { try { const allowedAddr = ipaddr_js_1.default.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, allowedDomains) { 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; } } /** * Middleware to validate API key and resolve dispensary -> dutchie_az store mapping */ async function validatePublicApiKey(req, res, next) { const apiKey = req.headers['x-api-key']; 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 migrate_1.pool.query(` SELECT p.id, p.user_name, p.api_key, p.allowed_ips, p.allowed_domains, p.is_active, p.store_id, p.store_name 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 const clientIp = req.headers['x-forwarded-for']?.split(',')[0].trim() || req.headers['x-real-ip'] || req.ip || req.connection.remoteAddress || ''; if (permission.allowed_ips) { const allowedIps = permission.allowed_ips.split('\n').filter((ip) => 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 const origin = req.get('origin') || req.get('referer') || ''; if (permission.allowed_domains && origin) { const allowedDomains = permission.allowed_domains.split('\n').filter((d) => 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 this store // Match by store name (from main DB) to dutchie_az.dispensaries.name const storeResult = await (0, connection_1.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) migrate_1.pool.query(` UPDATE wp_dutchie_api_permissions SET last_used_at = CURRENT_TIMESTAMP WHERE id = $1 `, [permission.id]).catch((err) => { console.error('Error updating last_used_at:', err); }); req.apiPermission = permission; next(); } catch (error) { console.error('Public API validation error:', error); return res.status(500).json({ error: 'Internal server error during API validation' }); } } // Apply middleware to all routes router.use(validatePublicApiKey); // ============================================================ // 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 * - in_stock_only: Only return in-stock products (default: false) * - limit: Max products to return (default: 100, max: 500) * - offset: Pagination offset (default: 0) */ router.get('/products', async (req, res) => { try { const permission = req.apiPermission; // Check if we have a dutchie_az store mapping if (!permission.dutchie_az_store_id) { return res.status(503).json({ error: 'No menu data available', message: `Menu data for ${permission.store_name} is not yet available. The dispensary may not be set up in the new data pipeline.`, dispensary_name: permission.store_name }); } const { category, brand, in_stock_only = 'false', limit = '100', offset = '0' } = req.query; // Build query let whereClause = 'WHERE p.dispensary_id = $1'; const params = [permission.dutchie_az_store_id]; let paramIndex = 2; // 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 (maps to 'type' in dutchie_az) if (category) { whereClause += ` AND LOWER(p.type) = LOWER($${paramIndex})`; params.push(category); paramIndex++; } // Filter by brand if (brand) { whereClause += ` AND LOWER(p.brand_name) LIKE LOWER($${paramIndex})`; params.push(`%${brand}%`); paramIndex++; } // Enforce limits const limitNum = Math.min(parseInt(limit, 10) || 100, 500); const offsetNum = parseInt(offset, 10) || 0; params.push(limitNum, offsetNum); // Query products with latest snapshot data const { rows: products } = await (0, connection_1.query)(` SELECT p.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.images, p.effects, p.created_at, p.updated_at, -- Latest snapshot data for pricing 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.med_min_special_price_cents, s.total_quantity_available, s.options, s.special, s.crawled_at as snapshot_at FROM dutchie_products p LEFT JOIN LATERAL ( SELECT * FROM dutchie_product_snapshots WHERE dutchie_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 for pagination const { rows: countRows } = await (0, connection_1.query)(` SELECT COUNT(*) as total FROM dutchie_products p ${whereClause} `, params.slice(0, -2)); // Transform products to backward-compatible format const transformedProducts = products.map((p) => { // Extract first image URL from images array let imageUrl = p.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; } // Convert prices from cents to dollars const regularPrice = p.rec_min_price_cents ? (p.rec_min_price_cents / 100).toFixed(2) : null; const salePrice = p.rec_min_special_price_cents ? (p.rec_min_special_price_cents / 100).toFixed(2) : null; return { id: p.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, // Not stored in dutchie_products, would need snapshot regular_price: regularPrice, sale_price: salePrice, thc_percentage: p.thc ? parseFloat(p.thc) : null, cbd_percentage: p.cbd ? parseFloat(p.cbd) : null, image_url: imageUrl || null, 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 }; }); res.json({ success: true, dispensary: permission.store_name, 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) { 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, res) => { try { const permission = req.apiPermission; const { id } = req.params; if (!permission.dutchie_az_store_id) { return res.status(503).json({ error: 'No menu data available', message: `Menu data for ${permission.store_name} is not yet available.` }); } // Get product with latest snapshot const { rows: products } = await (0, connection_1.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 dutchie_products p LEFT JOIN LATERAL ( SELECT * FROM dutchie_product_snapshots WHERE dutchie_product_id = p.id ORDER BY crawled_at DESC LIMIT 1 ) s ON true WHERE p.id = $1 AND p.dispensary_id = $2 `, [id, permission.dutchie_az_store_id]); if (products.length === 0) { return res.status(404).json({ error: 'Product not found' }); } const p = products[0]; // Extract first image URL 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, 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) { 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, res) => { try { const permission = req.apiPermission; if (!permission.dutchie_az_store_id) { return res.status(503).json({ error: 'No menu data available', message: `Menu data for ${permission.store_name} is not yet available.` }); } const { rows: categories } = await (0, connection_1.query)(` SELECT type as category, subcategory, COUNT(*) as product_count, COUNT(*) FILTER (WHERE stock_status = 'in_stock') as in_stock_count FROM dutchie_products WHERE dispensary_id = $1 AND type IS NOT NULL GROUP BY type, subcategory ORDER BY type, subcategory `, [permission.dutchie_az_store_id]); res.json({ success: true, dispensary: permission.store_name, categories }); } catch (error) { 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, res) => { try { const permission = req.apiPermission; if (!permission.dutchie_az_store_id) { return res.status(503).json({ error: 'No menu data available', message: `Menu data for ${permission.store_name} is not yet available.` }); } const { rows: brands } = await (0, connection_1.query)(` SELECT brand_name as brand, COUNT(*) as product_count, COUNT(*) FILTER (WHERE stock_status = 'in_stock') as in_stock_count FROM dutchie_products WHERE dispensary_id = $1 AND brand_name IS NOT NULL GROUP BY brand_name ORDER BY product_count DESC `, [permission.dutchie_az_store_id]); res.json({ success: true, dispensary: permission.store_name, brands }); } catch (error) { 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, res) => { try { const permission = req.apiPermission; if (!permission.dutchie_az_store_id) { return res.status(503).json({ error: 'No menu data available', message: `Menu data for ${permission.store_name} is not yet available.` }); } const { limit = '100', offset = '0' } = req.query; const limitNum = Math.min(parseInt(limit, 10) || 100, 500); const offsetNum = parseInt(offset, 10) || 0; // Get products with special pricing from latest snapshot const { rows: products } = await (0, connection_1.query)(` SELECT p.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 dutchie_products p INNER JOIN LATERAL ( SELECT * FROM dutchie_product_snapshots WHERE dutchie_product_id = p.id ORDER BY crawled_at DESC LIMIT 1 ) s ON true WHERE p.dispensary_id = $1 AND s.special = true AND p.stock_status = 'in_stock' ORDER BY p.name ASC LIMIT $2 OFFSET $3 `, [permission.dutchie_az_store_id, limitNum, offsetNum]); // Get total count const { rows: countRows } = await (0, connection_1.query)(` SELECT COUNT(*) as total FROM dutchie_products p INNER JOIN LATERAL ( SELECT special FROM dutchie_product_snapshots WHERE dutchie_product_id = p.id ORDER BY crawled_at DESC LIMIT 1 ) s ON true WHERE p.dispensary_id = $1 AND s.special = true AND p.stock_status = 'in_stock' `, [permission.dutchie_az_store_id]); const transformedProducts = products.map((p) => ({ id: p.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, dispensary: permission.store_name, 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) { console.error('Public API specials error:', error); res.status(500).json({ error: 'Failed to fetch specials', message: error.message }); } }); /** * GET /api/v1/menu * Get complete menu summary for the authenticated dispensary */ router.get('/menu', async (req, res) => { try { const permission = req.apiPermission; if (!permission.dutchie_az_store_id) { return res.status(503).json({ error: 'No menu data available', message: `Menu data for ${permission.store_name} is not yet available.` }); } // Get counts by category const { rows: categoryCounts } = await (0, connection_1.query)(` SELECT type as category, COUNT(*) as total, COUNT(*) FILTER (WHERE stock_status = 'in_stock') as in_stock FROM dutchie_products WHERE dispensary_id = $1 AND type IS NOT NULL GROUP BY type ORDER BY total DESC `, [permission.dutchie_az_store_id]); // Get overall stats const { rows: stats } = await (0, connection_1.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 dutchie_products WHERE dispensary_id = $1 `, [permission.dutchie_az_store_id]); // Get specials count const { rows: specialsCount } = await (0, connection_1.query)(` SELECT COUNT(*) as count FROM dutchie_products p INNER JOIN LATERAL ( SELECT special FROM dutchie_product_snapshots WHERE dutchie_product_id = p.id ORDER BY crawled_at DESC LIMIT 1 ) s ON true WHERE p.dispensary_id = $1 AND s.special = true AND p.stock_status = 'in_stock' `, [permission.dutchie_az_store_id]); const summary = stats[0] || {}; res.json({ success: true, dispensary: permission.store_name, 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) { console.error('Public API menu error:', error); res.status(500).json({ error: 'Failed to fetch menu summary', message: error.message }); } }); exports.default = router;