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.)
|
* Public API Routes for External Consumers (WordPress, etc.)
|
||||||
*
|
*
|
||||||
* These routes use the dutchie_az data pipeline and are protected by API key auth.
|
* 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 { Router, Request, Response, NextFunction } from 'express';
|
||||||
import { pool } from '../db/migrate';
|
import { pool } from '../db/migrate';
|
||||||
import { query as dutchieAzQuery } from '../dutchie-az/db/connection';
|
import { query as dutchieAzQuery } from '../dutchie-az/db/connection';
|
||||||
import ipaddr from 'ipaddr.js';
|
import ipaddr from 'ipaddr.js';
|
||||||
|
import {
|
||||||
|
ApiScope,
|
||||||
|
ApiKeyType,
|
||||||
|
buildApiScope,
|
||||||
|
getEffectiveDispensaryId,
|
||||||
|
validateResourceAccess,
|
||||||
|
buildDispensaryWhereClause,
|
||||||
|
} from '../middleware/apiScope';
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
@@ -20,16 +30,19 @@ interface ApiKeyPermission {
|
|||||||
id: number;
|
id: number;
|
||||||
user_name: string;
|
user_name: string;
|
||||||
api_key: string;
|
api_key: string;
|
||||||
|
key_type: string;
|
||||||
allowed_ips: string | null;
|
allowed_ips: string | null;
|
||||||
allowed_domains: string | null;
|
allowed_domains: string | null;
|
||||||
is_active: number;
|
is_active: number;
|
||||||
store_id: number;
|
store_id: number;
|
||||||
store_name: string;
|
store_name: string;
|
||||||
|
rate_limit: number;
|
||||||
dutchie_az_store_id?: number;
|
dutchie_az_store_id?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface PublicApiRequest extends Request {
|
interface PublicApiRequest extends Request {
|
||||||
apiPermission?: ApiKeyPermission;
|
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(
|
async function validatePublicApiKey(
|
||||||
req: PublicApiRequest,
|
req: PublicApiRequest,
|
||||||
@@ -132,11 +145,13 @@ async function validatePublicApiKey(
|
|||||||
p.id,
|
p.id,
|
||||||
p.user_name,
|
p.user_name,
|
||||||
p.api_key,
|
p.api_key,
|
||||||
|
COALESCE(p.key_type, 'wordpress') as key_type,
|
||||||
p.allowed_ips,
|
p.allowed_ips,
|
||||||
p.allowed_domains,
|
p.allowed_domains,
|
||||||
p.is_active,
|
p.is_active,
|
||||||
p.store_id,
|
p.store_id,
|
||||||
p.store_name
|
p.store_name,
|
||||||
|
COALESCE(p.rate_limit, 100) as rate_limit
|
||||||
FROM wp_dutchie_api_permissions p
|
FROM wp_dutchie_api_permissions p
|
||||||
WHERE p.api_key = $1 AND p.is_active = 1
|
WHERE p.api_key = $1 AND p.is_active = 1
|
||||||
`, [apiKey]);
|
`, [apiKey]);
|
||||||
@@ -149,14 +164,14 @@ async function validatePublicApiKey(
|
|||||||
|
|
||||||
const permission = result.rows[0];
|
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() ||
|
const clientIp = (req.headers['x-forwarded-for'] as string)?.split(',')[0].trim() ||
|
||||||
(req.headers['x-real-ip'] as string) ||
|
(req.headers['x-real-ip'] as string) ||
|
||||||
req.ip ||
|
req.ip ||
|
||||||
req.connection.remoteAddress ||
|
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());
|
const allowedIps = permission.allowed_ips.split('\n').filter((ip: string) => ip.trim());
|
||||||
|
|
||||||
if (allowedIps.length > 0 && !isIpAllowed(clientIp, allowedIps)) {
|
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') || '';
|
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());
|
const allowedDomains = permission.allowed_domains.split('\n').filter((d: string) => d.trim());
|
||||||
|
|
||||||
if (allowedDomains.length > 0 && !isDomainAllowed(origin, allowedDomains)) {
|
if (allowedDomains.length > 0 && !isDomainAllowed(origin, allowedDomains)) {
|
||||||
@@ -181,8 +196,8 @@ async function validatePublicApiKey(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Resolve the dutchie_az store for this store
|
// Resolve the dutchie_az store for wordpress keys
|
||||||
// Match by store name (from main DB) to dutchie_az.dispensaries.name
|
if (permission.key_type === 'wordpress' && permission.store_name) {
|
||||||
const storeResult = await dutchieAzQuery<{ id: number }>(`
|
const storeResult = await dutchieAzQuery<{ id: number }>(`
|
||||||
SELECT id FROM dispensaries
|
SELECT id FROM dispensaries
|
||||||
WHERE LOWER(TRIM(name)) = LOWER(TRIM($1))
|
WHERE LOWER(TRIM(name)) = LOWER(TRIM($1))
|
||||||
@@ -197,6 +212,7 @@ async function validatePublicApiKey(
|
|||||||
if (storeResult.rows.length > 0) {
|
if (storeResult.rows.length > 0) {
|
||||||
permission.dutchie_az_store_id = storeResult.rows[0].id;
|
permission.dutchie_az_store_id = storeResult.rows[0].id;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Update last_used_at timestamp (async, don't wait)
|
// Update last_used_at timestamp (async, don't wait)
|
||||||
pool.query(`
|
pool.query(`
|
||||||
@@ -207,18 +223,26 @@ async function validatePublicApiKey(
|
|||||||
console.error('Error updating last_used_at:', err);
|
console.error('Error updating last_used_at:', err);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Build the scope object
|
||||||
req.apiPermission = permission;
|
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();
|
next();
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error('Public API validation error:', error);
|
console.error('Public API validation error:', error);
|
||||||
|
|
||||||
// Provide more detailed error info for debugging
|
|
||||||
const errorDetails: any = {
|
const errorDetails: any = {
|
||||||
error: 'Internal server error during API validation',
|
error: 'Internal server error during API validation',
|
||||||
message: 'An unexpected error occurred while validating your API key. Please try again or contact support.',
|
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') {
|
if (error.code === 'ECONNREFUSED') {
|
||||||
errorDetails.hint = 'Database connection failed';
|
errorDetails.hint = 'Database connection failed';
|
||||||
} else if (error.code === '42P01') {
|
} else if (error.code === '42P01') {
|
||||||
@@ -234,6 +258,38 @@ async function validatePublicApiKey(
|
|||||||
// Apply middleware to all routes
|
// Apply middleware to all routes
|
||||||
router.use(validatePublicApiKey);
|
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
|
// PRODUCT ENDPOINTS
|
||||||
// ============================================================
|
// ============================================================
|
||||||
@@ -248,17 +304,18 @@ router.use(validatePublicApiKey);
|
|||||||
* - in_stock_only: Only return in-stock products (default: false)
|
* - in_stock_only: Only return in-stock products (default: false)
|
||||||
* - limit: Max products to return (default: 100, max: 500)
|
* - limit: Max products to return (default: 100, max: 500)
|
||||||
* - offset: Pagination offset (default: 0)
|
* - offset: Pagination offset (default: 0)
|
||||||
|
* - dispensary_id: (internal keys only) Filter by specific dispensary
|
||||||
*/
|
*/
|
||||||
router.get('/products', async (req: PublicApiRequest, res: Response) => {
|
router.get('/products', async (req: PublicApiRequest, res: Response) => {
|
||||||
try {
|
try {
|
||||||
const permission = req.apiPermission!;
|
const scope = req.scope!;
|
||||||
|
const { dispensaryId, error } = getScopedDispensaryId(req);
|
||||||
|
|
||||||
// Check if we have a dutchie_az store mapping
|
if (error) {
|
||||||
if (!permission.dutchie_az_store_id) {
|
|
||||||
return res.status(503).json({
|
return res.status(503).json({
|
||||||
error: 'No menu data available',
|
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.`,
|
message: error,
|
||||||
dispensary_name: permission.store_name
|
dispensary_name: req.apiPermission?.store_name
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -271,9 +328,22 @@ router.get('/products', async (req: PublicApiRequest, res: Response) => {
|
|||||||
} = req.query;
|
} = req.query;
|
||||||
|
|
||||||
// Build query
|
// Build query
|
||||||
let whereClause = 'WHERE p.dispensary_id = $1';
|
let whereClause = 'WHERE 1=1';
|
||||||
const params: any[] = [permission.dutchie_az_store_id];
|
const params: any[] = [];
|
||||||
let paramIndex = 2;
|
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
|
// Filter by stock status if requested
|
||||||
if (in_stock_only === 'true' || in_stock_only === '1') {
|
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(`
|
const { rows: products } = await dutchieAzQuery(`
|
||||||
SELECT
|
SELECT
|
||||||
p.id,
|
p.id,
|
||||||
|
p.dispensary_id,
|
||||||
p.external_product_id as dutchie_id,
|
p.external_product_id as dutchie_id,
|
||||||
p.name,
|
p.name,
|
||||||
p.brand_name as brand,
|
p.brand_name as brand,
|
||||||
@@ -317,7 +388,6 @@ router.get('/products', async (req: PublicApiRequest, res: Response) => {
|
|||||||
p.effects,
|
p.effects,
|
||||||
p.created_at,
|
p.created_at,
|
||||||
p.updated_at,
|
p.updated_at,
|
||||||
-- Latest snapshot data for pricing
|
|
||||||
s.rec_min_price_cents,
|
s.rec_min_price_cents,
|
||||||
s.rec_max_price_cents,
|
s.rec_max_price_cents,
|
||||||
s.rec_min_special_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
|
// Transform products to backward-compatible format
|
||||||
const transformedProducts = products.map((p) => {
|
const transformedProducts = products.map((p) => {
|
||||||
// Extract first image URL from images array
|
|
||||||
let imageUrl = p.image_url;
|
let imageUrl = p.image_url;
|
||||||
if (!imageUrl && p.images && Array.isArray(p.images) && p.images.length > 0) {
|
if (!imageUrl && p.images && Array.isArray(p.images) && p.images.length > 0) {
|
||||||
const firstImage = p.images[0];
|
const firstImage = p.images[0];
|
||||||
imageUrl = typeof firstImage === 'string' ? firstImage : firstImage?.url;
|
imageUrl = typeof firstImage === 'string' ? firstImage : firstImage?.url;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Convert prices from cents to dollars
|
|
||||||
const regularPrice = p.rec_min_price_cents
|
const regularPrice = p.rec_min_price_cents
|
||||||
? (p.rec_min_price_cents / 100).toFixed(2)
|
? (p.rec_min_price_cents / 100).toFixed(2)
|
||||||
: null;
|
: null;
|
||||||
@@ -364,13 +432,14 @@ router.get('/products', async (req: PublicApiRequest, res: Response) => {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
id: p.id,
|
id: p.id,
|
||||||
|
dispensary_id: p.dispensary_id,
|
||||||
dutchie_id: p.dutchie_id,
|
dutchie_id: p.dutchie_id,
|
||||||
name: p.name,
|
name: p.name,
|
||||||
brand: p.brand || null,
|
brand: p.brand || null,
|
||||||
category: p.category || null,
|
category: p.category || null,
|
||||||
subcategory: p.subcategory || null,
|
subcategory: p.subcategory || null,
|
||||||
strain_type: p.strain_type || null,
|
strain_type: p.strain_type || null,
|
||||||
description: null, // Not stored in dutchie_products, would need snapshot
|
description: null,
|
||||||
regular_price: regularPrice,
|
regular_price: regularPrice,
|
||||||
sale_price: salePrice,
|
sale_price: salePrice,
|
||||||
thc_percentage: p.thc ? parseFloat(p.thc) : null,
|
thc_percentage: p.thc ? parseFloat(p.thc) : null,
|
||||||
@@ -389,7 +458,8 @@ router.get('/products', async (req: PublicApiRequest, res: Response) => {
|
|||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
success: true,
|
success: true,
|
||||||
dispensary: permission.store_name,
|
scope: scope.type,
|
||||||
|
dispensary: scope.type === 'wordpress' ? req.apiPermission?.store_name : undefined,
|
||||||
products: transformedProducts,
|
products: transformedProducts,
|
||||||
pagination: {
|
pagination: {
|
||||||
total: parseInt(countRows[0]?.total || '0', 10),
|
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) => {
|
router.get('/products/:id', async (req: PublicApiRequest, res: Response) => {
|
||||||
try {
|
try {
|
||||||
const permission = req.apiPermission!;
|
const scope = req.scope!;
|
||||||
const { id } = req.params;
|
const { id } = req.params;
|
||||||
|
|
||||||
if (!permission.dutchie_az_store_id) {
|
// Get product (without dispensary filter to check access afterward)
|
||||||
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
|
|
||||||
const { rows: products } = await dutchieAzQuery(`
|
const { rows: products } = await dutchieAzQuery(`
|
||||||
SELECT
|
SELECT
|
||||||
p.*,
|
p.*,
|
||||||
@@ -443,8 +506,8 @@ router.get('/products/:id', async (req: PublicApiRequest, res: Response) => {
|
|||||||
ORDER BY crawled_at DESC
|
ORDER BY crawled_at DESC
|
||||||
LIMIT 1
|
LIMIT 1
|
||||||
) s ON true
|
) s ON true
|
||||||
WHERE p.id = $1 AND p.dispensary_id = $2
|
WHERE p.id = $1
|
||||||
`, [id, permission.dutchie_az_store_id]);
|
`, [id]);
|
||||||
|
|
||||||
if (products.length === 0) {
|
if (products.length === 0) {
|
||||||
return res.status(404).json({
|
return res.status(404).json({
|
||||||
@@ -454,7 +517,15 @@ router.get('/products/:id', async (req: PublicApiRequest, res: Response) => {
|
|||||||
|
|
||||||
const p = products[0];
|
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;
|
let imageUrl = p.primary_image_url;
|
||||||
if (!imageUrl && p.images && Array.isArray(p.images) && p.images.length > 0) {
|
if (!imageUrl && p.images && Array.isArray(p.images) && p.images.length > 0) {
|
||||||
const firstImage = p.images[0];
|
const firstImage = p.images[0];
|
||||||
@@ -465,6 +536,7 @@ router.get('/products/:id', async (req: PublicApiRequest, res: Response) => {
|
|||||||
success: true,
|
success: true,
|
||||||
product: {
|
product: {
|
||||||
id: p.id,
|
id: p.id,
|
||||||
|
dispensary_id: p.dispensary_id,
|
||||||
dutchie_id: p.external_product_id,
|
dutchie_id: p.external_product_id,
|
||||||
name: p.name,
|
name: p.name,
|
||||||
brand: p.brand_name || null,
|
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) => {
|
router.get('/categories', async (req: PublicApiRequest, res: Response) => {
|
||||||
try {
|
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({
|
return res.status(503).json({
|
||||||
error: 'No menu data available',
|
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(*) as product_count,
|
||||||
COUNT(*) FILTER (WHERE stock_status = 'in_stock') as in_stock_count
|
COUNT(*) FILTER (WHERE stock_status = 'in_stock') as in_stock_count
|
||||||
FROM dutchie_products
|
FROM dutchie_products
|
||||||
WHERE dispensary_id = $1 AND type IS NOT NULL
|
${whereClause}
|
||||||
GROUP BY type, subcategory
|
GROUP BY type, subcategory
|
||||||
ORDER BY type, subcategory
|
ORDER BY type, subcategory
|
||||||
`, [permission.dutchie_az_store_id]);
|
`, params);
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
success: true,
|
success: true,
|
||||||
dispensary: permission.store_name,
|
scope: scope.type,
|
||||||
|
dispensary: scope.type === 'wordpress' ? req.apiPermission?.store_name : undefined,
|
||||||
categories
|
categories
|
||||||
});
|
});
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
@@ -543,12 +630,26 @@ router.get('/categories', async (req: PublicApiRequest, res: Response) => {
|
|||||||
*/
|
*/
|
||||||
router.get('/brands', async (req: PublicApiRequest, res: Response) => {
|
router.get('/brands', async (req: PublicApiRequest, res: Response) => {
|
||||||
try {
|
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({
|
return res.status(503).json({
|
||||||
error: 'No menu data available',
|
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(*) as product_count,
|
||||||
COUNT(*) FILTER (WHERE stock_status = 'in_stock') as in_stock_count
|
COUNT(*) FILTER (WHERE stock_status = 'in_stock') as in_stock_count
|
||||||
FROM dutchie_products
|
FROM dutchie_products
|
||||||
WHERE dispensary_id = $1 AND brand_name IS NOT NULL
|
${whereClause}
|
||||||
GROUP BY brand_name
|
GROUP BY brand_name
|
||||||
ORDER BY product_count DESC
|
ORDER BY product_count DESC
|
||||||
`, [permission.dutchie_az_store_id]);
|
`, params);
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
success: true,
|
success: true,
|
||||||
dispensary: permission.store_name,
|
scope: scope.type,
|
||||||
|
dispensary: scope.type === 'wordpress' ? req.apiPermission?.store_name : undefined,
|
||||||
brands
|
brands
|
||||||
});
|
});
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
@@ -583,12 +685,13 @@ router.get('/brands', async (req: PublicApiRequest, res: Response) => {
|
|||||||
*/
|
*/
|
||||||
router.get('/specials', async (req: PublicApiRequest, res: Response) => {
|
router.get('/specials', async (req: PublicApiRequest, res: Response) => {
|
||||||
try {
|
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({
|
return res.status(503).json({
|
||||||
error: 'No menu data available',
|
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 limitNum = Math.min(parseInt(limit as string, 10) || 100, 500);
|
||||||
const offsetNum = parseInt(offset as string, 10) || 0;
|
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(`
|
const { rows: products } = await dutchieAzQuery(`
|
||||||
SELECT
|
SELECT
|
||||||
p.id,
|
p.id,
|
||||||
|
p.dispensary_id,
|
||||||
p.external_product_id as dutchie_id,
|
p.external_product_id as dutchie_id,
|
||||||
p.name,
|
p.name,
|
||||||
p.brand_name as brand,
|
p.brand_name as brand,
|
||||||
@@ -621,14 +741,13 @@ router.get('/specials', async (req: PublicApiRequest, res: Response) => {
|
|||||||
ORDER BY crawled_at DESC
|
ORDER BY crawled_at DESC
|
||||||
LIMIT 1
|
LIMIT 1
|
||||||
) s ON true
|
) s ON true
|
||||||
WHERE p.dispensary_id = $1
|
${whereClause}
|
||||||
AND s.special = true
|
|
||||||
AND p.stock_status = 'in_stock'
|
|
||||||
ORDER BY p.name ASC
|
ORDER BY p.name ASC
|
||||||
LIMIT $2 OFFSET $3
|
LIMIT $${paramIndex} OFFSET $${paramIndex + 1}
|
||||||
`, [permission.dutchie_az_store_id, limitNum, offsetNum]);
|
`, params);
|
||||||
|
|
||||||
// Get total count
|
// Get total count
|
||||||
|
const countParams = params.slice(0, -2);
|
||||||
const { rows: countRows } = await dutchieAzQuery(`
|
const { rows: countRows } = await dutchieAzQuery(`
|
||||||
SELECT COUNT(*) as total
|
SELECT COUNT(*) as total
|
||||||
FROM dutchie_products p
|
FROM dutchie_products p
|
||||||
@@ -638,13 +757,12 @@ router.get('/specials', async (req: PublicApiRequest, res: Response) => {
|
|||||||
ORDER BY crawled_at DESC
|
ORDER BY crawled_at DESC
|
||||||
LIMIT 1
|
LIMIT 1
|
||||||
) s ON true
|
) s ON true
|
||||||
WHERE p.dispensary_id = $1
|
${whereClause}
|
||||||
AND s.special = true
|
`, countParams);
|
||||||
AND p.stock_status = 'in_stock'
|
|
||||||
`, [permission.dutchie_az_store_id]);
|
|
||||||
|
|
||||||
const transformedProducts = products.map((p) => ({
|
const transformedProducts = products.map((p) => ({
|
||||||
id: p.id,
|
id: p.id,
|
||||||
|
dispensary_id: p.dispensary_id,
|
||||||
dutchie_id: p.dutchie_id,
|
dutchie_id: p.dutchie_id,
|
||||||
name: p.name,
|
name: p.name,
|
||||||
brand: p.brand || null,
|
brand: p.brand || null,
|
||||||
@@ -661,7 +779,8 @@ router.get('/specials', async (req: PublicApiRequest, res: Response) => {
|
|||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
success: true,
|
success: true,
|
||||||
dispensary: permission.store_name,
|
scope: scope.type,
|
||||||
|
dispensary: scope.type === 'wordpress' ? req.apiPermission?.store_name : undefined,
|
||||||
specials: transformedProducts,
|
specials: transformedProducts,
|
||||||
pagination: {
|
pagination: {
|
||||||
total: parseInt(countRows[0]?.total || '0', 10),
|
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 /api/v1/menu
|
||||||
* Get complete menu summary for the authenticated dispensary
|
* Get complete menu summary for the authenticated dispensary
|
||||||
*/
|
*/
|
||||||
router.get('/menu', async (req: PublicApiRequest, res: Response) => {
|
router.get('/menu', async (req: PublicApiRequest, res: Response) => {
|
||||||
try {
|
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({
|
return res.status(503).json({
|
||||||
error: 'No menu data available',
|
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(*) as total,
|
||||||
COUNT(*) FILTER (WHERE stock_status = 'in_stock') as in_stock
|
COUNT(*) FILTER (WHERE stock_status = 'in_stock') as in_stock
|
||||||
FROM dutchie_products
|
FROM dutchie_products
|
||||||
WHERE dispensary_id = $1 AND type IS NOT NULL
|
${whereClause} AND type IS NOT NULL
|
||||||
GROUP BY type
|
GROUP BY type
|
||||||
ORDER BY total DESC
|
ORDER BY total DESC
|
||||||
`, [permission.dutchie_az_store_id]);
|
`, params);
|
||||||
|
|
||||||
// Get overall stats
|
// Get overall stats
|
||||||
const { rows: stats } = await dutchieAzQuery(`
|
const { rows: stats } = await dutchieAzQuery(`
|
||||||
@@ -715,8 +1218,8 @@ router.get('/menu', async (req: PublicApiRequest, res: Response) => {
|
|||||||
COUNT(DISTINCT type) as category_count,
|
COUNT(DISTINCT type) as category_count,
|
||||||
MAX(updated_at) as last_updated
|
MAX(updated_at) as last_updated
|
||||||
FROM dutchie_products
|
FROM dutchie_products
|
||||||
WHERE dispensary_id = $1
|
${whereClause}
|
||||||
`, [permission.dutchie_az_store_id]);
|
`, params);
|
||||||
|
|
||||||
// Get specials count
|
// Get specials count
|
||||||
const { rows: specialsCount } = await dutchieAzQuery(`
|
const { rows: specialsCount } = await dutchieAzQuery(`
|
||||||
@@ -728,16 +1231,18 @@ router.get('/menu', async (req: PublicApiRequest, res: Response) => {
|
|||||||
ORDER BY crawled_at DESC
|
ORDER BY crawled_at DESC
|
||||||
LIMIT 1
|
LIMIT 1
|
||||||
) s ON true
|
) s ON true
|
||||||
WHERE p.dispensary_id = $1
|
${whereClause.replace('WHERE 1=1', 'WHERE 1=1')}
|
||||||
AND s.special = true
|
AND s.special = true
|
||||||
AND p.stock_status = 'in_stock'
|
AND p.stock_status = 'in_stock'
|
||||||
`, [permission.dutchie_az_store_id]);
|
${dispensaryId ? `AND p.dispensary_id = $1` : ''}
|
||||||
|
`, params);
|
||||||
|
|
||||||
const summary = stats[0] || {};
|
const summary = stats[0] || {};
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
success: true,
|
success: true,
|
||||||
dispensary: permission.store_name,
|
scope: scope.type,
|
||||||
|
dispensary: scope.type === 'wordpress' ? req.apiPermission?.store_name : undefined,
|
||||||
menu: {
|
menu: {
|
||||||
total_products: parseInt(summary.total_products || '0', 10),
|
total_products: parseInt(summary.total_products || '0', 10),
|
||||||
in_stock_count: parseInt(summary.in_stock_count || '0', 10),
|
in_stock_count: parseInt(summary.in_stock_count || '0', 10),
|
||||||
|
|||||||
Reference in New Issue
Block a user