feat: Add Findagram and FindADispo consumer frontends
- Add findagram.co React frontend with product search, brands, categories - Add findadispo.com React frontend with dispensary locator - Wire findagram to backend /api/az/* endpoints - Update category/brand links to route to /products with filters - Add k8s manifests for both frontends - Add multi-domain user support migrations 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
172
backend/migrations/035_multi_domain_users.sql
Normal file
172
backend/migrations/035_multi_domain_users.sql
Normal file
@@ -0,0 +1,172 @@
|
||||
-- Migration: Multi-domain user support with extended profile fields
|
||||
-- Adds domain tracking for findagram.co and findadispo.com users
|
||||
-- Adds extended profile fields (first_name, last_name, phone, sms_enabled)
|
||||
|
||||
-- Add new columns to users table
|
||||
ALTER TABLE users
|
||||
ADD COLUMN IF NOT EXISTS first_name VARCHAR(100),
|
||||
ADD COLUMN IF NOT EXISTS last_name VARCHAR(100),
|
||||
ADD COLUMN IF NOT EXISTS phone VARCHAR(20),
|
||||
ADD COLUMN IF NOT EXISTS sms_enabled BOOLEAN DEFAULT false,
|
||||
ADD COLUMN IF NOT EXISTS domain VARCHAR(50) DEFAULT 'cannaiq.co';
|
||||
|
||||
-- Create index for domain-based queries
|
||||
CREATE INDEX IF NOT EXISTS idx_users_domain ON users(domain);
|
||||
|
||||
-- Add domain column to wp_api_permissions
|
||||
ALTER TABLE wp_api_permissions
|
||||
ADD COLUMN IF NOT EXISTS domain VARCHAR(50) DEFAULT 'cannaiq.co';
|
||||
|
||||
-- Create index for domain-based permission queries
|
||||
CREATE INDEX IF NOT EXISTS idx_wp_api_permissions_domain ON wp_api_permissions(domain);
|
||||
|
||||
-- Create findagram_users table for Find a Gram specific user data
|
||||
CREATE TABLE IF NOT EXISTS findagram_users (
|
||||
id SERIAL PRIMARY KEY,
|
||||
user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
|
||||
-- Profile
|
||||
display_name VARCHAR(100),
|
||||
avatar_url TEXT,
|
||||
bio TEXT,
|
||||
-- Location preferences
|
||||
preferred_city VARCHAR(100),
|
||||
preferred_state VARCHAR(50),
|
||||
location_lat DECIMAL(10, 8),
|
||||
location_lng DECIMAL(11, 8),
|
||||
-- Preferences
|
||||
favorite_strains TEXT[], -- Array of strain types: hybrid, indica, sativa
|
||||
favorite_categories TEXT[], -- flower, edibles, concentrates, etc.
|
||||
price_alert_threshold DECIMAL(10, 2),
|
||||
notifications_enabled BOOLEAN DEFAULT true,
|
||||
-- Timestamps
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(user_id)
|
||||
);
|
||||
|
||||
-- Create findadispo_users table for Find a Dispo specific user data
|
||||
CREATE TABLE IF NOT EXISTS findadispo_users (
|
||||
id SERIAL PRIMARY KEY,
|
||||
user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
|
||||
-- Profile
|
||||
display_name VARCHAR(100),
|
||||
avatar_url TEXT,
|
||||
-- Location preferences
|
||||
preferred_city VARCHAR(100),
|
||||
preferred_state VARCHAR(50),
|
||||
location_lat DECIMAL(10, 8),
|
||||
location_lng DECIMAL(11, 8),
|
||||
search_radius_miles INTEGER DEFAULT 25,
|
||||
-- Preferences
|
||||
favorite_dispensary_ids INTEGER[], -- Array of dispensary IDs
|
||||
deal_notifications BOOLEAN DEFAULT true,
|
||||
-- Timestamps
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(user_id)
|
||||
);
|
||||
|
||||
-- Create findagram_saved_searches table
|
||||
CREATE TABLE IF NOT EXISTS findagram_saved_searches (
|
||||
id SERIAL PRIMARY KEY,
|
||||
user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
|
||||
name VARCHAR(100) NOT NULL,
|
||||
-- Search criteria
|
||||
query TEXT,
|
||||
category VARCHAR(50),
|
||||
brand VARCHAR(100),
|
||||
strain_type VARCHAR(20),
|
||||
min_price DECIMAL(10, 2),
|
||||
max_price DECIMAL(10, 2),
|
||||
min_thc DECIMAL(5, 2),
|
||||
max_thc DECIMAL(5, 2),
|
||||
city VARCHAR(100),
|
||||
state VARCHAR(50),
|
||||
-- Notification settings
|
||||
notify_on_new BOOLEAN DEFAULT false,
|
||||
notify_on_price_drop BOOLEAN DEFAULT false,
|
||||
-- Timestamps
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- Create findagram_favorites table
|
||||
CREATE TABLE IF NOT EXISTS findagram_favorites (
|
||||
id SERIAL PRIMARY KEY,
|
||||
user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
|
||||
product_id INTEGER, -- References products table
|
||||
dispensary_id INTEGER, -- References dispensaries table
|
||||
-- Product snapshot at time of save
|
||||
product_name VARCHAR(255),
|
||||
product_brand VARCHAR(100),
|
||||
product_price DECIMAL(10, 2),
|
||||
product_image_url TEXT,
|
||||
-- Timestamps
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(user_id, product_id)
|
||||
);
|
||||
|
||||
-- Create findagram_alerts table
|
||||
CREATE TABLE IF NOT EXISTS findagram_alerts (
|
||||
id SERIAL PRIMARY KEY,
|
||||
user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
|
||||
alert_type VARCHAR(50) NOT NULL, -- price_drop, back_in_stock, new_product
|
||||
-- Target
|
||||
product_id INTEGER,
|
||||
brand VARCHAR(100),
|
||||
category VARCHAR(50),
|
||||
dispensary_id INTEGER,
|
||||
-- Criteria
|
||||
target_price DECIMAL(10, 2),
|
||||
-- Status
|
||||
is_active BOOLEAN DEFAULT true,
|
||||
last_triggered_at TIMESTAMP,
|
||||
trigger_count INTEGER DEFAULT 0,
|
||||
-- Timestamps
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- Create indexes for findagram tables
|
||||
CREATE INDEX IF NOT EXISTS idx_findagram_users_user_id ON findagram_users(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_findadispo_users_user_id ON findadispo_users(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_findagram_saved_searches_user_id ON findagram_saved_searches(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_findagram_favorites_user_id ON findagram_favorites(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_findagram_favorites_product_id ON findagram_favorites(product_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_findagram_alerts_user_id ON findagram_alerts(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_findagram_alerts_active ON findagram_alerts(is_active) WHERE is_active = true;
|
||||
|
||||
-- Create view for admin user management across domains
|
||||
CREATE OR REPLACE VIEW admin_users_view AS
|
||||
SELECT
|
||||
u.id,
|
||||
u.email,
|
||||
u.first_name,
|
||||
u.last_name,
|
||||
u.phone,
|
||||
u.sms_enabled,
|
||||
u.role,
|
||||
u.domain,
|
||||
u.created_at,
|
||||
u.updated_at,
|
||||
CASE
|
||||
WHEN u.domain = 'findagram.co' THEN fg.display_name
|
||||
WHEN u.domain = 'findadispo.com' THEN fd.display_name
|
||||
ELSE NULL
|
||||
END as display_name,
|
||||
CASE
|
||||
WHEN u.domain = 'findagram.co' THEN fg.preferred_city
|
||||
WHEN u.domain = 'findadispo.com' THEN fd.preferred_city
|
||||
ELSE NULL
|
||||
END as preferred_city,
|
||||
CASE
|
||||
WHEN u.domain = 'findagram.co' THEN fg.preferred_state
|
||||
WHEN u.domain = 'findadispo.com' THEN fd.preferred_state
|
||||
ELSE NULL
|
||||
END as preferred_state
|
||||
FROM users u
|
||||
LEFT JOIN findagram_users fg ON u.id = fg.user_id AND u.domain = 'findagram.co'
|
||||
LEFT JOIN findadispo_users fd ON u.id = fd.user_id AND u.domain = 'findadispo.com';
|
||||
|
||||
-- Update existing cannaiq users to have domain set
|
||||
UPDATE users SET domain = 'cannaiq.co' WHERE domain IS NULL;
|
||||
47
backend/migrations/036_findadispo_dispensary_fields.sql
Normal file
47
backend/migrations/036_findadispo_dispensary_fields.sql
Normal file
@@ -0,0 +1,47 @@
|
||||
-- Migration 036: Add fields for findadispo.com frontend
|
||||
-- These fields are needed for the consumer-facing dispensary locator UI
|
||||
-- This migration is idempotent - safe to run multiple times
|
||||
|
||||
-- Add hours as JSONB to support structured hours data
|
||||
-- Example: {"monday": {"open": "09:00", "close": "21:00"}, "tuesday": {...}, ...}
|
||||
-- Or simple: {"formatted": "Mon-Sat 9am-9pm, Sun 10am-6pm"}
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'dispensaries' AND column_name = 'hours') THEN
|
||||
ALTER TABLE dispensaries ADD COLUMN hours JSONB DEFAULT NULL;
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
-- Add amenities as TEXT array
|
||||
-- Example: ['Wheelchair Accessible', 'ATM', 'Online Ordering', 'Curbside Pickup', 'Delivery']
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'dispensaries' AND column_name = 'amenities') THEN
|
||||
ALTER TABLE dispensaries ADD COLUMN amenities TEXT[] DEFAULT '{}';
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
-- Add description for the "About" section on dispensary detail pages
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'dispensaries' AND column_name = 'description') THEN
|
||||
ALTER TABLE dispensaries ADD COLUMN description TEXT DEFAULT NULL;
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
-- Add image_url for the main dispensary hero image
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'dispensaries' AND column_name = 'image_url') THEN
|
||||
ALTER TABLE dispensaries ADD COLUMN image_url TEXT DEFAULT NULL;
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
-- Add indexes for filtering (IF NOT EXISTS is supported for indexes in PG 9.5+)
|
||||
CREATE INDEX IF NOT EXISTS idx_dispensaries_amenities ON dispensaries USING GIN (amenities);
|
||||
|
||||
-- Add comments for documentation (COMMENT is idempotent - re-running just updates the comment)
|
||||
COMMENT ON COLUMN dispensaries.hours IS 'Store hours in JSONB format. Can be structured by day or formatted string.';
|
||||
COMMENT ON COLUMN dispensaries.amenities IS 'Array of amenity tags like Wheelchair Accessible, ATM, Online Ordering, etc.';
|
||||
COMMENT ON COLUMN dispensaries.description IS 'Description text for dispensary detail page About section.';
|
||||
COMMENT ON COLUMN dispensaries.image_url IS 'URL to main dispensary image for hero/card display.';
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "dutchie-menus-backend",
|
||||
"version": "1.0.0",
|
||||
"version": "1.5.1",
|
||||
"description": "Backend API for Dutchie Menus scraper and management",
|
||||
"main": "dist/index.js",
|
||||
"scripts": {
|
||||
|
||||
@@ -657,6 +657,193 @@ router.get('/products/:id/snapshots', async (req: Request, res: Response) => {
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/dutchie-az/products/:id/similar
|
||||
* Get similar products (same brand + category), limited to 4
|
||||
* Returns products with lowest prices first
|
||||
*/
|
||||
router.get('/products/:id/similar', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
|
||||
// Use the exact SQL query provided
|
||||
const { rows } = await query<{
|
||||
product_id: number;
|
||||
name: string;
|
||||
brand_name: string;
|
||||
image_url: string;
|
||||
rec_min_price_cents: number;
|
||||
}>(
|
||||
`
|
||||
WITH base AS (
|
||||
SELECT id AS base_product_id, brand_name, category
|
||||
FROM dutchie_products WHERE id = $1
|
||||
),
|
||||
latest_prices AS (
|
||||
SELECT DISTINCT ON (dps.dutchie_product_id)
|
||||
dps.dutchie_product_id, dps.rec_min_price_cents
|
||||
FROM dutchie_product_snapshots dps
|
||||
ORDER BY dps.dutchie_product_id, dps.crawled_at DESC
|
||||
)
|
||||
SELECT p.id AS product_id, p.name, p.brand_name, p.primary_image_url as image_url, lp.rec_min_price_cents
|
||||
FROM dutchie_products p
|
||||
JOIN base b ON p.category = b.category AND p.brand_name = b.brand_name
|
||||
JOIN latest_prices lp ON lp.dutchie_product_id = p.id
|
||||
WHERE p.id <> b.base_product_id AND lp.rec_min_price_cents IS NOT NULL
|
||||
ORDER BY lp.rec_min_price_cents ASC
|
||||
LIMIT 4
|
||||
`,
|
||||
[id]
|
||||
);
|
||||
|
||||
// Transform to the expected response format
|
||||
const similarProducts = rows.map((row) => ({
|
||||
productId: row.product_id,
|
||||
name: row.name,
|
||||
brandName: row.brand_name,
|
||||
imageUrl: row.image_url,
|
||||
price: row.rec_min_price_cents ? row.rec_min_price_cents / 100 : null,
|
||||
}));
|
||||
|
||||
res.json({ similarProducts });
|
||||
} catch (error: any) {
|
||||
console.error('Error fetching similar products:', error);
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/dutchie-az/products/:id/availability
|
||||
* Get dispensaries that carry this product, with distance from user location
|
||||
* Query params:
|
||||
* - lat: User latitude (required)
|
||||
* - lng: User longitude (required)
|
||||
* - max_radius_miles: Maximum search radius in miles (optional, default 50)
|
||||
*/
|
||||
router.get('/products/:id/availability', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const { lat, lng, max_radius_miles = '50' } = req.query;
|
||||
|
||||
// Validate required params
|
||||
if (!lat || !lng) {
|
||||
return res.status(400).json({ error: 'lat and lng query parameters are required' });
|
||||
}
|
||||
|
||||
const userLat = parseFloat(lat as string);
|
||||
const userLng = parseFloat(lng as string);
|
||||
const maxRadius = parseFloat(max_radius_miles as string);
|
||||
|
||||
if (isNaN(userLat) || isNaN(userLng)) {
|
||||
return res.status(400).json({ error: 'lat and lng must be valid numbers' });
|
||||
}
|
||||
|
||||
// First get the product to find its external_product_id
|
||||
const { rows: productRows } = await query(
|
||||
`SELECT external_product_id, name, brand_name FROM dutchie_products WHERE id = $1`,
|
||||
[id]
|
||||
);
|
||||
|
||||
if (productRows.length === 0) {
|
||||
return res.status(404).json({ error: 'Product not found' });
|
||||
}
|
||||
|
||||
const externalProductId = productRows[0].external_product_id;
|
||||
|
||||
// Find all dispensaries carrying this product (by external_product_id match)
|
||||
// with distance calculation using Haversine formula
|
||||
const { rows: offers } = await query<{
|
||||
dispensary_id: number;
|
||||
dispensary_name: string;
|
||||
city: string;
|
||||
state: string;
|
||||
address: string;
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
menu_url: string;
|
||||
stock_status: string;
|
||||
rec_min_price_cents: number;
|
||||
distance_miles: number;
|
||||
}>(
|
||||
`
|
||||
WITH latest_snapshots AS (
|
||||
SELECT DISTINCT ON (s.dutchie_product_id)
|
||||
s.dutchie_product_id,
|
||||
s.dispensary_id,
|
||||
s.stock_status,
|
||||
s.rec_min_price_cents,
|
||||
s.crawled_at
|
||||
FROM dutchie_product_snapshots s
|
||||
JOIN dutchie_products p ON s.dutchie_product_id = p.id
|
||||
WHERE p.external_product_id = $1
|
||||
ORDER BY s.dutchie_product_id, s.crawled_at DESC
|
||||
)
|
||||
SELECT
|
||||
d.id as dispensary_id,
|
||||
COALESCE(d.dba_name, d.name) as dispensary_name,
|
||||
d.city,
|
||||
d.state,
|
||||
d.address,
|
||||
d.latitude,
|
||||
d.longitude,
|
||||
d.menu_url,
|
||||
ls.stock_status,
|
||||
ls.rec_min_price_cents,
|
||||
-- Haversine distance formula (in miles)
|
||||
(3959 * acos(
|
||||
cos(radians($2)) * cos(radians(d.latitude)) *
|
||||
cos(radians(d.longitude) - radians($3)) +
|
||||
sin(radians($2)) * sin(radians(d.latitude))
|
||||
)) as distance_miles
|
||||
FROM latest_snapshots ls
|
||||
JOIN dispensaries d ON ls.dispensary_id = d.id
|
||||
WHERE d.latitude IS NOT NULL
|
||||
AND d.longitude IS NOT NULL
|
||||
HAVING (3959 * acos(
|
||||
cos(radians($2)) * cos(radians(d.latitude)) *
|
||||
cos(radians(d.longitude) - radians($3)) +
|
||||
sin(radians($2)) * sin(radians(d.latitude))
|
||||
)) <= $4
|
||||
ORDER BY distance_miles ASC
|
||||
`,
|
||||
[externalProductId, userLat, userLng, maxRadius]
|
||||
);
|
||||
|
||||
// Find the best (lowest) price for isBestPrice flag
|
||||
const validPrices = offers
|
||||
.filter(o => o.rec_min_price_cents && o.rec_min_price_cents > 0)
|
||||
.map(o => o.rec_min_price_cents);
|
||||
const bestPrice = validPrices.length > 0 ? Math.min(...validPrices) : null;
|
||||
|
||||
// Transform for frontend
|
||||
const availability = offers.map(o => ({
|
||||
dispensaryId: o.dispensary_id,
|
||||
dispensaryName: o.dispensary_name,
|
||||
city: o.city,
|
||||
state: o.state,
|
||||
address: o.address,
|
||||
latitude: o.latitude,
|
||||
longitude: o.longitude,
|
||||
menuUrl: o.menu_url,
|
||||
stockStatus: o.stock_status || 'unknown',
|
||||
price: o.rec_min_price_cents ? o.rec_min_price_cents / 100 : null,
|
||||
distanceMiles: Math.round(o.distance_miles * 10) / 10, // Round to 1 decimal
|
||||
isBestPrice: bestPrice !== null && o.rec_min_price_cents === bestPrice,
|
||||
}));
|
||||
|
||||
res.json({
|
||||
productId: parseInt(id, 10),
|
||||
productName: productRows[0].name,
|
||||
brandName: productRows[0].brand_name,
|
||||
totalCount: availability.length,
|
||||
offers: availability,
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error('Error fetching product availability:', error);
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// CATEGORIES
|
||||
// ============================================================
|
||||
|
||||
@@ -64,8 +64,13 @@ import { dutchieAZRouter, startScheduler as startDutchieAZScheduler, initializeD
|
||||
import { trackApiUsage, checkRateLimit } from './middleware/apiTokenTracker';
|
||||
import { startCrawlScheduler } from './services/crawl-scheduler';
|
||||
import { validateWordPressPermissions } from './middleware/wordpressPermissions';
|
||||
import { markTrustedDomains } from './middleware/trustedDomains';
|
||||
|
||||
// Apply WordPress permissions validation first (sets req.apiToken)
|
||||
// Mark requests from trusted domains (cannaiq.co, findagram.co, findadispo.com)
|
||||
// These domains can access the API without authentication
|
||||
app.use(markTrustedDomains);
|
||||
|
||||
// Apply WordPress permissions validation (sets req.apiToken for API key requests)
|
||||
app.use(validateWordPressPermissions);
|
||||
|
||||
// Apply API tracking middleware globally
|
||||
|
||||
107
backend/src/middleware/trustedDomains.ts
Normal file
107
backend/src/middleware/trustedDomains.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
import { Request, Response, NextFunction } from 'express';
|
||||
|
||||
/**
|
||||
* List of trusted domains that can access the API without authentication.
|
||||
* These are our own frontends that should have unrestricted access.
|
||||
*/
|
||||
const TRUSTED_DOMAINS = [
|
||||
'cannaiq.co',
|
||||
'www.cannaiq.co',
|
||||
'findagram.co',
|
||||
'www.findagram.co',
|
||||
'findadispo.com',
|
||||
'www.findadispo.com',
|
||||
// Development domains
|
||||
'localhost',
|
||||
'127.0.0.1',
|
||||
];
|
||||
|
||||
export interface TrustedDomainRequest extends Request {
|
||||
isTrustedDomain?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts domain from Origin or Referer header
|
||||
*/
|
||||
function extractDomain(header: string): string | null {
|
||||
try {
|
||||
const url = new URL(header);
|
||||
return url.hostname;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the request comes from a trusted domain
|
||||
*/
|
||||
function isRequestFromTrustedDomain(req: Request): boolean {
|
||||
const origin = req.get('origin');
|
||||
const referer = req.get('referer');
|
||||
|
||||
// Check Origin header first (preferred for CORS requests)
|
||||
if (origin) {
|
||||
const domain = extractDomain(origin);
|
||||
if (domain && TRUSTED_DOMAINS.includes(domain)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to Referer header
|
||||
if (referer) {
|
||||
const domain = extractDomain(referer);
|
||||
if (domain && TRUSTED_DOMAINS.includes(domain)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Middleware that marks requests from trusted domains.
|
||||
* This allows the auth middleware to skip authentication for these requests.
|
||||
*
|
||||
* Trusted domains: cannaiq.co, findagram.co, findadispo.com
|
||||
*
|
||||
* Usage: Apply this middleware BEFORE the auth middleware
|
||||
*/
|
||||
export function markTrustedDomains(
|
||||
req: TrustedDomainRequest,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
) {
|
||||
req.isTrustedDomain = isRequestFromTrustedDomain(req);
|
||||
next();
|
||||
}
|
||||
|
||||
/**
|
||||
* Middleware that allows requests from trusted domains to bypass auth.
|
||||
* Other requests must have a valid API key in x-api-key header.
|
||||
*
|
||||
* This replaces the standard auth middleware for public API endpoints.
|
||||
*/
|
||||
export function requireApiKeyOrTrustedDomain(
|
||||
req: TrustedDomainRequest,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
) {
|
||||
// Allow trusted domains without authentication
|
||||
if (req.isTrustedDomain) {
|
||||
return next();
|
||||
}
|
||||
|
||||
// Check for API key
|
||||
const apiKey = req.headers['x-api-key'] as string;
|
||||
|
||||
if (!apiKey) {
|
||||
return res.status(401).json({
|
||||
error: 'Unauthorized',
|
||||
message: 'API key required. Provide x-api-key header or access from a trusted domain.'
|
||||
});
|
||||
}
|
||||
|
||||
// If API key is provided, let the WordPress permissions middleware handle validation
|
||||
// The WordPress middleware should have already validated the key if present
|
||||
next();
|
||||
}
|
||||
@@ -840,6 +840,12 @@ router.get('/dispensaries', async (req: PublicApiRequest, res: Response) => {
|
||||
d.longitude,
|
||||
d.menu_type as platform,
|
||||
d.menu_url,
|
||||
d.hours,
|
||||
d.amenities,
|
||||
d.description,
|
||||
d.image_url,
|
||||
d.google_rating,
|
||||
d.google_review_count,
|
||||
COALESCE(pc.product_count, 0) as product_count,
|
||||
COALESCE(pc.in_stock_count, 0) as in_stock_count,
|
||||
pc.last_updated
|
||||
@@ -885,6 +891,12 @@ router.get('/dispensaries', async (req: PublicApiRequest, res: Response) => {
|
||||
longitude: parseFloat(d.longitude)
|
||||
} : null,
|
||||
platform: d.platform,
|
||||
hours: d.hours || null,
|
||||
amenities: d.amenities || [],
|
||||
description: d.description || null,
|
||||
image_url: d.image_url || null,
|
||||
rating: d.google_rating ? parseFloat(d.google_rating) : null,
|
||||
review_count: d.google_review_count ? parseInt(d.google_review_count, 10) : null,
|
||||
product_count: parseInt(d.product_count || '0', 10),
|
||||
in_stock_count: parseInt(d.in_stock_count || '0', 10),
|
||||
last_updated: d.last_updated,
|
||||
@@ -935,6 +947,12 @@ router.get('/dispensaries', async (req: PublicApiRequest, res: Response) => {
|
||||
d.longitude,
|
||||
d.menu_type as platform,
|
||||
d.menu_url,
|
||||
d.hours,
|
||||
d.amenities,
|
||||
d.description,
|
||||
d.image_url,
|
||||
d.google_rating,
|
||||
d.google_review_count,
|
||||
COALESCE(pc.product_count, 0) as product_count,
|
||||
COALESCE(pc.in_stock_count, 0) as in_stock_count,
|
||||
pc.last_updated
|
||||
@@ -980,6 +998,12 @@ router.get('/dispensaries', async (req: PublicApiRequest, res: Response) => {
|
||||
longitude: parseFloat(d.longitude)
|
||||
} : null,
|
||||
platform: d.platform,
|
||||
hours: d.hours || null,
|
||||
amenities: d.amenities || [],
|
||||
description: d.description || null,
|
||||
image_url: d.image_url || null,
|
||||
rating: d.google_rating ? parseFloat(d.google_rating) : null,
|
||||
review_count: d.google_review_count ? parseInt(d.google_review_count, 10) : null,
|
||||
product_count: parseInt(d.product_count || '0', 10),
|
||||
in_stock_count: parseInt(d.in_stock_count || '0', 10),
|
||||
last_updated: d.last_updated,
|
||||
|
||||
@@ -9,14 +9,36 @@ const router = Router();
|
||||
router.use(authMiddleware);
|
||||
router.use(requireRole('admin', 'superadmin'));
|
||||
|
||||
// Get all users
|
||||
// Get all users with search and filter
|
||||
router.get('/', async (req: AuthRequest, res) => {
|
||||
try {
|
||||
const result = await pool.query(`
|
||||
SELECT id, email, role, created_at, updated_at
|
||||
const { search, domain } = req.query;
|
||||
|
||||
let query = `
|
||||
SELECT id, email, role, first_name, last_name, phone, domain, created_at, updated_at
|
||||
FROM users
|
||||
ORDER BY created_at DESC
|
||||
`);
|
||||
WHERE 1=1
|
||||
`;
|
||||
const params: any[] = [];
|
||||
let paramIndex = 1;
|
||||
|
||||
// Search by email, first_name, or last_name
|
||||
if (search && typeof search === 'string') {
|
||||
query += ` AND (email ILIKE $${paramIndex} OR first_name ILIKE $${paramIndex} OR last_name ILIKE $${paramIndex})`;
|
||||
params.push(`%${search}%`);
|
||||
paramIndex++;
|
||||
}
|
||||
|
||||
// Filter by domain
|
||||
if (domain && typeof domain === 'string') {
|
||||
query += ` AND domain = $${paramIndex}`;
|
||||
params.push(domain);
|
||||
paramIndex++;
|
||||
}
|
||||
|
||||
query += ` ORDER BY created_at DESC`;
|
||||
|
||||
const result = await pool.query(query, params);
|
||||
res.json({ users: result.rows });
|
||||
} catch (error) {
|
||||
console.error('Error fetching users:', error);
|
||||
@@ -29,7 +51,7 @@ router.get('/:id', async (req: AuthRequest, res) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const result = await pool.query(`
|
||||
SELECT id, email, role, created_at, updated_at
|
||||
SELECT id, email, role, first_name, last_name, phone, domain, created_at, updated_at
|
||||
FROM users
|
||||
WHERE id = $1
|
||||
`, [id]);
|
||||
@@ -48,7 +70,7 @@ router.get('/:id', async (req: AuthRequest, res) => {
|
||||
// Create user
|
||||
router.post('/', async (req: AuthRequest, res) => {
|
||||
try {
|
||||
const { email, password, role } = req.body;
|
||||
const { email, password, role, first_name, last_name, phone, domain } = req.body;
|
||||
|
||||
if (!email || !password) {
|
||||
return res.status(400).json({ error: 'Email and password are required' });
|
||||
@@ -60,6 +82,12 @@ router.post('/', async (req: AuthRequest, res) => {
|
||||
return res.status(400).json({ error: 'Invalid role. Must be: admin, analyst, or viewer' });
|
||||
}
|
||||
|
||||
// Check for valid domain
|
||||
const validDomains = ['cannaiq.co', 'findagram.co', 'findadispo.com'];
|
||||
if (domain && !validDomains.includes(domain)) {
|
||||
return res.status(400).json({ error: 'Invalid domain. Must be: cannaiq.co, findagram.co, or findadispo.com' });
|
||||
}
|
||||
|
||||
// Check if email already exists
|
||||
const existing = await pool.query('SELECT id FROM users WHERE email = $1', [email]);
|
||||
if (existing.rows.length > 0) {
|
||||
@@ -70,10 +98,10 @@ router.post('/', async (req: AuthRequest, res) => {
|
||||
const passwordHash = await bcrypt.hash(password, 10);
|
||||
|
||||
const result = await pool.query(`
|
||||
INSERT INTO users (email, password_hash, role)
|
||||
VALUES ($1, $2, $3)
|
||||
RETURNING id, email, role, created_at, updated_at
|
||||
`, [email, passwordHash, role || 'viewer']);
|
||||
INSERT INTO users (email, password_hash, role, first_name, last_name, phone, domain)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||
RETURNING id, email, role, first_name, last_name, phone, domain, created_at, updated_at
|
||||
`, [email, passwordHash, role || 'viewer', first_name || null, last_name || null, phone || null, domain || 'cannaiq.co']);
|
||||
|
||||
res.status(201).json({ user: result.rows[0] });
|
||||
} catch (error) {
|
||||
@@ -86,7 +114,7 @@ router.post('/', async (req: AuthRequest, res) => {
|
||||
router.put('/:id', async (req: AuthRequest, res) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const { email, password, role } = req.body;
|
||||
const { email, password, role, first_name, last_name, phone, domain } = req.body;
|
||||
|
||||
// Check if user exists
|
||||
const existing = await pool.query('SELECT id FROM users WHERE id = $1', [id]);
|
||||
@@ -100,6 +128,12 @@ router.put('/:id', async (req: AuthRequest, res) => {
|
||||
return res.status(400).json({ error: 'Invalid role' });
|
||||
}
|
||||
|
||||
// Check for valid domain
|
||||
const validDomains = ['cannaiq.co', 'findagram.co', 'findadispo.com'];
|
||||
if (domain && !validDomains.includes(domain)) {
|
||||
return res.status(400).json({ error: 'Invalid domain. Must be: cannaiq.co, findagram.co, or findadispo.com' });
|
||||
}
|
||||
|
||||
// Prevent non-superadmin from modifying superadmin users
|
||||
const targetUser = await pool.query('SELECT role FROM users WHERE id = $1', [id]);
|
||||
if (targetUser.rows[0].role === 'superadmin' && req.user?.role !== 'superadmin') {
|
||||
@@ -132,6 +166,27 @@ router.put('/:id', async (req: AuthRequest, res) => {
|
||||
values.push(role);
|
||||
}
|
||||
|
||||
// Handle profile fields (allow setting to null with explicit undefined check)
|
||||
if (first_name !== undefined) {
|
||||
updates.push(`first_name = $${paramIndex++}`);
|
||||
values.push(first_name || null);
|
||||
}
|
||||
|
||||
if (last_name !== undefined) {
|
||||
updates.push(`last_name = $${paramIndex++}`);
|
||||
values.push(last_name || null);
|
||||
}
|
||||
|
||||
if (phone !== undefined) {
|
||||
updates.push(`phone = $${paramIndex++}`);
|
||||
values.push(phone || null);
|
||||
}
|
||||
|
||||
if (domain !== undefined) {
|
||||
updates.push(`domain = $${paramIndex++}`);
|
||||
values.push(domain);
|
||||
}
|
||||
|
||||
if (updates.length === 0) {
|
||||
return res.status(400).json({ error: 'No fields to update' });
|
||||
}
|
||||
@@ -143,7 +198,7 @@ router.put('/:id', async (req: AuthRequest, res) => {
|
||||
UPDATE users
|
||||
SET ${updates.join(', ')}
|
||||
WHERE id = $${paramIndex}
|
||||
RETURNING id, email, role, created_at, updated_at
|
||||
RETURNING id, email, role, first_name, last_name, phone, domain, created_at, updated_at
|
||||
`, values);
|
||||
|
||||
res.json({ user: result.rows[0] });
|
||||
|
||||
Reference in New Issue
Block a user