Files
cannaiq/backend/dist/routes/public-api.js
Kelly 66e07b2009 fix(monitor): remove non-existent worker columns from job_run_logs query
The job_run_logs table tracks scheduled job orchestration, not individual
worker jobs. Worker info (worker_id, worker_hostname) belongs on
dispensary_crawl_jobs, not job_run_logs.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-03 18:45:05 -07:00

669 lines
24 KiB
JavaScript

"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;