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>
2260 lines
70 KiB
TypeScript
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;
|