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:
Kelly
2025-12-05 06:13:20 -07:00
parent d91c55a344
commit 85e69ef6ad
3 changed files with 793 additions and 84 deletions

View 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)';

View 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',
};
}

View File

@@ -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),