diff --git a/backend/src/db/migrations/031_api_key_types.sql b/backend/src/db/migrations/031_api_key_types.sql new file mode 100644 index 00000000..9a31074f --- /dev/null +++ b/backend/src/db/migrations/031_api_key_types.sql @@ -0,0 +1,23 @@ +-- Migration 031: Add key_type to wp_dutchie_api_permissions +-- Supports: 'internal' (ALL access), 'wordpress' (single dispensary) + +-- Add key_type column with default 'wordpress' for backward compatibility +ALTER TABLE wp_dutchie_api_permissions +ADD COLUMN IF NOT EXISTS key_type VARCHAR(50) NOT NULL DEFAULT 'wordpress'; + +-- Add rate_limit column for per-key rate limiting +ALTER TABLE wp_dutchie_api_permissions +ADD COLUMN IF NOT EXISTS rate_limit INTEGER NOT NULL DEFAULT 100; + +-- Add description column for better key management +ALTER TABLE wp_dutchie_api_permissions +ADD COLUMN IF NOT EXISTS description TEXT; + +-- Create index on key_type for faster lookups +CREATE INDEX IF NOT EXISTS idx_wp_api_permissions_key_type ON wp_dutchie_api_permissions(key_type); + +-- Set all existing keys to 'wordpress' type +UPDATE wp_dutchie_api_permissions SET key_type = 'wordpress' WHERE key_type IS NULL OR key_type = ''; + +-- Add comment explaining key types +COMMENT ON COLUMN wp_dutchie_api_permissions.key_type IS 'API key type: internal (ALL dispensary access), wordpress (single dispensary)'; diff --git a/backend/src/middleware/apiScope.ts b/backend/src/middleware/apiScope.ts new file mode 100644 index 00000000..457281fe --- /dev/null +++ b/backend/src/middleware/apiScope.ts @@ -0,0 +1,181 @@ +/** + * API Scope Types and Helpers for /api/v1 + * + * Provides scoping for public API endpoints based on API key type: + * - 'internal': Full access to ALL dispensaries + * - 'wordpress': Restricted to a single dispensary + */ + +// ============================================================ +// TYPES +// ============================================================ + +export type ApiKeyType = 'internal' | 'wordpress'; + +export interface ApiScope { + type: ApiKeyType; + dispensaryIds: 'ALL' | number[]; + apiKeyId: number; + apiKeyName: string; + rateLimit: number; +} + +// Extended Express Request with scope +declare global { + namespace Express { + interface Request { + scope?: ApiScope; + } + } +} + +// ============================================================ +// SCOPE BUILDER +// ============================================================ + +interface ApiPermissionRow { + id: number; + user_name: string; + key_type: string; + store_id: number | null; + rate_limit: number; + dutchie_az_store_id?: number; +} + +/** + * Build an ApiScope from a database permission row + */ +export function buildApiScope(permission: ApiPermissionRow): ApiScope { + const type = (permission.key_type || 'wordpress') as ApiKeyType; + + // Internal keys get ALL access + if (type === 'internal') { + return { + type: 'internal', + dispensaryIds: 'ALL', + apiKeyId: permission.id, + apiKeyName: permission.user_name, + rateLimit: permission.rate_limit || 1000, // Higher rate limit for internal + }; + } + + // WordPress keys are locked to their dispensary + // Use dutchie_az_store_id (resolved from store_id/store_name) + const dispensaryId = permission.dutchie_az_store_id || permission.store_id; + + return { + type: 'wordpress', + dispensaryIds: dispensaryId ? [dispensaryId] : [], + apiKeyId: permission.id, + apiKeyName: permission.user_name, + rateLimit: permission.rate_limit || 100, + }; +} + +// ============================================================ +// SCOPE HELPERS +// ============================================================ + +/** + * Check if scope allows access to a specific dispensary + */ +export function scopeAllowsDispensary(scope: ApiScope, dispensaryId: number): boolean { + if (scope.dispensaryIds === 'ALL') { + return true; + } + return scope.dispensaryIds.includes(dispensaryId); +} + +/** + * Get the effective dispensary ID for queries + * + * For wordpress keys: Always returns the scoped dispensary ID (ignores user param) + * For internal keys: Returns user param if provided, otherwise null (all) + */ +export function getEffectiveDispensaryId( + scope: ApiScope, + userProvidedId?: number | string | null +): number | null { + // WordPress keys are locked to their dispensary + if (scope.type === 'wordpress') { + if (scope.dispensaryIds === 'ALL' || scope.dispensaryIds.length === 0) { + return null; // No dispensary configured - will error elsewhere + } + return scope.dispensaryIds[0]; + } + + // Internal keys can optionally filter by dispensary + if (userProvidedId) { + const id = typeof userProvidedId === 'string' ? parseInt(userProvidedId, 10) : userProvidedId; + return isNaN(id) ? null : id; + } + + return null; // Internal key with no filter = all dispensaries +} + +/** + * Build a WHERE clause fragment for dispensary scoping + * + * Returns SQL and params to add to queries + */ +export function buildDispensaryWhereClause( + scope: ApiScope, + columnName: string = 'dispensary_id', + startParamIndex: number = 1 +): { clause: string; params: any[]; nextParamIndex: number } { + // Internal keys: no restriction + if (scope.dispensaryIds === 'ALL') { + return { clause: '', params: [], nextParamIndex: startParamIndex }; + } + + // WordPress keys: filter to their dispensary(s) + if (scope.dispensaryIds.length === 0) { + // No dispensary configured - return impossible condition + return { clause: ` AND FALSE`, params: [], nextParamIndex: startParamIndex }; + } + + if (scope.dispensaryIds.length === 1) { + return { + clause: ` AND ${columnName} = $${startParamIndex}`, + params: [scope.dispensaryIds[0]], + nextParamIndex: startParamIndex + 1, + }; + } + + // Multiple dispensaries (future-proofing) + const placeholders = scope.dispensaryIds.map((_, i) => `$${startParamIndex + i}`).join(', '); + return { + clause: ` AND ${columnName} IN (${placeholders})`, + params: scope.dispensaryIds, + nextParamIndex: startParamIndex + scope.dispensaryIds.length, + }; +} + +/** + * Validate scope allows access to a resource + * Returns true if allowed, throws/returns error info if not + */ +export function validateResourceAccess( + scope: ApiScope, + resourceDispensaryId: number | null | undefined +): { allowed: boolean; reason?: string } { + // No dispensary ID on resource = public resource, always allowed + if (resourceDispensaryId === null || resourceDispensaryId === undefined) { + return { allowed: true }; + } + + // Internal keys can access anything + if (scope.dispensaryIds === 'ALL') { + return { allowed: true }; + } + + // WordPress keys must match + if (scope.dispensaryIds.includes(resourceDispensaryId)) { + return { allowed: true }; + } + + return { + allowed: false, + reason: 'Access denied: this API key does not have access to this dispensary', + }; +} diff --git a/backend/src/routes/public-api.ts b/backend/src/routes/public-api.ts index 6bb01efa..8c74cf77 100644 --- a/backend/src/routes/public-api.ts +++ b/backend/src/routes/public-api.ts @@ -2,13 +2,23 @@ * Public API Routes for External Consumers (WordPress, etc.) * * These routes use the dutchie_az data pipeline and are protected by API key auth. - * Designed for Deeply Rooted and other WordPress sites consuming menu data. + * 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/migrate'; import { query as dutchieAzQuery } from '../dutchie-az/db/connection'; import ipaddr from 'ipaddr.js'; +import { + ApiScope, + ApiKeyType, + buildApiScope, + getEffectiveDispensaryId, + validateResourceAccess, + buildDispensaryWhereClause, +} from '../middleware/apiScope'; const router = Router(); @@ -20,16 +30,19 @@ 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; } // ============================================================ @@ -109,7 +122,7 @@ function isDomainAllowed(origin: string, allowedDomains: string[]): boolean { } /** - * Middleware to validate API key and resolve dispensary -> dutchie_az store mapping + * Middleware to validate API key and build scope */ async function validatePublicApiKey( req: PublicApiRequest, @@ -132,11 +145,13 @@ async function validatePublicApiKey( 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 + 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]); @@ -149,14 +164,14 @@ async function validatePublicApiKey( const permission = result.rows[0]; - // Validate IP if configured + // 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.allowed_ips) { + 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)) { @@ -167,10 +182,10 @@ async function validatePublicApiKey( } } - // Validate domain if configured + // Validate domain if configured (only for wordpress keys) const origin = req.get('origin') || req.get('referer') || ''; - if (permission.allowed_domains && origin) { + 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)) { @@ -181,21 +196,22 @@ async function validatePublicApiKey( } } - // Resolve the dutchie_az store for this store - // Match by store name (from main DB) to dutchie_az.dispensaries.name - const storeResult = await dutchieAzQuery<{ id: number }>(` - 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]); + // Resolve the dutchie_az store for wordpress keys + if (permission.key_type === 'wordpress' && permission.store_name) { + const storeResult = await dutchieAzQuery<{ id: number }>(` + 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; + if (storeResult.rows.length > 0) { + permission.dutchie_az_store_id = storeResult.rows[0].id; + } } // Update last_used_at timestamp (async, don't wait) @@ -207,18 +223,26 @@ async function validatePublicApiKey( 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); - // Provide more detailed error info for debugging 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.', }; - // Add error type hint for debugging (without exposing sensitive details) if (error.code === 'ECONNREFUSED') { errorDetails.hint = 'Database connection failed'; } else if (error.code === '42P01') { @@ -234,6 +258,38 @@ async function validatePublicApiKey( // 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 // ============================================================ @@ -248,17 +304,18 @@ router.use(validatePublicApiKey); * - 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 */ router.get('/products', async (req: PublicApiRequest, res: Response) => { try { - const permission = req.apiPermission!; + const scope = req.scope!; + const { dispensaryId, error } = getScopedDispensaryId(req); - // Check if we have a dutchie_az store mapping - if (!permission.dutchie_az_store_id) { + if (error) { return res.status(503).json({ error: 'No menu data available', - message: `Menu data for ${permission.store_name} is not yet available. The dispensary may not be set up in the new data pipeline.`, - dispensary_name: permission.store_name + message: error, + dispensary_name: req.apiPermission?.store_name }); } @@ -271,9 +328,22 @@ router.get('/products', async (req: PublicApiRequest, res: Response) => { } = req.query; // Build query - let whereClause = 'WHERE p.dispensary_id = $1'; - const params: any[] = [permission.dutchie_az_store_id]; - let paramIndex = 2; + 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') { @@ -303,6 +373,7 @@ router.get('/products', async (req: PublicApiRequest, res: Response) => { const { rows: products } = await dutchieAzQuery(` SELECT p.id, + p.dispensary_id, p.external_product_id as dutchie_id, p.name, p.brand_name as brand, @@ -317,7 +388,6 @@ router.get('/products', async (req: PublicApiRequest, res: Response) => { p.effects, p.created_at, p.updated_at, - -- Latest snapshot data for pricing s.rec_min_price_cents, s.rec_max_price_cents, s.rec_min_special_price_cents, @@ -347,14 +417,12 @@ router.get('/products', async (req: PublicApiRequest, res: Response) => { // Transform products to backward-compatible format const transformedProducts = products.map((p) => { - // Extract first image URL from images array let imageUrl = p.image_url; if (!imageUrl && p.images && Array.isArray(p.images) && p.images.length > 0) { const firstImage = p.images[0]; imageUrl = typeof firstImage === 'string' ? firstImage : firstImage?.url; } - // Convert prices from cents to dollars const regularPrice = p.rec_min_price_cents ? (p.rec_min_price_cents / 100).toFixed(2) : null; @@ -364,13 +432,14 @@ router.get('/products', async (req: PublicApiRequest, res: Response) => { return { 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, // Not stored in dutchie_products, would need snapshot + description: null, regular_price: regularPrice, sale_price: salePrice, thc_percentage: p.thc ? parseFloat(p.thc) : null, @@ -389,7 +458,8 @@ router.get('/products', async (req: PublicApiRequest, res: Response) => { res.json({ success: true, - dispensary: permission.store_name, + scope: scope.type, + dispensary: scope.type === 'wordpress' ? req.apiPermission?.store_name : undefined, products: transformedProducts, pagination: { total: parseInt(countRows[0]?.total || '0', 10), @@ -413,17 +483,10 @@ router.get('/products', async (req: PublicApiRequest, res: Response) => { */ router.get('/products/:id', async (req: PublicApiRequest, res: Response) => { try { - const permission = req.apiPermission!; + const scope = req.scope!; const { id } = req.params; - if (!permission.dutchie_az_store_id) { - return res.status(503).json({ - error: 'No menu data available', - message: `Menu data for ${permission.store_name} is not yet available.` - }); - } - - // Get product with latest snapshot + // Get product (without dispensary filter to check access afterward) const { rows: products } = await dutchieAzQuery(` SELECT p.*, @@ -443,8 +506,8 @@ router.get('/products/:id', async (req: PublicApiRequest, res: Response) => { ORDER BY crawled_at DESC LIMIT 1 ) s ON true - WHERE p.id = $1 AND p.dispensary_id = $2 - `, [id, permission.dutchie_az_store_id]); + WHERE p.id = $1 + `, [id]); if (products.length === 0) { return res.status(404).json({ @@ -454,7 +517,15 @@ router.get('/products/:id', async (req: PublicApiRequest, res: Response) => { const p = products[0]; - // Extract first image URL + // 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]; @@ -465,6 +536,7 @@ router.get('/products/:id', async (req: PublicApiRequest, res: Response) => { 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, @@ -502,12 +574,26 @@ router.get('/products/:id', async (req: PublicApiRequest, res: Response) => { */ router.get('/categories', async (req: PublicApiRequest, res: Response) => { try { - const permission = req.apiPermission!; + const scope = req.scope!; + const { dispensaryId, error } = getScopedDispensaryId(req); - if (!permission.dutchie_az_store_id) { + if (error) { return res.status(503).json({ error: 'No menu data available', - message: `Menu data for ${permission.store_name} is not yet 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.' }); } @@ -518,14 +604,15 @@ router.get('/categories', async (req: PublicApiRequest, res: Response) => { COUNT(*) as product_count, COUNT(*) FILTER (WHERE stock_status = 'in_stock') as in_stock_count FROM dutchie_products - WHERE dispensary_id = $1 AND type IS NOT NULL + ${whereClause} GROUP BY type, subcategory ORDER BY type, subcategory - `, [permission.dutchie_az_store_id]); + `, params); res.json({ success: true, - dispensary: permission.store_name, + scope: scope.type, + dispensary: scope.type === 'wordpress' ? req.apiPermission?.store_name : undefined, categories }); } catch (error: any) { @@ -543,12 +630,26 @@ router.get('/categories', async (req: PublicApiRequest, res: Response) => { */ router.get('/brands', async (req: PublicApiRequest, res: Response) => { try { - const permission = req.apiPermission!; + const scope = req.scope!; + const { dispensaryId, error } = getScopedDispensaryId(req); - if (!permission.dutchie_az_store_id) { + if (error) { return res.status(503).json({ error: 'No menu data available', - message: `Menu data for ${permission.store_name} is not yet 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.' }); } @@ -558,14 +659,15 @@ router.get('/brands', async (req: PublicApiRequest, res: Response) => { COUNT(*) as product_count, COUNT(*) FILTER (WHERE stock_status = 'in_stock') as in_stock_count FROM dutchie_products - WHERE dispensary_id = $1 AND brand_name IS NOT NULL + ${whereClause} GROUP BY brand_name ORDER BY product_count DESC - `, [permission.dutchie_az_store_id]); + `, params); res.json({ success: true, - dispensary: permission.store_name, + scope: scope.type, + dispensary: scope.type === 'wordpress' ? req.apiPermission?.store_name : undefined, brands }); } catch (error: any) { @@ -583,12 +685,13 @@ router.get('/brands', async (req: PublicApiRequest, res: Response) => { */ router.get('/specials', async (req: PublicApiRequest, res: Response) => { try { - const permission = req.apiPermission!; + const scope = req.scope!; + const { dispensaryId, error } = getScopedDispensaryId(req); - if (!permission.dutchie_az_store_id) { + if (error) { return res.status(503).json({ error: 'No menu data available', - message: `Menu data for ${permission.store_name} is not yet available.` + message: error }); } @@ -596,10 +699,27 @@ router.get('/specials', async (req: PublicApiRequest, res: Response) => { const limitNum = Math.min(parseInt(limit as string, 10) || 100, 500); const offsetNum = parseInt(offset as string, 10) || 0; - // Get products with special pricing from latest snapshot + 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 dutchieAzQuery(` SELECT p.id, + p.dispensary_id, p.external_product_id as dutchie_id, p.name, p.brand_name as brand, @@ -621,14 +741,13 @@ router.get('/specials', async (req: PublicApiRequest, res: Response) => { ORDER BY crawled_at DESC LIMIT 1 ) s ON true - WHERE p.dispensary_id = $1 - AND s.special = true - AND p.stock_status = 'in_stock' + ${whereClause} ORDER BY p.name ASC - LIMIT $2 OFFSET $3 - `, [permission.dutchie_az_store_id, limitNum, offsetNum]); + LIMIT $${paramIndex} OFFSET $${paramIndex + 1} + `, params); // Get total count + const countParams = params.slice(0, -2); const { rows: countRows } = await dutchieAzQuery(` SELECT COUNT(*) as total FROM dutchie_products p @@ -638,13 +757,12 @@ router.get('/specials', async (req: PublicApiRequest, res: Response) => { ORDER BY crawled_at DESC LIMIT 1 ) s ON true - WHERE p.dispensary_id = $1 - AND s.special = true - AND p.stock_status = 'in_stock' - `, [permission.dutchie_az_store_id]); + ${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, @@ -661,7 +779,8 @@ router.get('/specials', async (req: PublicApiRequest, res: Response) => { res.json({ success: true, - dispensary: permission.store_name, + scope: scope.type, + dispensary: scope.type === 'wordpress' ? req.apiPermission?.store_name : undefined, specials: transformedProducts, pagination: { total: parseInt(countRows[0]?.total || '0', 10), @@ -679,18 +798,402 @@ router.get('/specials', async (req: PublicApiRequest, res: Response) => { } }); +/** + * 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 dutchieAzQuery(` + SELECT + d.id, + d.name, + d.address, + d.city, + d.state, + d.zip, + d.phone, + d.website, + d.latitude, + d.longitude, + d.menu_type as platform, + d.menu_url, + 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 dutchie_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, + address: d.address, + city: d.city, + state: d.state, + zip: d.zip, + phone: d.phone, + 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, + 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 dutchieAzQuery(` + SELECT + d.id, + d.name, + d.address, + d.city, + d.state, + d.zip, + d.phone, + d.website, + d.latitude, + d.longitude, + d.menu_type as platform, + d.menu_url, + 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 dutchie_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 dutchieAzQuery(` + SELECT COUNT(*) as total + FROM dispensaries d + LEFT JOIN LATERAL ( + SELECT COUNT(*) as product_count + FROM dutchie_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, + address: d.address, + city: d.city, + state: d.state, + zip: d.zip, + phone: d.phone, + 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, + 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 dutchieAzQuery(` + 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 dutchie_products p + LEFT JOIN LATERAL ( + SELECT * FROM dutchie_product_snapshots + WHERE dutchie_product_id = p.id + ORDER BY crawled_at DESC + LIMIT 1 + ) s ON true + ${whereClause} + ORDER BY 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 dutchieAzQuery(` + SELECT COUNT(*) as total + FROM dutchie_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 + }); + } +}); + /** * GET /api/v1/menu * Get complete menu summary for the authenticated dispensary */ router.get('/menu', async (req: PublicApiRequest, res: Response) => { try { - const permission = req.apiPermission!; + const scope = req.scope!; + const { dispensaryId, error } = getScopedDispensaryId(req); - if (!permission.dutchie_az_store_id) { + if (error) { return res.status(503).json({ error: 'No menu data available', - message: `Menu data for ${permission.store_name} is not yet 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.' }); } @@ -701,10 +1204,10 @@ router.get('/menu', async (req: PublicApiRequest, res: Response) => { COUNT(*) as total, COUNT(*) FILTER (WHERE stock_status = 'in_stock') as in_stock FROM dutchie_products - WHERE dispensary_id = $1 AND type IS NOT NULL + ${whereClause} AND type IS NOT NULL GROUP BY type ORDER BY total DESC - `, [permission.dutchie_az_store_id]); + `, params); // Get overall stats const { rows: stats } = await dutchieAzQuery(` @@ -715,8 +1218,8 @@ router.get('/menu', async (req: PublicApiRequest, res: Response) => { COUNT(DISTINCT type) as category_count, MAX(updated_at) as last_updated FROM dutchie_products - WHERE dispensary_id = $1 - `, [permission.dutchie_az_store_id]); + ${whereClause} + `, params); // Get specials count const { rows: specialsCount } = await dutchieAzQuery(` @@ -728,16 +1231,18 @@ router.get('/menu', async (req: PublicApiRequest, res: Response) => { ORDER BY crawled_at DESC LIMIT 1 ) s ON true - WHERE p.dispensary_id = $1 + ${whereClause.replace('WHERE 1=1', 'WHERE 1=1')} AND s.special = true AND p.stock_status = 'in_stock' - `, [permission.dutchie_az_store_id]); + ${dispensaryId ? `AND p.dispensary_id = $1` : ''} + `, params); const summary = stats[0] || {}; res.json({ success: true, - dispensary: permission.store_name, + 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),