diff --git a/CLAUDE.md b/CLAUDE.md
index a57c81bb..e3013911 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -1,5 +1,136 @@
## Claude Guidelines for this Project
+### Multi-Site Architecture (CRITICAL)
+
+This project has **5 working locations** - always clarify which one before making changes:
+
+| Folder | Domain | Type | Purpose |
+|--------|--------|------|---------|
+| `backend/` | (shared) | Express API | Single backend serving all frontends |
+| `frontend/` | dispos.crawlsy.com | React SPA (Vite) | Legacy admin dashboard (internal use) |
+| `cannaiq/` | cannaiq.co | React SPA + PWA | NEW admin dashboard / B2B analytics |
+| `findadispo/` | findadispo.com | React SPA + PWA | Consumer dispensary finder |
+| `findagram/` | findagram.co | React SPA + PWA | Consumer delivery marketplace |
+
+**IMPORTANT: `frontend/` vs `cannaiq/` confusion:**
+- `frontend/` = OLD/legacy dashboard design, deployed to `dispos.crawlsy.com` (internal admin)
+- `cannaiq/` = NEW dashboard design, deployed to `cannaiq.co` (customer-facing B2B)
+- These are DIFFERENT codebases - do NOT confuse them!
+
+**Before any frontend work, ASK: "Which site? cannaiq, findadispo, findagram, or legacy (frontend/)?"**
+
+All four frontends share:
+- Same backend API (port 3010)
+- Same PostgreSQL database
+- Same Kubernetes deployment for backend
+
+Each frontend has:
+- Its own folder, package.json, Dockerfile
+- Its own domain and branding
+- Its own PWA manifest and service worker (cannaiq, findadispo, findagram)
+- Separate Docker containers in production
+
+---
+
+### Multi-Domain Hosting Architecture
+
+All three frontends are served from the **same IP** using **host-based routing**:
+
+**Kubernetes Ingress (Production):**
+```yaml
+# Each domain routes to its own frontend service
+apiVersion: networking.k8s.io/v1
+kind: Ingress
+metadata:
+ name: multi-site-ingress
+spec:
+ rules:
+ - host: cannaiq.co
+ http:
+ paths:
+ - path: /
+ backend:
+ service:
+ name: cannaiq-frontend
+ port: 80
+ - path: /api
+ backend:
+ service:
+ name: scraper # shared backend
+ port: 3010
+ - host: findadispo.com
+ http:
+ paths:
+ - path: /
+ backend:
+ service:
+ name: findadispo-frontend
+ port: 80
+ - path: /api
+ backend:
+ service:
+ name: scraper
+ port: 3010
+ - host: findagram.co
+ http:
+ paths:
+ - path: /
+ backend:
+ service:
+ name: findagram-frontend
+ port: 80
+ - path: /api
+ backend:
+ service:
+ name: scraper
+ port: 3010
+```
+
+**Key Points:**
+- DNS A records for all 3 domains point to same IP
+- Ingress controller routes based on `Host` header
+- Each frontend is a separate Docker container (nginx serving static files)
+- All frontends share the same backend API at `/api/*`
+- SSL/TLS handled at ingress level (cert-manager)
+
+---
+
+### PWA Setup Requirements
+
+Each frontend is a **Progressive Web App (PWA)**. Required files in each `public/` folder:
+
+1. **manifest.json** - App metadata, icons, theme colors
+2. **service-worker.js** - Offline caching, background sync
+3. **Icons** - 192x192 and 512x512 PNG icons
+
+**Vite PWA Plugin Setup** (in each frontend's vite.config.ts):
+```typescript
+import { VitePWA } from 'vite-plugin-pwa'
+
+export default defineConfig({
+ plugins: [
+ react(),
+ VitePWA({
+ registerType: 'autoUpdate',
+ manifest: {
+ name: 'Site Name',
+ short_name: 'Short',
+ theme_color: '#10b981',
+ icons: [
+ { src: '/icon-192.png', sizes: '192x192', type: 'image/png' },
+ { src: '/icon-512.png', sizes: '512x512', type: 'image/png' }
+ ]
+ },
+ workbox: {
+ globPatterns: ['**/*.{js,css,html,ico,png,svg,woff2}']
+ }
+ })
+ ]
+})
+```
+
+---
+
### Core Rules Summary
- **DB**: Use the single consolidated DB (CRAWLSY_DATABASE_URL → DATABASE_URL); no dual pools; schema_migrations must exist; apply migrations 031/032/033.
@@ -293,3 +424,101 @@
```bash
pkill -f "port-forward.*dispensary-scraper"
```
+
+25) **Frontend Architecture - AVOID OVER-ENGINEERING**
+
+ **Key Principles:**
+ - **ONE BACKEND** serves ALL domains (cannaiq.co, findadispo.com, findagram.co)
+ - Do NOT create separate backend services for each domain
+ - The existing `dispensary-scraper` backend handles everything
+
+ **Frontend Build Differences:**
+ - `frontend/` uses **Vite** (outputs to `dist/`, uses `VITE_` env vars) → dispos.crawlsy.com (legacy)
+ - `cannaiq/` uses **Vite** (outputs to `dist/`, uses `VITE_` env vars) → cannaiq.co (NEW)
+ - `findadispo/` uses **Create React App** (outputs to `build/`, uses `REACT_APP_` env vars) → findadispo.com
+ - `findagram/` uses **Create React App** (outputs to `build/`, uses `REACT_APP_` env vars) → findagram.co
+
+ **CRA vs Vite Dockerfile Differences:**
+ ```dockerfile
+ # Vite (frontend, cannaiq)
+ ENV VITE_API_URL=https://api.domain.com
+ RUN npm run build
+ COPY --from=builder /app/dist /usr/share/nginx/html
+
+ # CRA (findadispo, findagram)
+ ENV REACT_APP_API_URL=https://api.domain.com
+ RUN npm run build
+ COPY --from=builder /app/build /usr/share/nginx/html
+ ```
+
+ **lucide-react Icon Gotchas:**
+ - Not all icons exist in older versions (e.g., `Cannabis` doesn't exist)
+ - Use `Leaf` as a substitute for cannabis-related icons
+ - When doing search/replace for icon names, be careful not to replace text content
+ - Example: "Cannabis-infused food" should NOT become "Leaf-infused food"
+
+ **Deployment Options:**
+ 1. **Separate containers** (current): Each frontend in its own nginx container
+ 2. **Single container** (better): One nginx with multi-domain config serving all frontends
+
+ **Single Container Multi-Domain Approach:**
+ ```dockerfile
+ # Build all frontends
+ FROM node:20-slim AS builder-cannaiq
+ WORKDIR /app/cannaiq
+ COPY cannaiq/package*.json ./
+ RUN npm install
+ COPY cannaiq/ ./
+ RUN npm run build
+
+ FROM node:20-slim AS builder-findadispo
+ WORKDIR /app/findadispo
+ COPY findadispo/package*.json ./
+ RUN npm install
+ COPY findadispo/ ./
+ RUN npm run build
+
+ FROM node:20-slim AS builder-findagram
+ WORKDIR /app/findagram
+ COPY findagram/package*.json ./
+ RUN npm install
+ COPY findagram/ ./
+ RUN npm run build
+
+ # Production nginx with multi-domain routing
+ FROM nginx:alpine
+ COPY --from=builder-cannaiq /app/cannaiq/dist /var/www/cannaiq
+ COPY --from=builder-findadispo /app/findadispo/dist /var/www/findadispo
+ COPY --from=builder-findagram /app/findagram/build /var/www/findagram
+ COPY nginx-multi-domain.conf /etc/nginx/conf.d/default.conf
+ ```
+
+ **nginx-multi-domain.conf:**
+ ```nginx
+ server {
+ listen 80;
+ server_name cannaiq.co www.cannaiq.co;
+ root /var/www/cannaiq;
+ location / { try_files $uri $uri/ /index.html; }
+ }
+
+ server {
+ listen 80;
+ server_name findadispo.com www.findadispo.com;
+ root /var/www/findadispo;
+ location / { try_files $uri $uri/ /index.html; }
+ }
+
+ server {
+ listen 80;
+ server_name findagram.co www.findagram.co;
+ root /var/www/findagram;
+ location / { try_files $uri $uri/ /index.html; }
+ }
+ ```
+
+ **Common Mistakes to AVOID:**
+ - Creating a FastAPI/Express backend just for findagram or findadispo
+ - Creating separate Docker images per domain when one would work
+ - Replacing icon names with sed without checking for text content collisions
+ - Using `npm ci` in Dockerfiles when package-lock.json doesn't exist (use `npm install`)
diff --git a/backend/migrations/035_multi_domain_users.sql b/backend/migrations/035_multi_domain_users.sql
new file mode 100644
index 00000000..8ba9aca5
--- /dev/null
+++ b/backend/migrations/035_multi_domain_users.sql
@@ -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;
diff --git a/backend/migrations/036_findadispo_dispensary_fields.sql b/backend/migrations/036_findadispo_dispensary_fields.sql
new file mode 100644
index 00000000..535343ed
--- /dev/null
+++ b/backend/migrations/036_findadispo_dispensary_fields.sql
@@ -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.';
diff --git a/backend/package.json b/backend/package.json
index 424312ce..d2eeba55 100755
--- a/backend/package.json
+++ b/backend/package.json
@@ -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": {
diff --git a/backend/src/dutchie-az/routes/index.ts b/backend/src/dutchie-az/routes/index.ts
index a16d0680..375799d5 100644
--- a/backend/src/dutchie-az/routes/index.ts
+++ b/backend/src/dutchie-az/routes/index.ts
@@ -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
// ============================================================
diff --git a/backend/src/index.ts b/backend/src/index.ts
index f63d1d24..32f00193 100755
--- a/backend/src/index.ts
+++ b/backend/src/index.ts
@@ -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
diff --git a/backend/src/middleware/trustedDomains.ts b/backend/src/middleware/trustedDomains.ts
new file mode 100644
index 00000000..f904dbaf
--- /dev/null
+++ b/backend/src/middleware/trustedDomains.ts
@@ -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();
+}
diff --git a/backend/src/routes/public-api.ts b/backend/src/routes/public-api.ts
index 8c74cf77..489fe735 100644
--- a/backend/src/routes/public-api.ts
+++ b/backend/src/routes/public-api.ts
@@ -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,
diff --git a/backend/src/routes/users.ts b/backend/src/routes/users.ts
index a1b22958..d900350b 100644
--- a/backend/src/routes/users.ts
+++ b/backend/src/routes/users.ts
@@ -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] });
diff --git a/findadispo/backend/.env.example b/findadispo/backend/.env.example
new file mode 100644
index 00000000..2bde2722
--- /dev/null
+++ b/findadispo/backend/.env.example
@@ -0,0 +1,18 @@
+# Database
+DATABASE_URL=postgresql://user:password@localhost:5432/findadispo
+
+# JWT Settings
+SECRET_KEY=your-super-secret-key-change-in-production
+ALGORITHM=HS256
+ACCESS_TOKEN_EXPIRE_MINUTES=30
+
+# External API
+DISPENSARY_API_URL=http://localhost:3010
+DISPENSARY_API_KEY=your-api-key
+
+# CORS
+FRONTEND_URL=http://localhost:3000
+
+# Server
+HOST=0.0.0.0
+PORT=8000
diff --git a/findadispo/backend/auth.py b/findadispo/backend/auth.py
new file mode 100644
index 00000000..0a89eb53
--- /dev/null
+++ b/findadispo/backend/auth.py
@@ -0,0 +1,139 @@
+from datetime import datetime, timedelta
+from typing import Optional
+from jose import JWTError, jwt
+from passlib.context import CryptContext
+from fastapi import Depends, HTTPException, status
+from fastapi.security import OAuth2PasswordBearer
+from sqlalchemy.orm import Session
+from pydantic import BaseModel, EmailStr
+
+from config import get_settings
+from database import get_db
+from models import User
+
+settings = get_settings()
+
+pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
+oauth2_scheme = OAuth2PasswordBearer(tokenUrl="auth/login")
+
+
+# Pydantic models
+class UserCreate(BaseModel):
+ email: EmailStr
+ password: str
+ full_name: str
+ phone: Optional[str] = None
+ default_location: Optional[str] = None
+
+
+class UserUpdate(BaseModel):
+ full_name: Optional[str] = None
+ phone: Optional[str] = None
+ default_location: Optional[str] = None
+ notify_price_alerts: Optional[bool] = None
+ notify_new_dispensaries: Optional[bool] = None
+ notify_weekly_digest: Optional[bool] = None
+ notify_promotions: Optional[bool] = None
+
+
+class UserResponse(BaseModel):
+ id: int
+ email: str
+ full_name: Optional[str]
+ phone: Optional[str]
+ default_location: Optional[str]
+ is_active: bool
+ is_verified: bool
+ notify_price_alerts: bool
+ notify_new_dispensaries: bool
+ notify_weekly_digest: bool
+ notify_promotions: bool
+
+ class Config:
+ from_attributes = True
+
+
+class Token(BaseModel):
+ access_token: str
+ token_type: str
+
+
+class TokenData(BaseModel):
+ email: Optional[str] = None
+
+
+def verify_password(plain_password: str, hashed_password: str) -> bool:
+ return pwd_context.verify(plain_password, hashed_password)
+
+
+def get_password_hash(password: str) -> str:
+ return pwd_context.hash(password)
+
+
+def create_access_token(data: dict, expires_delta: Optional[timedelta] = None) -> str:
+ to_encode = data.copy()
+ if expires_delta:
+ expire = datetime.utcnow() + expires_delta
+ else:
+ expire = datetime.utcnow() + timedelta(minutes=settings.access_token_expire_minutes)
+ to_encode.update({"exp": expire})
+ encoded_jwt = jwt.encode(to_encode, settings.secret_key, algorithm=settings.algorithm)
+ return encoded_jwt
+
+
+def get_user_by_email(db: Session, email: str) -> Optional[User]:
+ return db.query(User).filter(User.email == email).first()
+
+
+def authenticate_user(db: Session, email: str, password: str) -> Optional[User]:
+ user = get_user_by_email(db, email)
+ if not user:
+ return None
+ if not verify_password(password, user.hashed_password):
+ return None
+ return user
+
+
+def create_user(db: Session, user_data: UserCreate) -> User:
+ hashed_password = get_password_hash(user_data.password)
+ db_user = User(
+ email=user_data.email,
+ hashed_password=hashed_password,
+ full_name=user_data.full_name,
+ phone=user_data.phone,
+ default_location=user_data.default_location,
+ )
+ db.add(db_user)
+ db.commit()
+ db.refresh(db_user)
+ return db_user
+
+
+async def get_current_user(
+ token: str = Depends(oauth2_scheme),
+ db: Session = Depends(get_db)
+) -> User:
+ credentials_exception = HTTPException(
+ status_code=status.HTTP_401_UNAUTHORIZED,
+ detail="Could not validate credentials",
+ headers={"WWW-Authenticate": "Bearer"},
+ )
+ try:
+ payload = jwt.decode(token, settings.secret_key, algorithms=[settings.algorithm])
+ email: str = payload.get("sub")
+ if email is None:
+ raise credentials_exception
+ token_data = TokenData(email=email)
+ except JWTError:
+ raise credentials_exception
+
+ user = get_user_by_email(db, email=token_data.email)
+ if user is None:
+ raise credentials_exception
+ return user
+
+
+async def get_current_active_user(current_user: User = Depends(get_current_user)) -> User:
+ if not current_user.is_active:
+ raise HTTPException(status_code=400, detail="Inactive user")
+ return current_user
diff --git a/findadispo/backend/config.py b/findadispo/backend/config.py
new file mode 100644
index 00000000..8f569a46
--- /dev/null
+++ b/findadispo/backend/config.py
@@ -0,0 +1,31 @@
+from pydantic_settings import BaseSettings
+from functools import lru_cache
+
+
+class Settings(BaseSettings):
+ # Database
+ database_url: str = "postgresql://user:password@localhost:5432/findadispo"
+
+ # JWT Settings
+ secret_key: str = "your-super-secret-key-change-in-production"
+ algorithm: str = "HS256"
+ access_token_expire_minutes: int = 30
+
+ # External API
+ dispensary_api_url: str = "http://localhost:3010"
+ dispensary_api_key: str = ""
+
+ # CORS
+ frontend_url: str = "http://localhost:3000"
+
+ # Server
+ host: str = "0.0.0.0"
+ port: int = 8000
+
+ class Config:
+ env_file = ".env"
+
+
+@lru_cache()
+def get_settings():
+ return Settings()
diff --git a/findadispo/backend/database.py b/findadispo/backend/database.py
new file mode 100644
index 00000000..43f70a9f
--- /dev/null
+++ b/findadispo/backend/database.py
@@ -0,0 +1,25 @@
+from sqlalchemy import create_engine
+from sqlalchemy.ext.declarative import declarative_base
+from sqlalchemy.orm import sessionmaker
+from config import get_settings
+
+settings = get_settings()
+
+engine = create_engine(settings.database_url)
+SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
+
+Base = declarative_base()
+
+
+def get_db():
+ """Dependency to get database session"""
+ db = SessionLocal()
+ try:
+ yield db
+ finally:
+ db.close()
+
+
+def init_db():
+ """Initialize database tables"""
+ Base.metadata.create_all(bind=engine)
diff --git a/findadispo/backend/models.py b/findadispo/backend/models.py
new file mode 100644
index 00000000..595be06c
--- /dev/null
+++ b/findadispo/backend/models.py
@@ -0,0 +1,77 @@
+from sqlalchemy import Column, Integer, String, Float, Boolean, DateTime, ForeignKey, Text, JSON
+from sqlalchemy.orm import relationship
+from sqlalchemy.sql import func
+from database import Base
+
+
+class User(Base):
+ __tablename__ = "users"
+
+ id = Column(Integer, primary_key=True, index=True)
+ email = Column(String(255), unique=True, index=True, nullable=False)
+ hashed_password = Column(String(255), nullable=False)
+ full_name = Column(String(255))
+ phone = Column(String(50))
+ default_location = Column(String(255))
+ is_active = Column(Boolean, default=True)
+ is_verified = Column(Boolean, default=False)
+ created_at = Column(DateTime(timezone=True), server_default=func.now())
+ updated_at = Column(DateTime(timezone=True), onupdate=func.now())
+
+ # Notification preferences
+ notify_price_alerts = Column(Boolean, default=True)
+ notify_new_dispensaries = Column(Boolean, default=False)
+ notify_weekly_digest = Column(Boolean, default=True)
+ notify_promotions = Column(Boolean, default=False)
+
+ # Relationships
+ saved_searches = relationship("SavedSearch", back_populates="user", cascade="all, delete-orphan")
+ alerts = relationship("PriceAlert", back_populates="user", cascade="all, delete-orphan")
+
+
+class SavedSearch(Base):
+ __tablename__ = "saved_searches"
+
+ id = Column(Integer, primary_key=True, index=True)
+ user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
+ name = Column(String(255), nullable=False)
+ query = Column(String(255))
+ filters = Column(JSON) # Store filters as JSON
+ results_count = Column(Integer, default=0)
+ last_used = Column(DateTime(timezone=True), server_default=func.now())
+ created_at = Column(DateTime(timezone=True), server_default=func.now())
+
+ # Relationship
+ user = relationship("User", back_populates="saved_searches")
+
+
+class PriceAlert(Base):
+ __tablename__ = "price_alerts"
+
+ id = Column(Integer, primary_key=True, index=True)
+ user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
+ product_name = Column(String(255), nullable=False)
+ dispensary_id = Column(Integer)
+ dispensary_name = Column(String(255))
+ target_price = Column(Float, nullable=False)
+ current_price = Column(Float)
+ is_active = Column(Boolean, default=True)
+ is_triggered = Column(Boolean, default=False)
+ triggered_at = Column(DateTime(timezone=True))
+ created_at = Column(DateTime(timezone=True), server_default=func.now())
+ updated_at = Column(DateTime(timezone=True), onupdate=func.now())
+
+ # Relationship
+ user = relationship("User", back_populates="alerts")
+
+
+class ContactMessage(Base):
+ __tablename__ = "contact_messages"
+
+ id = Column(Integer, primary_key=True, index=True)
+ name = Column(String(255), nullable=False)
+ email = Column(String(255), nullable=False)
+ subject = Column(String(255))
+ message = Column(Text, nullable=False)
+ is_read = Column(Boolean, default=False)
+ created_at = Column(DateTime(timezone=True), server_default=func.now())
diff --git a/findadispo/backend/requirements.txt b/findadispo/backend/requirements.txt
new file mode 100644
index 00000000..1ba60b49
--- /dev/null
+++ b/findadispo/backend/requirements.txt
@@ -0,0 +1,11 @@
+fastapi==0.109.0
+uvicorn[standard]==0.27.0
+sqlalchemy==2.0.25
+psycopg2-binary==2.9.9
+python-jose[cryptography]==3.3.0
+passlib[bcrypt]==1.7.4
+python-multipart==0.0.6
+pydantic[email]==2.5.3
+pydantic-settings==2.1.0
+httpx==0.26.0
+alembic==1.13.1
diff --git a/findadispo/backend/routes/__init__.py b/findadispo/backend/routes/__init__.py
new file mode 100644
index 00000000..d212dab6
--- /dev/null
+++ b/findadispo/backend/routes/__init__.py
@@ -0,0 +1 @@
+# Routes package
diff --git a/findadispo/backend/routes/alerts_routes.py b/findadispo/backend/routes/alerts_routes.py
new file mode 100644
index 00000000..d1c4b828
--- /dev/null
+++ b/findadispo/backend/routes/alerts_routes.py
@@ -0,0 +1,234 @@
+from typing import List, Optional
+from fastapi import APIRouter, Depends, HTTPException, status
+from sqlalchemy.orm import Session
+from pydantic import BaseModel
+
+from database import get_db
+from auth import get_current_active_user
+from models import User, PriceAlert
+
+router = APIRouter(prefix="/alerts", tags=["Price Alerts"])
+
+
+class AlertCreate(BaseModel):
+ product_name: str
+ dispensary_id: Optional[int] = None
+ dispensary_name: Optional[str] = None
+ target_price: float
+ current_price: Optional[float] = None
+
+
+class AlertUpdate(BaseModel):
+ target_price: Optional[float] = None
+ is_active: Optional[bool] = None
+
+
+class AlertResponse(BaseModel):
+ id: int
+ product_name: str
+ dispensary_id: Optional[int]
+ dispensary_name: Optional[str]
+ target_price: float
+ current_price: Optional[float]
+ is_active: bool
+ is_triggered: bool
+ created_at: str
+
+ class Config:
+ from_attributes = True
+
+
+@router.get("/", response_model=List[AlertResponse])
+def get_alerts(
+ current_user: User = Depends(get_current_active_user),
+ db: Session = Depends(get_db)
+):
+ """Get all price alerts for the current user"""
+ alerts = db.query(PriceAlert).filter(
+ PriceAlert.user_id == current_user.id
+ ).order_by(PriceAlert.created_at.desc()).all()
+
+ return [
+ AlertResponse(
+ id=a.id,
+ product_name=a.product_name,
+ dispensary_id=a.dispensary_id,
+ dispensary_name=a.dispensary_name,
+ target_price=a.target_price,
+ current_price=a.current_price,
+ is_active=a.is_active,
+ is_triggered=a.is_triggered,
+ created_at=a.created_at.isoformat() if a.created_at else ""
+ )
+ for a in alerts
+ ]
+
+
+@router.post("/", response_model=AlertResponse, status_code=status.HTTP_201_CREATED)
+def create_alert(
+ alert_data: AlertCreate,
+ current_user: User = Depends(get_current_active_user),
+ db: Session = Depends(get_db)
+):
+ """Create a new price alert"""
+ alert = PriceAlert(
+ user_id=current_user.id,
+ product_name=alert_data.product_name,
+ dispensary_id=alert_data.dispensary_id,
+ dispensary_name=alert_data.dispensary_name,
+ target_price=alert_data.target_price,
+ current_price=alert_data.current_price,
+ is_triggered=alert_data.current_price and alert_data.current_price <= alert_data.target_price
+ )
+ db.add(alert)
+ db.commit()
+ db.refresh(alert)
+
+ return AlertResponse(
+ id=alert.id,
+ product_name=alert.product_name,
+ dispensary_id=alert.dispensary_id,
+ dispensary_name=alert.dispensary_name,
+ target_price=alert.target_price,
+ current_price=alert.current_price,
+ is_active=alert.is_active,
+ is_triggered=alert.is_triggered,
+ created_at=alert.created_at.isoformat() if alert.created_at else ""
+ )
+
+
+@router.get("/{alert_id}", response_model=AlertResponse)
+def get_alert(
+ alert_id: int,
+ current_user: User = Depends(get_current_active_user),
+ db: Session = Depends(get_db)
+):
+ """Get a specific price alert"""
+ alert = db.query(PriceAlert).filter(
+ PriceAlert.id == alert_id,
+ PriceAlert.user_id == current_user.id
+ ).first()
+
+ if not alert:
+ raise HTTPException(
+ status_code=status.HTTP_404_NOT_FOUND,
+ detail="Alert not found"
+ )
+
+ return AlertResponse(
+ id=alert.id,
+ product_name=alert.product_name,
+ dispensary_id=alert.dispensary_id,
+ dispensary_name=alert.dispensary_name,
+ target_price=alert.target_price,
+ current_price=alert.current_price,
+ is_active=alert.is_active,
+ is_triggered=alert.is_triggered,
+ created_at=alert.created_at.isoformat() if alert.created_at else ""
+ )
+
+
+@router.put("/{alert_id}", response_model=AlertResponse)
+def update_alert(
+ alert_id: int,
+ alert_update: AlertUpdate,
+ current_user: User = Depends(get_current_active_user),
+ db: Session = Depends(get_db)
+):
+ """Update a price alert"""
+ alert = db.query(PriceAlert).filter(
+ PriceAlert.id == alert_id,
+ PriceAlert.user_id == current_user.id
+ ).first()
+
+ if not alert:
+ raise HTTPException(
+ status_code=status.HTTP_404_NOT_FOUND,
+ detail="Alert not found"
+ )
+
+ update_data = alert_update.model_dump(exclude_unset=True)
+ for field, value in update_data.items():
+ setattr(alert, field, value)
+
+ # Check if alert should be triggered
+ if alert.current_price and alert.current_price <= alert.target_price:
+ alert.is_triggered = True
+
+ db.commit()
+ db.refresh(alert)
+
+ return AlertResponse(
+ id=alert.id,
+ product_name=alert.product_name,
+ dispensary_id=alert.dispensary_id,
+ dispensary_name=alert.dispensary_name,
+ target_price=alert.target_price,
+ current_price=alert.current_price,
+ is_active=alert.is_active,
+ is_triggered=alert.is_triggered,
+ created_at=alert.created_at.isoformat() if alert.created_at else ""
+ )
+
+
+@router.patch("/{alert_id}/toggle")
+def toggle_alert(
+ alert_id: int,
+ current_user: User = Depends(get_current_active_user),
+ db: Session = Depends(get_db)
+):
+ """Toggle alert active status"""
+ alert = db.query(PriceAlert).filter(
+ PriceAlert.id == alert_id,
+ PriceAlert.user_id == current_user.id
+ ).first()
+
+ if not alert:
+ raise HTTPException(
+ status_code=status.HTTP_404_NOT_FOUND,
+ detail="Alert not found"
+ )
+
+ alert.is_active = not alert.is_active
+ db.commit()
+ return {"message": f"Alert {'activated' if alert.is_active else 'deactivated'}"}
+
+
+@router.delete("/{alert_id}")
+def delete_alert(
+ alert_id: int,
+ current_user: User = Depends(get_current_active_user),
+ db: Session = Depends(get_db)
+):
+ """Delete a price alert"""
+ alert = db.query(PriceAlert).filter(
+ PriceAlert.id == alert_id,
+ PriceAlert.user_id == current_user.id
+ ).first()
+
+ if not alert:
+ raise HTTPException(
+ status_code=status.HTTP_404_NOT_FOUND,
+ detail="Alert not found"
+ )
+
+ db.delete(alert)
+ db.commit()
+ return {"message": "Alert deleted"}
+
+
+@router.get("/stats/summary")
+def get_alert_stats(
+ current_user: User = Depends(get_current_active_user),
+ db: Session = Depends(get_db)
+):
+ """Get alert statistics for dashboard"""
+ alerts = db.query(PriceAlert).filter(
+ PriceAlert.user_id == current_user.id
+ ).all()
+
+ return {
+ "total": len(alerts),
+ "active": len([a for a in alerts if a.is_active]),
+ "triggered": len([a for a in alerts if a.is_triggered]),
+ }
diff --git a/findadispo/backend/routes/auth_routes.py b/findadispo/backend/routes/auth_routes.py
new file mode 100644
index 00000000..1be143ce
--- /dev/null
+++ b/findadispo/backend/routes/auth_routes.py
@@ -0,0 +1,108 @@
+from datetime import timedelta
+from fastapi import APIRouter, Depends, HTTPException, status
+from fastapi.security import OAuth2PasswordRequestForm
+from sqlalchemy.orm import Session
+
+from database import get_db
+from config import get_settings
+from auth import (
+ UserCreate, UserUpdate, UserResponse, Token,
+ authenticate_user, create_user, create_access_token,
+ get_user_by_email, get_current_active_user, get_password_hash
+)
+from models import User
+
+router = APIRouter(prefix="/auth", tags=["Authentication"])
+settings = get_settings()
+
+
+@router.post("/signup", response_model=UserResponse, status_code=status.HTTP_201_CREATED)
+def signup(user_data: UserCreate, db: Session = Depends(get_db)):
+ """Register a new user"""
+ # Check if user exists
+ existing_user = get_user_by_email(db, user_data.email)
+ if existing_user:
+ raise HTTPException(
+ status_code=status.HTTP_400_BAD_REQUEST,
+ detail="Email already registered"
+ )
+
+ # Create user
+ user = create_user(db, user_data)
+ return user
+
+
+@router.post("/login", response_model=Token)
+def login(
+ form_data: OAuth2PasswordRequestForm = Depends(),
+ db: Session = Depends(get_db)
+):
+ """Login and get access token"""
+ user = authenticate_user(db, form_data.username, form_data.password)
+ if not user:
+ raise HTTPException(
+ status_code=status.HTTP_401_UNAUTHORIZED,
+ detail="Incorrect email or password",
+ headers={"WWW-Authenticate": "Bearer"},
+ )
+
+ access_token_expires = timedelta(minutes=settings.access_token_expire_minutes)
+ access_token = create_access_token(
+ data={"sub": user.email}, expires_delta=access_token_expires
+ )
+ return {"access_token": access_token, "token_type": "bearer"}
+
+
+@router.get("/me", response_model=UserResponse)
+def get_current_user_info(current_user: User = Depends(get_current_active_user)):
+ """Get current user information"""
+ return current_user
+
+
+@router.put("/me", response_model=UserResponse)
+def update_current_user(
+ user_update: UserUpdate,
+ current_user: User = Depends(get_current_active_user),
+ db: Session = Depends(get_db)
+):
+ """Update current user information"""
+ update_data = user_update.model_dump(exclude_unset=True)
+
+ for field, value in update_data.items():
+ setattr(current_user, field, value)
+
+ db.commit()
+ db.refresh(current_user)
+ return current_user
+
+
+@router.post("/change-password")
+def change_password(
+ current_password: str,
+ new_password: str,
+ current_user: User = Depends(get_current_active_user),
+ db: Session = Depends(get_db)
+):
+ """Change user password"""
+ from auth import verify_password
+
+ if not verify_password(current_password, current_user.hashed_password):
+ raise HTTPException(
+ status_code=status.HTTP_400_BAD_REQUEST,
+ detail="Incorrect current password"
+ )
+
+ current_user.hashed_password = get_password_hash(new_password)
+ db.commit()
+ return {"message": "Password updated successfully"}
+
+
+@router.delete("/me")
+def delete_account(
+ current_user: User = Depends(get_current_active_user),
+ db: Session = Depends(get_db)
+):
+ """Delete user account"""
+ db.delete(current_user)
+ db.commit()
+ return {"message": "Account deleted successfully"}
diff --git a/findadispo/backend/routes/contact_routes.py b/findadispo/backend/routes/contact_routes.py
new file mode 100644
index 00000000..f6f543c9
--- /dev/null
+++ b/findadispo/backend/routes/contact_routes.py
@@ -0,0 +1,49 @@
+from fastapi import APIRouter, Depends, status
+from sqlalchemy.orm import Session
+from pydantic import BaseModel, EmailStr
+
+from database import get_db
+from models import ContactMessage
+
+router = APIRouter(prefix="/contact", tags=["Contact"])
+
+
+class ContactCreate(BaseModel):
+ name: str
+ email: EmailStr
+ subject: str
+ message: str
+
+
+class ContactResponse(BaseModel):
+ id: int
+ name: str
+ email: str
+ subject: str
+ message: str
+ created_at: str
+
+ class Config:
+ from_attributes = True
+
+
+@router.post("/", status_code=status.HTTP_201_CREATED)
+def submit_contact(
+ contact_data: ContactCreate,
+ db: Session = Depends(get_db)
+):
+ """Submit a contact form message"""
+ message = ContactMessage(
+ name=contact_data.name,
+ email=contact_data.email,
+ subject=contact_data.subject,
+ message=contact_data.message
+ )
+ db.add(message)
+ db.commit()
+ db.refresh(message)
+
+ return {
+ "message": "Thank you for your message! We'll get back to you soon.",
+ "id": message.id
+ }
diff --git a/findadispo/backend/routes/dispensary_routes.py b/findadispo/backend/routes/dispensary_routes.py
new file mode 100644
index 00000000..2d1eb98c
--- /dev/null
+++ b/findadispo/backend/routes/dispensary_routes.py
@@ -0,0 +1,164 @@
+from typing import Optional
+from fastapi import APIRouter, HTTPException
+import httpx
+from pydantic import BaseModel
+
+from config import get_settings
+
+router = APIRouter(prefix="/dispensaries", tags=["Dispensaries"])
+settings = get_settings()
+
+
+class DispensaryQuery(BaseModel):
+ lat: Optional[float] = None
+ lng: Optional[float] = None
+ city: Optional[str] = None
+ state: Optional[str] = None
+ radius: Optional[int] = 25
+ limit: Optional[int] = 20
+ offset: Optional[int] = 0
+
+
+async def fetch_from_api(endpoint: str, params: dict = None):
+ """Fetch data from the external dispensary API"""
+ headers = {}
+ if settings.dispensary_api_key:
+ headers["X-API-Key"] = settings.dispensary_api_key
+
+ async with httpx.AsyncClient() as client:
+ try:
+ response = await client.get(
+ f"{settings.dispensary_api_url}{endpoint}",
+ params=params,
+ headers=headers,
+ timeout=30.0
+ )
+ response.raise_for_status()
+ return response.json()
+ except httpx.HTTPStatusError as e:
+ raise HTTPException(
+ status_code=e.response.status_code,
+ detail=f"API error: {e.response.text}"
+ )
+ except httpx.RequestError as e:
+ raise HTTPException(
+ status_code=503,
+ detail=f"Failed to connect to dispensary API: {str(e)}"
+ )
+
+
+@router.get("/")
+async def search_dispensaries(
+ lat: Optional[float] = None,
+ lng: Optional[float] = None,
+ city: Optional[str] = None,
+ state: Optional[str] = "AZ",
+ radius: int = 25,
+ limit: int = 20,
+ offset: int = 0,
+ open_now: bool = False,
+ min_rating: Optional[float] = None
+):
+ """Search for dispensaries by location"""
+ params = {
+ "limit": limit,
+ "offset": offset,
+ "state": state
+ }
+
+ if lat and lng:
+ params["lat"] = lat
+ params["lng"] = lng
+ params["radius"] = radius
+
+ if city:
+ params["city"] = city
+
+ # Fetch from external API
+ data = await fetch_from_api("/api/az/stores", params)
+
+ # Apply client-side filters if needed
+ stores = data.get("stores", [])
+
+ if open_now:
+ # Filter stores that are currently open
+ # This would need actual business hours logic
+ pass
+
+ if min_rating:
+ stores = [s for s in stores if (s.get("rating") or 0) >= min_rating]
+
+ return {
+ "dispensaries": stores,
+ "total": len(stores),
+ "limit": limit,
+ "offset": offset
+ }
+
+
+@router.get("/{dispensary_id}")
+async def get_dispensary(dispensary_id: int):
+ """Get details for a specific dispensary"""
+ data = await fetch_from_api(f"/api/az/stores/{dispensary_id}")
+ return data
+
+
+@router.get("/{dispensary_id}/products")
+async def get_dispensary_products(
+ dispensary_id: int,
+ category: Optional[str] = None,
+ search: Optional[str] = None,
+ limit: int = 50,
+ offset: int = 0
+):
+ """Get products for a specific dispensary"""
+ params = {
+ "limit": limit,
+ "offset": offset
+ }
+
+ if category:
+ params["category"] = category
+
+ if search:
+ params["search"] = search
+
+ data = await fetch_from_api(f"/api/az/stores/{dispensary_id}/products", params)
+ return data
+
+
+@router.get("/{dispensary_id}/categories")
+async def get_dispensary_categories(dispensary_id: int):
+ """Get product categories for a dispensary"""
+ data = await fetch_from_api(f"/api/az/stores/{dispensary_id}/categories")
+ return data
+
+
+@router.get("/nearby")
+async def get_nearby_dispensaries(
+ lat: float,
+ lng: float,
+ radius: int = 10,
+ limit: int = 10
+):
+ """Get nearby dispensaries by coordinates"""
+ params = {
+ "lat": lat,
+ "lng": lng,
+ "radius": radius,
+ "limit": limit
+ }
+ data = await fetch_from_api("/api/az/stores", params)
+ return data.get("stores", [])
+
+
+@router.get("/featured")
+async def get_featured_dispensaries(limit: int = 6):
+ """Get featured dispensaries for the homepage"""
+ # For now, return top-rated dispensaries
+ params = {
+ "limit": limit,
+ "sort": "rating"
+ }
+ data = await fetch_from_api("/api/az/stores", params)
+ return data.get("stores", [])
diff --git a/findadispo/backend/routes/search_routes.py b/findadispo/backend/routes/search_routes.py
new file mode 100644
index 00000000..95689a14
--- /dev/null
+++ b/findadispo/backend/routes/search_routes.py
@@ -0,0 +1,201 @@
+from typing import List, Optional
+from fastapi import APIRouter, Depends, HTTPException, status
+from sqlalchemy.orm import Session
+from sqlalchemy.sql import func
+from pydantic import BaseModel
+
+from database import get_db
+from auth import get_current_active_user
+from models import User, SavedSearch
+
+router = APIRouter(prefix="/searches", tags=["Saved Searches"])
+
+
+class SavedSearchCreate(BaseModel):
+ name: str
+ query: str
+ filters: Optional[dict] = None
+ results_count: Optional[int] = 0
+
+
+class SavedSearchUpdate(BaseModel):
+ name: Optional[str] = None
+ filters: Optional[dict] = None
+ results_count: Optional[int] = None
+
+
+class SavedSearchResponse(BaseModel):
+ id: int
+ name: str
+ query: str
+ filters: Optional[dict]
+ results_count: int
+ last_used: str
+
+ class Config:
+ from_attributes = True
+
+
+@router.get("/", response_model=List[SavedSearchResponse])
+def get_saved_searches(
+ current_user: User = Depends(get_current_active_user),
+ db: Session = Depends(get_db)
+):
+ """Get all saved searches for the current user"""
+ searches = db.query(SavedSearch).filter(
+ SavedSearch.user_id == current_user.id
+ ).order_by(SavedSearch.last_used.desc()).all()
+
+ return [
+ SavedSearchResponse(
+ id=s.id,
+ name=s.name,
+ query=s.query,
+ filters=s.filters,
+ results_count=s.results_count,
+ last_used=s.last_used.isoformat() if s.last_used else ""
+ )
+ for s in searches
+ ]
+
+
+@router.post("/", response_model=SavedSearchResponse, status_code=status.HTTP_201_CREATED)
+def create_saved_search(
+ search_data: SavedSearchCreate,
+ current_user: User = Depends(get_current_active_user),
+ db: Session = Depends(get_db)
+):
+ """Create a new saved search"""
+ search = SavedSearch(
+ user_id=current_user.id,
+ name=search_data.name,
+ query=search_data.query,
+ filters=search_data.filters,
+ results_count=search_data.results_count
+ )
+ db.add(search)
+ db.commit()
+ db.refresh(search)
+
+ return SavedSearchResponse(
+ id=search.id,
+ name=search.name,
+ query=search.query,
+ filters=search.filters,
+ results_count=search.results_count,
+ last_used=search.last_used.isoformat() if search.last_used else ""
+ )
+
+
+@router.get("/{search_id}", response_model=SavedSearchResponse)
+def get_saved_search(
+ search_id: int,
+ current_user: User = Depends(get_current_active_user),
+ db: Session = Depends(get_db)
+):
+ """Get a specific saved search"""
+ search = db.query(SavedSearch).filter(
+ SavedSearch.id == search_id,
+ SavedSearch.user_id == current_user.id
+ ).first()
+
+ if not search:
+ raise HTTPException(
+ status_code=status.HTTP_404_NOT_FOUND,
+ detail="Saved search not found"
+ )
+
+ return SavedSearchResponse(
+ id=search.id,
+ name=search.name,
+ query=search.query,
+ filters=search.filters,
+ results_count=search.results_count,
+ last_used=search.last_used.isoformat() if search.last_used else ""
+ )
+
+
+@router.put("/{search_id}", response_model=SavedSearchResponse)
+def update_saved_search(
+ search_id: int,
+ search_update: SavedSearchUpdate,
+ current_user: User = Depends(get_current_active_user),
+ db: Session = Depends(get_db)
+):
+ """Update a saved search"""
+ search = db.query(SavedSearch).filter(
+ SavedSearch.id == search_id,
+ SavedSearch.user_id == current_user.id
+ ).first()
+
+ if not search:
+ raise HTTPException(
+ status_code=status.HTTP_404_NOT_FOUND,
+ detail="Saved search not found"
+ )
+
+ update_data = search_update.model_dump(exclude_unset=True)
+ for field, value in update_data.items():
+ setattr(search, field, value)
+
+ db.commit()
+ db.refresh(search)
+
+ return SavedSearchResponse(
+ id=search.id,
+ name=search.name,
+ query=search.query,
+ filters=search.filters,
+ results_count=search.results_count,
+ last_used=search.last_used.isoformat() if search.last_used else ""
+ )
+
+
+@router.post("/{search_id}/use")
+def mark_search_used(
+ search_id: int,
+ results_count: Optional[int] = None,
+ current_user: User = Depends(get_current_active_user),
+ db: Session = Depends(get_db)
+):
+ """Mark a saved search as used (updates last_used timestamp)"""
+ search = db.query(SavedSearch).filter(
+ SavedSearch.id == search_id,
+ SavedSearch.user_id == current_user.id
+ ).first()
+
+ if not search:
+ raise HTTPException(
+ status_code=status.HTTP_404_NOT_FOUND,
+ detail="Saved search not found"
+ )
+
+ search.last_used = func.now()
+ if results_count is not None:
+ search.results_count = results_count
+
+ db.commit()
+ return {"message": "Search marked as used"}
+
+
+@router.delete("/{search_id}")
+def delete_saved_search(
+ search_id: int,
+ current_user: User = Depends(get_current_active_user),
+ db: Session = Depends(get_db)
+):
+ """Delete a saved search"""
+ search = db.query(SavedSearch).filter(
+ SavedSearch.id == search_id,
+ SavedSearch.user_id == current_user.id
+ ).first()
+
+ if not search:
+ raise HTTPException(
+ status_code=status.HTTP_404_NOT_FOUND,
+ detail="Saved search not found"
+ )
+
+ db.delete(search)
+ db.commit()
+ return {"message": "Saved search deleted"}
diff --git a/findadispo/backend/server.py b/findadispo/backend/server.py
new file mode 100644
index 00000000..29b54cb6
--- /dev/null
+++ b/findadispo/backend/server.py
@@ -0,0 +1,81 @@
+from fastapi import FastAPI
+from fastapi.middleware.cors import CORSMiddleware
+import uvicorn
+
+from config import get_settings
+from database import init_db
+from routes.auth_routes import router as auth_router
+from routes.search_routes import router as search_router
+from routes.alerts_routes import router as alerts_router
+from routes.dispensary_routes import router as dispensary_router
+from routes.contact_routes import router as contact_router
+
+settings = get_settings()
+
+# Create FastAPI app
+app = FastAPI(
+ title="Find a Dispensary API",
+ description="Backend API for Find a Dispensary - Cannabis dispensary locator",
+ version="1.0.0",
+ docs_url="/docs",
+ redoc_url="/redoc"
+)
+
+# Configure CORS
+app.add_middleware(
+ CORSMiddleware,
+ allow_origins=[
+ settings.frontend_url,
+ "http://localhost:3000",
+ "http://localhost:5173",
+ "https://findadispo.com",
+ "https://www.findadispo.com"
+ ],
+ allow_credentials=True,
+ allow_methods=["*"],
+ allow_headers=["*"],
+)
+
+# Include routers
+app.include_router(auth_router, prefix="/api")
+app.include_router(search_router, prefix="/api")
+app.include_router(alerts_router, prefix="/api")
+app.include_router(dispensary_router, prefix="/api")
+app.include_router(contact_router, prefix="/api")
+
+
+@app.on_event("startup")
+async def startup_event():
+ """Initialize database on startup"""
+ init_db()
+
+
+@app.get("/")
+def root():
+ """Root endpoint"""
+ return {
+ "name": "Find a Dispensary API",
+ "version": "1.0.0",
+ "status": "running"
+ }
+
+
+@app.get("/health")
+def health_check():
+ """Health check endpoint"""
+ return {"status": "healthy"}
+
+
+@app.get("/api/version")
+def api_version():
+ """API version endpoint"""
+ return {"version": "1.0.0"}
+
+
+if __name__ == "__main__":
+ uvicorn.run(
+ "server:app",
+ host=settings.host,
+ port=settings.port,
+ reload=True
+ )
diff --git a/findadispo/frontend/.env.example b/findadispo/frontend/.env.example
new file mode 100644
index 00000000..48001a73
--- /dev/null
+++ b/findadispo/frontend/.env.example
@@ -0,0 +1,14 @@
+# Findadispo Frontend Environment Variables
+# Copy this file to .env.development or .env.production
+
+# API URL for dispensary data endpoints (public API)
+# Local development: http://localhost:3010
+# Production: https://dispos.crawlsy.com (or your production API URL)
+REACT_APP_DATA_API_URL=http://localhost:3010
+
+# API Key for accessing the /api/v1/* endpoints
+# Get this from the backend admin panel or database
+REACT_APP_DATA_API_KEY=your_api_key_here
+
+# Backend URL (for other backend services if needed)
+REACT_APP_BACKEND_URL=http://localhost:8001
diff --git a/findadispo/frontend/Dockerfile b/findadispo/frontend/Dockerfile
new file mode 100644
index 00000000..dffca9c6
--- /dev/null
+++ b/findadispo/frontend/Dockerfile
@@ -0,0 +1,52 @@
+# Build stage
+FROM node:20-slim AS builder
+
+WORKDIR /app
+
+# Copy package files
+COPY package*.json ./
+
+# Install dependencies (using npm install since package-lock.json may not exist)
+RUN npm install
+
+# Copy source files
+COPY . .
+
+# Set build-time environment variable for API URL (CRA uses REACT_APP_ prefix)
+ENV REACT_APP_API_URL=https://api.findadispo.com
+
+# Build the app (CRA produces /build, not /dist)
+RUN npm run build
+
+# Production stage
+FROM nginx:alpine
+
+# Copy built assets from builder stage (CRA outputs to /build)
+COPY --from=builder /app/build /usr/share/nginx/html
+
+# Copy nginx config for SPA routing
+RUN echo 'server { \
+ listen 80; \
+ server_name _; \
+ root /usr/share/nginx/html; \
+ index index.html; \
+ \
+ # Gzip compression \
+ gzip on; \
+ gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript; \
+ \
+ # Cache static assets \
+ location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ { \
+ expires 1y; \
+ add_header Cache-Control "public, immutable"; \
+ } \
+ \
+ # SPA fallback - serve index.html for all routes \
+ location / { \
+ try_files $uri $uri/ /index.html; \
+ } \
+}' > /etc/nginx/conf.d/default.conf
+
+EXPOSE 80
+
+CMD ["nginx", "-g", "daemon off;"]
diff --git a/findadispo/frontend/package.json b/findadispo/frontend/package.json
new file mode 100644
index 00000000..1b8b57ea
--- /dev/null
+++ b/findadispo/frontend/package.json
@@ -0,0 +1,62 @@
+{
+ "name": "findadispo-frontend",
+ "version": "1.0.0",
+ "private": true,
+ "dependencies": {
+ "@radix-ui/react-accordion": "^1.1.2",
+ "@radix-ui/react-alert-dialog": "^1.0.5",
+ "@radix-ui/react-avatar": "^1.0.4",
+ "@radix-ui/react-checkbox": "^1.0.4",
+ "@radix-ui/react-dialog": "^1.0.5",
+ "@radix-ui/react-dropdown-menu": "^2.0.6",
+ "@radix-ui/react-label": "^2.0.2",
+ "@radix-ui/react-navigation-menu": "^1.1.4",
+ "@radix-ui/react-popover": "^1.0.7",
+ "@radix-ui/react-select": "^2.0.0",
+ "@radix-ui/react-separator": "^1.0.3",
+ "@radix-ui/react-slider": "^1.1.2",
+ "@radix-ui/react-slot": "^1.0.2",
+ "@radix-ui/react-switch": "^1.0.3",
+ "@radix-ui/react-tabs": "^1.0.4",
+ "@radix-ui/react-toast": "^1.1.5",
+ "@radix-ui/react-tooltip": "^1.0.7",
+ "class-variance-authority": "^0.7.0",
+ "clsx": "^2.0.0",
+ "lucide-react": "^0.294.0",
+ "react": "^18.2.0",
+ "react-dom": "^18.2.0",
+ "react-router-dom": "^6.21.0",
+ "react-scripts": "5.0.1",
+ "tailwind-merge": "^2.1.0",
+ "tailwindcss-animate": "^1.0.7"
+ },
+ "devDependencies": {
+ "autoprefixer": "^10.4.16",
+ "postcss": "^8.4.32",
+ "tailwindcss": "^3.3.6"
+ },
+ "scripts": {
+ "start": "react-scripts start",
+ "build": "react-scripts build",
+ "test": "react-scripts test",
+ "eject": "react-scripts eject"
+ },
+ "eslintConfig": {
+ "extends": [
+ "react-app",
+ "react-app/jest"
+ ]
+ },
+ "browserslist": {
+ "production": [
+ ">0.2%",
+ "not dead",
+ "not op_mini all"
+ ],
+ "development": [
+ "last 1 chrome version",
+ "last 1 firefox version",
+ "last 1 safari version"
+ ]
+ }
+}
diff --git a/findadispo/frontend/postcss.config.js b/findadispo/frontend/postcss.config.js
new file mode 100644
index 00000000..33ad091d
--- /dev/null
+++ b/findadispo/frontend/postcss.config.js
@@ -0,0 +1,6 @@
+module.exports = {
+ plugins: {
+ tailwindcss: {},
+ autoprefixer: {},
+ },
+}
diff --git a/findadispo/frontend/public/index.html b/findadispo/frontend/public/index.html
new file mode 100644
index 00000000..c154071a
--- /dev/null
+++ b/findadispo/frontend/public/index.html
@@ -0,0 +1,17 @@
+
+
+
+
+
+
+
+
+
+
+ Find a Dispensary - Cannabis Dispensary Locator
+
+
+
+
+
+
diff --git a/findadispo/frontend/public/manifest.json b/findadispo/frontend/public/manifest.json
new file mode 100644
index 00000000..8a9783f8
--- /dev/null
+++ b/findadispo/frontend/public/manifest.json
@@ -0,0 +1,29 @@
+{
+ "short_name": "Find a Dispo",
+ "name": "Find a Dispensary - Cannabis Locator",
+ "icons": [
+ {
+ "src": "favicon.ico",
+ "sizes": "64x64 32x32 24x24 16x16",
+ "type": "image/x-icon"
+ },
+ {
+ "src": "logo192.png",
+ "type": "image/png",
+ "sizes": "192x192",
+ "purpose": "any maskable"
+ },
+ {
+ "src": "logo512.png",
+ "type": "image/png",
+ "sizes": "512x512",
+ "purpose": "any maskable"
+ }
+ ],
+ "start_url": ".",
+ "display": "standalone",
+ "theme_color": "#10B981",
+ "background_color": "#ffffff",
+ "orientation": "portrait-primary",
+ "categories": ["lifestyle", "shopping"]
+}
diff --git a/findadispo/frontend/public/service-worker.js b/findadispo/frontend/public/service-worker.js
new file mode 100644
index 00000000..252d4b0f
--- /dev/null
+++ b/findadispo/frontend/public/service-worker.js
@@ -0,0 +1,113 @@
+// Find a Dispensary PWA Service Worker
+const CACHE_NAME = 'findadispo-v1';
+const STATIC_ASSETS = [
+ '/',
+ '/index.html',
+ '/manifest.json',
+ '/favicon.ico',
+];
+
+// Install event - cache static assets
+self.addEventListener('install', (event) => {
+ event.waitUntil(
+ caches.open(CACHE_NAME).then((cache) => {
+ console.log('Service Worker: Caching static assets');
+ return cache.addAll(STATIC_ASSETS);
+ })
+ );
+ // Activate immediately
+ self.skipWaiting();
+});
+
+// Activate event - clean up old caches
+self.addEventListener('activate', (event) => {
+ event.waitUntil(
+ caches.keys().then((cacheNames) => {
+ return Promise.all(
+ cacheNames
+ .filter((name) => name !== CACHE_NAME)
+ .map((name) => {
+ console.log('Service Worker: Clearing old cache', name);
+ return caches.delete(name);
+ })
+ );
+ })
+ );
+ // Take control immediately
+ self.clients.claim();
+});
+
+// Fetch event - network first, fallback to cache
+self.addEventListener('fetch', (event) => {
+ // Skip non-GET requests
+ if (event.request.method !== 'GET') return;
+
+ // Skip API requests
+ if (event.request.url.includes('/api/')) return;
+
+ event.respondWith(
+ fetch(event.request)
+ .then((response) => {
+ // Clone response for caching
+ const responseClone = response.clone();
+
+ caches.open(CACHE_NAME).then((cache) => {
+ cache.put(event.request, responseClone);
+ });
+
+ return response;
+ })
+ .catch(() => {
+ // Return cached version if offline
+ return caches.match(event.request).then((cachedResponse) => {
+ if (cachedResponse) {
+ return cachedResponse;
+ }
+ // Return offline page for navigation requests
+ if (event.request.mode === 'navigate') {
+ return caches.match('/index.html');
+ }
+ });
+ })
+ );
+});
+
+// Background sync for saved searches
+self.addEventListener('sync', (event) => {
+ if (event.tag === 'sync-searches') {
+ console.log('Service Worker: Syncing saved searches');
+ }
+});
+
+// Push notifications
+self.addEventListener('push', (event) => {
+ const options = {
+ body: event.data?.text() || 'New update from Find a Dispensary',
+ icon: '/logo192.png',
+ badge: '/logo192.png',
+ vibrate: [100, 50, 100],
+ data: {
+ dateOfArrival: Date.now(),
+ primaryKey: 1
+ },
+ actions: [
+ { action: 'explore', title: 'View Details' },
+ { action: 'close', title: 'Close' }
+ ]
+ };
+
+ event.waitUntil(
+ self.registration.showNotification('Find a Dispensary', options)
+ );
+});
+
+// Handle notification click
+self.addEventListener('notificationclick', (event) => {
+ event.notification.close();
+
+ if (event.action === 'explore') {
+ event.waitUntil(
+ clients.openWindow('/dashboard/alerts')
+ );
+ }
+});
diff --git a/findadispo/frontend/src/App.js b/findadispo/frontend/src/App.js
new file mode 100644
index 00000000..bb491db6
--- /dev/null
+++ b/findadispo/frontend/src/App.js
@@ -0,0 +1,130 @@
+import React from 'react';
+import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router-dom';
+
+// Layout Components
+import { Header } from './components/findadispo/Header';
+import { Footer } from './components/findadispo/Footer';
+
+// Page Components
+import { Home } from './pages/findadispo/Home';
+import { StoreLocator } from './pages/findadispo/StoreLocator';
+import { DispensaryDetail } from './pages/findadispo/DispensaryDetail';
+import { About } from './pages/findadispo/About';
+import { Contact } from './pages/findadispo/Contact';
+import { Login } from './pages/findadispo/Login';
+import { Signup } from './pages/findadispo/Signup';
+
+// Dashboard Components
+import { Dashboard } from './pages/findadispo/Dashboard';
+import { DashboardHome } from './pages/findadispo/DashboardHome';
+import { SavedSearches } from './pages/findadispo/SavedSearches';
+import { Alerts } from './pages/findadispo/Alerts';
+import { Profile } from './pages/findadispo/Profile';
+
+// Protected Route Component
+function ProtectedRoute({ children }) {
+ const isAuthenticated = localStorage.getItem('isAuthenticated') === 'true';
+
+ if (!isAuthenticated) {
+ return ;
+ }
+
+ return children;
+}
+
+// Main Layout with Header and Footer
+function MainLayout({ children }) {
+ return (
+
+
+ {children}
+
+
+ );
+}
+
+// Dashboard Layout (no footer, custom header handled in Dashboard component)
+function DashboardLayout({ children }) {
+ return (
+
+
+ {children}
+
+ );
+}
+
+function App() {
+ return (
+
+
+ {/* Public Routes with Main Layout */}
+
+
+
+ }
+ />
+
+
+
+ }
+ />
+
+
+
+ }
+ />
+
+
+
+ }
+ />
+
+
+
+ }
+ />
+
+ {/* Auth Routes (no header/footer) */}
+ } />
+ } />
+
+ {/* Dashboard Routes (protected) */}
+
+
+
+
+
+ }
+ >
+ } />
+ } />
+ } />
+ } />
+
+
+ {/* Catch-all redirect */}
+ } />
+
+
+ );
+}
+
+export default App;
diff --git a/findadispo/frontend/src/api/client.js b/findadispo/frontend/src/api/client.js
new file mode 100644
index 00000000..92957259
--- /dev/null
+++ b/findadispo/frontend/src/api/client.js
@@ -0,0 +1,290 @@
+// Findadispo API Client
+// Connects to /api/v1/* endpoints with X-API-Key authentication
+
+import { API_CONFIG } from '../lib/utils';
+
+const API_BASE_URL = API_CONFIG.DATA_API_URL;
+const API_KEY = API_CONFIG.DATA_API_KEY;
+
+// Helper function to make authenticated API requests
+async function apiRequest(endpoint, options = {}) {
+ const url = `${API_BASE_URL}${endpoint}`;
+
+ const headers = {
+ 'Content-Type': 'application/json',
+ ...(API_KEY && { 'X-API-Key': API_KEY }),
+ ...options.headers,
+ };
+
+ const response = await fetch(url, {
+ ...options,
+ headers,
+ });
+
+ if (!response.ok) {
+ const error = await response.json().catch(() => ({ message: 'Request failed' }));
+ throw new Error(error.message || `API request failed: ${response.status}`);
+ }
+
+ return response.json();
+}
+
+/**
+ * Fetch dispensaries with optional filters
+ * @param {Object} params - Query parameters
+ * @param {string} params.search - Search query (name, city, zip)
+ * @param {string} params.state - State filter
+ * @param {string} params.city - City filter
+ * @param {number} params.limit - Results per page
+ * @param {number} params.offset - Pagination offset
+ * @returns {Promise<{dispensaries: Array, total: number, limit: number, offset: number}>}
+ */
+export async function getDispensaries(params = {}) {
+ const queryParams = new URLSearchParams();
+
+ if (params.search) queryParams.append('search', params.search);
+ if (params.state) queryParams.append('state', params.state);
+ if (params.city) queryParams.append('city', params.city);
+ if (params.limit) queryParams.append('limit', params.limit);
+ if (params.offset) queryParams.append('offset', params.offset);
+
+ const queryString = queryParams.toString();
+ const endpoint = `/api/v1/dispensaries${queryString ? `?${queryString}` : ''}`;
+
+ return apiRequest(endpoint);
+}
+
+/**
+ * Fetch a single dispensary by slug or ID
+ * @param {string} slugOrId - Dispensary slug or ID
+ * @returns {Promise