Files
cannaiq/backend/src/routes/public-api.ts
Kelly 22dad6d0fc feat: Add wildcard trusted origins for cannaiq.co and cannabrands.app
Add *.cannaiq.co and *.cannabrands.app patterns to both:
- auth/middleware.ts (admin routes)
- public-api.ts (consumer /api/v1/* routes)

This allows any subdomain of these domains to access the API without
requiring an API key.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-10 15:25:04 -07:00

2260 lines
70 KiB
TypeScript

/**
* 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;