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>
669 lines
24 KiB
JavaScript
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;
|