feat(api): Add API key scoping for /api/v1 endpoints
- Add key_type column to wp_dutchie_api_permissions (internal/wordpress) - Create apiScope middleware with scope types and helpers - Internal keys: full access to ALL dispensaries - WordPress keys: restricted to single dispensary - Update all /api/v1 handlers to honor scope - Add /dispensaries and /search endpoints to public API 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
23
backend/src/db/migrations/031_api_key_types.sql
Normal file
23
backend/src/db/migrations/031_api_key_types.sql
Normal file
@@ -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)';
|
||||
181
backend/src/middleware/apiScope.ts
Normal file
181
backend/src/middleware/apiScope.ts
Normal file
@@ -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',
|
||||
};
|
||||
}
|
||||
@@ -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,8 +196,8 @@ async function validatePublicApiKey(
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve the dutchie_az store for this store
|
||||
// Match by store name (from main DB) to dutchie_az.dispensaries.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))
|
||||
@@ -197,6 +212,7 @@ async function validatePublicApiKey(
|
||||
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(`
|
||||
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user