diff --git a/CLAUDE.md b/CLAUDE.md index 114470ce..90a4a716 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -340,6 +340,59 @@ The custom connection module at `src/dutchie-az/db/connection` is **DEPRECATED** --- +## PERFORMANCE REQUIREMENTS + +**Database Queries:** +- NEVER write N+1 queries - always batch fetch related data before iterating +- NEVER run queries inside loops - batch them before the loop +- Avoid multiple queries when one JOIN or subquery works +- Dashboard/index pages should use MAX 5-10 queries total, not 50+ +- Mentally trace query count - if a page would run 20+ queries, refactor +- Cache expensive aggregations (in-memory or Redis, 5-min TTL) instead of recalculating every request +- Use query logging during development to verify query count + +**Before submitting route/controller code, verify:** +1. No queries inside `forEach`/`map`/`for` loops +2. All related data fetched in batches before iteration +3. Aggregations done in SQL (`COUNT`, `SUM`, `AVG`, `GROUP BY`), not in JS +4. **Would this cause a 503 under load? If unsure, simplify.** + +**Examples of BAD patterns:** +```typescript +// BAD: N+1 query - runs a query for each store +const stores = await getStores(); +for (const store of stores) { + store.products = await getProductsByStoreId(store.id); // N queries! +} + +// BAD: Query inside map +const results = await Promise.all( + storeIds.map(id => pool.query('SELECT * FROM products WHERE store_id = $1', [id])) +); +``` + +**Examples of GOOD patterns:** +```typescript +// GOOD: Batch fetch all products, then group in JS +const stores = await getStores(); +const storeIds = stores.map(s => s.id); +const allProducts = await pool.query( + 'SELECT * FROM products WHERE store_id = ANY($1)', [storeIds] +); +const productsByStore = groupBy(allProducts.rows, 'store_id'); +stores.forEach(s => s.products = productsByStore[s.id] || []); + +// GOOD: Single query with JOIN +const result = await pool.query(` + SELECT s.*, COUNT(p.id) as product_count + FROM stores s + LEFT JOIN products p ON p.store_id = s.id + GROUP BY s.id +`); +``` + +--- + ## FORBIDDEN ACTIONS 1. **Deleting any data** (products, snapshots, images, logs, traces) diff --git a/backend/migrations/060_consumer_verification_notifications.sql b/backend/migrations/060_consumer_verification_notifications.sql new file mode 100644 index 00000000..31de2f65 --- /dev/null +++ b/backend/migrations/060_consumer_verification_notifications.sql @@ -0,0 +1,108 @@ +-- Migration: Consumer verification and notification tracking +-- Adds email/SMS verification columns and notification history table + +-- Add verification columns to users table +ALTER TABLE users +ADD COLUMN IF NOT EXISTS email_verified BOOLEAN DEFAULT false, +ADD COLUMN IF NOT EXISTS email_verification_token VARCHAR(64), +ADD COLUMN IF NOT EXISTS email_verification_sent_at TIMESTAMP, +ADD COLUMN IF NOT EXISTS phone_verified BOOLEAN DEFAULT false, +ADD COLUMN IF NOT EXISTS phone_verification_code VARCHAR(6), +ADD COLUMN IF NOT EXISTS phone_verification_sent_at TIMESTAMP, +ADD COLUMN IF NOT EXISTS notification_preference VARCHAR(20) DEFAULT 'email'; -- email, sms, both + +-- Add city/state to users (for notification filtering) +ALTER TABLE users +ADD COLUMN IF NOT EXISTS city VARCHAR(100), +ADD COLUMN IF NOT EXISTS state VARCHAR(50); + +-- Create notification tracking table +CREATE TABLE IF NOT EXISTS consumer_notifications ( + id SERIAL PRIMARY KEY, + user_id INTEGER REFERENCES users(id) ON DELETE CASCADE, + alert_id INTEGER, -- References findagram_alerts or findadispo saved search + alert_source VARCHAR(20), -- 'findagram' or 'findadispo' + notification_type VARCHAR(20) NOT NULL, -- 'email' or 'sms' + -- What triggered this notification + trigger_type VARCHAR(50) NOT NULL, -- 'price_drop', 'back_in_stock', 'product_on_special', 'deal_alert' + product_id INTEGER, + dispensary_id INTEGER, + -- Content + subject VARCHAR(255), + message_content TEXT, + -- Tracking + sent_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + delivered_at TIMESTAMP, + opened_at TIMESTAMP, + clicked_at TIMESTAMP, + completed_at TIMESTAMP, -- When user has "seen" it or we mark it done + -- External IDs (for SMS gateway tracking) + external_message_id VARCHAR(100), + -- Status + status VARCHAR(20) DEFAULT 'pending', -- pending, sent, delivered, failed + error_message TEXT +); + +-- Create findadispo_favorites table (mirroring findagram_favorites) +CREATE TABLE IF NOT EXISTS findadispo_favorites ( + id SERIAL PRIMARY KEY, + user_id INTEGER REFERENCES users(id) ON DELETE CASCADE, + dispensary_id INTEGER, -- References dispensaries table + -- Dispensary snapshot at time of save + dispensary_name VARCHAR(255), + dispensary_city VARCHAR(100), + dispensary_state VARCHAR(50), + -- Timestamps + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + UNIQUE(user_id, dispensary_id) +); + +-- Create findadispo_alerts table +CREATE TABLE IF NOT EXISTS findadispo_alerts ( + id SERIAL PRIMARY KEY, + user_id INTEGER REFERENCES users(id) ON DELETE CASCADE, + alert_type VARCHAR(50) NOT NULL, -- 'new_dispensary', 'deal_available' + -- Target + dispensary_id INTEGER, + city VARCHAR(100), + state VARCHAR(50), + -- 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 findadispo_saved_searches table +CREATE TABLE IF NOT EXISTS findadispo_saved_searches ( + id SERIAL PRIMARY KEY, + user_id INTEGER REFERENCES users(id) ON DELETE CASCADE, + name VARCHAR(100) NOT NULL, + -- Search criteria + query TEXT, + city VARCHAR(100), + state VARCHAR(50), + min_rating DECIMAL(3, 2), + max_distance INTEGER, + amenities TEXT[], -- Array of amenity filters + -- Notification settings + notify_on_new_dispensary BOOLEAN DEFAULT false, + notify_on_deals BOOLEAN DEFAULT false, + -- Timestamps + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +-- Create indexes +CREATE INDEX IF NOT EXISTS idx_users_email_verified ON users(email_verified); +CREATE INDEX IF NOT EXISTS idx_users_phone_verified ON users(phone_verified); +CREATE INDEX IF NOT EXISTS idx_users_city_state ON users(city, state); +CREATE INDEX IF NOT EXISTS idx_consumer_notifications_user_id ON consumer_notifications(user_id); +CREATE INDEX IF NOT EXISTS idx_consumer_notifications_status ON consumer_notifications(status); +CREATE INDEX IF NOT EXISTS idx_consumer_notifications_sent_at ON consumer_notifications(sent_at); +CREATE INDEX IF NOT EXISTS idx_findadispo_favorites_user_id ON findadispo_favorites(user_id); +CREATE INDEX IF NOT EXISTS idx_findadispo_alerts_user_id ON findadispo_alerts(user_id); +CREATE INDEX IF NOT EXISTS idx_findadispo_alerts_active ON findadispo_alerts(is_active) WHERE is_active = true; +CREATE INDEX IF NOT EXISTS idx_findadispo_saved_searches_user_id ON findadispo_saved_searches(user_id); diff --git a/backend/migrations/061_product_click_events.sql b/backend/migrations/061_product_click_events.sql new file mode 100644 index 00000000..a5ad7790 --- /dev/null +++ b/backend/migrations/061_product_click_events.sql @@ -0,0 +1,50 @@ +-- Migration: Product click event tracking for campaigns and analytics +-- Tracks user interactions with products across the platform + +-- Create the product click events table +CREATE TABLE IF NOT EXISTS product_click_events ( + id SERIAL PRIMARY KEY, + + -- Core identifiers + product_id VARCHAR(100) NOT NULL, -- Internal product identifier + store_id VARCHAR(100), -- Store context (nullable) + brand_id VARCHAR(100), -- Brand context (nullable) + campaign_id INTEGER, -- Campaign context (nullable, FK to campaigns) + + -- Action details + action VARCHAR(50) NOT NULL, -- 'view', 'open_store', 'open_product', 'compare', 'other' + source VARCHAR(100) NOT NULL, -- Page/component where click occurred + + -- User context (optional - pulled from auth if available) + user_id INTEGER, -- Admin/user who triggered event + + -- Request metadata + ip_address INET, + user_agent TEXT, + + -- Timestamps + occurred_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +-- Create indexes for efficient querying +CREATE INDEX IF NOT EXISTS idx_product_click_events_product_id ON product_click_events(product_id); +CREATE INDEX IF NOT EXISTS idx_product_click_events_store_id ON product_click_events(store_id); +CREATE INDEX IF NOT EXISTS idx_product_click_events_brand_id ON product_click_events(brand_id); +CREATE INDEX IF NOT EXISTS idx_product_click_events_campaign_id ON product_click_events(campaign_id); +CREATE INDEX IF NOT EXISTS idx_product_click_events_action ON product_click_events(action); +CREATE INDEX IF NOT EXISTS idx_product_click_events_source ON product_click_events(source); +CREATE INDEX IF NOT EXISTS idx_product_click_events_occurred_at ON product_click_events(occurred_at); +CREATE INDEX IF NOT EXISTS idx_product_click_events_user_id ON product_click_events(user_id); + +-- Composite index for campaign analytics +CREATE INDEX IF NOT EXISTS idx_product_click_events_campaign_product + ON product_click_events(campaign_id, product_id) + WHERE campaign_id IS NOT NULL; + +-- Composite index for time-series queries +CREATE INDEX IF NOT EXISTS idx_product_click_events_time_action + ON product_click_events(occurred_at, action); + +-- Add comment for documentation +COMMENT ON TABLE product_click_events IS 'Tracks product interactions across the CannaIQ platform for analytics and campaign measurement'; diff --git a/backend/migrations/062_click_analytics_enhancements.sql b/backend/migrations/062_click_analytics_enhancements.sql new file mode 100644 index 00000000..25c07cc8 --- /dev/null +++ b/backend/migrations/062_click_analytics_enhancements.sql @@ -0,0 +1,41 @@ +-- Migration: Enhance click events for brand & campaign analytics +-- Adds event_type and page_type for better categorization + +-- Add event_type column (default to 'product_click' for existing rows) +ALTER TABLE product_click_events +ADD COLUMN IF NOT EXISTS event_type VARCHAR(50) DEFAULT 'product_click'; + +-- Add page_type column for tracking which page the event originated from +ALTER TABLE product_click_events +ADD COLUMN IF NOT EXISTS page_type VARCHAR(100); + +-- Add URL path for debugging/analysis +ALTER TABLE product_click_events +ADD COLUMN IF NOT EXISTS url_path TEXT; + +-- Add device type (desktop, mobile, tablet) +ALTER TABLE product_click_events +ADD COLUMN IF NOT EXISTS device_type VARCHAR(20); + +-- Create index on event_type for filtering +CREATE INDEX IF NOT EXISTS idx_product_click_events_event_type +ON product_click_events(event_type); + +-- Create index on page_type for page-level analytics +CREATE INDEX IF NOT EXISTS idx_product_click_events_page_type +ON product_click_events(page_type); + +-- Create composite index for brand analytics (brand_id + occurred_at) +CREATE INDEX IF NOT EXISTS idx_product_click_events_brand_time +ON product_click_events(brand_id, occurred_at) +WHERE brand_id IS NOT NULL; + +-- Create composite index for store+brand analytics +CREATE INDEX IF NOT EXISTS idx_product_click_events_store_brand +ON product_click_events(store_id, brand_id) +WHERE store_id IS NOT NULL; + +-- Add comment for documentation +COMMENT ON COLUMN product_click_events.event_type IS 'Type of event: product_click, page_view, etc.'; +COMMENT ON COLUMN product_click_events.page_type IS 'Page where event occurred: StoreDetailPage, BrandsIntelligence, CampaignDetail, etc.'; +COMMENT ON COLUMN product_click_events.device_type IS 'Device type: desktop, mobile, tablet'; diff --git a/backend/migrations/063_seo_pages.sql b/backend/migrations/063_seo_pages.sql new file mode 100644 index 00000000..cc2121ca --- /dev/null +++ b/backend/migrations/063_seo_pages.sql @@ -0,0 +1,22 @@ +-- SEO Pages table for CannaiQ marketing content +-- All content stored here must be sanitized before insertion + +CREATE TABLE IF NOT EXISTS seo_pages ( + id SERIAL PRIMARY KEY, + slug VARCHAR(255) NOT NULL UNIQUE, + type VARCHAR(50) NOT NULL, -- state, brand, competitor, landing, blog + content JSONB NOT NULL, + status VARCHAR(20) NOT NULL DEFAULT 'draft', -- draft, published, archived + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() +); + +-- Indexes for efficient lookup +CREATE INDEX IF NOT EXISTS idx_seo_pages_slug ON seo_pages(slug); +CREATE INDEX IF NOT EXISTS idx_seo_pages_type ON seo_pages(type); +CREATE INDEX IF NOT EXISTS idx_seo_pages_status ON seo_pages(status); +CREATE INDEX IF NOT EXISTS idx_seo_pages_type_status ON seo_pages(type, status); + +-- Add comment explaining content requirements +COMMENT ON TABLE seo_pages IS 'SEO content for CannaiQ marketing pages. All content must use approved enterprise-safe phrasing.'; +COMMENT ON COLUMN seo_pages.content IS 'JSON content with blocks structure. Must be sanitized via ContentValidator before insert.'; diff --git a/backend/node_modules/.package-lock.json b/backend/node_modules/.package-lock.json index 4526ecb8..1b115bb0 100644 --- a/backend/node_modules/.package-lock.json +++ b/backend/node_modules/.package-lock.json @@ -41,6 +41,11 @@ "node": ">=18" } }, + "node_modules/@ioredis/commands": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@ioredis/commands/-/commands-1.4.0.tgz", + "integrity": "sha512-aFT2yemJJo+TZCmieA7qnYGQooOS7QfNmYrzGtsYd3g9j5iDP8AimYYAesf79ohjbLG12XxC4nG5DyEnC88AsQ==" + }, "node_modules/@mapbox/node-pre-gyp": { "version": "1.0.11", "resolved": "https://registry.npmjs.org/@mapbox/node-pre-gyp/-/node-pre-gyp-1.0.11.tgz", @@ -104,6 +109,18 @@ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" }, + "node_modules/@msgpackr-extract/msgpackr-extract-linux-x64": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-x64/-/msgpackr-extract-linux-x64-3.0.3.tgz", + "integrity": "sha512-cvwNfbP07pKUfq1uH+S6KJ7dT9K8WOE4ZiAcsrSes+UY55E/0jLYc+vq+DO7jlmqRb5zAggExKm0H7O/CBaesg==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "linux" + ] + }, "node_modules/@puppeteer/browsers": { "version": "1.9.1", "resolved": "https://registry.npmjs.org/@puppeteer/browsers/-/browsers-1.9.1.tgz", @@ -159,6 +176,15 @@ "@types/node": "*" } }, + "node_modules/@types/bcryptjs": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@types/bcryptjs/-/bcryptjs-3.0.0.tgz", + "integrity": "sha512-WRZOuCuaz8UcZZE4R5HXTco2goQSI2XxjGY3hbM/xDvwmqFWd4ivooImsMx65OKM6CtNKbnZ5YL+YwAwK7c1dg==", + "deprecated": "This is a stub types definition. bcryptjs provides its own type definitions, so you do not need this installed.", + "dependencies": { + "bcryptjs": "*" + } + }, "node_modules/@types/body-parser": { "version": "1.19.6", "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz", @@ -534,6 +560,14 @@ "node": ">= 10.0.0" } }, + "node_modules/bcryptjs": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-3.0.3.tgz", + "integrity": "sha512-GlF5wPWnSa/X5LKM1o0wz0suXIINz1iHRLvTS+sLyi7XPbe5ycmYI3DlZqVGZZtDgl4DmasFg7gOB3JYbphV5g==", + "bin": { + "bcrypt": "bin/bcrypt" + } + }, "node_modules/bl": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", @@ -630,6 +664,32 @@ "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==" }, + "node_modules/bullmq": { + "version": "5.65.1", + "resolved": "https://registry.npmjs.org/bullmq/-/bullmq-5.65.1.tgz", + "integrity": "sha512-QgDAzX1G9L5IRy4Orva5CfQTXZT+5K+OfO/kbPrAqN+pmL9LJekCzxijXehlm/u2eXfWPfWvIdJJIqiuz3WJSg==", + "dependencies": { + "cron-parser": "^4.9.0", + "ioredis": "^5.8.2", + "msgpackr": "^1.11.2", + "node-abort-controller": "^3.1.1", + "semver": "^7.5.4", + "tslib": "^2.0.0", + "uuid": "^11.1.0" + } + }, + "node_modules/bullmq/node_modules/uuid": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.0.tgz", + "integrity": "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "bin": { + "uuid": "dist/esm/bin/uuid" + } + }, "node_modules/bytes": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", @@ -778,6 +838,14 @@ "node": ">=0.10.0" } }, + "node_modules/cluster-key-slot": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz", + "integrity": "sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/color": { "version": "4.2.3", "resolved": "https://registry.npmjs.org/color/-/color-4.2.3.tgz", @@ -913,6 +981,17 @@ } } }, + "node_modules/cron-parser": { + "version": "4.9.0", + "resolved": "https://registry.npmjs.org/cron-parser/-/cron-parser-4.9.0.tgz", + "integrity": "sha512-p0SaNjrHOnQeR8/VnfGbmg9te2kfyYSQ7Sc/j/6DtPL3JQvKxmjO9TSjNFpujqV3vEYYBvNNvXSxzyksBWAx1Q==", + "dependencies": { + "luxon": "^3.2.1" + }, + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/cross-fetch": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/cross-fetch/-/cross-fetch-4.0.0.tgz", @@ -1043,6 +1122,14 @@ "resolved": "https://registry.npmjs.org/delegates/-/delegates-1.0.0.tgz", "integrity": "sha512-bd2L678uiWATM6m5Z1VzNCErI3jiGzt6HGY8OVICs40JQq/HALfbyNJmp0UDakEY4pMMaN0Ly5om/B1VI/+xfQ==" }, + "node_modules/denque": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz", + "integrity": "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==", + "engines": { + "node": ">=0.10" + } + }, "node_modules/depd": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", @@ -2096,6 +2183,50 @@ "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==" }, + "node_modules/ioredis": { + "version": "5.8.2", + "resolved": "https://registry.npmjs.org/ioredis/-/ioredis-5.8.2.tgz", + "integrity": "sha512-C6uC+kleiIMmjViJINWk80sOQw5lEzse1ZmvD+S/s8p8CWapftSaC+kocGTx6xrbrJ4WmYQGC08ffHLr6ToR6Q==", + "dependencies": { + "@ioredis/commands": "1.4.0", + "cluster-key-slot": "^1.1.0", + "debug": "^4.3.4", + "denque": "^2.1.0", + "lodash.defaults": "^4.2.0", + "lodash.isarguments": "^3.1.0", + "redis-errors": "^1.2.0", + "redis-parser": "^3.0.0", + "standard-as-callback": "^2.1.0" + }, + "engines": { + "node": ">=12.22.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/ioredis" + } + }, + "node_modules/ioredis/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/ioredis/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" + }, "node_modules/ip-address": { "version": "10.1.0", "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz", @@ -2343,11 +2474,21 @@ "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==" }, + "node_modules/lodash.defaults": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz", + "integrity": "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==" + }, "node_modules/lodash.includes": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==" }, + "node_modules/lodash.isarguments": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/lodash.isarguments/-/lodash.isarguments-3.1.0.tgz", + "integrity": "sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg==" + }, "node_modules/lodash.isboolean": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", @@ -2386,6 +2527,14 @@ "node": ">=12" } }, + "node_modules/luxon": { + "version": "3.7.2", + "resolved": "https://registry.npmjs.org/luxon/-/luxon-3.7.2.tgz", + "integrity": "sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew==", + "engines": { + "node": ">=12" + } + }, "node_modules/make-dir": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-3.1.0.tgz", @@ -2614,6 +2763,35 @@ "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==" }, + "node_modules/msgpackr": { + "version": "1.11.5", + "resolved": "https://registry.npmjs.org/msgpackr/-/msgpackr-1.11.5.tgz", + "integrity": "sha512-UjkUHN0yqp9RWKy0Lplhh+wlpdt9oQBYgULZOiFhV3VclSF1JnSQWZ5r9gORQlNYaUKQoR8itv7g7z1xDDuACA==", + "optionalDependencies": { + "msgpackr-extract": "^3.0.2" + } + }, + "node_modules/msgpackr-extract": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/msgpackr-extract/-/msgpackr-extract-3.0.3.tgz", + "integrity": "sha512-P0efT1C9jIdVRefqjzOQ9Xml57zpOXnIuS+csaB4MdZbTdmGDLo8XhzBG1N7aO11gKDDkJvBLULeFTo46wwreA==", + "hasInstallScript": true, + "optional": true, + "dependencies": { + "node-gyp-build-optional-packages": "5.2.2" + }, + "bin": { + "download-msgpackr-prebuilds": "bin/download-prebuilds.js" + }, + "optionalDependencies": { + "@msgpackr-extract/msgpackr-extract-darwin-arm64": "3.0.3", + "@msgpackr-extract/msgpackr-extract-darwin-x64": "3.0.3", + "@msgpackr-extract/msgpackr-extract-linux-arm": "3.0.3", + "@msgpackr-extract/msgpackr-extract-linux-arm64": "3.0.3", + "@msgpackr-extract/msgpackr-extract-linux-x64": "3.0.3", + "@msgpackr-extract/msgpackr-extract-win32-x64": "3.0.3" + } + }, "node_modules/napi-build-utils": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz", @@ -2646,6 +2824,11 @@ "node": ">=10" } }, + "node_modules/node-abort-controller": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/node-abort-controller/-/node-abort-controller-3.1.1.tgz", + "integrity": "sha512-AGK2yQKIjRuqnc6VkX2Xj5d+QW8xZ87pa1UK6yA6ouUyuxfHuMP6umE5QK7UmTeOAymo+Zx1Fxiuw9rVx8taHQ==" + }, "node_modules/node-addon-api": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-5.1.0.tgz", @@ -2689,6 +2872,20 @@ } } }, + "node_modules/node-gyp-build-optional-packages": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/node-gyp-build-optional-packages/-/node-gyp-build-optional-packages-5.2.2.tgz", + "integrity": "sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw==", + "optional": true, + "dependencies": { + "detect-libc": "^2.0.1" + }, + "bin": { + "node-gyp-build-optional-packages": "bin.js", + "node-gyp-build-optional-packages-optional": "optional.js", + "node-gyp-build-optional-packages-test": "build-test.js" + } + }, "node_modules/nopt": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/nopt/-/nopt-5.0.0.tgz", @@ -3623,6 +3820,25 @@ "node": ">= 6" } }, + "node_modules/redis-errors": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/redis-errors/-/redis-errors-1.2.0.tgz", + "integrity": "sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==", + "engines": { + "node": ">=4" + } + }, + "node_modules/redis-parser": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/redis-parser/-/redis-parser-3.0.0.tgz", + "integrity": "sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==", + "dependencies": { + "redis-errors": "^1.0.0" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/require-directory": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", @@ -4065,6 +4281,11 @@ "node": ">= 10.x" } }, + "node_modules/standard-as-callback": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/standard-as-callback/-/standard-as-callback-2.1.0.tgz", + "integrity": "sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==" + }, "node_modules/statuses": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", diff --git a/backend/package-lock.json b/backend/package-lock.json index 0a430dc7..1d03ed60 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -8,8 +8,11 @@ "name": "dutchie-menus-backend", "version": "1.5.1", "dependencies": { + "@types/bcryptjs": "^3.0.0", "axios": "^1.6.2", "bcrypt": "^5.1.1", + "bcryptjs": "^3.0.3", + "bullmq": "^5.65.1", "cheerio": "^1.1.2", "cors": "^2.8.5", "dotenv": "^16.3.1", @@ -17,6 +20,7 @@ "express-rate-limit": "^7.1.5", "helmet": "^7.1.0", "https-proxy-agent": "^7.0.2", + "ioredis": "^5.8.2", "ipaddr.js": "^2.2.0", "jsonwebtoken": "^9.0.2", "minio": "^7.1.3", @@ -482,6 +486,11 @@ "node": ">=18" } }, + "node_modules/@ioredis/commands": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@ioredis/commands/-/commands-1.4.0.tgz", + "integrity": "sha512-aFT2yemJJo+TZCmieA7qnYGQooOS7QfNmYrzGtsYd3g9j5iDP8AimYYAesf79ohjbLG12XxC4nG5DyEnC88AsQ==" + }, "node_modules/@mapbox/node-pre-gyp": { "version": "1.0.11", "resolved": "https://registry.npmjs.org/@mapbox/node-pre-gyp/-/node-pre-gyp-1.0.11.tgz", @@ -545,6 +554,78 @@ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" }, + "node_modules/@msgpackr-extract/msgpackr-extract-darwin-arm64": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-arm64/-/msgpackr-extract-darwin-arm64-3.0.3.tgz", + "integrity": "sha512-QZHtlVgbAdy2zAqNA9Gu1UpIuI8Xvsd1v8ic6B2pZmeFnFcMWiPLfWXh7TVw4eGEZ/C9TH281KwhVoeQUKbyjw==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-darwin-x64": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-x64/-/msgpackr-extract-darwin-x64-3.0.3.tgz", + "integrity": "sha512-mdzd3AVzYKuUmiWOQ8GNhl64/IoFGol569zNRdkLReh6LRLHOXxU4U8eq0JwaD8iFHdVGqSy4IjFL4reoWCDFw==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-linux-arm": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm/-/msgpackr-extract-linux-arm-3.0.3.tgz", + "integrity": "sha512-fg0uy/dG/nZEXfYilKoRe7yALaNmHoYeIoJuJ7KJ+YyU2bvY8vPv27f7UKhGRpY6euFYqEVhxCFZgAUNQBM3nw==", + "cpu": [ + "arm" + ], + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-linux-arm64": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm64/-/msgpackr-extract-linux-arm64-3.0.3.tgz", + "integrity": "sha512-YxQL+ax0XqBJDZiKimS2XQaf+2wDGVa1enVRGzEvLLVFeqa5kx2bWbtcSXgsxjQB7nRqqIGFIcLteF/sHeVtQg==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-linux-x64": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-x64/-/msgpackr-extract-linux-x64-3.0.3.tgz", + "integrity": "sha512-cvwNfbP07pKUfq1uH+S6KJ7dT9K8WOE4ZiAcsrSes+UY55E/0jLYc+vq+DO7jlmqRb5zAggExKm0H7O/CBaesg==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-win32-x64": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-win32-x64/-/msgpackr-extract-win32-x64-3.0.3.tgz", + "integrity": "sha512-x0fWaQtYp4E6sktbsdAqnehxDgEc/VwM7uLsRCYWaiGu0ykYdZPiS8zCWdnjHwyiumousxfBm4SO31eXqwEZhQ==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "win32" + ] + }, "node_modules/@puppeteer/browsers": { "version": "1.9.1", "resolved": "https://registry.npmjs.org/@puppeteer/browsers/-/browsers-1.9.1.tgz", @@ -600,6 +681,15 @@ "@types/node": "*" } }, + "node_modules/@types/bcryptjs": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@types/bcryptjs/-/bcryptjs-3.0.0.tgz", + "integrity": "sha512-WRZOuCuaz8UcZZE4R5HXTco2goQSI2XxjGY3hbM/xDvwmqFWd4ivooImsMx65OKM6CtNKbnZ5YL+YwAwK7c1dg==", + "deprecated": "This is a stub types definition. bcryptjs provides its own type definitions, so you do not need this installed.", + "dependencies": { + "bcryptjs": "*" + } + }, "node_modules/@types/body-parser": { "version": "1.19.6", "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz", @@ -975,6 +1065,14 @@ "node": ">= 10.0.0" } }, + "node_modules/bcryptjs": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-3.0.3.tgz", + "integrity": "sha512-GlF5wPWnSa/X5LKM1o0wz0suXIINz1iHRLvTS+sLyi7XPbe5ycmYI3DlZqVGZZtDgl4DmasFg7gOB3JYbphV5g==", + "bin": { + "bcrypt": "bin/bcrypt" + } + }, "node_modules/bl": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", @@ -1071,6 +1169,32 @@ "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==" }, + "node_modules/bullmq": { + "version": "5.65.1", + "resolved": "https://registry.npmjs.org/bullmq/-/bullmq-5.65.1.tgz", + "integrity": "sha512-QgDAzX1G9L5IRy4Orva5CfQTXZT+5K+OfO/kbPrAqN+pmL9LJekCzxijXehlm/u2eXfWPfWvIdJJIqiuz3WJSg==", + "dependencies": { + "cron-parser": "^4.9.0", + "ioredis": "^5.8.2", + "msgpackr": "^1.11.2", + "node-abort-controller": "^3.1.1", + "semver": "^7.5.4", + "tslib": "^2.0.0", + "uuid": "^11.1.0" + } + }, + "node_modules/bullmq/node_modules/uuid": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.0.tgz", + "integrity": "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "bin": { + "uuid": "dist/esm/bin/uuid" + } + }, "node_modules/bytes": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", @@ -1219,6 +1343,14 @@ "node": ">=0.10.0" } }, + "node_modules/cluster-key-slot": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz", + "integrity": "sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/color": { "version": "4.2.3", "resolved": "https://registry.npmjs.org/color/-/color-4.2.3.tgz", @@ -1354,6 +1486,17 @@ } } }, + "node_modules/cron-parser": { + "version": "4.9.0", + "resolved": "https://registry.npmjs.org/cron-parser/-/cron-parser-4.9.0.tgz", + "integrity": "sha512-p0SaNjrHOnQeR8/VnfGbmg9te2kfyYSQ7Sc/j/6DtPL3JQvKxmjO9TSjNFpujqV3vEYYBvNNvXSxzyksBWAx1Q==", + "dependencies": { + "luxon": "^3.2.1" + }, + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/cross-fetch": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/cross-fetch/-/cross-fetch-4.0.0.tgz", @@ -1484,6 +1627,14 @@ "resolved": "https://registry.npmjs.org/delegates/-/delegates-1.0.0.tgz", "integrity": "sha512-bd2L678uiWATM6m5Z1VzNCErI3jiGzt6HGY8OVICs40JQq/HALfbyNJmp0UDakEY4pMMaN0Ly5om/B1VI/+xfQ==" }, + "node_modules/denque": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz", + "integrity": "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==", + "engines": { + "node": ">=0.10" + } + }, "node_modules/depd": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", @@ -2551,6 +2702,50 @@ "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==" }, + "node_modules/ioredis": { + "version": "5.8.2", + "resolved": "https://registry.npmjs.org/ioredis/-/ioredis-5.8.2.tgz", + "integrity": "sha512-C6uC+kleiIMmjViJINWk80sOQw5lEzse1ZmvD+S/s8p8CWapftSaC+kocGTx6xrbrJ4WmYQGC08ffHLr6ToR6Q==", + "dependencies": { + "@ioredis/commands": "1.4.0", + "cluster-key-slot": "^1.1.0", + "debug": "^4.3.4", + "denque": "^2.1.0", + "lodash.defaults": "^4.2.0", + "lodash.isarguments": "^3.1.0", + "redis-errors": "^1.2.0", + "redis-parser": "^3.0.0", + "standard-as-callback": "^2.1.0" + }, + "engines": { + "node": ">=12.22.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/ioredis" + } + }, + "node_modules/ioredis/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/ioredis/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" + }, "node_modules/ip-address": { "version": "10.1.0", "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz", @@ -2798,11 +2993,21 @@ "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==" }, + "node_modules/lodash.defaults": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz", + "integrity": "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==" + }, "node_modules/lodash.includes": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==" }, + "node_modules/lodash.isarguments": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/lodash.isarguments/-/lodash.isarguments-3.1.0.tgz", + "integrity": "sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg==" + }, "node_modules/lodash.isboolean": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", @@ -2841,6 +3046,14 @@ "node": ">=12" } }, + "node_modules/luxon": { + "version": "3.7.2", + "resolved": "https://registry.npmjs.org/luxon/-/luxon-3.7.2.tgz", + "integrity": "sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew==", + "engines": { + "node": ">=12" + } + }, "node_modules/make-dir": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-3.1.0.tgz", @@ -3069,6 +3282,35 @@ "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==" }, + "node_modules/msgpackr": { + "version": "1.11.5", + "resolved": "https://registry.npmjs.org/msgpackr/-/msgpackr-1.11.5.tgz", + "integrity": "sha512-UjkUHN0yqp9RWKy0Lplhh+wlpdt9oQBYgULZOiFhV3VclSF1JnSQWZ5r9gORQlNYaUKQoR8itv7g7z1xDDuACA==", + "optionalDependencies": { + "msgpackr-extract": "^3.0.2" + } + }, + "node_modules/msgpackr-extract": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/msgpackr-extract/-/msgpackr-extract-3.0.3.tgz", + "integrity": "sha512-P0efT1C9jIdVRefqjzOQ9Xml57zpOXnIuS+csaB4MdZbTdmGDLo8XhzBG1N7aO11gKDDkJvBLULeFTo46wwreA==", + "hasInstallScript": true, + "optional": true, + "dependencies": { + "node-gyp-build-optional-packages": "5.2.2" + }, + "bin": { + "download-msgpackr-prebuilds": "bin/download-prebuilds.js" + }, + "optionalDependencies": { + "@msgpackr-extract/msgpackr-extract-darwin-arm64": "3.0.3", + "@msgpackr-extract/msgpackr-extract-darwin-x64": "3.0.3", + "@msgpackr-extract/msgpackr-extract-linux-arm": "3.0.3", + "@msgpackr-extract/msgpackr-extract-linux-arm64": "3.0.3", + "@msgpackr-extract/msgpackr-extract-linux-x64": "3.0.3", + "@msgpackr-extract/msgpackr-extract-win32-x64": "3.0.3" + } + }, "node_modules/napi-build-utils": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz", @@ -3101,6 +3343,11 @@ "node": ">=10" } }, + "node_modules/node-abort-controller": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/node-abort-controller/-/node-abort-controller-3.1.1.tgz", + "integrity": "sha512-AGK2yQKIjRuqnc6VkX2Xj5d+QW8xZ87pa1UK6yA6ouUyuxfHuMP6umE5QK7UmTeOAymo+Zx1Fxiuw9rVx8taHQ==" + }, "node_modules/node-addon-api": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-5.1.0.tgz", @@ -3144,6 +3391,20 @@ } } }, + "node_modules/node-gyp-build-optional-packages": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/node-gyp-build-optional-packages/-/node-gyp-build-optional-packages-5.2.2.tgz", + "integrity": "sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw==", + "optional": true, + "dependencies": { + "detect-libc": "^2.0.1" + }, + "bin": { + "node-gyp-build-optional-packages": "bin.js", + "node-gyp-build-optional-packages-optional": "optional.js", + "node-gyp-build-optional-packages-test": "build-test.js" + } + }, "node_modules/nopt": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/nopt/-/nopt-5.0.0.tgz", @@ -4091,6 +4352,25 @@ "node": ">= 6" } }, + "node_modules/redis-errors": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/redis-errors/-/redis-errors-1.2.0.tgz", + "integrity": "sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==", + "engines": { + "node": ">=4" + } + }, + "node_modules/redis-parser": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/redis-parser/-/redis-parser-3.0.0.tgz", + "integrity": "sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==", + "dependencies": { + "redis-errors": "^1.0.0" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/require-directory": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", @@ -4533,6 +4813,11 @@ "node": ">= 10.x" } }, + "node_modules/standard-as-callback": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/standard-as-callback/-/standard-as-callback-2.1.0.tgz", + "integrity": "sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==" + }, "node_modules/statuses": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", diff --git a/backend/package.json b/backend/package.json index 23e85ad8..147b90e7 100755 --- a/backend/package.json +++ b/backend/package.json @@ -5,8 +5,11 @@ "main": "dist/index.js", "scripts": { "dev": "tsx watch src/index.ts", + "dev:worker": "tsx watch src/cli.ts --worker", "build": "tsc", "start": "node dist/index.js", + "start:worker": "node dist/cli.js --worker", + "worker": "tsx src/cli.ts --worker", "migrate": "tsx src/db/migrate.ts", "seed": "tsx src/db/seed.ts", "migrate:az": "tsx src/dutchie-az/db/migrate.ts", @@ -19,8 +22,11 @@ "seed:dt:cities:bulk": "tsx src/scripts/seed-dt-cities-bulk.ts" }, "dependencies": { + "@types/bcryptjs": "^3.0.0", "axios": "^1.6.2", "bcrypt": "^5.1.1", + "bcryptjs": "^3.0.3", + "bullmq": "^5.65.1", "cheerio": "^1.1.2", "cors": "^2.8.5", "dotenv": "^16.3.1", @@ -28,6 +34,7 @@ "express-rate-limit": "^7.1.5", "helmet": "^7.1.0", "https-proxy-agent": "^7.0.2", + "ioredis": "^5.8.2", "ipaddr.js": "^2.2.0", "jsonwebtoken": "^9.0.2", "minio": "^7.1.3", diff --git a/backend/src/auth/middleware.ts b/backend/src/auth/middleware.ts index a0bc9a64..5c4b3166 100755 --- a/backend/src/auth/middleware.ts +++ b/backend/src/auth/middleware.ts @@ -140,11 +140,72 @@ export function requireRole(...roles: string[]) { if (!req.user) { return res.status(401).json({ error: 'Not authenticated' }); } - + if (!roles.includes(req.user.role)) { return res.status(403).json({ error: 'Insufficient permissions' }); } - + next(); }; } + +/** + * Optional auth middleware - attempts to authenticate but allows unauthenticated requests + * + * If a valid token is provided, sets req.user with the authenticated user. + * If no token or invalid token, continues without setting req.user. + * + * Use this for endpoints that work for both authenticated and anonymous users + * (e.g., product click tracking where we want user_id when available). + */ +export async function optionalAuthMiddleware(req: AuthRequest, res: Response, next: NextFunction) { + const authHeader = req.headers.authorization; + + // No token provided - continue without auth + if (!authHeader || !authHeader.startsWith('Bearer ')) { + return next(); + } + + const token = authHeader.substring(7); + + // Try JWT first + const jwtUser = verifyToken(token); + + if (jwtUser) { + req.user = jwtUser; + return next(); + } + + // If JWT fails, try API token + try { + const result = await pool.query(` + SELECT id, name, rate_limit, active, expires_at + FROM api_tokens + WHERE token = $1 + `, [token]); + + if (result.rows.length > 0) { + const apiToken = result.rows[0]; + + // Check if token is active and not expired + if (apiToken.active && (!apiToken.expires_at || new Date(apiToken.expires_at) >= new Date())) { + req.apiToken = { + id: apiToken.id, + name: apiToken.name, + rate_limit: apiToken.rate_limit + }; + + req.user = { + id: apiToken.id, + email: `api-token-${apiToken.id}@system`, + role: 'api' + }; + } + } + } catch (error) { + // Silently ignore errors - optional auth should not fail the request + console.warn('[OptionalAuth] Error checking API token:', error); + } + + next(); +} diff --git a/backend/src/cli.ts b/backend/src/cli.ts new file mode 100644 index 00000000..b6e77ffe --- /dev/null +++ b/backend/src/cli.ts @@ -0,0 +1,67 @@ +#!/usr/bin/env node +/** + * CLI Entrypoint for CannaIQ Backend + * + * Usage: + * npx tsx src/cli.ts # Start API server + * npx tsx src/cli.ts --worker # Start worker process + * npx tsx src/cli.ts --help # Show help + * + * Environment Variables: + * DATABASE_URL - PostgreSQL connection string (required) + * PORT - API server port (default: 3010) + * WORKER_ID - Worker instance identifier (auto-generated if not set) + */ + +const args = process.argv.slice(2); + +function showHelp() { + console.log(` +CannaIQ Backend CLI + +Usage: + npx tsx src/cli.ts [options] + +Options: + --worker Start as a job queue worker (processes crawl jobs) + --api Start as API server (default) + --help Show this help message + +Environment Variables: + DATABASE_URL PostgreSQL connection string (required) + PORT API server port (default: 3010) + WORKER_ID Worker instance identifier (auto-generated) + +Examples: + # Start API server on default port + DATABASE_URL="postgresql://..." npx tsx src/cli.ts + + # Start worker process + DATABASE_URL="postgresql://..." npx tsx src/cli.ts --worker + + # Start API on custom port + PORT=3015 DATABASE_URL="postgresql://..." npx tsx src/cli.ts --api +`); + process.exit(0); +} + +async function main() { + if (args.includes('--help') || args.includes('-h')) { + showHelp(); + } + + if (args.includes('--worker')) { + console.log('[CLI] Starting worker process...'); + const { startWorker } = await import('./dutchie-az/services/worker'); + await startWorker(); + } else { + // Default: start API server + console.log('[CLI] Starting API server...'); + await import('./index'); + } +} + +main().catch((error) => { + console.error('[CLI] Fatal error:', error); + process.exit(1); +}); diff --git a/backend/src/dutchie-az/routes/index.ts b/backend/src/dutchie-az/routes/index.ts index bf0fa79e..3613a30d 100644 --- a/backend/src/dutchie-az/routes/index.ts +++ b/backend/src/dutchie-az/routes/index.ts @@ -1,8 +1,9 @@ /** - * Dutchie AZ API Routes + * Market Data API Routes * - * Express routes for the Dutchie AZ data pipeline. + * Express routes for the cannabis market data pipeline. * Provides API endpoints for stores, products, categories, and dashboard. + * Mounted at /api/markets (with legacy aliases at /api/az and /api/dutchie-az) */ import { Router, Request, Response } from 'express'; @@ -203,101 +204,113 @@ router.get('/stores/:id', async (req: Request, res: Response) => { * GET /api/dutchie-az/stores/:id/summary * Get store summary with product count, categories, and brands * This is the main endpoint for the DispensaryDetail panel + * OPTIMIZED: Combined 5 sequential queries into 2 parallel queries */ router.get('/stores/:id/summary', async (req: Request, res: Response) => { try { const { id } = req.params; + const dispensaryId = parseInt(id, 10); - // Get dispensary info - const { rows: dispensaryRows } = await query( - `SELECT ${DISPENSARY_COLUMNS} FROM dispensaries WHERE id = $1`, - [parseInt(id, 10)] - ); + // Run all queries in parallel using Promise.all + const [dispensaryResult, aggregateResult] = await Promise.all([ + // Query 1: Get dispensary info + query( + `SELECT ${DISPENSARY_COLUMNS} FROM dispensaries WHERE id = $1`, + [dispensaryId] + ), - if (dispensaryRows.length === 0) { + // Query 2: All product aggregations in one query using CTEs + query( + ` + WITH stock_counts AS ( + SELECT + COUNT(*) as total_products, + COUNT(*) FILTER (WHERE stock_status = 'in_stock') as in_stock_count, + COUNT(*) FILTER (WHERE stock_status = 'out_of_stock') as out_of_stock_count, + COUNT(*) FILTER (WHERE stock_status = 'unknown') as unknown_count, + COUNT(*) FILTER (WHERE stock_status = 'missing_from_feed') as missing_count + FROM dutchie_products + WHERE dispensary_id = $1 + ), + category_agg AS ( + SELECT jsonb_agg( + jsonb_build_object('type', type, 'subcategory', subcategory, 'product_count', cnt) + ORDER BY type, subcategory + ) as categories + FROM ( + SELECT type, subcategory, COUNT(*) as cnt + FROM dutchie_products + WHERE dispensary_id = $1 AND type IS NOT NULL + GROUP BY type, subcategory + ) cat + ), + brand_agg AS ( + SELECT jsonb_agg( + jsonb_build_object('brand_name', brand_name, 'product_count', cnt) + ORDER BY cnt DESC + ) as brands + FROM ( + SELECT brand_name, COUNT(*) as cnt + FROM dutchie_products + WHERE dispensary_id = $1 AND brand_name IS NOT NULL + GROUP BY brand_name + ) br + ), + last_crawl AS ( + SELECT + id, status, started_at, completed_at, + products_found, products_new, products_updated, error_message + FROM dispensary_crawl_jobs + WHERE dispensary_id = $1 + ORDER BY created_at DESC + LIMIT 1 + ) + SELECT + sc.total_products, sc.in_stock_count, sc.out_of_stock_count, sc.unknown_count, sc.missing_count, + COALESCE(ca.categories, '[]'::jsonb) as categories, + COALESCE(ba.brands, '[]'::jsonb) as brands, + lc.id as last_crawl_id, lc.status as last_crawl_status, + lc.started_at as last_crawl_started, lc.completed_at as last_crawl_completed, + lc.products_found, lc.products_new, lc.products_updated, lc.error_message + FROM stock_counts sc + CROSS JOIN category_agg ca + CROSS JOIN brand_agg ba + LEFT JOIN last_crawl lc ON true + `, + [dispensaryId] + ) + ]); + + if (dispensaryResult.rows.length === 0) { return res.status(404).json({ error: 'Store not found' }); } - const dispensary = dispensaryRows[0]; - - // Get product counts by stock status - const { rows: countRows } = await query( - ` - SELECT - COUNT(*) as total_products, - COUNT(*) FILTER (WHERE stock_status = 'in_stock') as in_stock_count, - COUNT(*) FILTER (WHERE stock_status = 'out_of_stock') as out_of_stock_count, - COUNT(*) FILTER (WHERE stock_status = 'unknown') as unknown_count, - COUNT(*) FILTER (WHERE stock_status = 'missing_from_feed') as missing_count - FROM dutchie_products - WHERE dispensary_id = $1 - `, - [id] - ); - - // Get categories with counts for this store - const { rows: categories } = await query( - ` - SELECT - type, - subcategory, - COUNT(*) as product_count - FROM dutchie_products - WHERE dispensary_id = $1 AND type IS NOT NULL - GROUP BY type, subcategory - ORDER BY type, subcategory - `, - [id] - ); - - // Get brands with counts for this store - const { rows: brands } = await query( - ` - SELECT - brand_name, - COUNT(*) as product_count - FROM dutchie_products - WHERE dispensary_id = $1 AND brand_name IS NOT NULL - GROUP BY brand_name - ORDER BY product_count DESC - `, - [id] - ); - - // Get last crawl info - const { rows: lastCrawl } = await query( - ` - SELECT - id, - status, - started_at, - completed_at, - products_found, - products_new, - products_updated, - error_message - FROM dispensary_crawl_jobs - WHERE dispensary_id = $1 - ORDER BY created_at DESC - LIMIT 1 - `, - [id] - ); - - const counts = countRows[0] || {}; + const dispensary = dispensaryResult.rows[0]; + const agg = aggregateResult.rows[0] || {}; + const categories = agg.categories || []; + const brands = agg.brands || []; res.json({ dispensary, - totalProducts: parseInt(counts.total_products || '0', 10), - inStockCount: parseInt(counts.in_stock_count || '0', 10), - outOfStockCount: parseInt(counts.out_of_stock_count || '0', 10), - unknownStockCount: parseInt(counts.unknown_count || '0', 10), - missingFromFeedCount: parseInt(counts.missing_count || '0', 10), + totalProducts: parseInt(agg.total_products || '0', 10), + inStockCount: parseInt(agg.in_stock_count || '0', 10), + outOfStockCount: parseInt(agg.out_of_stock_count || '0', 10), + unknownStockCount: parseInt(agg.unknown_count || '0', 10), + missingFromFeedCount: parseInt(agg.missing_count || '0', 10), categories, brands, brandCount: brands.length, categoryCount: categories.length, - lastCrawl: lastCrawl[0] || null, + lastCrawl: agg.last_crawl_id ? { + id: agg.last_crawl_id, + status: agg.last_crawl_status, + started_at: agg.last_crawl_started, + completed_at: agg.last_crawl_completed, + products_found: agg.products_found, + products_new: agg.products_new, + products_updated: agg.products_updated, + error_message: agg.error_message + } : null, }); } catch (error: any) { res.status(500).json({ error: error.message }); @@ -1082,12 +1095,24 @@ router.post('/admin/crawl/:id', async (req: Request, res: Response) => { import { bulkEnqueueJobs, getQueueStats as getJobQueueStats } from '../services/job-queue'; /** - * GET /api/dutchie-az/admin/dutchie-stores - * Get all Dutchie stores with their crawl status + * GET /api/markets/admin/crawlable-stores + * Get all crawlable stores with their crawl status + * OPTIMIZED: Replaced correlated subqueries with LEFT JOINs */ -router.get('/admin/dutchie-stores', async (_req: Request, res: Response) => { +router.get('/admin/crawlable-stores', async (_req: Request, res: Response) => { try { const { rows } = await query(` + WITH product_counts AS ( + SELECT dispensary_id, COUNT(*) as product_count + FROM dutchie_products + GROUP BY dispensary_id + ), + snapshot_times AS ( + SELECT p.dispensary_id, MAX(s.crawled_at) as last_snapshot_at + FROM dutchie_product_snapshots s + JOIN dutchie_products p ON s.dutchie_product_id = p.id + GROUP BY p.dispensary_id + ) SELECT d.id, d.name, @@ -1100,18 +1125,11 @@ router.get('/admin/dutchie-stores', async (_req: Request, res: Response) => { d.last_crawl_at, d.consecutive_failures, d.failed_at, - ( - SELECT COUNT(*) - FROM dutchie_products - WHERE dispensary_id = d.id - ) as product_count, - ( - SELECT MAX(crawled_at) - FROM dutchie_product_snapshots s - JOIN dutchie_products p ON s.dutchie_product_id = p.id - WHERE p.dispensary_id = d.id - ) as last_snapshot_at + COALESCE(pc.product_count, 0) as product_count, + st.last_snapshot_at FROM dispensaries d + LEFT JOIN product_counts pc ON pc.dispensary_id = d.id + LEFT JOIN snapshot_times st ON st.dispensary_id = d.id WHERE d.menu_type = 'dutchie' AND d.state = 'AZ' ORDER BY d.name @@ -1150,9 +1168,14 @@ router.get('/admin/dutchie-stores', async (_req: Request, res: Response) => { } }); +// Legacy alias (deprecated - use /admin/crawlable-stores) +router.get('/admin/dutchie-stores', (req: Request, res: Response) => { + res.redirect(307, '/api/markets/admin/crawlable-stores'); +}); + /** - * POST /api/dutchie-az/admin/crawl-all - * Enqueue crawl jobs for ALL ready Dutchie stores + * POST /api/markets/admin/crawl-all + * Enqueue crawl jobs for ALL ready stores * This is a convenience endpoint to queue all stores without triggering the scheduler */ router.post('/admin/crawl-all', async (req: Request, res: Response) => { @@ -1699,69 +1722,74 @@ import { /** * GET /api/dutchie-az/monitor/active-jobs * Get all currently running jobs with real-time status including worker info + * OPTIMIZED: Run all queries in parallel */ router.get('/monitor/active-jobs', async (_req: Request, res: Response) => { try { - // Get running jobs from job_run_logs (scheduled jobs like "enqueue all") - // Includes worker_name and run_role for named workforce display - const { rows: runningScheduledJobs } = await query(` - SELECT - jrl.id, - jrl.schedule_id, - jrl.job_name, - jrl.status, - jrl.started_at, - jrl.items_processed, - jrl.items_succeeded, - jrl.items_failed, - jrl.metadata, - jrl.worker_name, - jrl.run_role, - js.description as job_description, - js.worker_name as schedule_worker_name, - js.worker_role as schedule_worker_role, - EXTRACT(EPOCH FROM (NOW() - jrl.started_at)) as duration_seconds - FROM job_run_logs jrl - LEFT JOIN job_schedules js ON jrl.schedule_id = js.id - WHERE jrl.status = 'running' - ORDER BY jrl.started_at DESC - `); + // Run all queries in parallel for better performance + const [scheduledJobsResult, crawlJobsResult, queueStats, activeWorkers] = await Promise.all([ + // Query 1: Running scheduled jobs from job_run_logs + query(` + SELECT + jrl.id, + jrl.schedule_id, + jrl.job_name, + jrl.status, + jrl.started_at, + jrl.items_processed, + jrl.items_succeeded, + jrl.items_failed, + jrl.metadata, + jrl.worker_name, + jrl.run_role, + js.description as job_description, + js.worker_name as schedule_worker_name, + js.worker_role as schedule_worker_role, + EXTRACT(EPOCH FROM (NOW() - jrl.started_at)) as duration_seconds + FROM job_run_logs jrl + LEFT JOIN job_schedules js ON jrl.schedule_id = js.id + WHERE jrl.status = 'running' + ORDER BY jrl.started_at DESC + `), - // Get running crawl jobs (individual store crawls with worker info) - // Includes enqueued_by_worker for tracking which named worker enqueued the job - const { rows: runningCrawlJobs } = await query(` - SELECT - cj.id, - cj.job_type, - cj.dispensary_id, - d.name as dispensary_name, - d.city, - d.platform_dispensary_id, - cj.status, - cj.started_at, - cj.claimed_by as worker_id, - cj.worker_hostname, - cj.claimed_at, - cj.enqueued_by_worker, - cj.products_found, - cj.products_upserted, - cj.snapshots_created, - cj.current_page, - cj.total_pages, - cj.last_heartbeat_at, - cj.retry_count, - EXTRACT(EPOCH FROM (NOW() - cj.started_at)) as duration_seconds - FROM dispensary_crawl_jobs cj - LEFT JOIN dispensaries d ON cj.dispensary_id = d.id - WHERE cj.status = 'running' - ORDER BY cj.started_at DESC - `); + // Query 2: Running crawl jobs with dispensary info + query(` + SELECT + cj.id, + cj.job_type, + cj.dispensary_id, + d.name as dispensary_name, + d.city, + d.platform_dispensary_id, + cj.status, + cj.started_at, + cj.claimed_by as worker_id, + cj.worker_hostname, + cj.claimed_at, + cj.enqueued_by_worker, + cj.products_found, + cj.products_upserted, + cj.snapshots_created, + cj.current_page, + cj.total_pages, + cj.last_heartbeat_at, + cj.retry_count, + EXTRACT(EPOCH FROM (NOW() - cj.started_at)) as duration_seconds + FROM dispensary_crawl_jobs cj + LEFT JOIN dispensaries d ON cj.dispensary_id = d.id + WHERE cj.status = 'running' + ORDER BY cj.started_at DESC + `), - // Get queue stats - const queueStats = await getQueueStats(); + // Query 3: Queue stats + getQueueStats(), - // Get active workers - const activeWorkers = await getActiveWorkers(); + // Query 4: Active workers + getActiveWorkers() + ]); + + const runningScheduledJobs = scheduledJobsResult.rows; + const runningCrawlJobs = crawlJobsResult.rows; // Also get in-memory scrapers if any (from the legacy system) let inMemoryScrapers: any[] = []; @@ -2490,102 +2518,146 @@ router.get('/admin/crawl-traces/run/:runId', async (req: Request, res: Response) /** * GET /api/dutchie-az/scraper/overview * Comprehensive scraper overview for the new dashboard + * OPTIMIZED: Combined 6 queries into 4 using CTEs (was 6) */ router.get('/scraper/overview', async (_req: Request, res: Response) => { try { - // 1. Core KPI metrics - const { rows: kpiRows } = await query(` - SELECT - -- Total products - (SELECT COUNT(*) FROM dutchie_products) AS total_products, - (SELECT COUNT(*) FROM dutchie_products WHERE stock_status = 'in_stock') AS in_stock_products, - -- Total dispensaries - (SELECT COUNT(*) FROM dispensaries WHERE menu_type = 'dutchie' AND state = 'AZ') AS total_dispensaries, - (SELECT COUNT(*) FROM dispensaries WHERE menu_type = 'dutchie' AND state = 'AZ' AND platform_dispensary_id IS NOT NULL) AS crawlable_dispensaries, - -- Visibility stats (24h) - (SELECT COUNT(*) FROM dutchie_products WHERE visibility_lost = true AND visibility_lost_at > NOW() - INTERVAL '24 hours') AS visibility_lost_24h, - (SELECT COUNT(*) FROM dutchie_products WHERE visibility_restored_at > NOW() - INTERVAL '24 hours') AS visibility_restored_24h, - (SELECT COUNT(*) FROM dutchie_products WHERE visibility_lost = true) AS total_visibility_lost, - -- Job stats (24h) - (SELECT COUNT(*) FROM job_run_logs WHERE status IN ('error', 'partial') AND created_at > NOW() - INTERVAL '24 hours') AS errors_24h, - (SELECT COUNT(*) FROM job_run_logs WHERE status = 'success' AND created_at > NOW() - INTERVAL '24 hours') AS successful_jobs_24h, - -- Active workers - (SELECT COUNT(*) FROM job_schedules WHERE enabled = true) AS active_workers - `); + // Run all queries in parallel using Promise.all for better performance + const [kpiResult, workerResult, timeSeriesResult, visibilityResult] = await Promise.all([ + // Query 1: All KPI metrics in a single query using CTEs + query(` + WITH product_stats AS ( + SELECT + COUNT(*) AS total_products, + COUNT(*) FILTER (WHERE stock_status = 'in_stock') AS in_stock_products, + COUNT(*) FILTER (WHERE visibility_lost = true AND visibility_lost_at > NOW() - INTERVAL '24 hours') AS visibility_lost_24h, + COUNT(*) FILTER (WHERE visibility_restored_at > NOW() - INTERVAL '24 hours') AS visibility_restored_24h, + COUNT(*) FILTER (WHERE visibility_lost = true) AS total_visibility_lost + FROM dutchie_products + ), + dispensary_stats AS ( + SELECT + COUNT(*) FILTER (WHERE menu_type = 'dutchie' AND state = 'AZ') AS total_dispensaries, + COUNT(*) FILTER (WHERE menu_type = 'dutchie' AND state = 'AZ' AND platform_dispensary_id IS NOT NULL) AS crawlable_dispensaries + FROM dispensaries + ), + job_stats AS ( + SELECT + COUNT(*) FILTER (WHERE status IN ('error', 'partial') AND created_at > NOW() - INTERVAL '24 hours') AS errors_24h, + COUNT(*) FILTER (WHERE status = 'success' AND created_at > NOW() - INTERVAL '24 hours') AS successful_jobs_24h + FROM job_run_logs + ), + worker_stats AS ( + SELECT COUNT(*) AS active_workers FROM job_schedules WHERE enabled = true + ) + SELECT + ps.total_products, ps.in_stock_products, ps.visibility_lost_24h, ps.visibility_restored_24h, ps.total_visibility_lost, + ds.total_dispensaries, ds.crawlable_dispensaries, + js.errors_24h, js.successful_jobs_24h, + ws.active_workers + FROM product_stats ps, dispensary_stats ds, job_stats js, worker_stats ws + `), - // 2. Get active worker names - const { rows: workerRows } = await query(` - SELECT worker_name, worker_role, enabled, last_status, last_run_at, next_run_at - FROM job_schedules - WHERE enabled = true - ORDER BY next_run_at ASC NULLS LAST - `); + // Query 2: Active worker details + query(` + SELECT worker_name, worker_role, enabled, last_status, last_run_at, next_run_at + FROM job_schedules + WHERE enabled = true + ORDER BY next_run_at ASC NULLS LAST + `), - // 3. Scrape activity by hour (last 24h) - const { rows: activityRows } = await query(` - SELECT - date_trunc('hour', started_at) AS hour, - COUNT(*) FILTER (WHERE status = 'success') AS successful, - COUNT(*) FILTER (WHERE status IN ('error', 'partial')) AS failed, - COUNT(*) AS total - FROM job_run_logs - WHERE started_at > NOW() - INTERVAL '24 hours' - GROUP BY date_trunc('hour', started_at) - ORDER BY hour ASC - `); + // Query 3: Time-series data (activity + growth + recent runs) + query(` + WITH activity_by_hour AS ( + SELECT + date_trunc('hour', started_at) AS hour, + COUNT(*) FILTER (WHERE status = 'success') AS successful, + COUNT(*) FILTER (WHERE status IN ('error', 'partial')) AS failed, + COUNT(*) AS total + FROM job_run_logs + WHERE started_at > NOW() - INTERVAL '24 hours' + GROUP BY date_trunc('hour', started_at) + ), + product_growth AS ( + SELECT + date_trunc('day', created_at) AS day, + COUNT(*) AS new_products + FROM dutchie_products + WHERE created_at > NOW() - INTERVAL '7 days' + GROUP BY date_trunc('day', created_at) + ), + recent_runs AS ( + SELECT + jrl.id, + jrl.job_name, + jrl.status, + jrl.started_at, + jrl.completed_at, + jrl.items_processed, + jrl.items_succeeded, + jrl.items_failed, + jrl.metadata, + js.worker_name, + js.worker_role + FROM job_run_logs jrl + LEFT JOIN job_schedules js ON jrl.schedule_id = js.id + ORDER BY jrl.started_at DESC + LIMIT 20 + ) + SELECT + 'activity' AS query_type, + jsonb_agg(jsonb_build_object('hour', hour, 'successful', successful, 'failed', failed, 'total', total) ORDER BY hour) AS data + FROM activity_by_hour + UNION ALL + SELECT + 'growth' AS query_type, + jsonb_agg(jsonb_build_object('day', day, 'new_products', new_products) ORDER BY day) AS data + FROM product_growth + UNION ALL + SELECT + 'runs' AS query_type, + jsonb_agg(jsonb_build_object( + 'id', id, 'job_name', job_name, 'status', status, 'started_at', started_at, + 'completed_at', completed_at, 'items_processed', items_processed, + 'items_succeeded', items_succeeded, 'items_failed', items_failed, + 'metadata', metadata, 'worker_name', worker_name, 'worker_role', worker_role + ) ORDER BY started_at DESC) AS data + FROM recent_runs + `), - // 4. Product growth / coverage (last 7 days) - const { rows: growthRows } = await query(` - SELECT - date_trunc('day', created_at) AS day, - COUNT(*) AS new_products - FROM dutchie_products - WHERE created_at > NOW() - INTERVAL '7 days' - GROUP BY date_trunc('day', created_at) - ORDER BY day ASC - `); + // Query 4: Visibility changes by store + query(` + SELECT + d.id AS dispensary_id, + d.name AS dispensary_name, + d.state, + COUNT(dp.id) FILTER (WHERE dp.visibility_lost = true AND dp.visibility_lost_at > NOW() - INTERVAL '24 hours') AS lost_24h, + COUNT(dp.id) FILTER (WHERE dp.visibility_restored_at > NOW() - INTERVAL '24 hours') AS restored_24h, + MAX(dp.visibility_lost_at) AS latest_loss, + MAX(dp.visibility_restored_at) AS latest_restore + FROM dispensaries d + LEFT JOIN dutchie_products dp ON d.id = dp.dispensary_id + WHERE d.menu_type = 'dutchie' + GROUP BY d.id, d.name, d.state + HAVING COUNT(dp.id) FILTER (WHERE dp.visibility_lost = true AND dp.visibility_lost_at > NOW() - INTERVAL '24 hours') > 0 + OR COUNT(dp.id) FILTER (WHERE dp.visibility_restored_at > NOW() - INTERVAL '24 hours') > 0 + ORDER BY lost_24h DESC, restored_24h DESC + LIMIT 15 + `) + ]); - // 5. Recent worker runs (last 20) - const { rows: recentRuns } = await query(` - SELECT - jrl.id, - jrl.job_name, - jrl.status, - jrl.started_at, - jrl.completed_at, - jrl.items_processed, - jrl.items_succeeded, - jrl.items_failed, - jrl.metadata, - js.worker_name, - js.worker_role - FROM job_run_logs jrl - LEFT JOIN job_schedules js ON jrl.schedule_id = js.id - ORDER BY jrl.started_at DESC - LIMIT 20 - `); + // Parse results + const kpi = kpiResult.rows[0] || {}; + const workerRows = workerResult.rows; + const visibilityChanges = visibilityResult.rows; - // 6. Recent visibility changes by store - const { rows: visibilityChanges } = await query(` - SELECT - d.id AS dispensary_id, - d.name AS dispensary_name, - d.state, - COUNT(dp.id) FILTER (WHERE dp.visibility_lost = true AND dp.visibility_lost_at > NOW() - INTERVAL '24 hours') AS lost_24h, - COUNT(dp.id) FILTER (WHERE dp.visibility_restored_at > NOW() - INTERVAL '24 hours') AS restored_24h, - MAX(dp.visibility_lost_at) AS latest_loss, - MAX(dp.visibility_restored_at) AS latest_restore - FROM dispensaries d - LEFT JOIN dutchie_products dp ON d.id = dp.dispensary_id - WHERE d.menu_type = 'dutchie' - GROUP BY d.id, d.name, d.state - HAVING COUNT(dp.id) FILTER (WHERE dp.visibility_lost = true AND dp.visibility_lost_at > NOW() - INTERVAL '24 hours') > 0 - OR COUNT(dp.id) FILTER (WHERE dp.visibility_restored_at > NOW() - INTERVAL '24 hours') > 0 - ORDER BY lost_24h DESC, restored_24h DESC - LIMIT 15 - `); - - const kpi = kpiRows[0] || {}; + // Parse time-series aggregated results + const timeSeriesMap = Object.fromEntries( + timeSeriesResult.rows.map((r: any) => [r.query_type, r.data || []]) + ); + const activityRows = timeSeriesMap['activity'] || []; + const growthRows = timeSeriesMap['growth'] || []; + const recentRuns = timeSeriesMap['runs'] || []; res.json({ kpi: { diff --git a/backend/src/index.ts b/backend/src/index.ts index 15831602..0c16274e 100755 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -23,10 +23,14 @@ app.use('/images', express.static(LOCAL_IMAGES_PATH)); const LOCAL_DOWNLOADS_PATH = process.env.LOCAL_DOWNLOADS_PATH || '/app/public/downloads'; app.use('/downloads', express.static(LOCAL_DOWNLOADS_PATH)); +// Simple health check for load balancers/K8s probes app.get('/health', (req, res) => { res.json({ status: 'ok', timestamp: new Date().toISOString() }); }); +// Comprehensive health endpoints for monitoring (no auth required) +app.use('/api/health', healthRoutes); + // Endpoint to check server's outbound IP (for proxy whitelist setup) app.get('/outbound-ip', async (req, res) => { try { @@ -62,6 +66,8 @@ import usersRoutes from './routes/users'; import staleProcessesRoutes from './routes/stale-processes'; import orchestratorAdminRoutes from './routes/orchestrator-admin'; import adminRoutes from './routes/admin'; +import healthRoutes from './routes/health'; +import workersRoutes from './routes/workers'; import { dutchieAZRouter, startScheduler as startDutchieAZScheduler, initializeDefaultSchedules } from './dutchie-az'; import { getPool } from './dutchie-az/db/connection'; import { createAnalyticsRouter } from './dutchie-az/routes/analytics'; @@ -77,6 +83,16 @@ import { createAnalyticsV2Router } from './routes/analytics-v2'; import { createDiscoveryRoutes } from './discovery'; import { createDutchieDiscoveryRoutes, promoteDiscoveryLocation } from './dutchie-az/discovery'; +// Consumer API routes (findadispo.com, findagram.co) +import consumerAuthRoutes from './routes/consumer-auth'; +import consumerFavoritesRoutes from './routes/consumer-favorites'; +import consumerAlertsRoutes from './routes/consumer-alerts'; +import consumerSavedSearchesRoutes from './routes/consumer-saved-searches'; +import consumerDealsRoutes from './routes/consumer-deals'; +import eventsRoutes from './routes/events'; +import clickAnalyticsRoutes from './routes/click-analytics'; +import seoRoutes from './routes/seo'; + // Mark requests from trusted domains (cannaiq.co, findagram.co, findadispo.com) // These domains can access the API without authentication app.use(markTrustedDomains); @@ -96,6 +112,18 @@ app.use('/api/changes', changesRoutes); app.use('/api/categories', categoriesRoutes); app.use('/api/products', productsRoutes); app.use('/api/campaigns', campaignsRoutes); + +// Multi-state API routes - national analytics and cross-state comparisons (NO AUTH) +// IMPORTANT: Must be mounted BEFORE /api/analytics to avoid auth middleware blocking these routes +try { + const multiStateRoutes = createMultiStateRoutes(getPool()); + app.use('/api', multiStateRoutes); + console.log('[MultiState] Routes registered at /api (analytics/national/*, states/*, etc.)'); +} catch (error) { + console.warn('[MultiState] Failed to register routes (DB may not be configured):', error); +} + +// Legacy click analytics routes (requires auth) app.use('/api/analytics', analyticsRoutes); app.use('/api/settings', settingsRoutes); app.use('/api/proxies', proxiesRoutes); @@ -112,16 +140,29 @@ app.use('/api/stale-processes', staleProcessesRoutes); // Admin routes - operator actions (crawl triggers, health checks) app.use('/api/admin', adminRoutes); app.use('/api/admin/orchestrator', orchestratorAdminRoutes); -// Vendor-agnostic AZ data pipeline routes (new public surface) + +// SEO orchestrator routes +app.use('/api/seo', seoRoutes); + +// Provider-agnostic worker management routes (replaces /api/dutchie-az/admin/schedules) +app.use('/api/workers', workersRoutes); +// Monitor routes - aliased from workers for convenience +app.use('/api/monitor', workersRoutes); +console.log('[Workers] Routes registered at /api/workers and /api/monitor'); + +// Market data pipeline routes (provider-agnostic) +app.use('/api/markets', dutchieAZRouter); +// Legacy aliases (deprecated - remove after frontend migration) app.use('/api/az', dutchieAZRouter); -// Legacy alias (kept temporarily for backward compatibility) app.use('/api/dutchie-az', dutchieAZRouter); // Phase 3: Analytics Dashboards - price trends, penetration, category growth, etc. try { const analyticsRouter = createAnalyticsRouter(getPool()); + app.use('/api/markets/analytics', analyticsRouter); + // Legacy alias for backwards compatibility app.use('/api/az/analytics', analyticsRouter); - console.log('[Analytics] Routes registered at /api/az/analytics'); + console.log('[Analytics] Routes registered at /api/markets/analytics'); } catch (error) { console.warn('[Analytics] Failed to register routes:', error); } @@ -139,15 +180,24 @@ try { // Uses dutchie_az data pipeline with per-dispensary API key auth app.use('/api/v1', publicApiRoutes); -// Multi-state API routes - national analytics and cross-state comparisons -// Phase 4: Multi-State Expansion -try { - const multiStateRoutes = createMultiStateRoutes(getPool()); - app.use('/api', multiStateRoutes); - console.log('[MultiState] Routes registered'); -} catch (error) { - console.warn('[MultiState] Failed to register routes (DB may not be configured):', error); -} +// Consumer API - findadispo.com and findagram.co user features +// Auth routes don't require authentication +app.use('/api/consumer/auth', consumerAuthRoutes); +// Protected consumer routes (favorites, alerts, saved searches) +app.use('/api/consumer/favorites', consumerFavoritesRoutes); +app.use('/api/consumer/alerts', consumerAlertsRoutes); +app.use('/api/consumer/saved-searches', consumerSavedSearchesRoutes); +// Deals endpoint - public, no auth required +app.use('/api/v1/deals', consumerDealsRoutes); +console.log('[Consumer] Routes registered at /api/consumer/*'); + +// Events API - product click tracking for analytics and campaigns +app.use('/api/events', eventsRoutes); +console.log('[Events] Routes registered at /api/events'); + +// Click Analytics API - brand and campaign engagement aggregations +app.use('/api/analytics/clicks', clickAnalyticsRoutes); +console.log('[ClickAnalytics] Routes registered at /api/analytics/clicks'); // States API routes - cannabis legalization status and targeting try { diff --git a/backend/src/lib/redis.ts b/backend/src/lib/redis.ts new file mode 100644 index 00000000..288e11cc --- /dev/null +++ b/backend/src/lib/redis.ts @@ -0,0 +1,108 @@ +/** + * Redis Connection Utility + * + * Provides a singleton Redis connection for: + * - BullMQ job queues (notifications, crawl jobs) + * - Rate limiting + * - Caching + * - Session storage + */ + +import Redis from 'ioredis'; + +// Lazy-initialized Redis client singleton +let _redis: Redis | null = null; + +/** + * Get Redis connection URL from environment + */ +function getRedisUrl(): string { + // Priority 1: Full Redis URL + if (process.env.REDIS_URL) { + return process.env.REDIS_URL; + } + + // Priority 2: Individual env vars + const host = process.env.REDIS_HOST || 'localhost'; + const port = process.env.REDIS_PORT || '6379'; + const password = process.env.REDIS_PASSWORD; + + if (password) { + return `redis://:${password}@${host}:${port}`; + } + + return `redis://${host}:${port}`; +} + +/** + * Get the Redis client (lazy singleton) + */ +export function getRedis(): Redis { + if (!_redis) { + const url = getRedisUrl(); + console.log(`[Redis] Connecting to ${url.replace(/:[^:@]+@/, ':***@')}`); + + _redis = new Redis(url, { + maxRetriesPerRequest: null, // Required for BullMQ + enableReadyCheck: false, + retryStrategy: (times: number) => { + if (times > 10) { + console.error('[Redis] Max retries reached, giving up'); + return null; + } + const delay = Math.min(times * 100, 3000); + console.log(`[Redis] Retry attempt ${times}, waiting ${delay}ms`); + return delay; + }, + }); + + _redis.on('connect', () => { + console.log('[Redis] Connected'); + }); + + _redis.on('error', (err) => { + console.error('[Redis] Error:', err.message); + }); + + _redis.on('close', () => { + console.log('[Redis] Connection closed'); + }); + } + + return _redis; +} + +/** + * Check if Redis is available + */ +export async function isRedisAvailable(): Promise { + try { + const redis = getRedis(); + const pong = await redis.ping(); + return pong === 'PONG'; + } catch { + return false; + } +} + +/** + * Close Redis connection + */ +export async function closeRedis(): Promise { + if (_redis) { + await _redis.quit(); + _redis = null; + console.log('[Redis] Disconnected'); + } +} + +/** + * Get BullMQ-compatible connection options + * BullMQ requires a specific connection format + */ +export function getBullMQConnection(): Redis { + return getRedis(); +} + +// Export types for convenience +export type { Redis }; diff --git a/backend/src/migrations/052_create_seo_pages.sql b/backend/src/migrations/052_create_seo_pages.sql new file mode 100644 index 00000000..d3df2e1f --- /dev/null +++ b/backend/src/migrations/052_create_seo_pages.sql @@ -0,0 +1,27 @@ +-- Migration: 052_create_seo_pages.sql +-- Purpose: Create seo_pages table for SEO orchestrator + +CREATE TABLE IF NOT EXISTS seo_pages ( + id SERIAL PRIMARY KEY, + type VARCHAR(50) NOT NULL CHECK (type IN ('state', 'brand', 'competitor_alternative', 'high_intent', 'insight_post')), + slug VARCHAR(255) NOT NULL UNIQUE, + page_key VARCHAR(255) NOT NULL, + primary_keyword VARCHAR(255), + status VARCHAR(50) DEFAULT 'pending_generation' CHECK (status IN ('draft', 'pending_generation', 'live', 'stale')), + data_source VARCHAR(100), + meta_title VARCHAR(255), + meta_description TEXT, + last_generated_at TIMESTAMPTZ, + last_reviewed_at TIMESTAMPTZ, + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_seo_pages_type ON seo_pages(type); +CREATE INDEX IF NOT EXISTS idx_seo_pages_status ON seo_pages(status); +CREATE INDEX IF NOT EXISTS idx_seo_pages_slug ON seo_pages(slug); + +-- Record migration +INSERT INTO schema_migrations (version, name, applied_at) +VALUES (52, '052_create_seo_pages', NOW()) +ON CONFLICT (version) DO NOTHING; diff --git a/backend/src/migrations/053_create_seo_page_contents.sql b/backend/src/migrations/053_create_seo_page_contents.sql new file mode 100644 index 00000000..43e98848 --- /dev/null +++ b/backend/src/migrations/053_create_seo_page_contents.sql @@ -0,0 +1,24 @@ +-- Migration: 053_create_seo_page_contents.sql +-- Stores generated SEO content for each page + +CREATE TABLE IF NOT EXISTS seo_page_contents ( + id SERIAL PRIMARY KEY, + page_id INTEGER NOT NULL REFERENCES seo_pages(id) ON DELETE CASCADE, + blocks JSONB NOT NULL DEFAULT '[]', + meta_title VARCHAR(255), + meta_description TEXT, + h1 VARCHAR(255), + canonical_url VARCHAR(500), + og_title VARCHAR(255), + og_description TEXT, + og_image_url VARCHAR(500), + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW(), + UNIQUE(page_id) +); + +CREATE INDEX IF NOT EXISTS idx_seo_page_contents_page_id ON seo_page_contents(page_id); + +INSERT INTO schema_migrations (version, name, applied_at) +VALUES (53, '053_create_seo_page_contents', NOW()) +ON CONFLICT (version) DO NOTHING; diff --git a/backend/src/multi-state/routes.ts b/backend/src/multi-state/routes.ts index 9285a5a7..9058b134 100644 --- a/backend/src/multi-state/routes.ts +++ b/backend/src/multi-state/routes.ts @@ -447,5 +447,55 @@ export function createMultiStateRoutes(pool: Pool): Router { } }); + // ========================================================================= + // Health Check Endpoint + // ========================================================================= + + /** + * GET /api/health/analytics + * Health check for analytics subsystem + */ + router.get('/health/analytics', async (req: Request, res: Response) => { + try { + const startTime = Date.now(); + + // Check materialized view is accessible + const result = await pool.query(` + SELECT COUNT(*) as state_count, + MAX(refreshed_at) as last_refresh + FROM mv_state_metrics + `); + + const dbLatency = Date.now() - startTime; + const stateCount = parseInt(result.rows[0]?.state_count || '0', 10); + const lastRefresh = result.rows[0]?.last_refresh; + + // Check if data is stale (more than 24 hours old) + const isStale = lastRefresh + ? Date.now() - new Date(lastRefresh).getTime() > 24 * 60 * 60 * 1000 + : true; + + res.json({ + success: true, + status: isStale ? 'degraded' : 'healthy', + data: { + statesInCache: stateCount, + lastRefresh: lastRefresh || null, + isStale, + dbLatencyMs: dbLatency, + }, + timestamp: new Date().toISOString(), + }); + } catch (error: any) { + console.error('[MultiState] Health check failed:', error); + res.status(503).json({ + success: false, + status: 'unhealthy', + error: error.message, + timestamp: new Date().toISOString(), + }); + } + }); + return router; } diff --git a/backend/src/routes/campaigns.ts b/backend/src/routes/campaigns.ts index 91fb1fb4..f85ed2ee 100755 --- a/backend/src/routes/campaigns.ts +++ b/backend/src/routes/campaigns.ts @@ -162,17 +162,17 @@ router.post('/:id/products', requireRole('superadmin', 'admin'), async (req, res router.delete('/:id/products/:product_id', requireRole('superadmin', 'admin'), async (req, res) => { try { const { id, product_id } = req.params; - + const result = await pool.query(` - DELETE FROM campaign_products + DELETE FROM campaign_products WHERE campaign_id = $1 AND product_id = $2 RETURNING * `, [id, product_id]); - + if (result.rows.length === 0) { return res.status(404).json({ error: 'Product not in campaign' }); } - + res.json({ message: 'Product removed from campaign' }); } catch (error) { console.error('Error removing product from campaign:', error); @@ -180,4 +180,139 @@ router.delete('/:id/products/:product_id', requireRole('superadmin', 'admin'), a } }); +/** + * GET /api/campaigns/:id/click-summary + * Get product click event summary for a campaign + * + * Query params: + * - from: Start date (ISO) + * - to: End date (ISO) + */ +router.get('/:id/click-summary', async (req, res) => { + try { + const { id } = req.params; + const { from, to } = req.query; + + // Check campaign exists + const campaignResult = await pool.query( + 'SELECT id, name FROM campaigns WHERE id = $1', + [id] + ); + if (campaignResult.rows.length === 0) { + return res.status(404).json({ error: 'Campaign not found' }); + } + + // Build date filter conditions + const conditions: string[] = ['campaign_id = $1']; + const params: any[] = [id]; + let paramIndex = 2; + + if (from) { + conditions.push(`occurred_at >= $${paramIndex++}`); + params.push(new Date(from as string)); + } + if (to) { + conditions.push(`occurred_at <= $${paramIndex++}`); + params.push(new Date(to as string)); + } + + const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : ''; + + // Get overall stats + const statsResult = await pool.query(` + SELECT + COUNT(*) as total_clicks, + COUNT(DISTINCT product_id) as unique_products, + COUNT(DISTINCT store_id) as unique_stores, + COUNT(DISTINCT brand_id) as unique_brands, + COUNT(DISTINCT user_id) FILTER (WHERE user_id IS NOT NULL) as unique_users + FROM product_click_events + ${whereClause} + `, params); + + // Get clicks by action type + const byActionResult = await pool.query(` + SELECT + action, + COUNT(*) as count + FROM product_click_events + ${whereClause} + GROUP BY action + ORDER BY count DESC + `, params); + + // Get clicks by source + const bySourceResult = await pool.query(` + SELECT + source, + COUNT(*) as count + FROM product_click_events + ${whereClause} + GROUP BY source + ORDER BY count DESC + `, params); + + // Get top products (by click count) + const topProductsResult = await pool.query(` + SELECT + product_id, + COUNT(*) as click_count + FROM product_click_events + ${whereClause} + GROUP BY product_id + ORDER BY click_count DESC + LIMIT 10 + `, params); + + // Get daily click counts (last 30 days by default) + const dailyParams = [...params]; + let dailyWhereClause = whereClause; + if (!from) { + // Default to last 30 days + conditions.push(`occurred_at >= NOW() - INTERVAL '30 days'`); + dailyWhereClause = `WHERE ${conditions.join(' AND ')}`; + } + + const dailyResult = await pool.query(` + SELECT + DATE(occurred_at) as date, + COUNT(*) as click_count + FROM product_click_events + ${dailyWhereClause} + GROUP BY DATE(occurred_at) + ORDER BY date ASC + `, dailyParams); + + res.json({ + campaign: campaignResult.rows[0], + summary: { + totalClicks: parseInt(statsResult.rows[0].total_clicks, 10), + uniqueProducts: parseInt(statsResult.rows[0].unique_products, 10), + uniqueStores: parseInt(statsResult.rows[0].unique_stores, 10), + uniqueBrands: parseInt(statsResult.rows[0].unique_brands, 10), + uniqueUsers: parseInt(statsResult.rows[0].unique_users, 10) + }, + byAction: byActionResult.rows.map(row => ({ + action: row.action, + count: parseInt(row.count, 10) + })), + bySource: bySourceResult.rows.map(row => ({ + source: row.source, + count: parseInt(row.count, 10) + })), + topProducts: topProductsResult.rows.map(row => ({ + productId: row.product_id, + clickCount: parseInt(row.click_count, 10) + })), + daily: dailyResult.rows.map(row => ({ + date: row.date, + clickCount: parseInt(row.click_count, 10) + })) + }); + } catch (error: any) { + console.error('[Campaigns] Error fetching click summary:', error.message); + res.status(500).json({ error: 'Failed to fetch campaign click summary' }); + } +}); + export default router; diff --git a/backend/src/routes/click-analytics.ts b/backend/src/routes/click-analytics.ts new file mode 100644 index 00000000..2bb1e9d4 --- /dev/null +++ b/backend/src/routes/click-analytics.ts @@ -0,0 +1,521 @@ +/** + * Click Analytics API Routes + * + * Aggregates product click events by brand and campaign for analytics dashboards. + * + * Endpoints: + * GET /api/analytics/clicks/brands - Top brands by click engagement + * GET /api/analytics/clicks/campaigns - Top campaigns/specials by engagement + * GET /api/analytics/clicks/stores/:storeId/brands - Per-store brand engagement + * GET /api/analytics/clicks/summary - Overall click summary stats + */ + +import { Router, Request, Response } from 'express'; +import { pool } from '../db/pool'; +import { authMiddleware } from '../auth/middleware'; + +const router = Router(); + +// All click analytics endpoints require authentication +router.use(authMiddleware); + +/** + * GET /api/analytics/clicks/brands + * Get top brands by click engagement + * + * Query params: + * - state: Filter by store state (e.g., 'AZ') + * - store_id: Filter by specific store + * - brand_id: Filter by specific brand + * - days: Lookback window (default 30) + * - limit: Max results (default 25) + */ +router.get('/brands', async (req: Request, res: Response) => { + try { + const { + state, + store_id, + brand_id, + days = '30', + limit = '25' + } = req.query; + + const daysNum = parseInt(days as string, 10) || 30; + const limitNum = Math.min(parseInt(limit as string, 10) || 25, 100); + + // Build conditions and params + const conditions: string[] = [ + 'e.brand_id IS NOT NULL', + `e.occurred_at >= NOW() - INTERVAL '${daysNum} days'` + ]; + const params: any[] = []; + let paramIdx = 1; + + if (state) { + conditions.push(`d.state = $${paramIdx++}`); + params.push(state); + } + + if (store_id) { + conditions.push(`e.store_id = $${paramIdx++}`); + params.push(store_id); + } + + if (brand_id) { + conditions.push(`e.brand_id = $${paramIdx++}`); + params.push(brand_id); + } + + const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : ''; + + // Query for brand engagement + const result = await pool.query(` + SELECT + e.brand_id, + e.brand_id as brand_name, + COUNT(*) as clicks, + COUNT(DISTINCT e.product_id) as unique_products, + COUNT(DISTINCT e.store_id) as unique_stores, + MIN(e.occurred_at) as first_click_at, + MAX(e.occurred_at) as last_click_at + FROM product_click_events e + LEFT JOIN dispensaries d ON e.store_id::int = d.id + ${whereClause} + GROUP BY e.brand_id + ORDER BY clicks DESC + LIMIT ${limitNum} + `, params); + + // Try to get actual brand names from products + const brandIds = result.rows.map(r => r.brand_id).filter(Boolean); + let brandNamesMap: Record = {}; + + if (brandIds.length > 0) { + const brandNamesResult = await pool.query(` + SELECT DISTINCT brand_name + FROM dutchie_products + WHERE brand_name = ANY($1) + `, [brandIds]); + + brandNamesResult.rows.forEach(r => { + brandNamesMap[r.brand_name] = r.brand_name; + }); + } + + const brands = result.rows.map(row => ({ + brand_id: row.brand_id, + brand_name: brandNamesMap[row.brand_id] || row.brand_id, + clicks: parseInt(row.clicks, 10), + unique_products: parseInt(row.unique_products, 10), + unique_stores: parseInt(row.unique_stores, 10), + first_click_at: row.first_click_at, + last_click_at: row.last_click_at + })); + + res.json({ + filters: { + state: state || null, + store_id: store_id || null, + brand_id: brand_id || null, + days: daysNum + }, + brands + }); + } catch (error: any) { + console.error('[ClickAnalytics] Error fetching brand analytics:', error.message); + res.status(500).json({ error: 'Failed to fetch brand analytics' }); + } +}); + +/** + * GET /api/analytics/clicks/products + * Get top products by click engagement + * + * Query params: + * - state: Filter by store state (e.g., 'AZ') + * - store_id: Filter by specific store + * - brand_id: Filter by specific brand + * - days: Lookback window (default 30) + * - limit: Max results (default 25) + */ +router.get('/products', async (req: Request, res: Response) => { + try { + const { + state, + store_id, + brand_id, + days = '30', + limit = '25' + } = req.query; + + const daysNum = parseInt(days as string, 10) || 30; + const limitNum = Math.min(parseInt(limit as string, 10) || 25, 100); + + // Build conditions and params + const conditions: string[] = [ + 'e.product_id IS NOT NULL', + `e.occurred_at >= NOW() - INTERVAL '${daysNum} days'` + ]; + const params: any[] = []; + let paramIdx = 1; + + if (state) { + conditions.push(`d.state = $${paramIdx++}`); + params.push(state); + } + + if (store_id) { + conditions.push(`e.store_id = $${paramIdx++}`); + params.push(store_id); + } + + if (brand_id) { + conditions.push(`e.brand_id = $${paramIdx++}`); + params.push(brand_id); + } + + const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : ''; + + // Query for product engagement with product details from dutchie_products + const result = await pool.query(` + SELECT + e.product_id, + e.brand_id, + COUNT(*) as clicks, + COUNT(DISTINCT e.store_id) as unique_stores, + MIN(e.occurred_at) as first_click_at, + MAX(e.occurred_at) as last_click_at + FROM product_click_events e + LEFT JOIN dispensaries d ON e.store_id::int = d.id + ${whereClause} + GROUP BY e.product_id, e.brand_id + ORDER BY clicks DESC + LIMIT ${limitNum} + `, params); + + // Try to get product details from dutchie_products + const productIds = result.rows.map(r => r.product_id).filter(Boolean); + let productDetailsMap: Record = {}; + + if (productIds.length > 0) { + // Try to match by external_id or id + const productDetailsResult = await pool.query(` + SELECT + external_id, + id::text as product_id, + name, + brand_name, + type, + subcategory + FROM dutchie_products + WHERE external_id = ANY($1) OR id::text = ANY($1) + `, [productIds]); + + productDetailsResult.rows.forEach(r => { + productDetailsMap[r.external_id] = { + name: r.name, + brand: r.brand_name, + type: r.type, + subcategory: r.subcategory + }; + productDetailsMap[r.product_id] = { + name: r.name, + brand: r.brand_name, + type: r.type, + subcategory: r.subcategory + }; + }); + } + + const products = result.rows.map(row => { + const details = productDetailsMap[row.product_id]; + return { + product_id: row.product_id, + product_name: details?.name || `Product ${row.product_id}`, + brand_id: row.brand_id, + brand_name: details?.brand || row.brand_id || 'Unknown', + category: details?.type || null, + subcategory: details?.subcategory || null, + clicks: parseInt(row.clicks, 10), + unique_stores: parseInt(row.unique_stores, 10), + first_click_at: row.first_click_at, + last_click_at: row.last_click_at + }; + }); + + res.json({ + filters: { + state: state || null, + store_id: store_id || null, + brand_id: brand_id || null, + days: daysNum + }, + products + }); + } catch (error: any) { + console.error('[ClickAnalytics] Error fetching product analytics:', error.message); + res.status(500).json({ error: 'Failed to fetch product analytics' }); + } +}); + +/** + * GET /api/analytics/clicks/campaigns + * Get top campaigns/specials by click engagement + * + * Query params: + * - state: Filter by store state + * - store_id: Filter by specific store + * - days: Lookback window (default 30) + * - limit: Max results (default 25) + */ +router.get('/campaigns', async (req: Request, res: Response) => { + try { + const { + state, + store_id, + days = '30', + limit = '25' + } = req.query; + + const daysNum = parseInt(days as string, 10) || 30; + const limitNum = Math.min(parseInt(limit as string, 10) || 25, 100); + + // Build conditions + const conditions: string[] = [ + 'e.campaign_id IS NOT NULL', + `e.occurred_at >= NOW() - INTERVAL '${daysNum} days'` + ]; + const params: any[] = []; + let paramIdx = 1; + + if (state) { + conditions.push(`d.state = $${paramIdx++}`); + params.push(state); + } + + if (store_id) { + conditions.push(`e.store_id = $${paramIdx++}`); + params.push(store_id); + } + + const whereClause = `WHERE ${conditions.join(' AND ')}`; + + // Query for campaign engagement with campaign details + const result = await pool.query(` + SELECT + e.campaign_id, + c.name as campaign_name, + c.slug as campaign_slug, + c.description as campaign_description, + c.active as is_active, + c.start_date, + c.end_date, + COUNT(*) as clicks, + COUNT(DISTINCT e.product_id) as unique_products, + COUNT(DISTINCT e.store_id) as unique_stores, + MIN(e.occurred_at) as first_event_at, + MAX(e.occurred_at) as last_event_at + FROM product_click_events e + LEFT JOIN dispensaries d ON e.store_id::int = d.id + LEFT JOIN campaigns c ON e.campaign_id = c.id + ${whereClause} + GROUP BY e.campaign_id, c.name, c.slug, c.description, c.active, c.start_date, c.end_date + ORDER BY clicks DESC + LIMIT ${limitNum} + `, params); + + const campaigns = result.rows.map(row => ({ + campaign_id: row.campaign_id, + campaign_name: row.campaign_name || `Campaign ${row.campaign_id}`, + campaign_slug: row.campaign_slug, + campaign_description: row.campaign_description, + is_active: row.is_active, + start_date: row.start_date, + end_date: row.end_date, + clicks: parseInt(row.clicks, 10), + unique_products: parseInt(row.unique_products, 10), + unique_stores: parseInt(row.unique_stores, 10), + first_event_at: row.first_event_at, + last_event_at: row.last_event_at + })); + + res.json({ + filters: { + state: state || null, + store_id: store_id || null, + days: daysNum + }, + campaigns + }); + } catch (error: any) { + console.error('[ClickAnalytics] Error fetching campaign analytics:', error.message); + res.status(500).json({ error: 'Failed to fetch campaign analytics' }); + } +}); + +/** + * GET /api/analytics/clicks/stores/:storeId/brands + * Get brand engagement for a specific store + * + * Query params: + * - days: Lookback window (default 30) + * - limit: Max results (default 25) + */ +router.get('/stores/:storeId/brands', async (req: Request, res: Response) => { + try { + const { storeId } = req.params; + const { days = '30', limit = '25' } = req.query; + + const daysNum = parseInt(days as string, 10) || 30; + const limitNum = Math.min(parseInt(limit as string, 10) || 25, 100); + + // Get store info + const storeResult = await pool.query( + 'SELECT id, name, dba_name, city, state FROM dispensaries WHERE id = $1', + [storeId] + ); + + if (storeResult.rows.length === 0) { + return res.status(404).json({ error: 'Store not found' }); + } + + const store = storeResult.rows[0]; + + // Query brand engagement for this store + const result = await pool.query(` + SELECT + e.brand_id, + COUNT(*) as clicks, + COUNT(DISTINCT e.product_id) as unique_products, + MIN(e.occurred_at) as first_click_at, + MAX(e.occurred_at) as last_click_at + FROM product_click_events e + WHERE e.store_id = $1 + AND e.brand_id IS NOT NULL + AND e.occurred_at >= NOW() - INTERVAL '${daysNum} days' + GROUP BY e.brand_id + ORDER BY clicks DESC + LIMIT ${limitNum} + `, [storeId]); + + const brands = result.rows.map(row => ({ + brand_id: row.brand_id, + brand_name: row.brand_id, // Use brand_id as name for now + clicks: parseInt(row.clicks, 10), + unique_products: parseInt(row.unique_products, 10), + first_click_at: row.first_click_at, + last_click_at: row.last_click_at + })); + + res.json({ + store: { + id: store.id, + name: store.dba_name || store.name, + city: store.city, + state: store.state + }, + filters: { + days: daysNum + }, + brands + }); + } catch (error: any) { + console.error('[ClickAnalytics] Error fetching store brand analytics:', error.message); + res.status(500).json({ error: 'Failed to fetch store brand analytics' }); + } +}); + +/** + * GET /api/analytics/clicks/summary + * Get overall click summary stats + * + * Query params: + * - state: Filter by store state + * - days: Lookback window (default 30) + */ +router.get('/summary', async (req: Request, res: Response) => { + try { + const { state, days = '30' } = req.query; + const daysNum = parseInt(days as string, 10) || 30; + + const conditions: string[] = [`e.occurred_at >= NOW() - INTERVAL '${daysNum} days'`]; + const params: any[] = []; + let paramIdx = 1; + + if (state) { + conditions.push(`d.state = $${paramIdx++}`); + params.push(state); + } + + const whereClause = `WHERE ${conditions.join(' AND ')}`; + + // Get overall stats + const statsResult = await pool.query(` + SELECT + COUNT(*) as total_clicks, + COUNT(DISTINCT e.product_id) as unique_products, + COUNT(DISTINCT e.store_id) as unique_stores, + COUNT(DISTINCT e.brand_id) FILTER (WHERE e.brand_id IS NOT NULL) as unique_brands, + COUNT(*) FILTER (WHERE e.campaign_id IS NOT NULL) as campaign_clicks, + COUNT(DISTINCT e.campaign_id) FILTER (WHERE e.campaign_id IS NOT NULL) as unique_campaigns + FROM product_click_events e + LEFT JOIN dispensaries d ON e.store_id::int = d.id + ${whereClause} + `, params); + + // Get clicks by action + const actionResult = await pool.query(` + SELECT + action, + COUNT(*) as count + FROM product_click_events e + LEFT JOIN dispensaries d ON e.store_id::int = d.id + ${whereClause} + GROUP BY action + ORDER BY count DESC + `, params); + + // Get clicks by day (last 14 days for chart) + const dailyResult = await pool.query(` + SELECT + DATE(occurred_at) as date, + COUNT(*) as clicks + FROM product_click_events e + LEFT JOIN dispensaries d ON e.store_id::int = d.id + ${whereClause} + GROUP BY DATE(occurred_at) + ORDER BY date DESC + LIMIT 14 + `, params); + + const stats = statsResult.rows[0]; + + res.json({ + filters: { + state: state || null, + days: daysNum + }, + summary: { + total_clicks: parseInt(stats.total_clicks, 10), + unique_products: parseInt(stats.unique_products, 10), + unique_stores: parseInt(stats.unique_stores, 10), + unique_brands: parseInt(stats.unique_brands, 10), + campaign_clicks: parseInt(stats.campaign_clicks, 10), + unique_campaigns: parseInt(stats.unique_campaigns, 10) + }, + by_action: actionResult.rows.map(row => ({ + action: row.action, + count: parseInt(row.count, 10) + })), + daily: dailyResult.rows.map(row => ({ + date: row.date, + clicks: parseInt(row.clicks, 10) + })).reverse() + }); + } catch (error: any) { + console.error('[ClickAnalytics] Error fetching click summary:', error.message); + res.status(500).json({ error: 'Failed to fetch click summary' }); + } +}); + +export default router; diff --git a/backend/src/routes/consumer-alerts.ts b/backend/src/routes/consumer-alerts.ts new file mode 100644 index 00000000..9c38aced --- /dev/null +++ b/backend/src/routes/consumer-alerts.ts @@ -0,0 +1,378 @@ +/** + * Consumer Alerts API Routes + * Handles price alerts for findagram.co and deal alerts for findadispo.com + */ + +import { Router, Request, Response } from 'express'; +import { pool } from '../db/pool'; +import { authenticateConsumer } from './consumer-auth'; + +const router = Router(); + +// All routes require authentication +router.use(authenticateConsumer); + +/** + * GET /api/consumer/alerts + * Get user's alerts + */ +router.get('/', async (req: Request, res: Response) => { + try { + const userId = (req as any).userId; + const domain = (req as any).domain; + + if (domain === 'findagram.co') { + const result = await pool.query( + `SELECT a.*, + p.name as product_name, + p.brand as product_brand, + p.image_url as product_image, + d.name as dispensary_name, + d.dba_name, + d.city as dispensary_city, + d.state as dispensary_state, + ls.price as current_price, + ls.stock_status as current_stock_status + FROM findagram_alerts a + LEFT JOIN store_products p ON a.product_id = p.id + LEFT JOIN dispensaries d ON a.dispensary_id = d.id + LEFT JOIN LATERAL ( + SELECT price, stock_status FROM store_product_snapshots + WHERE product_id = a.product_id + ORDER BY crawled_at DESC LIMIT 1 + ) ls ON true + WHERE a.user_id = $1 + ORDER BY a.created_at DESC`, + [userId] + ); + + res.json({ + alerts: result.rows.map(row => ({ + id: row.id, + alertType: row.alert_type, + productId: row.product_id, + productName: row.product_name, + productBrand: row.product_brand, + productImage: row.product_image, + dispensaryId: row.dispensary_id, + dispensaryName: row.dba_name || row.dispensary_name, + dispensaryCity: row.dispensary_city, + dispensaryState: row.dispensary_state, + brand: row.brand, + category: row.category, + targetPrice: row.target_price, + currentPrice: row.current_price, + currentStockStatus: row.current_stock_status, + isActive: row.is_active, + lastTriggeredAt: row.last_triggered_at, + triggerCount: row.trigger_count, + // Computed: is alert condition met? + isTriggered: row.alert_type === 'price_drop' && row.current_price && row.target_price && + parseFloat(row.current_price) <= parseFloat(row.target_price), + createdAt: row.created_at + })) + }); + } else if (domain === 'findadispo.com') { + const result = await pool.query( + `SELECT a.*, + d.name as dispensary_name, + d.dba_name, + d.city as dispensary_city, + d.state as dispensary_state + FROM findadispo_alerts a + LEFT JOIN dispensaries d ON a.dispensary_id = d.id + WHERE a.user_id = $1 + ORDER BY a.created_at DESC`, + [userId] + ); + + res.json({ + alerts: result.rows.map(row => ({ + id: row.id, + alertType: row.alert_type, + dispensaryId: row.dispensary_id, + dispensaryName: row.dba_name || row.dispensary_name, + dispensaryCity: row.dispensary_city || row.city, + dispensaryState: row.dispensary_state || row.state, + city: row.city, + state: row.state, + isActive: row.is_active, + lastTriggeredAt: row.last_triggered_at, + triggerCount: row.trigger_count, + createdAt: row.created_at + })) + }); + } else { + res.status(400).json({ error: 'Invalid domain' }); + } + } catch (error) { + console.error('[Consumer Alerts] Get error:', error); + res.status(500).json({ error: 'Failed to get alerts' }); + } +}); + +/** + * POST /api/consumer/alerts + * Create a new alert + */ +router.post('/', async (req: Request, res: Response) => { + try { + const userId = (req as any).userId; + const domain = (req as any).domain; + + if (domain === 'findagram.co') { + const { alertType, productId, dispensaryId, brand, category, targetPrice } = req.body; + + // Validate alert type + const validTypes = ['price_drop', 'back_in_stock', 'product_on_special']; + if (!validTypes.includes(alertType)) { + return res.status(400).json({ + error: 'Invalid alert type', + validTypes + }); + } + + // At least one target must be specified + if (!productId && !brand && !category) { + return res.status(400).json({ + error: 'Must specify at least one of: productId, brand, or category' + }); + } + + // Price drop alerts require target price + if (alertType === 'price_drop' && !targetPrice) { + return res.status(400).json({ + error: 'targetPrice required for price_drop alerts' + }); + } + + const result = await pool.query( + `INSERT INTO findagram_alerts + (user_id, alert_type, product_id, dispensary_id, brand, category, target_price, is_active) + VALUES ($1, $2, $3, $4, $5, $6, $7, true) + RETURNING id`, + [userId, alertType, productId || null, dispensaryId || null, brand || null, category || null, targetPrice || null] + ); + + res.status(201).json({ + success: true, + alertId: result.rows[0].id, + message: 'Alert created' + }); + + } else if (domain === 'findadispo.com') { + const { alertType, dispensaryId, city, state } = req.body; + + const validTypes = ['new_dispensary', 'deal_available']; + if (!validTypes.includes(alertType)) { + return res.status(400).json({ + error: 'Invalid alert type', + validTypes + }); + } + + // Location alerts require city/state, dispensary alerts require dispensaryId + if (alertType === 'new_dispensary' && !city && !state) { + return res.status(400).json({ + error: 'city or state required for new_dispensary alerts' + }); + } + + if (alertType === 'deal_available' && !dispensaryId && !city && !state) { + return res.status(400).json({ + error: 'dispensaryId, city, or state required for deal_available alerts' + }); + } + + const result = await pool.query( + `INSERT INTO findadispo_alerts + (user_id, alert_type, dispensary_id, city, state, is_active) + VALUES ($1, $2, $3, $4, $5, true) + RETURNING id`, + [userId, alertType, dispensaryId || null, city || null, state || null] + ); + + res.status(201).json({ + success: true, + alertId: result.rows[0].id, + message: 'Alert created' + }); + + } else { + res.status(400).json({ error: 'Invalid domain' }); + } + } catch (error) { + console.error('[Consumer Alerts] Create error:', error); + res.status(500).json({ error: 'Failed to create alert' }); + } +}); + +/** + * PUT /api/consumer/alerts/:id + * Update an alert + */ +router.put('/:id', async (req: Request, res: Response) => { + try { + const userId = (req as any).userId; + const domain = (req as any).domain; + const alertId = parseInt(req.params.id); + + if (isNaN(alertId)) { + return res.status(400).json({ error: 'Invalid alert ID' }); + } + + const { isActive, targetPrice } = req.body; + + const table = domain === 'findagram.co' ? 'findagram_alerts' : 'findadispo_alerts'; + + // Build update query dynamically + const updates: string[] = []; + const values: any[] = []; + let paramIndex = 1; + + if (typeof isActive === 'boolean') { + updates.push(`is_active = $${paramIndex++}`); + values.push(isActive); + } + + if (domain === 'findagram.co' && targetPrice !== undefined) { + updates.push(`target_price = $${paramIndex++}`); + values.push(targetPrice); + } + + updates.push(`updated_at = NOW()`); + + if (updates.length === 1) { + return res.status(400).json({ error: 'No valid fields to update' }); + } + + values.push(alertId, userId); + + const result = await pool.query( + `UPDATE ${table} SET ${updates.join(', ')} + WHERE id = $${paramIndex++} AND user_id = $${paramIndex} + RETURNING id`, + values + ); + + if (result.rows.length === 0) { + return res.status(404).json({ error: 'Alert not found' }); + } + + res.json({ success: true, message: 'Alert updated' }); + } catch (error) { + console.error('[Consumer Alerts] Update error:', error); + res.status(500).json({ error: 'Failed to update alert' }); + } +}); + +/** + * DELETE /api/consumer/alerts/:id + * Delete an alert + */ +router.delete('/:id', async (req: Request, res: Response) => { + try { + const userId = (req as any).userId; + const domain = (req as any).domain; + const alertId = parseInt(req.params.id); + + if (isNaN(alertId)) { + return res.status(400).json({ error: 'Invalid alert ID' }); + } + + const table = domain === 'findagram.co' ? 'findagram_alerts' : 'findadispo_alerts'; + + const result = await pool.query( + `DELETE FROM ${table} WHERE id = $1 AND user_id = $2 RETURNING id`, + [alertId, userId] + ); + + if (result.rows.length === 0) { + return res.status(404).json({ error: 'Alert not found' }); + } + + res.json({ success: true, message: 'Alert deleted' }); + } catch (error) { + console.error('[Consumer Alerts] Delete error:', error); + res.status(500).json({ error: 'Failed to delete alert' }); + } +}); + +/** + * POST /api/consumer/alerts/:id/toggle + * Toggle alert active status + */ +router.post('/:id/toggle', async (req: Request, res: Response) => { + try { + const userId = (req as any).userId; + const domain = (req as any).domain; + const alertId = parseInt(req.params.id); + + if (isNaN(alertId)) { + return res.status(400).json({ error: 'Invalid alert ID' }); + } + + const table = domain === 'findagram.co' ? 'findagram_alerts' : 'findadispo_alerts'; + + const result = await pool.query( + `UPDATE ${table} + SET is_active = NOT is_active, updated_at = NOW() + WHERE id = $1 AND user_id = $2 + RETURNING id, is_active`, + [alertId, userId] + ); + + if (result.rows.length === 0) { + return res.status(404).json({ error: 'Alert not found' }); + } + + res.json({ + success: true, + isActive: result.rows[0].is_active, + message: result.rows[0].is_active ? 'Alert activated' : 'Alert deactivated' + }); + } catch (error) { + console.error('[Consumer Alerts] Toggle error:', error); + res.status(500).json({ error: 'Failed to toggle alert' }); + } +}); + +/** + * GET /api/consumer/alerts/stats + * Get alert statistics for user + */ +router.get('/stats', async (req: Request, res: Response) => { + try { + const userId = (req as any).userId; + const domain = (req as any).domain; + + const table = domain === 'findagram.co' ? 'findagram_alerts' : 'findadispo_alerts'; + + const result = await pool.query( + `SELECT + COUNT(*) as total, + COUNT(*) FILTER (WHERE is_active = true) as active, + COUNT(*) FILTER (WHERE is_active = false) as inactive, + SUM(trigger_count) as total_triggers, + COUNT(*) FILTER (WHERE last_triggered_at > NOW() - INTERVAL '7 days') as triggered_this_week + FROM ${table} + WHERE user_id = $1`, + [userId] + ); + + const stats = result.rows[0]; + + res.json({ + total: parseInt(stats.total) || 0, + active: parseInt(stats.active) || 0, + inactive: parseInt(stats.inactive) || 0, + totalTriggers: parseInt(stats.total_triggers) || 0, + triggeredThisWeek: parseInt(stats.triggered_this_week) || 0 + }); + } catch (error) { + console.error('[Consumer Alerts] Stats error:', error); + res.status(500).json({ error: 'Failed to get alert stats' }); + } +}); + +export default router; diff --git a/backend/src/routes/consumer-auth.ts b/backend/src/routes/consumer-auth.ts new file mode 100644 index 00000000..f40f62f3 --- /dev/null +++ b/backend/src/routes/consumer-auth.ts @@ -0,0 +1,531 @@ +/** + * Consumer Authentication Routes + * Handles registration, login, and verification for findadispo.com and findagram.co users + */ + +import { Router, Request, Response } from 'express'; +import { pool } from '../db/pool'; +import bcrypt from 'bcryptjs'; +import jwt from 'jsonwebtoken'; +import crypto from 'crypto'; + +const router = Router(); + +// JWT secret - should be in env vars +const JWT_SECRET = process.env.JWT_SECRET || 'consumer-jwt-secret-change-in-production'; +const JWT_EXPIRES_IN = '30d'; + +// SMS API configuration (sms.cannabrands.com) +const SMS_API_URL = process.env.SMS_API_URL || 'https://sms.cannabrands.com/api'; +const SMS_API_KEY = process.env.SMS_API_KEY || ''; + +interface RegisterRequest { + firstName: string; + lastName: string; + email: string; + password: string; + phone?: string; + city?: string; + state?: string; + notificationPreference?: 'email' | 'sms' | 'both'; + domain: 'findadispo.com' | 'findagram.co'; +} + +interface LoginRequest { + email: string; + password: string; + domain: 'findadispo.com' | 'findagram.co'; +} + +/** + * POST /api/consumer/auth/register + * Register a new consumer user + */ +router.post('/register', async (req: Request, res: Response) => { + try { + const { + firstName, + lastName, + email, + password, + phone, + city, + state, + notificationPreference = 'email', + domain + } = req.body as RegisterRequest; + + // Validation + if (!firstName || !lastName || !email || !password || !domain) { + return res.status(400).json({ + error: 'Missing required fields', + required: ['firstName', 'lastName', 'email', 'password', 'domain'] + }); + } + + if (!['findadispo.com', 'findagram.co'].includes(domain)) { + return res.status(400).json({ error: 'Invalid domain' }); + } + + // Check if email already exists for this domain + const existingUser = await pool.query( + 'SELECT id FROM users WHERE email = $1 AND domain = $2', + [email.toLowerCase(), domain] + ); + + if (existingUser.rows.length > 0) { + return res.status(409).json({ error: 'Email already registered' }); + } + + // Hash password + const passwordHash = await bcrypt.hash(password, 12); + + // Generate email verification token + const emailVerificationToken = crypto.randomBytes(32).toString('hex'); + + // Insert user + const userResult = await pool.query( + `INSERT INTO users ( + email, password_hash, first_name, last_name, phone, city, state, + domain, role, notification_preference, + email_verification_token, email_verification_sent_at, + created_at, updated_at + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, NOW(), NOW(), NOW()) + RETURNING id, email, first_name, last_name, phone, city, state, domain, notification_preference`, + [ + email.toLowerCase(), + passwordHash, + firstName, + lastName, + phone || null, + city || null, + state || null, + domain, + 'consumer', + notificationPreference, + emailVerificationToken + ] + ); + + const user = userResult.rows[0]; + + // Create domain-specific profile + if (domain === 'findagram.co') { + await pool.query( + `INSERT INTO findagram_users (user_id, preferred_city, preferred_state, created_at) + VALUES ($1, $2, $3, NOW())`, + [user.id, city || null, state || null] + ); + } else if (domain === 'findadispo.com') { + await pool.query( + `INSERT INTO findadispo_users (user_id, preferred_city, preferred_state, created_at) + VALUES ($1, $2, $3, NOW())`, + [user.id, city || null, state || null] + ); + } + + // Generate JWT token + const token = jwt.sign( + { userId: user.id, email: user.email, domain }, + JWT_SECRET, + { expiresIn: JWT_EXPIRES_IN } + ); + + // TODO: Send email verification (integrate with SMTP service) + // For now, just log it + console.log(`[Consumer Auth] Verification email would be sent to ${email} with token ${emailVerificationToken}`); + + // If phone provided, send SMS verification code + if (phone) { + const verificationCode = Math.floor(100000 + Math.random() * 900000).toString(); + await pool.query( + 'UPDATE users SET phone_verification_code = $1, phone_verification_sent_at = NOW() WHERE id = $2', + [verificationCode, user.id] + ); + + // TODO: Send SMS via sms.cannabrands.com + console.log(`[Consumer Auth] SMS verification code ${verificationCode} would be sent to ${phone}`); + } + + res.status(201).json({ + success: true, + user: { + id: user.id, + email: user.email, + firstName: user.first_name, + lastName: user.last_name, + phone: user.phone, + city: user.city, + state: user.state, + domain: user.domain, + notificationPreference: user.notification_preference, + emailVerified: false, + phoneVerified: false + }, + token, + message: 'Registration successful. Please verify your email.' + }); + } catch (error) { + console.error('[Consumer Auth] Registration error:', error); + res.status(500).json({ error: 'Registration failed' }); + } +}); + +/** + * POST /api/consumer/auth/login + * Login a consumer user + */ +router.post('/login', async (req: Request, res: Response) => { + try { + const { email, password, domain } = req.body as LoginRequest; + + if (!email || !password || !domain) { + return res.status(400).json({ + error: 'Missing required fields', + required: ['email', 'password', 'domain'] + }); + } + + // Find user + const userResult = await pool.query( + `SELECT id, email, password_hash, first_name, last_name, phone, city, state, + domain, notification_preference, email_verified, phone_verified + FROM users + WHERE email = $1 AND domain = $2 AND role = 'consumer'`, + [email.toLowerCase(), domain] + ); + + if (userResult.rows.length === 0) { + return res.status(401).json({ error: 'Invalid email or password' }); + } + + const user = userResult.rows[0]; + + // Verify password + const isValidPassword = await bcrypt.compare(password, user.password_hash); + if (!isValidPassword) { + return res.status(401).json({ error: 'Invalid email or password' }); + } + + // Generate JWT token + const token = jwt.sign( + { userId: user.id, email: user.email, domain }, + JWT_SECRET, + { expiresIn: JWT_EXPIRES_IN } + ); + + res.json({ + success: true, + user: { + id: user.id, + email: user.email, + firstName: user.first_name, + lastName: user.last_name, + phone: user.phone, + city: user.city, + state: user.state, + domain: user.domain, + notificationPreference: user.notification_preference, + emailVerified: user.email_verified, + phoneVerified: user.phone_verified + }, + token + }); + } catch (error) { + console.error('[Consumer Auth] Login error:', error); + res.status(500).json({ error: 'Login failed' }); + } +}); + +/** + * POST /api/consumer/auth/verify-email + * Verify email with token + */ +router.post('/verify-email', async (req: Request, res: Response) => { + try { + const { token } = req.body; + + if (!token) { + return res.status(400).json({ error: 'Verification token required' }); + } + + const result = await pool.query( + `UPDATE users + SET email_verified = true, + email_verification_token = NULL, + updated_at = NOW() + WHERE email_verification_token = $1 + RETURNING id, email, domain`, + [token] + ); + + if (result.rows.length === 0) { + return res.status(400).json({ error: 'Invalid or expired verification token' }); + } + + res.json({ + success: true, + message: 'Email verified successfully' + }); + } catch (error) { + console.error('[Consumer Auth] Email verification error:', error); + res.status(500).json({ error: 'Verification failed' }); + } +}); + +/** + * POST /api/consumer/auth/verify-phone + * Verify phone with SMS code + */ +router.post('/verify-phone', async (req: Request, res: Response) => { + try { + const { email, code, domain } = req.body; + + if (!email || !code || !domain) { + return res.status(400).json({ error: 'Email, code, and domain required' }); + } + + // Check if code is valid and not expired (15 minute window) + const result = await pool.query( + `UPDATE users + SET phone_verified = true, + phone_verification_code = NULL, + updated_at = NOW() + WHERE email = $1 + AND domain = $2 + AND phone_verification_code = $3 + AND phone_verification_sent_at > NOW() - INTERVAL '15 minutes' + RETURNING id, email`, + [email.toLowerCase(), domain, code] + ); + + if (result.rows.length === 0) { + return res.status(400).json({ error: 'Invalid or expired verification code' }); + } + + res.json({ + success: true, + message: 'Phone verified successfully' + }); + } catch (error) { + console.error('[Consumer Auth] Phone verification error:', error); + res.status(500).json({ error: 'Verification failed' }); + } +}); + +/** + * POST /api/consumer/auth/resend-email-verification + * Resend email verification + */ +router.post('/resend-email-verification', async (req: Request, res: Response) => { + try { + const { email, domain } = req.body; + + if (!email || !domain) { + return res.status(400).json({ error: 'Email and domain required' }); + } + + const newToken = crypto.randomBytes(32).toString('hex'); + + const result = await pool.query( + `UPDATE users + SET email_verification_token = $1, + email_verification_sent_at = NOW() + WHERE email = $2 AND domain = $3 AND email_verified = false + RETURNING id, email`, + [newToken, email.toLowerCase(), domain] + ); + + if (result.rows.length === 0) { + return res.status(400).json({ error: 'User not found or already verified' }); + } + + // TODO: Send email + console.log(`[Consumer Auth] Resending verification email to ${email} with token ${newToken}`); + + res.json({ + success: true, + message: 'Verification email sent' + }); + } catch (error) { + console.error('[Consumer Auth] Resend verification error:', error); + res.status(500).json({ error: 'Failed to resend verification' }); + } +}); + +/** + * POST /api/consumer/auth/resend-phone-verification + * Resend SMS verification code + */ +router.post('/resend-phone-verification', async (req: Request, res: Response) => { + try { + const { email, domain } = req.body; + + if (!email || !domain) { + return res.status(400).json({ error: 'Email and domain required' }); + } + + const verificationCode = Math.floor(100000 + Math.random() * 900000).toString(); + + const result = await pool.query( + `UPDATE users + SET phone_verification_code = $1, + phone_verification_sent_at = NOW() + WHERE email = $2 AND domain = $3 AND phone IS NOT NULL AND phone_verified = false + RETURNING id, phone`, + [verificationCode, email.toLowerCase(), domain] + ); + + if (result.rows.length === 0) { + return res.status(400).json({ error: 'User not found, no phone, or already verified' }); + } + + // TODO: Send SMS via sms.cannabrands.com + console.log(`[Consumer Auth] Resending SMS code ${verificationCode} to ${result.rows[0].phone}`); + + res.json({ + success: true, + message: 'Verification code sent' + }); + } catch (error) { + console.error('[Consumer Auth] Resend phone verification error:', error); + res.status(500).json({ error: 'Failed to resend verification' }); + } +}); + +/** + * GET /api/consumer/auth/me + * Get current user profile (requires auth) + */ +router.get('/me', authenticateConsumer, async (req: Request, res: Response) => { + try { + const userId = (req as any).userId; + const domain = (req as any).domain; + + const userResult = await pool.query( + `SELECT id, email, first_name, last_name, phone, city, state, + domain, notification_preference, email_verified, phone_verified + FROM users WHERE id = $1`, + [userId] + ); + + if (userResult.rows.length === 0) { + return res.status(404).json({ error: 'User not found' }); + } + + const user = userResult.rows[0]; + + // Get domain-specific profile + let profile = null; + if (domain === 'findagram.co') { + const profileResult = await pool.query( + 'SELECT * FROM findagram_users WHERE user_id = $1', + [userId] + ); + profile = profileResult.rows[0] || null; + } else if (domain === 'findadispo.com') { + const profileResult = await pool.query( + 'SELECT * FROM findadispo_users WHERE user_id = $1', + [userId] + ); + profile = profileResult.rows[0] || null; + } + + res.json({ + user: { + id: user.id, + email: user.email, + firstName: user.first_name, + lastName: user.last_name, + phone: user.phone, + city: user.city, + state: user.state, + domain: user.domain, + notificationPreference: user.notification_preference, + emailVerified: user.email_verified, + phoneVerified: user.phone_verified + }, + profile + }); + } catch (error) { + console.error('[Consumer Auth] Get profile error:', error); + res.status(500).json({ error: 'Failed to get profile' }); + } +}); + +/** + * PUT /api/consumer/auth/me + * Update current user profile + */ +router.put('/me', authenticateConsumer, async (req: Request, res: Response) => { + try { + const userId = (req as any).userId; + const domain = (req as any).domain; + const { firstName, lastName, phone, city, state, notificationPreference } = req.body; + + // Update user table + await pool.query( + `UPDATE users SET + first_name = COALESCE($1, first_name), + last_name = COALESCE($2, last_name), + phone = COALESCE($3, phone), + city = COALESCE($4, city), + state = COALESCE($5, state), + notification_preference = COALESCE($6, notification_preference), + updated_at = NOW() + WHERE id = $7`, + [firstName, lastName, phone, city, state, notificationPreference, userId] + ); + + // Update domain-specific profile + if (domain === 'findagram.co') { + await pool.query( + `UPDATE findagram_users SET + preferred_city = COALESCE($1, preferred_city), + preferred_state = COALESCE($2, preferred_state), + updated_at = NOW() + WHERE user_id = $3`, + [city, state, userId] + ); + } else if (domain === 'findadispo.com') { + await pool.query( + `UPDATE findadispo_users SET + preferred_city = COALESCE($1, preferred_city), + preferred_state = COALESCE($2, preferred_state), + updated_at = NOW() + WHERE user_id = $3`, + [city, state, userId] + ); + } + + res.json({ success: true, message: 'Profile updated' }); + } catch (error) { + console.error('[Consumer Auth] Update profile error:', error); + res.status(500).json({ error: 'Failed to update profile' }); + } +}); + +/** + * Middleware to authenticate consumer requests + */ +export function authenticateConsumer(req: Request, res: Response, next: Function) { + const authHeader = req.headers.authorization; + + if (!authHeader || !authHeader.startsWith('Bearer ')) { + return res.status(401).json({ error: 'Authorization required' }); + } + + const token = authHeader.substring(7); + + try { + const decoded = jwt.verify(token, JWT_SECRET) as { userId: number; email: string; domain: string }; + (req as any).userId = decoded.userId; + (req as any).email = decoded.email; + (req as any).domain = decoded.domain; + next(); + } catch (error) { + return res.status(401).json({ error: 'Invalid or expired token' }); + } +} + +export default router; diff --git a/backend/src/routes/consumer-deals.ts b/backend/src/routes/consumer-deals.ts new file mode 100644 index 00000000..c26eded3 --- /dev/null +++ b/backend/src/routes/consumer-deals.ts @@ -0,0 +1,409 @@ +/** + * Consumer Deals API Routes + * Provides pre-calculated deals and specials for findagram.co and findadispo.com + */ + +import { Router, Request, Response } from 'express'; +import { pool } from '../db/pool'; + +const router = Router(); + +/** + * GET /api/v1/deals + * Get products currently on special with calculated savings + * + * Query params: + * - state: Filter by state (default: AZ) + * - city: Filter by city + * - dispensaryId: Filter by specific dispensary + * - category: Filter by product category + * - brand: Filter by brand + * - minSavings: Minimum savings percentage (e.g., 20 for 20%) + * - limit: Results per page (default: 50) + * - offset: Pagination offset + * - sortBy: 'savings_percent' | 'savings_amount' | 'price' | 'newest' (default: savings_percent) + */ +router.get('/', async (req: Request, res: Response) => { + try { + const { + state = 'AZ', + city, + dispensaryId, + category, + brand, + minSavings, + limit = '50', + offset = '0', + sortBy = 'savings_percent' + } = req.query; + + const params: any[] = []; + let paramIndex = 1; + const conditions: string[] = [ + 'sp.is_on_special = TRUE', + 'sp.is_in_stock = TRUE' + ]; + + // State filter + if (state) { + conditions.push(`d.state = $${paramIndex}`); + params.push(state); + paramIndex++; + } + + // City filter + if (city) { + conditions.push(`d.city = $${paramIndex}`); + params.push(city); + paramIndex++; + } + + // Dispensary filter + if (dispensaryId) { + conditions.push(`sp.dispensary_id = $${paramIndex}`); + params.push(parseInt(dispensaryId as string)); + paramIndex++; + } + + // Category filter + if (category) { + conditions.push(`sp.category_raw ILIKE $${paramIndex}`); + params.push(`%${category}%`); + paramIndex++; + } + + // Brand filter + if (brand) { + conditions.push(`sp.brand_name_raw ILIKE $${paramIndex}`); + params.push(`%${brand}%`); + paramIndex++; + } + + // Min savings filter + if (minSavings) { + conditions.push(`sp.discount_percent >= $${paramIndex}`); + params.push(parseFloat(minSavings as string)); + paramIndex++; + } + + // Build ORDER BY clause + let orderBy = 'sp.discount_percent DESC NULLS LAST'; + switch (sortBy) { + case 'savings_amount': + orderBy = 'savings_amount DESC NULLS LAST'; + break; + case 'price': + orderBy = 'COALESCE(sp.price_rec_special, sp.price_rec) ASC NULLS LAST'; + break; + case 'newest': + orderBy = 'sp.updated_at DESC'; + break; + case 'savings_percent': + default: + orderBy = 'sp.discount_percent DESC NULLS LAST'; + } + + // Add pagination + params.push(parseInt(limit as string)); + params.push(parseInt(offset as string)); + + const query = ` + SELECT + sp.id, + sp.dispensary_id, + sp.name_raw as product_name, + sp.brand_name_raw as brand, + sp.category_raw as category, + sp.subcategory_raw as subcategory, + -- Pricing + sp.price_rec as original_price, + sp.price_rec_special as sale_price, + sp.price_med as original_price_med, + sp.price_med_special as sale_price_med, + sp.is_on_special, + sp.special_name, + -- Calculated savings + sp.discount_percent as savings_percent, + CASE + WHEN sp.price_rec IS NOT NULL AND sp.price_rec_special IS NOT NULL + THEN sp.price_rec - sp.price_rec_special + ELSE NULL + END as savings_amount, + -- Product info + sp.thc_percent, + sp.cbd_percent, + sp.image_url, + sp.stock_status, + sp.provider_product_id, + -- Dispensary info + d.id as disp_id, + COALESCE(d.dba_name, d.name) as dispensary_name, + d.city as dispensary_city, + d.state as dispensary_state, + d.address as dispensary_address, + d.slug as dispensary_slug, + sp.updated_at as last_updated + FROM store_products sp + INNER JOIN dispensaries d ON sp.dispensary_id = d.id + WHERE ${conditions.join(' AND ')} + ORDER BY ${orderBy} + LIMIT $${paramIndex} OFFSET $${paramIndex + 1} + `; + + const result = await pool.query(query, params); + + // Get total count + const countQuery = ` + SELECT COUNT(*) as total + FROM store_products sp + INNER JOIN dispensaries d ON sp.dispensary_id = d.id + WHERE ${conditions.join(' AND ')} + `; + const countResult = await pool.query(countQuery, params.slice(0, -2)); + const total = parseInt(countResult.rows[0]?.total || '0'); + + res.json({ + deals: result.rows.map(row => ({ + id: row.id, + productName: row.product_name, + brand: row.brand, + category: row.category, + subcategory: row.subcategory, + // Pricing with calculated savings + originalPrice: parseFloat(row.original_price) || null, + salePrice: parseFloat(row.sale_price) || null, + originalPriceMed: parseFloat(row.original_price_med) || null, + salePriceMed: parseFloat(row.sale_price_med) || null, + savingsAmount: parseFloat(row.savings_amount) || null, + savingsPercent: parseFloat(row.savings_percent) || null, + specialName: row.special_name, + // Product details + thcPercent: parseFloat(row.thc_percent) || null, + cbdPercent: parseFloat(row.cbd_percent) || null, + imageUrl: row.image_url, + stockStatus: row.stock_status, + productId: row.provider_product_id, + // Dispensary + dispensary: { + id: row.disp_id, + name: row.dispensary_name, + city: row.dispensary_city, + state: row.dispensary_state, + address: row.dispensary_address, + slug: row.dispensary_slug + }, + lastUpdated: row.last_updated + })), + pagination: { + total, + limit: parseInt(limit as string), + offset: parseInt(offset as string), + hasMore: parseInt(offset as string) + result.rows.length < total + } + }); + } catch (error) { + console.error('[Consumer Deals] Error:', error); + res.status(500).json({ error: 'Failed to get deals' }); + } +}); + +/** + * GET /api/v1/deals/summary + * Get summary statistics about current deals + */ +router.get('/summary', async (req: Request, res: Response) => { + try { + const { state = 'AZ' } = req.query; + + const result = await pool.query(` + SELECT + COUNT(*) as total_deals, + COUNT(DISTINCT sp.dispensary_id) as dispensaries_with_deals, + COUNT(DISTINCT sp.brand_name_raw) as brands_on_sale, + AVG(sp.discount_percent) as avg_discount, + MAX(sp.discount_percent) as max_discount, + MIN(sp.discount_percent) FILTER (WHERE sp.discount_percent > 0) as min_discount, + SUM(CASE WHEN sp.discount_percent >= 20 THEN 1 ELSE 0 END) as deals_over_20_pct, + SUM(CASE WHEN sp.discount_percent >= 30 THEN 1 ELSE 0 END) as deals_over_30_pct + FROM store_products sp + INNER JOIN dispensaries d ON sp.dispensary_id = d.id + WHERE sp.is_on_special = TRUE + AND sp.is_in_stock = TRUE + AND d.state = $1 + `, [state]); + + const stats = result.rows[0] || {}; + + // Get top categories with deals + const categoriesResult = await pool.query(` + SELECT sp.category_raw as category, COUNT(*) as deal_count + FROM store_products sp + INNER JOIN dispensaries d ON sp.dispensary_id = d.id + WHERE sp.is_on_special = TRUE + AND sp.is_in_stock = TRUE + AND d.state = $1 + AND sp.category_raw IS NOT NULL + GROUP BY sp.category_raw + ORDER BY deal_count DESC + LIMIT 5 + `, [state]); + + res.json({ + totalDeals: parseInt(stats.total_deals) || 0, + dispensariesWithDeals: parseInt(stats.dispensaries_with_deals) || 0, + brandsOnSale: parseInt(stats.brands_on_sale) || 0, + avgDiscount: parseFloat(stats.avg_discount)?.toFixed(1) || 0, + maxDiscount: parseFloat(stats.max_discount)?.toFixed(1) || 0, + minDiscount: parseFloat(stats.min_discount)?.toFixed(1) || 0, + dealsOver20Pct: parseInt(stats.deals_over_20_pct) || 0, + dealsOver30Pct: parseInt(stats.deals_over_30_pct) || 0, + topCategories: categoriesResult.rows.map(r => ({ + category: r.category, + dealCount: parseInt(r.deal_count) + })) + }); + } catch (error) { + console.error('[Consumer Deals] Summary error:', error); + res.status(500).json({ error: 'Failed to get deals summary' }); + } +}); + +/** + * GET /api/v1/deals/by-dispensary/:dispensaryId + * Get all deals for a specific dispensary + */ +router.get('/by-dispensary/:dispensaryId', async (req: Request, res: Response) => { + try { + const dispensaryId = parseInt(req.params.dispensaryId); + const { limit = '100', offset = '0' } = req.query; + + if (isNaN(dispensaryId)) { + return res.status(400).json({ error: 'Invalid dispensary ID' }); + } + + const result = await pool.query(` + SELECT + sp.id, + sp.name_raw as product_name, + sp.brand_name_raw as brand, + sp.category_raw as category, + sp.price_rec as original_price, + sp.price_rec_special as sale_price, + sp.discount_percent as savings_percent, + CASE + WHEN sp.price_rec IS NOT NULL AND sp.price_rec_special IS NOT NULL + THEN sp.price_rec - sp.price_rec_special + ELSE NULL + END as savings_amount, + sp.special_name, + sp.thc_percent, + sp.image_url, + sp.provider_product_id + FROM store_products sp + WHERE sp.dispensary_id = $1 + AND sp.is_on_special = TRUE + AND sp.is_in_stock = TRUE + ORDER BY sp.discount_percent DESC NULLS LAST + LIMIT $2 OFFSET $3 + `, [dispensaryId, parseInt(limit as string), parseInt(offset as string)]); + + // Get dispensary info + const dispResult = await pool.query(` + SELECT id, COALESCE(dba_name, name) as name, city, state, address, slug + FROM dispensaries WHERE id = $1 + `, [dispensaryId]); + + const dispensary = dispResult.rows[0]; + + res.json({ + dispensary: dispensary ? { + id: dispensary.id, + name: dispensary.name, + city: dispensary.city, + state: dispensary.state, + address: dispensary.address, + slug: dispensary.slug + } : null, + deals: result.rows.map(row => ({ + id: row.id, + productName: row.product_name, + brand: row.brand, + category: row.category, + originalPrice: parseFloat(row.original_price) || null, + salePrice: parseFloat(row.sale_price) || null, + savingsAmount: parseFloat(row.savings_amount) || null, + savingsPercent: parseFloat(row.savings_percent) || null, + specialName: row.special_name, + thcPercent: parseFloat(row.thc_percent) || null, + imageUrl: row.image_url, + productId: row.provider_product_id + })), + totalDeals: result.rows.length + }); + } catch (error) { + console.error('[Consumer Deals] By dispensary error:', error); + res.status(500).json({ error: 'Failed to get dispensary deals' }); + } +}); + +/** + * GET /api/v1/deals/featured + * Get featured/best deals (highest savings, manually curated, etc.) + */ +router.get('/featured', async (req: Request, res: Response) => { + try { + const { state = 'AZ', limit = '10' } = req.query; + + // Get top deals by savings percent, ensuring variety + const result = await pool.query(` + WITH ranked_deals AS ( + SELECT + sp.*, + d.id as disp_id, + COALESCE(d.dba_name, d.name) as dispensary_name, + d.city as dispensary_city, + d.state as dispensary_state, + d.slug as dispensary_slug, + ROW_NUMBER() OVER (PARTITION BY sp.category_raw ORDER BY sp.discount_percent DESC) as category_rank + FROM store_products sp + INNER JOIN dispensaries d ON sp.dispensary_id = d.id + WHERE sp.is_on_special = TRUE + AND sp.is_in_stock = TRUE + AND d.state = $1 + AND sp.discount_percent >= 15 -- Only significant discounts + ) + SELECT * + FROM ranked_deals + WHERE category_rank <= 3 -- Top 3 per category for variety + ORDER BY discount_percent DESC + LIMIT $2 + `, [state, parseInt(limit as string)]); + + res.json({ + featuredDeals: result.rows.map(row => ({ + id: row.id, + productName: row.name_raw, + brand: row.brand_name_raw, + category: row.category_raw, + originalPrice: parseFloat(row.price_rec) || null, + salePrice: parseFloat(row.price_rec_special) || null, + savingsPercent: parseFloat(row.discount_percent) || null, + specialName: row.special_name, + thcPercent: parseFloat(row.thc_percent) || null, + imageUrl: row.image_url, + dispensary: { + id: row.disp_id, + name: row.dispensary_name, + city: row.dispensary_city, + state: row.dispensary_state, + slug: row.dispensary_slug + } + })) + }); + } catch (error) { + console.error('[Consumer Deals] Featured error:', error); + res.status(500).json({ error: 'Failed to get featured deals' }); + } +}); + +export default router; diff --git a/backend/src/routes/consumer-favorites.ts b/backend/src/routes/consumer-favorites.ts new file mode 100644 index 00000000..e9eccb2d --- /dev/null +++ b/backend/src/routes/consumer-favorites.ts @@ -0,0 +1,369 @@ +/** + * Consumer Favorites API Routes + * Handles product/dispensary favorites for findadispo.com and findagram.co users + */ + +import { Router, Request, Response } from 'express'; +import { pool } from '../db/pool'; +import { authenticateConsumer } from './consumer-auth'; + +const router = Router(); + +// All routes require authentication +router.use(authenticateConsumer); + +/** + * GET /api/consumer/favorites + * Get user's favorites (products for findagram, dispensaries for findadispo) + */ +router.get('/', async (req: Request, res: Response) => { + try { + const userId = (req as any).userId; + const domain = (req as any).domain; + + if (domain === 'findagram.co') { + // Product favorites + const result = await pool.query( + `SELECT f.*, + p.name as current_name, + p.brand as current_brand, + ls.price as current_price, + ls.stock_status as current_stock_status, + d.name as dispensary_name, + d.city as dispensary_city, + d.state as dispensary_state + FROM findagram_favorites f + LEFT JOIN store_products p ON f.product_id = p.id + LEFT JOIN LATERAL ( + SELECT price, stock_status + FROM store_product_snapshots + WHERE product_id = f.product_id + ORDER BY crawled_at DESC LIMIT 1 + ) ls ON true + LEFT JOIN dispensaries d ON f.dispensary_id = d.id + WHERE f.user_id = $1 + ORDER BY f.created_at DESC`, + [userId] + ); + + res.json({ + favorites: result.rows.map(row => ({ + id: row.id, + productId: row.product_id, + dispensaryId: row.dispensary_id, + // Saved snapshot + savedName: row.product_name, + savedBrand: row.product_brand, + savedPrice: row.product_price, + imageUrl: row.product_image_url, + // Current data + currentName: row.current_name || row.product_name, + currentBrand: row.current_brand || row.product_brand, + currentPrice: row.current_price, + currentStockStatus: row.current_stock_status, + // Dispensary info + dispensaryName: row.dispensary_name, + dispensaryCity: row.dispensary_city, + dispensaryState: row.dispensary_state, + // Price change detection + priceChanged: row.current_price && row.product_price && + parseFloat(row.current_price) !== parseFloat(row.product_price), + priceDrop: row.current_price && row.product_price && + parseFloat(row.current_price) < parseFloat(row.product_price), + createdAt: row.created_at + })) + }); + } else if (domain === 'findadispo.com') { + // Dispensary favorites + const result = await pool.query( + `SELECT f.*, + d.name as current_name, + d.dba_name, + d.address, + d.city as current_city, + d.state as current_state, + d.phone, + d.website, + d.menu_url, + d.hours, + d.latitude, + d.longitude, + (SELECT COUNT(*) FROM store_products WHERE dispensary_id = d.id AND stock_status = 'in_stock') as product_count + FROM findadispo_favorites f + LEFT JOIN dispensaries d ON f.dispensary_id = d.id + WHERE f.user_id = $1 + ORDER BY f.created_at DESC`, + [userId] + ); + + res.json({ + favorites: result.rows.map(row => ({ + id: row.id, + dispensaryId: row.dispensary_id, + // Saved snapshot + savedName: row.dispensary_name, + savedCity: row.dispensary_city, + savedState: row.dispensary_state, + // Current data + currentName: row.dba_name || row.current_name || row.dispensary_name, + currentCity: row.current_city, + currentState: row.current_state, + address: row.address, + phone: row.phone, + website: row.website, + menuUrl: row.menu_url, + hours: row.hours, + latitude: row.latitude, + longitude: row.longitude, + productCount: parseInt(row.product_count) || 0, + createdAt: row.created_at + })) + }); + } else { + res.status(400).json({ error: 'Invalid domain' }); + } + } catch (error) { + console.error('[Consumer Favorites] Get error:', error); + res.status(500).json({ error: 'Failed to get favorites' }); + } +}); + +/** + * POST /api/consumer/favorites + * Add a favorite + */ +router.post('/', async (req: Request, res: Response) => { + try { + const userId = (req as any).userId; + const domain = (req as any).domain; + + if (domain === 'findagram.co') { + const { productId, dispensaryId } = req.body; + + if (!productId) { + return res.status(400).json({ error: 'productId required' }); + } + + // Get product details for snapshot + const productResult = await pool.query( + `SELECT p.name, p.brand, p.image_url, + ls.price + FROM store_products p + LEFT JOIN LATERAL ( + SELECT price FROM store_product_snapshots + WHERE product_id = p.id ORDER BY crawled_at DESC LIMIT 1 + ) ls ON true + WHERE p.id = $1`, + [productId] + ); + + if (productResult.rows.length === 0) { + return res.status(404).json({ error: 'Product not found' }); + } + + const product = productResult.rows[0]; + + // Insert favorite (ON CONFLICT to handle duplicates) + const result = await pool.query( + `INSERT INTO findagram_favorites + (user_id, product_id, dispensary_id, product_name, product_brand, product_price, product_image_url) + VALUES ($1, $2, $3, $4, $5, $6, $7) + ON CONFLICT (user_id, product_id) DO UPDATE SET + product_price = EXCLUDED.product_price, + created_at = findagram_favorites.created_at + RETURNING id`, + [userId, productId, dispensaryId, product.name, product.brand, product.price, product.image_url] + ); + + res.status(201).json({ + success: true, + favoriteId: result.rows[0].id, + message: 'Product added to favorites' + }); + + } else if (domain === 'findadispo.com') { + const { dispensaryId } = req.body; + + if (!dispensaryId) { + return res.status(400).json({ error: 'dispensaryId required' }); + } + + // Get dispensary details for snapshot + const dispResult = await pool.query( + 'SELECT name, dba_name, city, state FROM dispensaries WHERE id = $1', + [dispensaryId] + ); + + if (dispResult.rows.length === 0) { + return res.status(404).json({ error: 'Dispensary not found' }); + } + + const dispensary = dispResult.rows[0]; + + // Insert favorite + const result = await pool.query( + `INSERT INTO findadispo_favorites + (user_id, dispensary_id, dispensary_name, dispensary_city, dispensary_state) + VALUES ($1, $2, $3, $4, $5) + ON CONFLICT (user_id, dispensary_id) DO UPDATE SET + created_at = findadispo_favorites.created_at + RETURNING id`, + [userId, dispensaryId, dispensary.dba_name || dispensary.name, dispensary.city, dispensary.state] + ); + + res.status(201).json({ + success: true, + favoriteId: result.rows[0].id, + message: 'Dispensary added to favorites' + }); + + } else { + res.status(400).json({ error: 'Invalid domain' }); + } + } catch (error) { + console.error('[Consumer Favorites] Add error:', error); + res.status(500).json({ error: 'Failed to add favorite' }); + } +}); + +/** + * DELETE /api/consumer/favorites/:id + * Remove a favorite by ID + */ +router.delete('/:id', async (req: Request, res: Response) => { + try { + const userId = (req as any).userId; + const domain = (req as any).domain; + const favoriteId = parseInt(req.params.id); + + if (isNaN(favoriteId)) { + return res.status(400).json({ error: 'Invalid favorite ID' }); + } + + const table = domain === 'findagram.co' ? 'findagram_favorites' : 'findadispo_favorites'; + + const result = await pool.query( + `DELETE FROM ${table} WHERE id = $1 AND user_id = $2 RETURNING id`, + [favoriteId, userId] + ); + + if (result.rows.length === 0) { + return res.status(404).json({ error: 'Favorite not found' }); + } + + res.json({ success: true, message: 'Favorite removed' }); + } catch (error) { + console.error('[Consumer Favorites] Delete error:', error); + res.status(500).json({ error: 'Failed to remove favorite' }); + } +}); + +/** + * DELETE /api/consumer/favorites/product/:productId + * Remove a product favorite by product ID (findagram only) + */ +router.delete('/product/:productId', async (req: Request, res: Response) => { + try { + const userId = (req as any).userId; + const domain = (req as any).domain; + const productId = parseInt(req.params.productId); + + if (domain !== 'findagram.co') { + return res.status(400).json({ error: 'This endpoint is for findagram only' }); + } + + if (isNaN(productId)) { + return res.status(400).json({ error: 'Invalid product ID' }); + } + + const result = await pool.query( + 'DELETE FROM findagram_favorites WHERE product_id = $1 AND user_id = $2 RETURNING id', + [productId, userId] + ); + + if (result.rows.length === 0) { + return res.status(404).json({ error: 'Favorite not found' }); + } + + res.json({ success: true, message: 'Product removed from favorites' }); + } catch (error) { + console.error('[Consumer Favorites] Delete by product error:', error); + res.status(500).json({ error: 'Failed to remove favorite' }); + } +}); + +/** + * DELETE /api/consumer/favorites/dispensary/:dispensaryId + * Remove a dispensary favorite by dispensary ID (findadispo only) + */ +router.delete('/dispensary/:dispensaryId', async (req: Request, res: Response) => { + try { + const userId = (req as any).userId; + const domain = (req as any).domain; + const dispensaryId = parseInt(req.params.dispensaryId); + + if (domain !== 'findadispo.com') { + return res.status(400).json({ error: 'This endpoint is for findadispo only' }); + } + + if (isNaN(dispensaryId)) { + return res.status(400).json({ error: 'Invalid dispensary ID' }); + } + + const result = await pool.query( + 'DELETE FROM findadispo_favorites WHERE dispensary_id = $1 AND user_id = $2 RETURNING id', + [dispensaryId, userId] + ); + + if (result.rows.length === 0) { + return res.status(404).json({ error: 'Favorite not found' }); + } + + res.json({ success: true, message: 'Dispensary removed from favorites' }); + } catch (error) { + console.error('[Consumer Favorites] Delete by dispensary error:', error); + res.status(500).json({ error: 'Failed to remove favorite' }); + } +}); + +/** + * GET /api/consumer/favorites/check/:type/:id + * Check if an item is favorited + */ +router.get('/check/:type/:id', async (req: Request, res: Response) => { + try { + const userId = (req as any).userId; + const domain = (req as any).domain; + const { type, id } = req.params; + const itemId = parseInt(id); + + if (isNaN(itemId)) { + return res.status(400).json({ error: 'Invalid ID' }); + } + + let isFavorited = false; + + if (type === 'product' && domain === 'findagram.co') { + const result = await pool.query( + 'SELECT id FROM findagram_favorites WHERE user_id = $1 AND product_id = $2', + [userId, itemId] + ); + isFavorited = result.rows.length > 0; + } else if (type === 'dispensary' && domain === 'findadispo.com') { + const result = await pool.query( + 'SELECT id FROM findadispo_favorites WHERE user_id = $1 AND dispensary_id = $2', + [userId, itemId] + ); + isFavorited = result.rows.length > 0; + } else { + return res.status(400).json({ error: 'Invalid type for this domain' }); + } + + res.json({ isFavorited }); + } catch (error) { + console.error('[Consumer Favorites] Check error:', error); + res.status(500).json({ error: 'Failed to check favorite status' }); + } +}); + +export default router; diff --git a/backend/src/routes/consumer-saved-searches.ts b/backend/src/routes/consumer-saved-searches.ts new file mode 100644 index 00000000..bae3a1af --- /dev/null +++ b/backend/src/routes/consumer-saved-searches.ts @@ -0,0 +1,389 @@ +/** + * Consumer Saved Searches API Routes + * Handles saved searches for findagram.co (products) and findadispo.com (dispensaries) + */ + +import { Router, Request, Response } from 'express'; +import { pool } from '../db/pool'; +import { authenticateConsumer } from './consumer-auth'; + +const router = Router(); + +// All routes require authentication +router.use(authenticateConsumer); + +/** + * GET /api/consumer/saved-searches + * Get user's saved searches + */ +router.get('/', async (req: Request, res: Response) => { + try { + const userId = (req as any).userId; + const domain = (req as any).domain; + + if (domain === 'findagram.co') { + const result = await pool.query( + `SELECT * FROM findagram_saved_searches + WHERE user_id = $1 + ORDER BY created_at DESC`, + [userId] + ); + + res.json({ + savedSearches: result.rows.map(row => ({ + id: row.id, + name: row.name, + query: row.query, + category: row.category, + brand: row.brand, + strainType: row.strain_type, + minPrice: row.min_price, + maxPrice: row.max_price, + minThc: row.min_thc, + maxThc: row.max_thc, + city: row.city, + state: row.state, + notifyOnNew: row.notify_on_new, + notifyOnPriceDrop: row.notify_on_price_drop, + createdAt: row.created_at, + updatedAt: row.updated_at + })) + }); + } else if (domain === 'findadispo.com') { + const result = await pool.query( + `SELECT * FROM findadispo_saved_searches + WHERE user_id = $1 + ORDER BY created_at DESC`, + [userId] + ); + + res.json({ + savedSearches: result.rows.map(row => ({ + id: row.id, + name: row.name, + query: row.query, + city: row.city, + state: row.state, + minRating: row.min_rating, + maxDistance: row.max_distance, + amenities: row.amenities || [], + notifyOnNewDispensary: row.notify_on_new_dispensary, + notifyOnDeals: row.notify_on_deals, + createdAt: row.created_at, + updatedAt: row.updated_at + })) + }); + } else { + res.status(400).json({ error: 'Invalid domain' }); + } + } catch (error) { + console.error('[Consumer Saved Searches] Get error:', error); + res.status(500).json({ error: 'Failed to get saved searches' }); + } +}); + +/** + * POST /api/consumer/saved-searches + * Create a saved search + */ +router.post('/', async (req: Request, res: Response) => { + try { + const userId = (req as any).userId; + const domain = (req as any).domain; + + if (domain === 'findagram.co') { + const { + name, + query, + category, + brand, + strainType, + minPrice, + maxPrice, + minThc, + maxThc, + city, + state, + notifyOnNew = false, + notifyOnPriceDrop = false + } = req.body; + + if (!name) { + return res.status(400).json({ error: 'name is required' }); + } + + const result = await pool.query( + `INSERT INTO findagram_saved_searches + (user_id, name, query, category, brand, strain_type, min_price, max_price, + min_thc, max_thc, city, state, notify_on_new, notify_on_price_drop) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14) + RETURNING id`, + [ + userId, name, query || null, category || null, brand || null, + strainType || null, minPrice || null, maxPrice || null, + minThc || null, maxThc || null, city || null, state || null, + notifyOnNew, notifyOnPriceDrop + ] + ); + + res.status(201).json({ + success: true, + savedSearchId: result.rows[0].id, + message: 'Search saved' + }); + + } else if (domain === 'findadispo.com') { + const { + name, + query, + city, + state, + minRating, + maxDistance, + amenities, + notifyOnNewDispensary = false, + notifyOnDeals = false + } = req.body; + + if (!name) { + return res.status(400).json({ error: 'name is required' }); + } + + const result = await pool.query( + `INSERT INTO findadispo_saved_searches + (user_id, name, query, city, state, min_rating, max_distance, amenities, + notify_on_new_dispensary, notify_on_deals) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) + RETURNING id`, + [ + userId, name, query || null, city || null, state || null, + minRating || null, maxDistance || null, amenities || null, + notifyOnNewDispensary, notifyOnDeals + ] + ); + + res.status(201).json({ + success: true, + savedSearchId: result.rows[0].id, + message: 'Search saved' + }); + + } else { + res.status(400).json({ error: 'Invalid domain' }); + } + } catch (error) { + console.error('[Consumer Saved Searches] Create error:', error); + res.status(500).json({ error: 'Failed to save search' }); + } +}); + +/** + * PUT /api/consumer/saved-searches/:id + * Update a saved search + */ +router.put('/:id', async (req: Request, res: Response) => { + try { + const userId = (req as any).userId; + const domain = (req as any).domain; + const searchId = parseInt(req.params.id); + + if (isNaN(searchId)) { + return res.status(400).json({ error: 'Invalid search ID' }); + } + + if (domain === 'findagram.co') { + const { + name, + query, + category, + brand, + strainType, + minPrice, + maxPrice, + minThc, + maxThc, + city, + state, + notifyOnNew, + notifyOnPriceDrop + } = req.body; + + const result = await pool.query( + `UPDATE findagram_saved_searches SET + name = COALESCE($1, name), + query = COALESCE($2, query), + category = COALESCE($3, category), + brand = COALESCE($4, brand), + strain_type = COALESCE($5, strain_type), + min_price = COALESCE($6, min_price), + max_price = COALESCE($7, max_price), + min_thc = COALESCE($8, min_thc), + max_thc = COALESCE($9, max_thc), + city = COALESCE($10, city), + state = COALESCE($11, state), + notify_on_new = COALESCE($12, notify_on_new), + notify_on_price_drop = COALESCE($13, notify_on_price_drop), + updated_at = NOW() + WHERE id = $14 AND user_id = $15 + RETURNING id`, + [ + name, query, category, brand, strainType, minPrice, maxPrice, + minThc, maxThc, city, state, notifyOnNew, notifyOnPriceDrop, + searchId, userId + ] + ); + + if (result.rows.length === 0) { + return res.status(404).json({ error: 'Saved search not found' }); + } + + res.json({ success: true, message: 'Search updated' }); + + } else if (domain === 'findadispo.com') { + const { + name, + query, + city, + state, + minRating, + maxDistance, + amenities, + notifyOnNewDispensary, + notifyOnDeals + } = req.body; + + const result = await pool.query( + `UPDATE findadispo_saved_searches SET + name = COALESCE($1, name), + query = COALESCE($2, query), + city = COALESCE($3, city), + state = COALESCE($4, state), + min_rating = COALESCE($5, min_rating), + max_distance = COALESCE($6, max_distance), + amenities = COALESCE($7, amenities), + notify_on_new_dispensary = COALESCE($8, notify_on_new_dispensary), + notify_on_deals = COALESCE($9, notify_on_deals), + updated_at = NOW() + WHERE id = $10 AND user_id = $11 + RETURNING id`, + [ + name, query, city, state, minRating, maxDistance, + amenities, notifyOnNewDispensary, notifyOnDeals, + searchId, userId + ] + ); + + if (result.rows.length === 0) { + return res.status(404).json({ error: 'Saved search not found' }); + } + + res.json({ success: true, message: 'Search updated' }); + + } else { + res.status(400).json({ error: 'Invalid domain' }); + } + } catch (error) { + console.error('[Consumer Saved Searches] Update error:', error); + res.status(500).json({ error: 'Failed to update search' }); + } +}); + +/** + * DELETE /api/consumer/saved-searches/:id + * Delete a saved search + */ +router.delete('/:id', async (req: Request, res: Response) => { + try { + const userId = (req as any).userId; + const domain = (req as any).domain; + const searchId = parseInt(req.params.id); + + if (isNaN(searchId)) { + return res.status(400).json({ error: 'Invalid search ID' }); + } + + const table = domain === 'findagram.co' ? 'findagram_saved_searches' : 'findadispo_saved_searches'; + + const result = await pool.query( + `DELETE FROM ${table} WHERE id = $1 AND user_id = $2 RETURNING id`, + [searchId, userId] + ); + + if (result.rows.length === 0) { + return res.status(404).json({ error: 'Saved search not found' }); + } + + res.json({ success: true, message: 'Search deleted' }); + } catch (error) { + console.error('[Consumer Saved Searches] Delete error:', error); + res.status(500).json({ error: 'Failed to delete search' }); + } +}); + +/** + * POST /api/consumer/saved-searches/:id/run + * Execute a saved search and return results + * This builds the search URL/params that the frontend can use + */ +router.post('/:id/run', async (req: Request, res: Response) => { + try { + const userId = (req as any).userId; + const domain = (req as any).domain; + const searchId = parseInt(req.params.id); + + if (isNaN(searchId)) { + return res.status(400).json({ error: 'Invalid search ID' }); + } + + const table = domain === 'findagram.co' ? 'findagram_saved_searches' : 'findadispo_saved_searches'; + + const result = await pool.query( + `SELECT * FROM ${table} WHERE id = $1 AND user_id = $2`, + [searchId, userId] + ); + + if (result.rows.length === 0) { + return res.status(404).json({ error: 'Saved search not found' }); + } + + const search = result.rows[0]; + + // Build search parameters for frontend to use + if (domain === 'findagram.co') { + const params: Record = {}; + if (search.query) params.q = search.query; + if (search.category) params.category = search.category; + if (search.brand) params.brand = search.brand; + if (search.strain_type) params.strainType = search.strain_type; + if (search.min_price) params.minPrice = search.min_price; + if (search.max_price) params.maxPrice = search.max_price; + if (search.min_thc) params.minThc = search.min_thc; + if (search.max_thc) params.maxThc = search.max_thc; + if (search.city) params.city = search.city; + if (search.state) params.state = search.state; + + res.json({ + searchParams: params, + searchUrl: `/products?${new URLSearchParams(params as any).toString()}` + }); + } else { + const params: Record = {}; + if (search.query) params.q = search.query; + if (search.city) params.city = search.city; + if (search.state) params.state = search.state; + if (search.min_rating) params.minRating = search.min_rating; + if (search.max_distance) params.maxDistance = search.max_distance; + if (search.amenities?.length) params.amenities = search.amenities.join(','); + + res.json({ + searchParams: params, + searchUrl: `/?${new URLSearchParams(params as any).toString()}` + }); + } + } catch (error) { + console.error('[Consumer Saved Searches] Run error:', error); + res.status(500).json({ error: 'Failed to run search' }); + } +}); + +export default router; diff --git a/backend/src/routes/dashboard.ts b/backend/src/routes/dashboard.ts index 42d4ce71..0ff381f4 100755 --- a/backend/src/routes/dashboard.ts +++ b/backend/src/routes/dashboard.ts @@ -6,48 +6,57 @@ const router = Router(); router.use(authMiddleware); // Get dashboard stats - uses consolidated dutchie-az DB +// OPTIMIZED: Combined 4 sequential queries into 1 using CTEs router.get('/stats', async (req, res) => { try { - // Store stats from dispensaries table in consolidated DB - const dispensariesResult = await azQuery(` + // All stats in a single query using CTEs + const result = await azQuery(` + WITH dispensary_stats AS ( + SELECT + COUNT(*) as total, + COUNT(*) FILTER (WHERE menu_type IS NOT NULL AND menu_type != 'unknown') as active, + COUNT(*) FILTER (WHERE platform_dispensary_id IS NOT NULL) as with_platform_id, + COUNT(*) FILTER (WHERE menu_url IS NOT NULL) as with_menu_url, + MIN(last_crawled_at) as oldest_crawl, + MAX(last_crawled_at) as latest_crawl + FROM dispensaries + ), + product_stats AS ( + SELECT + COUNT(*) as total, + COUNT(*) FILTER (WHERE stock_status = 'in_stock') as in_stock, + COUNT(*) FILTER (WHERE primary_image_url IS NOT NULL) as with_images, + COUNT(DISTINCT brand_name) FILTER (WHERE brand_name IS NOT NULL AND brand_name != '') as unique_brands, + COUNT(DISTINCT dispensary_id) as dispensaries_with_products, + COUNT(*) FILTER (WHERE created_at >= NOW() - INTERVAL '24 hours') as new_products_24h + FROM dutchie_products + ) SELECT - COUNT(*) as total, - COUNT(*) FILTER (WHERE menu_type IS NOT NULL AND menu_type != 'unknown') as active, - COUNT(*) FILTER (WHERE platform_dispensary_id IS NOT NULL) as with_platform_id, - COUNT(*) FILTER (WHERE menu_url IS NOT NULL) as with_menu_url, - MIN(last_crawled_at) as oldest_crawl, - MAX(last_crawled_at) as latest_crawl - FROM dispensaries + ds.total as store_total, ds.active as store_active, + ds.with_platform_id as store_with_platform_id, ds.with_menu_url as store_with_menu_url, + ds.oldest_crawl, ds.latest_crawl, + ps.total as product_total, ps.in_stock as product_in_stock, + ps.with_images as product_with_images, ps.unique_brands as product_unique_brands, + ps.dispensaries_with_products, ps.new_products_24h + FROM dispensary_stats ds, product_stats ps `); - // Product stats from dutchie_products table - const productsResult = await azQuery(` - SELECT - COUNT(*) as total, - COUNT(*) FILTER (WHERE stock_status = 'in_stock') as in_stock, - COUNT(*) FILTER (WHERE primary_image_url IS NOT NULL) as with_images, - COUNT(DISTINCT brand_name) FILTER (WHERE brand_name IS NOT NULL AND brand_name != '') as unique_brands, - COUNT(DISTINCT dispensary_id) as dispensaries_with_products - FROM dutchie_products - `); - - // Brand stats from dutchie_products - const brandResult = await azQuery(` - SELECT COUNT(DISTINCT brand_name) as total - FROM dutchie_products - WHERE brand_name IS NOT NULL AND brand_name != '' - `); - - // Recent products added (last 24 hours) - const recentProductsResult = await azQuery(` - SELECT COUNT(*) as new_products_24h - FROM dutchie_products - WHERE created_at >= NOW() - INTERVAL '24 hours' - `); - - // Combine results - const storeStats = dispensariesResult.rows[0]; - const productStats = productsResult.rows[0]; + const stats = result.rows[0] || {}; + const storeStats = { + total: stats.store_total, + active: stats.store_active, + with_platform_id: stats.store_with_platform_id, + with_menu_url: stats.store_with_menu_url, + oldest_crawl: stats.oldest_crawl, + latest_crawl: stats.latest_crawl + }; + const productStats = { + total: stats.product_total, + in_stock: stats.product_in_stock, + with_images: stats.product_with_images, + unique_brands: stats.product_unique_brands, + dispensaries_with_products: stats.dispensaries_with_products + }; res.json({ stores: { @@ -66,11 +75,11 @@ router.get('/stats', async (req, res) => { dispensaries_with_products: parseInt(productStats.dispensaries_with_products) || 0 }, brands: { - total: parseInt(brandResult.rows[0].total) || 0 + total: parseInt(productStats.unique_brands) || 0 // Same as unique_brands from product stats }, campaigns: { total: 0, active: 0 }, // Legacy - no longer used clicks: { clicks_24h: 0 }, // Legacy - no longer used - recent: recentProductsResult.rows[0] + recent: { new_products_24h: parseInt(stats.new_products_24h) || 0 } }); } catch (error) { console.error('Error fetching dashboard stats:', error); diff --git a/backend/src/routes/events.ts b/backend/src/routes/events.ts new file mode 100644 index 00000000..43473c02 --- /dev/null +++ b/backend/src/routes/events.ts @@ -0,0 +1,209 @@ +/** + * Events API Routes - Product click tracking + * + * Tracks user interactions with products for analytics and campaign measurement. + * + * Endpoints: + * POST /api/events/product-click - Record a product click event + * GET /api/events/product-clicks - Get product click events (admin) + */ + +import { Router, Request, Response } from 'express'; +import { pool } from '../db/pool'; +import { authMiddleware, optionalAuthMiddleware } from '../auth/middleware'; + +const router = Router(); + +// Valid action types +const VALID_ACTIONS = ['view', 'open_store', 'open_product', 'compare', 'other']; + +interface ProductClickEventPayload { + product_id: string; + store_id?: string; + brand_id?: string; + campaign_id?: string; + action: 'view' | 'open_store' | 'open_product' | 'compare' | 'other'; + source: string; + page_type?: string; // Page where event occurred (e.g., StoreDetailPage, BrandsIntelligence) + url_path?: string; // URL path for debugging + occurred_at?: string; +} + +/** + * POST /api/events/product-click + * Record a product click event + * + * Fire-and-forget from frontend - returns quickly with minimal validation + */ +router.post('/product-click', optionalAuthMiddleware, async (req: Request, res: Response) => { + try { + const payload: ProductClickEventPayload = req.body; + + // Basic validation + if (!payload.product_id || typeof payload.product_id !== 'string') { + return res.status(400).json({ status: 'error', error: 'product_id is required' }); + } + + if (!payload.action || !VALID_ACTIONS.includes(payload.action)) { + return res.status(400).json({ + status: 'error', + error: `action must be one of: ${VALID_ACTIONS.join(', ')}` + }); + } + + if (!payload.source || typeof payload.source !== 'string') { + return res.status(400).json({ status: 'error', error: 'source is required' }); + } + + // Get user ID from auth context if available + const userId = (req as any).user?.id || null; + + // Get IP and user agent from request + const ipAddress = req.ip || req.headers['x-forwarded-for'] || null; + const userAgent = req.headers['user-agent'] || null; + + // Parse occurred_at or use current time + const occurredAt = payload.occurred_at ? new Date(payload.occurred_at) : new Date(); + + // Detect device type from user agent (simple heuristic) + let deviceType = 'desktop'; + if (userAgent) { + const ua = userAgent.toLowerCase(); + if (/mobile|android|iphone|ipad|ipod|blackberry|windows phone/i.test(ua)) { + deviceType = /ipad|tablet/i.test(ua) ? 'tablet' : 'mobile'; + } + } + + // Insert the event with enhanced fields + await pool.query( + `INSERT INTO product_click_events + (product_id, store_id, brand_id, campaign_id, action, source, user_id, ip_address, user_agent, occurred_at, event_type, page_type, url_path, device_type) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14)`, + [ + payload.product_id, + payload.store_id || null, + payload.brand_id || null, + payload.campaign_id || null, + payload.action, + payload.source, + userId, + ipAddress, + userAgent, + occurredAt, + 'product_click', // event_type + payload.page_type || null, + payload.url_path || null, + deviceType + ] + ); + + res.json({ status: 'ok' }); + } catch (error: any) { + console.error('[Events] Error recording product click:', error.message); + // Still return ok to not break frontend - events are non-critical + res.json({ status: 'ok' }); + } +}); + +/** + * GET /api/events/product-clicks + * Get product click events (admin only) + * + * Query params: + * - product_id: Filter by product + * - store_id: Filter by store + * - brand_id: Filter by brand + * - campaign_id: Filter by campaign + * - action: Filter by action type + * - source: Filter by source + * - from: Start date (ISO) + * - to: End date (ISO) + * - limit: Max results (default 100) + * - offset: Pagination offset + */ +router.get('/product-clicks', authMiddleware, async (req: Request, res: Response) => { + try { + const { + product_id, + store_id, + brand_id, + campaign_id, + action, + source, + from, + to, + limit = '100', + offset = '0' + } = req.query; + + const conditions: string[] = []; + const params: any[] = []; + let paramIndex = 1; + + if (product_id) { + conditions.push(`product_id = $${paramIndex++}`); + params.push(product_id); + } + if (store_id) { + conditions.push(`store_id = $${paramIndex++}`); + params.push(store_id); + } + if (brand_id) { + conditions.push(`brand_id = $${paramIndex++}`); + params.push(brand_id); + } + if (campaign_id) { + conditions.push(`campaign_id = $${paramIndex++}`); + params.push(campaign_id); + } + if (action) { + conditions.push(`action = $${paramIndex++}`); + params.push(action); + } + if (source) { + conditions.push(`source = $${paramIndex++}`); + params.push(source); + } + if (from) { + conditions.push(`occurred_at >= $${paramIndex++}`); + params.push(new Date(from as string)); + } + if (to) { + conditions.push(`occurred_at <= $${paramIndex++}`); + params.push(new Date(to as string)); + } + + const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : ''; + + // Get total count + const countResult = await pool.query( + `SELECT COUNT(*) as total FROM product_click_events ${whereClause}`, + params + ); + const total = parseInt(countResult.rows[0].total, 10); + + // Get events + params.push(parseInt(limit as string, 10)); + params.push(parseInt(offset as string, 10)); + + const result = await pool.query( + `SELECT * FROM product_click_events + ${whereClause} + ORDER BY occurred_at DESC + LIMIT $${paramIndex++} OFFSET $${paramIndex}`, + params + ); + + res.json({ + events: result.rows, + total, + limit: parseInt(limit as string, 10), + offset: parseInt(offset as string, 10) + }); + } catch (error: any) { + console.error('[Events] Error fetching product clicks:', error.message); + res.status(500).json({ error: 'Failed to fetch product click events' }); + } +}); + +export default router; diff --git a/backend/src/routes/health.ts b/backend/src/routes/health.ts new file mode 100644 index 00000000..09a3423f --- /dev/null +++ b/backend/src/routes/health.ts @@ -0,0 +1,455 @@ +/** + * Health Check Routes + * + * Comprehensive health endpoints for monitoring API, DB, Redis, Workers, Crawls, and Analytics. + * + * Endpoints: + * GET /api/health - Quick API health check + * GET /api/health/db - Postgres health + * GET /api/health/redis - Redis health + * GET /api/health/workers - Queue and worker status + * GET /api/health/crawls - Crawl activity summary + * GET /api/health/analytics - Analytics/aggregates status + * GET /api/health/full - Aggregated view of all subsystems + */ + +import { Router, Request, Response } from 'express'; +import { getPool, healthCheck as dbHealthCheck } from '../dutchie-az/db/connection'; +import { getRedis } from '../lib/redis'; +import * as fs from 'fs'; +import * as path from 'path'; + +const router = Router(); + +// Read package version +let packageVersion = '1.0.0'; +try { + const packagePath = path.join(__dirname, '../../package.json'); + if (fs.existsSync(packagePath)) { + const pkg = JSON.parse(fs.readFileSync(packagePath, 'utf8')); + packageVersion = pkg.version || '1.0.0'; + } +} catch { + // Ignore errors reading package.json +} + +// Store server start time for uptime calculation +const serverStartTime = Date.now(); + +// Types +interface HealthStatus { + status: 'ok' | 'degraded' | 'error' | 'stale'; +} + +interface ApiHealth extends HealthStatus { + uptime: number; + timestamp: string; + version: string; +} + +interface DbHealth extends HealthStatus { + connected: boolean; + latency_ms: number; + error?: string; +} + +interface RedisHealth extends HealthStatus { + connected: boolean; + latency_ms: number; + error?: string; +} + +interface QueueInfo { + name: string; + waiting: number; + active: number; + completed: number; + failed: number; + paused: boolean; +} + +interface WorkerInfo { + id: string; + queue: string; + status: string; + last_heartbeat?: string; +} + +interface WorkersHealth extends HealthStatus { + queues: QueueInfo[]; + workers: WorkerInfo[]; +} + +interface CrawlsHealth extends HealthStatus { + last_run: string | null; + runs_last_24h: number; + stores_with_recent_crawl: number; + stores_total: number; + stale_stores: number; +} + +interface AnalyticsHealth extends HealthStatus { + last_aggregate: string | null; + daily_runs_last_7d: number; + missing_days: number; +} + +interface FullHealth extends HealthStatus { + api: ApiHealth; + db: DbHealth; + redis: RedisHealth; + workers: WorkersHealth; + crawls: CrawlsHealth; + analytics: AnalyticsHealth; +} + +// ============================================================ +// Helper Functions +// ============================================================ + +async function getApiHealth(): Promise { + return { + status: 'ok', + uptime: Math.floor((Date.now() - serverStartTime) / 1000), + timestamp: new Date().toISOString(), + version: packageVersion, + }; +} + +async function getDbHealth(): Promise { + const start = Date.now(); + try { + const pool = getPool(); + await pool.query('SELECT 1'); + return { + status: 'ok', + connected: true, + latency_ms: Date.now() - start, + }; + } catch (err: any) { + return { + status: 'error', + connected: false, + latency_ms: Date.now() - start, + error: err.message || 'Database connection failed', + }; + } +} + +async function getRedisHealth(): Promise { + const start = Date.now(); + + // Check if Redis is configured + if (!process.env.REDIS_URL && !process.env.REDIS_HOST) { + return { + status: 'ok', // Redis is optional + connected: false, + latency_ms: 0, + error: 'Redis not configured', + }; + } + + try { + const redis = getRedis(); + // Use a timeout to prevent hanging + const pingPromise = redis.ping(); + const timeoutPromise = new Promise((_, reject) => + setTimeout(() => reject(new Error('Redis ping timeout')), 3000) + ); + + await Promise.race([pingPromise, timeoutPromise]); + return { + status: 'ok', + connected: true, + latency_ms: Date.now() - start, + }; + } catch (err: any) { + return { + status: 'degraded', + connected: false, + latency_ms: Date.now() - start, + error: err.message || 'Redis ping failed', + }; + } +} + +async function getWorkersHealth(): Promise { + try { + const pool = getPool(); + + // Get queue stats from v_queue_stats view or equivalent + const queueStatsResult = await pool.query(` + SELECT + job_type as name, + COUNT(*) FILTER (WHERE status = 'pending') as waiting, + COUNT(*) FILTER (WHERE status = 'running') as active, + COUNT(*) FILTER (WHERE status = 'success') as completed, + COUNT(*) FILTER (WHERE status IN ('error', 'failed')) as failed, + false as paused + FROM dispensary_crawl_jobs + WHERE created_at > NOW() - INTERVAL '7 days' + GROUP BY job_type + `); + + const queues: QueueInfo[] = queueStatsResult.rows.map((row: any) => ({ + name: row.name || 'unknown', + waiting: parseInt(row.waiting) || 0, + active: parseInt(row.active) || 0, + completed: parseInt(row.completed) || 0, + failed: parseInt(row.failed) || 0, + paused: row.paused || false, + })); + + // Get active workers from job_schedules or active heartbeats + const workersResult = await pool.query(` + SELECT + COALESCE(job_config->>'worker_name', job_name) as id, + job_name as queue, + CASE WHEN enabled THEN 'connected' ELSE 'disconnected' END as status, + last_run_at as last_heartbeat + FROM job_schedules + WHERE enabled = true + ORDER BY last_run_at DESC NULLS LAST + LIMIT 20 + `); + + const workers: WorkerInfo[] = workersResult.rows.map((row: any) => ({ + id: row.id, + queue: row.queue, + status: row.status, + last_heartbeat: row.last_heartbeat?.toISOString() || undefined, + })); + + // Determine overall status + const hasActiveWorkers = workers.length > 0; + const hasFailedJobs = queues.some((q) => q.failed > 0); + const hasStuckJobs = queues.some((q) => q.active > 5); // Arbitrary threshold + + let status: 'ok' | 'degraded' | 'error' = 'ok'; + if (!hasActiveWorkers) { + status = 'degraded'; + } else if (hasFailedJobs || hasStuckJobs) { + status = 'degraded'; + } + + return { + status, + queues, + workers, + }; + } catch (err: any) { + return { + status: 'error', + queues: [], + workers: [], + }; + } +} + +async function getCrawlsHealth(): Promise { + try { + const pool = getPool(); + + // Get crawl statistics + const statsResult = await pool.query(` + SELECT + (SELECT MAX(completed_at) FROM dispensary_crawl_jobs WHERE status = 'success') as last_run, + (SELECT COUNT(*) FROM dispensary_crawl_jobs WHERE status = 'success' AND completed_at > NOW() - INTERVAL '24 hours') as runs_24h, + (SELECT COUNT(*) FROM dispensaries WHERE last_crawl_at > NOW() - INTERVAL '24 hours') as stores_recent, + (SELECT COUNT(*) FROM dispensaries WHERE menu_type IS NOT NULL AND platform_dispensary_id IS NOT NULL) as stores_total, + (SELECT COUNT(*) FROM dispensaries WHERE menu_type = 'dutchie' AND platform_dispensary_id IS NOT NULL AND (last_crawl_at IS NULL OR last_crawl_at < NOW() - INTERVAL '24 hours')) as stores_stale + `); + + const stats = statsResult.rows[0] || {}; + const storesTotal = parseInt(stats.stores_total) || 0; + const storesRecent = parseInt(stats.stores_recent) || 0; + const staleStores = parseInt(stats.stores_stale) || 0; + + // Calculate freshness percentage + const freshPercent = storesTotal > 0 ? (storesRecent / storesTotal) * 100 : 0; + + let status: 'ok' | 'degraded' | 'stale' | 'error' = 'ok'; + if (freshPercent >= 90) { + status = 'ok'; + } else if (freshPercent >= 50) { + status = 'degraded'; + } else { + status = 'stale'; + } + + return { + status, + last_run: stats.last_run?.toISOString() || null, + runs_last_24h: parseInt(stats.runs_24h) || 0, + stores_with_recent_crawl: storesRecent, + stores_total: storesTotal, + stale_stores: staleStores, + }; + } catch (err: any) { + return { + status: 'error', + last_run: null, + runs_last_24h: 0, + stores_with_recent_crawl: 0, + stores_total: 0, + stale_stores: 0, + }; + } +} + +async function getAnalyticsHealth(): Promise { + try { + const pool = getPool(); + + // Check analytics/aggregate job runs + const statsResult = await pool.query(` + SELECT + (SELECT MAX(completed_at) FROM job_run_logs WHERE job_name LIKE '%analytics%' AND status = 'success') as last_aggregate, + (SELECT COUNT(DISTINCT DATE(started_at)) FROM job_run_logs WHERE job_name LIKE '%analytics%' AND status = 'success' AND started_at > NOW() - INTERVAL '7 days') as runs_7d + `); + + const stats = statsResult.rows[0] || {}; + const runsLast7d = parseInt(stats.runs_7d) || 0; + const missingDays = Math.max(0, 7 - runsLast7d); + + let status: 'ok' | 'degraded' | 'stale' | 'error' = 'ok'; + if (missingDays === 0) { + status = 'ok'; + } else if (missingDays <= 2) { + status = 'degraded'; + } else { + status = 'stale'; + } + + return { + status, + last_aggregate: stats.last_aggregate?.toISOString() || null, + daily_runs_last_7d: runsLast7d, + missing_days: missingDays, + }; + } catch (err: any) { + return { + status: 'error', + last_aggregate: null, + daily_runs_last_7d: 0, + missing_days: 7, + }; + } +} + +function determineOverallStatus( + api: ApiHealth, + db: DbHealth, + redis: RedisHealth, + workers: WorkersHealth, + crawls: CrawlsHealth, + analytics: AnalyticsHealth +): 'ok' | 'degraded' | 'error' { + const statuses = [api.status, db.status, redis.status, workers.status, crawls.status, analytics.status]; + + if (statuses.includes('error')) { + return 'error'; + } + if (statuses.includes('degraded') || statuses.includes('stale')) { + return 'degraded'; + } + return 'ok'; +} + +// ============================================================ +// Routes +// ============================================================ + +/** + * GET /api/health - Quick API health check (no auth required) + */ +router.get('/', async (_req: Request, res: Response) => { + const health = await getApiHealth(); + res.json(health); +}); + +/** + * GET /api/health/db - Postgres health + */ +router.get('/db', async (_req: Request, res: Response) => { + const health = await getDbHealth(); + const statusCode = health.status === 'ok' ? 200 : 503; + res.status(statusCode).json(health); +}); + +/** + * GET /api/health/redis - Redis health + */ +router.get('/redis', async (_req: Request, res: Response) => { + const health = await getRedisHealth(); + const statusCode = health.status === 'ok' ? 200 : health.status === 'degraded' ? 200 : 503; + res.status(statusCode).json(health); +}); + +/** + * GET /api/health/workers - Queue and worker status + */ +router.get('/workers', async (_req: Request, res: Response) => { + const health = await getWorkersHealth(); + const statusCode = health.status === 'ok' ? 200 : health.status === 'degraded' ? 200 : 503; + res.status(statusCode).json(health); +}); + +/** + * GET /api/health/crawls - Crawl activity summary + */ +router.get('/crawls', async (_req: Request, res: Response) => { + const health = await getCrawlsHealth(); + const statusCode = health.status === 'ok' ? 200 : health.status === 'degraded' ? 200 : 503; + res.status(statusCode).json(health); +}); + +/** + * GET /api/health/analytics - Analytics/aggregates status + */ +router.get('/analytics', async (_req: Request, res: Response) => { + const health = await getAnalyticsHealth(); + const statusCode = health.status === 'ok' ? 200 : health.status === 'degraded' ? 200 : 503; + res.status(statusCode).json(health); +}); + +/** + * GET /api/health/full - Aggregated view of all subsystems + */ +router.get('/full', async (_req: Request, res: Response) => { + const [api, db, redis, workers, crawls, analytics] = await Promise.all([ + getApiHealth(), + getDbHealth(), + getRedisHealth(), + getWorkersHealth(), + getCrawlsHealth(), + getAnalyticsHealth(), + ]); + + const overallStatus = determineOverallStatus(api, db, redis, workers, crawls, analytics); + + const fullHealth: FullHealth = { + status: overallStatus, + api, + db, + redis, + workers, + crawls, + analytics, + }; + + const statusCode = overallStatus === 'ok' ? 200 : overallStatus === 'degraded' ? 200 : 503; + res.status(statusCode).json(fullHealth); +}); + +export default router; + +// Export helper functions for reuse in other modules +export { + getApiHealth, + getDbHealth, + getRedisHealth, + getWorkersHealth, + getCrawlsHealth, + getAnalyticsHealth, +}; diff --git a/backend/src/routes/orchestrator-admin.ts b/backend/src/routes/orchestrator-admin.ts index 90da6ca6..bb9f47ed 100644 --- a/backend/src/routes/orchestrator-admin.ts +++ b/backend/src/routes/orchestrator-admin.ts @@ -269,13 +269,12 @@ router.get('/dispensaries/:id/profile', async (req: Request, res: Response) => { dcp.dispensary_id, dcp.profile_key, dcp.profile_name, - dcp.platform, + dcp.crawler_type, dcp.version, dcp.status, dcp.config, dcp.enabled, - dcp.sandbox_attempt_count, - dcp.next_retry_at, + dcp.sandbox_attempts, dcp.created_at, dcp.updated_at, d.name as dispensary_name, @@ -318,13 +317,12 @@ router.get('/dispensaries/:id/profile', async (req: Request, res: Response) => { id: profile.id, profileKey: profile.profile_key, profileName: profile.profile_name, - platform: profile.platform, + crawlerType: profile.crawler_type, version: profile.version, status: profile.status || profile.config?.status || 'unknown', config: profile.config, enabled: profile.enabled, - sandboxAttemptCount: profile.sandbox_attempt_count, - nextRetryAt: profile.next_retry_at, + sandboxAttempts: profile.sandbox_attempts || [], createdAt: profile.created_at, updatedAt: profile.updated_at, }, @@ -349,7 +347,7 @@ router.get('/dispensaries/:id/crawler-module', async (req: Request, res: Respons // Get the profile key for this dispensary const { rows } = await pool.query(` - SELECT profile_key, platform + SELECT profile_key, crawler_type FROM dispensary_crawler_profiles WHERE dispensary_id = $1 AND enabled = true ORDER BY updated_at DESC @@ -364,14 +362,14 @@ router.get('/dispensaries/:id/crawler-module', async (req: Request, res: Respons } const profileKey = rows[0].profile_key; - const platform = rows[0].platform || 'dutchie'; + const crawlerType = rows[0].crawler_type || 'dutchie'; // Construct file path const modulePath = path.join( __dirname, '..', 'crawlers', - platform, + crawlerType, 'stores', `${profileKey}.ts` ); @@ -381,7 +379,7 @@ router.get('/dispensaries/:id/crawler-module', async (req: Request, res: Respons return res.status(404).json({ error: `Crawler module file not found: ${profileKey}.ts`, hasModule: false, - expectedPath: `crawlers/${platform}/stores/${profileKey}.ts`, + expectedPath: `crawlers/${crawlerType}/stores/${profileKey}.ts`, }); } @@ -391,9 +389,9 @@ router.get('/dispensaries/:id/crawler-module', async (req: Request, res: Respons res.json({ hasModule: true, profileKey, - platform, + crawlerType, fileName: `${profileKey}.ts`, - filePath: `crawlers/${platform}/stores/${profileKey}.ts`, + filePath: `crawlers/${crawlerType}/stores/${profileKey}.ts`, content, lines: content.split('\n').length, }); diff --git a/backend/src/routes/public-api.ts b/backend/src/routes/public-api.ts index acabce04..fcd91350 100644 --- a/backend/src/routes/public-api.ts +++ b/backend/src/routes/public-api.ts @@ -301,10 +301,19 @@ function getScopedDispensaryId(req: PublicApiRequest): { dispensaryId: number | * Query params: * - category: Filter by product type (e.g., 'flower', 'edible') * - brand: Filter by brand name + * - strain_type: Filter by strain type (indica, sativa, hybrid) + * - min_price: Minimum price filter (in dollars) + * - max_price: Maximum price filter (in dollars) + * - min_thc: Minimum THC percentage filter + * - max_thc: Maximum THC percentage filter + * - on_special: Only return products on special (true/false) + * - search: Search by name or brand * - in_stock_only: Only return in-stock products (default: false) * - limit: Max products to return (default: 100, max: 500) * - offset: Pagination offset (default: 0) * - dispensary_id: (internal keys only) Filter by specific dispensary + * - sort_by: Sort field (name, price, thc, updated) (default: name) + * - sort_dir: Sort direction (asc, desc) (default: asc) */ router.get('/products', async (req: PublicApiRequest, res: Response) => { try { @@ -322,9 +331,18 @@ router.get('/products', async (req: PublicApiRequest, res: Response) => { const { category, brand, + strain_type, + min_price, + max_price, + min_thc, + max_thc, + on_special, + search, in_stock_only = 'false', limit = '100', - offset = '0' + offset = '0', + sort_by = 'name', + sort_dir = 'asc' } = req.query; // Build query @@ -364,12 +382,63 @@ router.get('/products', async (req: PublicApiRequest, res: Response) => { paramIndex++; } + // Filter by strain type (indica, sativa, hybrid) + if (strain_type) { + whereClause += ` AND LOWER(p.strain_type) = LOWER($${paramIndex})`; + params.push(strain_type); + paramIndex++; + } + + // Filter by THC range + if (min_thc) { + whereClause += ` AND CAST(NULLIF(p.thc, '') AS NUMERIC) >= $${paramIndex}`; + params.push(parseFloat(min_thc as string)); + paramIndex++; + } + if (max_thc) { + whereClause += ` AND CAST(NULLIF(p.thc, '') AS NUMERIC) <= $${paramIndex}`; + params.push(parseFloat(max_thc as string)); + paramIndex++; + } + + // Filter by on special + if (on_special === 'true' || on_special === '1') { + whereClause += ` AND s.special = TRUE`; + } + + // Search by name or brand + if (search) { + whereClause += ` AND (LOWER(p.name) LIKE LOWER($${paramIndex}) OR LOWER(p.brand_name) LIKE LOWER($${paramIndex}))`; + params.push(`%${search}%`); + paramIndex++; + } + // Enforce limits const limitNum = Math.min(parseInt(limit as string, 10) || 100, 500); const offsetNum = parseInt(offset as string, 10) || 0; + + // Build ORDER BY clause + const sortDirection = sort_dir === 'desc' ? 'DESC' : 'ASC'; + let orderBy = 'p.name ASC'; + switch (sort_by) { + case 'price': + orderBy = `s.rec_min_price_cents ${sortDirection} NULLS LAST`; + break; + case 'thc': + orderBy = `CAST(NULLIF(p.thc, '') AS NUMERIC) ${sortDirection} NULLS LAST`; + break; + case 'updated': + orderBy = `p.updated_at ${sortDirection}`; + break; + case 'name': + default: + orderBy = `p.name ${sortDirection}`; + } + params.push(limitNum, offsetNum); // Query products with latest snapshot data + // Note: Price filters use HAVING clause since they reference the snapshot subquery const { rows: products } = await dutchieAzQuery(` SELECT p.id, @@ -406,13 +475,24 @@ router.get('/products', async (req: PublicApiRequest, res: Response) => { LIMIT 1 ) s ON true ${whereClause} - ORDER BY p.name ASC + ${min_price ? `AND (s.rec_min_price_cents / 100.0) >= ${parseFloat(min_price as string)}` : ''} + ${max_price ? `AND (s.rec_min_price_cents / 100.0) <= ${parseFloat(max_price as string)}` : ''} + ORDER BY ${orderBy} LIMIT $${paramIndex} OFFSET $${paramIndex + 1} `, params); - // Get total count for pagination + // Get total count for pagination (include price filters if specified) const { rows: countRows } = await dutchieAzQuery(` - SELECT COUNT(*) as total FROM dutchie_products p ${whereClause} + SELECT COUNT(*) as total FROM dutchie_products p + LEFT JOIN LATERAL ( + SELECT rec_min_price_cents, special FROM dutchie_product_snapshots + WHERE dutchie_product_id = p.id + ORDER BY crawled_at DESC + LIMIT 1 + ) s ON true + ${whereClause} + ${min_price ? `AND (s.rec_min_price_cents / 100.0) >= ${parseFloat(min_price as string)}` : ''} + ${max_price ? `AND (s.rec_min_price_cents / 100.0) <= ${parseFloat(max_price as string)}` : ''} `, params.slice(0, -2)); // Transform products to backward-compatible format diff --git a/backend/src/routes/seo.ts b/backend/src/routes/seo.ts new file mode 100644 index 00000000..2594a0c9 --- /dev/null +++ b/backend/src/routes/seo.ts @@ -0,0 +1,238 @@ +/** + * SEO API Routes - Content generation and management for CannaiQ marketing pages + * + * All content returned by these endpoints is sanitized to ensure + * enterprise-safe phrasing with no forbidden terminology. + */ + +import { Router, Request, Response } from 'express'; +import { getPool } from '../db/pool'; +import { authMiddleware } from '../auth/middleware'; +import { ContentValidator } from '../utils/ContentValidator'; +import { generateSeoPageWithClaude } from '../services/seoGenerator'; + +const router = Router(); + +/** + * GET /api/seo/page - Get SEO page content by slug (public, sanitized) + */ +router.get('/page', async (req: Request, res: Response) => { + try { + const { slug } = req.query; + if (!slug || typeof slug !== 'string') { + return res.status(400).json({ error: 'slug query parameter required' }); + } + + const pool = getPool(); + const result = await pool.query( + `SELECT id, slug, type, meta_title, meta_description, status, updated_at + FROM seo_pages WHERE slug = $1 AND status = 'live'`, + [slug] + ); + + if (result.rows.length === 0) { + return res.status(404).json({ error: 'Page not found' }); + } + + const page = result.rows[0]; + // Always sanitize content before returning (safety net) + const content = { + metaTitle: page.meta_title, + metaDescription: page.meta_description, + }; + const sanitizedContent = ContentValidator.sanitizeContent(content); + + res.json({ + id: page.id, + slug: page.slug, + type: page.type, + content: sanitizedContent, + updatedAt: page.updated_at, + }); + } catch (error: any) { + console.error('[SEO] Error fetching page:', error.message); + res.status(500).json({ error: 'Failed to fetch SEO page' }); + } +}); + +/** + * POST /api/seo/page - Create/update SEO page (admin, auto-sanitizes) + */ +router.post('/page', authMiddleware, async (req: Request, res: Response) => { + try { + const { slug, type, metaTitle, metaDescription, status = 'draft' } = req.body; + if (!slug || !type) { + return res.status(400).json({ error: 'slug and type required' }); + } + + // Validate and sanitize content + const content = { metaTitle, metaDescription }; + const validation = ContentValidator.validate(content); + if (!validation.valid) { + console.warn(`[SEO] Forbidden terms sanitized for ${slug}:`, validation.forbiddenTerms); + } + + const sanitized = validation.sanitized as { metaTitle?: string; metaDescription?: string }; + const pool = getPool(); + + // Always store sanitized content + const result = await pool.query( + `INSERT INTO seo_pages (slug, type, page_key, meta_title, meta_description, status, updated_at) + VALUES ($1, $2, $3, $4, $5, $6, NOW()) + ON CONFLICT (slug) DO UPDATE SET + type = EXCLUDED.type, meta_title = EXCLUDED.meta_title, + meta_description = EXCLUDED.meta_description, + status = EXCLUDED.status, updated_at = NOW() + RETURNING id, slug, type, status, updated_at`, + [slug, type, slug, sanitized.metaTitle || null, sanitized.metaDescription || null, status] + ); + + res.json({ + success: true, + page: result.rows[0], + sanitized: !validation.valid, + forbiddenTermsRemoved: validation.forbiddenTerms, + }); + } catch (error: any) { + console.error('[SEO] Error saving page:', error.message); + res.status(500).json({ error: 'Failed to save SEO page' }); + } +}); + +/** + * POST /api/seo/validate - Validate content for forbidden terms + */ +router.post('/validate', async (req: Request, res: Response) => { + try { + const { content } = req.body; + if (!content) { + return res.status(400).json({ error: 'content is required' }); + } + + const validation = ContentValidator.validate(content); + res.json({ + valid: validation.valid, + forbiddenTerms: validation.forbiddenTerms, + sanitized: validation.sanitized, + approvedPhrases: ContentValidator.getApprovedPhrases(), + }); + } catch (error: any) { + res.status(500).json({ error: 'Failed to validate content' }); + } +}); + +/** + * GET /api/seo/state/:stateCode - State SEO data with metrics + */ +router.get('/state/:stateCode', async (req: Request, res: Response) => { + try { + const { stateCode } = req.params; + const code = stateCode.toUpperCase(); + const pool = getPool(); + + const metricsResult = await pool.query(` + SELECT COUNT(DISTINCT d.id) as dispensary_count, + COUNT(DISTINCT p.id) as product_count, + COUNT(DISTINCT p.brand_name) as brand_count + FROM dispensaries d + LEFT JOIN dutchie_products p ON p.dispensary_id = d.id + WHERE d.state = $1`, [code]); + + const brandsResult = await pool.query(` + SELECT brand_name, COUNT(*) as product_count + FROM dutchie_products p JOIN dispensaries d ON p.dispensary_id = d.id + WHERE d.state = $1 AND p.brand_name IS NOT NULL + GROUP BY brand_name ORDER BY product_count DESC LIMIT 10`, [code]); + + const metrics = metricsResult.rows[0]; + const response = ContentValidator.sanitizeContent({ + stateCode: code, + metrics: { + dispensaryCount: parseInt(metrics.dispensary_count, 10) || 0, + productCount: parseInt(metrics.product_count, 10) || 0, + brandCount: parseInt(metrics.brand_count, 10) || 0, + }, + topBrands: brandsResult.rows.map(r => ({ + name: r.brand_name, + productCount: parseInt(r.product_count, 10), + })), + }); + + res.json(response); + } catch (error: any) { + console.error('[SEO] Error fetching state data:', error.message); + res.status(500).json({ error: 'Failed to fetch state SEO data' }); + } +}); + +/** + * POST /api/seo/pages/:id/generate - Generate SEO content for a page + */ +router.post('/pages/:id/generate', authMiddleware, async (req: Request, res: Response) => { + try { + const { id } = req.params; + const pageId = parseInt(id, 10); + if (isNaN(pageId)) { + return res.status(400).json({ error: 'Invalid page ID' }); + } + + const content = await generateSeoPageWithClaude(pageId); + res.json({ success: true, content }); + } catch (error: any) { + console.error('[SEO] Error generating page:', error.message); + res.status(500).json({ error: error.message || 'Failed to generate SEO content' }); + } +}); + +/** + * GET /api/seo/public/content - Get full SEO page content by slug (public) + */ +router.get('/public/content', async (req: Request, res: Response) => { + try { + const { slug } = req.query; + if (!slug || typeof slug !== 'string') { + return res.status(400).json({ error: 'slug query parameter required' }); + } + + const pool = getPool(); + + // Find page and content + const result = await pool.query(` + SELECT p.id, p.slug, p.type, p.status, + c.blocks, c.meta_title, c.meta_description, c.h1, + c.canonical_url, c.og_title, c.og_description, c.og_image_url + FROM seo_pages p + LEFT JOIN seo_page_contents c ON c.page_id = p.id + WHERE p.slug = $1 AND p.status = 'live' + `, [slug]); + + if (result.rows.length === 0) { + return res.status(404).json({ error: 'Page not found or not published' }); + } + + const row = result.rows[0]; + const sanitized = ContentValidator.sanitizeContent({ + meta: { + title: row.meta_title, + description: row.meta_description, + h1: row.h1, + canonicalUrl: row.canonical_url, + ogTitle: row.og_title, + ogDescription: row.og_description, + ogImageUrl: row.og_image_url + }, + blocks: row.blocks || [] + }); + + res.json({ + slug: row.slug, + type: row.type, + ...sanitized + }); + } catch (error: any) { + console.error('[SEO] Error fetching public content:', error.message); + res.status(500).json({ error: 'Failed to fetch SEO content' }); + } +}); + +export default router; diff --git a/backend/src/routes/workers.ts b/backend/src/routes/workers.ts new file mode 100644 index 00000000..013a867b --- /dev/null +++ b/backend/src/routes/workers.ts @@ -0,0 +1,622 @@ +/** + * Workers API Routes + * + * Provider-agnostic worker management and job monitoring. + * Replaces legacy /api/dutchie-az/admin/schedules and /api/dutchie-az/monitor/* routes. + * + * Endpoints: + * GET /api/workers - List all workers/schedules + * GET /api/workers/active - List currently active workers + * GET /api/workers/schedule - Get all job schedules + * GET /api/workers/:workerName - Get specific worker details + * GET /api/workers/:workerName/scope - Get worker's scope (states, etc.) + * GET /api/workers/:workerName/stats - Get worker statistics + * GET /api/workers/:workerName/logs - Get worker's recent logs + * POST /api/workers/:workerName/trigger - Trigger worker manually + * + * GET /api/monitor/jobs - Get recent job history + * GET /api/monitor/active-jobs - Get currently running jobs + * GET /api/monitor/summary - Get monitoring summary + */ + +import { Router, Request, Response } from 'express'; +import { getPool } from '../dutchie-az/db/connection'; + +const router = Router(); + +// ============================================================ +// WORKER TYPES +// ============================================================ + +interface Worker { + id: number; + worker_name: string; + run_role: string; + scope: string[]; + description: string; + enabled: boolean; + base_interval_minutes: number; + jitter_minutes: number; + next_run_at: string | null; + last_run_at: string | null; + last_status: string | null; + last_seen: string | null; + visibility_lost: number; + visibility_restored: number; +} + +interface JobLog { + id: number; + worker_name: string; + run_role: string; + job_name: string; + status: string; + started_at: string; + completed_at: string | null; + duration_seconds: number | null; + items_processed: number; + items_succeeded: number; + items_failed: number; + error_message: string | null; + scope: string[]; +} + +// ============================================================ +// HELPERS +// ============================================================ + +function parseScope(jobConfig: any): string[] { + if (!jobConfig) return []; + if (jobConfig.scope) return Array.isArray(jobConfig.scope) ? jobConfig.scope : [jobConfig.scope]; + if (jobConfig.states) return Array.isArray(jobConfig.states) ? jobConfig.states : [jobConfig.states]; + return []; +} + +function extractWorkerName(jobName: string, jobConfig: any): string { + // Priority: explicit worker_name > job_config.worker_name > derive from job_name + if (jobConfig?.worker_name) return jobConfig.worker_name; + + // Extract from job_name like "dutchie_az_product_crawl" -> "ProductCrawl" + const parts = jobName.replace(/^(dutchie_)?az_?/i, '').split('_'); + return parts.map(p => p.charAt(0).toUpperCase() + p.slice(1).toLowerCase()).join(''); +} + +function extractRunRole(jobName: string, jobConfig: any): string { + if (jobConfig?.run_role) return jobConfig.run_role; + + // Map job names to roles + const roleMap: Record = { + 'menu_detection': 'StoreDiscovery', + 'menu_detection_single': 'StoreDiscovery', + 'dutchie_product_crawl': 'ProductSync', + 'product_crawl': 'ProductSync', + 'analytics_refresh': 'Analytics', + 'id_resolution': 'IdResolution', + }; + + for (const [key, role] of Object.entries(roleMap)) { + if (jobName.toLowerCase().includes(key.toLowerCase())) { + return role; + } + } + + return 'General'; +} + +// ============================================================ +// WORKERS ROUTES +// ============================================================ + +/** + * GET /api/workers - List all workers/schedules + */ +router.get('/', async (_req: Request, res: Response) => { + try { + const pool = getPool(); + const { rows } = await pool.query(` + SELECT + id, + job_name, + description, + enabled, + base_interval_minutes, + jitter_minutes, + next_run_at, + last_run_at, + last_status, + job_config + FROM job_schedules + ORDER BY enabled DESC, last_run_at DESC NULLS LAST + `); + + const workers: Worker[] = rows.map((row: any) => ({ + id: row.id, + worker_name: extractWorkerName(row.job_name, row.job_config), + run_role: extractRunRole(row.job_name, row.job_config), + scope: parseScope(row.job_config), + description: row.description || row.job_name, + enabled: row.enabled, + base_interval_minutes: row.base_interval_minutes, + jitter_minutes: row.jitter_minutes, + next_run_at: row.next_run_at?.toISOString() || null, + last_run_at: row.last_run_at?.toISOString() || null, + last_status: row.last_status, + last_seen: row.last_run_at?.toISOString() || null, + visibility_lost: 0, + visibility_restored: 0, + })); + + res.json({ success: true, workers }); + } catch (error: any) { + console.error('[Workers] Error listing workers:', error); + res.status(500).json({ success: false, error: error.message }); + } +}); + +/** + * GET /api/workers/active - List currently active workers + */ +router.get('/active', async (_req: Request, res: Response) => { + try { + const pool = getPool(); + const { rows } = await pool.query(` + SELECT DISTINCT ON (claimed_by) + claimed_by as worker_id, + worker_hostname, + job_type, + started_at, + last_heartbeat_at + FROM dispensary_crawl_jobs + WHERE status = 'running' + AND claimed_by IS NOT NULL + ORDER BY claimed_by, started_at DESC + `); + + const activeWorkers = rows.map((row: any) => ({ + worker_id: row.worker_id, + hostname: row.worker_hostname, + current_job_type: row.job_type, + started_at: row.started_at?.toISOString(), + last_heartbeat: row.last_heartbeat_at?.toISOString(), + run_role: extractRunRole(row.job_type, null), + })); + + res.json({ success: true, active_workers: activeWorkers, count: activeWorkers.length }); + } catch (error: any) { + console.error('[Workers] Error getting active workers:', error); + res.status(500).json({ success: false, error: error.message }); + } +}); + +/** + * GET /api/workers/schedule - Get all job schedules (alias for /) + */ +router.get('/schedule', async (req: Request, res: Response) => { + // Delegate to main workers endpoint + const pool = getPool(); + try { + const { rows } = await pool.query(` + SELECT + id, + job_name, + description, + enabled, + base_interval_minutes, + jitter_minutes, + next_run_at, + last_run_at, + last_status, + job_config + FROM job_schedules + ORDER BY next_run_at ASC NULLS LAST + `); + + res.json({ success: true, schedules: rows }); + } catch (error: any) { + res.status(500).json({ success: false, error: error.message }); + } +}); + +/** + * GET /api/workers/:workerIdOrName - Get specific worker details + */ +router.get('/:workerIdOrName', async (req: Request, res: Response) => { + try { + const { workerIdOrName } = req.params; + const pool = getPool(); + + // Try to find by ID or job_name + const { rows } = await pool.query(` + SELECT + id, + job_name, + description, + enabled, + base_interval_minutes, + jitter_minutes, + next_run_at, + last_run_at, + last_status, + job_config + FROM job_schedules + WHERE id = $1::int OR job_name ILIKE $2 + LIMIT 1 + `, [parseInt(workerIdOrName) || 0, `%${workerIdOrName}%`]); + + if (rows.length === 0) { + return res.status(404).json({ success: false, error: 'Worker not found' }); + } + + const row = rows[0]; + const worker: Worker = { + id: row.id, + worker_name: extractWorkerName(row.job_name, row.job_config), + run_role: extractRunRole(row.job_name, row.job_config), + scope: parseScope(row.job_config), + description: row.description || row.job_name, + enabled: row.enabled, + base_interval_minutes: row.base_interval_minutes, + jitter_minutes: row.jitter_minutes, + next_run_at: row.next_run_at?.toISOString() || null, + last_run_at: row.last_run_at?.toISOString() || null, + last_status: row.last_status, + last_seen: row.last_run_at?.toISOString() || null, + visibility_lost: 0, + visibility_restored: 0, + }; + + res.json({ success: true, worker }); + } catch (error: any) { + console.error('[Workers] Error getting worker:', error); + res.status(500).json({ success: false, error: error.message }); + } +}); + +/** + * GET /api/workers/:workerIdOrName/scope - Get worker's scope + */ +router.get('/:workerIdOrName/scope', async (req: Request, res: Response) => { + try { + const { workerIdOrName } = req.params; + const pool = getPool(); + + const { rows } = await pool.query(` + SELECT job_config + FROM job_schedules + WHERE id = $1::int OR job_name ILIKE $2 + LIMIT 1 + `, [parseInt(workerIdOrName) || 0, `%${workerIdOrName}%`]); + + if (rows.length === 0) { + return res.status(404).json({ success: false, error: 'Worker not found' }); + } + + const scope = parseScope(rows[0].job_config); + res.json({ success: true, scope }); + } catch (error: any) { + res.status(500).json({ success: false, error: error.message }); + } +}); + +/** + * GET /api/workers/:workerIdOrName/stats - Get worker statistics + */ +router.get('/:workerIdOrName/stats', async (req: Request, res: Response) => { + try { + const { workerIdOrName } = req.params; + const pool = getPool(); + + // Get schedule info + const scheduleResult = await pool.query(` + SELECT id, job_name FROM job_schedules + WHERE id = $1::int OR job_name ILIKE $2 + LIMIT 1 + `, [parseInt(workerIdOrName) || 0, `%${workerIdOrName}%`]); + + if (scheduleResult.rows.length === 0) { + return res.status(404).json({ success: false, error: 'Worker not found' }); + } + + const scheduleId = scheduleResult.rows[0].id; + + // Get stats + const statsResult = await pool.query(` + SELECT + COUNT(*) FILTER (WHERE status = 'success') as success_count, + COUNT(*) FILTER (WHERE status IN ('error', 'partial')) as failure_count, + COUNT(*) as total_runs, + AVG(EXTRACT(EPOCH FROM (completed_at - started_at))) as avg_duration_seconds, + SUM(items_processed) as total_items_processed, + MAX(completed_at) as last_completed + FROM job_run_logs + WHERE schedule_id = $1 + AND started_at > NOW() - INTERVAL '7 days' + `, [scheduleId]); + + const stats = statsResult.rows[0]; + res.json({ + success: true, + stats: { + success_count: parseInt(stats.success_count) || 0, + failure_count: parseInt(stats.failure_count) || 0, + total_runs: parseInt(stats.total_runs) || 0, + avg_duration_seconds: parseFloat(stats.avg_duration_seconds) || 0, + total_items_processed: parseInt(stats.total_items_processed) || 0, + last_completed: stats.last_completed?.toISOString() || null, + } + }); + } catch (error: any) { + res.status(500).json({ success: false, error: error.message }); + } +}); + +/** + * GET /api/workers/:workerIdOrName/logs - Get worker's recent logs + */ +router.get('/:workerIdOrName/logs', async (req: Request, res: Response) => { + try { + const { workerIdOrName } = req.params; + const limit = parseInt(req.query.limit as string) || 20; + const pool = getPool(); + + // Get schedule info + const scheduleResult = await pool.query(` + SELECT id, job_name, job_config FROM job_schedules + WHERE id = $1::int OR job_name ILIKE $2 + LIMIT 1 + `, [parseInt(workerIdOrName) || 0, `%${workerIdOrName}%`]); + + if (scheduleResult.rows.length === 0) { + return res.status(404).json({ success: false, error: 'Worker not found' }); + } + + const schedule = scheduleResult.rows[0]; + + const { rows } = await pool.query(` + SELECT + id, + job_name, + status, + started_at, + completed_at, + EXTRACT(EPOCH FROM (completed_at - started_at)) as duration_seconds, + items_processed, + items_succeeded, + items_failed, + error_message, + metadata + FROM job_run_logs + WHERE schedule_id = $1 + ORDER BY started_at DESC + LIMIT $2 + `, [schedule.id, limit]); + + const logs: JobLog[] = rows.map((row: any) => ({ + id: row.id, + worker_name: extractWorkerName(schedule.job_name, schedule.job_config), + run_role: extractRunRole(schedule.job_name, schedule.job_config), + job_name: row.job_name, + status: row.status, + started_at: row.started_at?.toISOString(), + completed_at: row.completed_at?.toISOString() || null, + duration_seconds: row.duration_seconds ? Math.round(row.duration_seconds) : null, + items_processed: row.items_processed || 0, + items_succeeded: row.items_succeeded || 0, + items_failed: row.items_failed || 0, + error_message: row.error_message, + scope: parseScope(schedule.job_config), + })); + + res.json({ success: true, logs }); + } catch (error: any) { + res.status(500).json({ success: false, error: error.message }); + } +}); + +/** + * POST /api/workers/:workerIdOrName/trigger - Trigger worker manually + */ +router.post('/:workerIdOrName/trigger', async (req: Request, res: Response) => { + try { + const { workerIdOrName } = req.params; + const pool = getPool(); + + // Get schedule info + const scheduleResult = await pool.query(` + SELECT id, job_name FROM job_schedules + WHERE id = $1::int OR job_name ILIKE $2 + LIMIT 1 + `, [parseInt(workerIdOrName) || 0, `%${workerIdOrName}%`]); + + if (scheduleResult.rows.length === 0) { + return res.status(404).json({ success: false, error: 'Worker not found' }); + } + + const scheduleId = scheduleResult.rows[0].id; + + // Set next_run_at to now to trigger immediately + await pool.query(` + UPDATE job_schedules + SET next_run_at = NOW() + WHERE id = $1 + `, [scheduleId]); + + res.json({ success: true, message: 'Worker triggered', schedule_id: scheduleId }); + } catch (error: any) { + res.status(500).json({ success: false, error: error.message }); + } +}); + +// ============================================================ +// MONITOR ROUTES (for /api/monitor prefix) +// ============================================================ + +/** + * GET /api/monitor/jobs - Get recent job history + */ +router.get('/jobs', async (req: Request, res: Response) => { + try { + const limit = parseInt(req.query.limit as string) || 50; + const status = req.query.status as string | undefined; + const pool = getPool(); + + let query = ` + SELECT + j.id, + j.job_name, + j.status, + j.started_at, + j.completed_at, + EXTRACT(EPOCH FROM (j.completed_at - j.started_at)) as duration_seconds, + j.items_processed, + j.items_succeeded, + j.items_failed, + j.error_message, + j.metadata, + s.job_config + FROM job_run_logs j + LEFT JOIN job_schedules s ON j.schedule_id = s.id + WHERE 1=1 + `; + const params: any[] = []; + + if (status) { + params.push(status); + query += ` AND j.status = $${params.length}`; + } + + params.push(limit); + query += ` ORDER BY j.started_at DESC LIMIT $${params.length}`; + + const { rows } = await pool.query(query, params); + + const jobs: JobLog[] = rows.map((row: any) => ({ + id: row.id, + worker_name: extractWorkerName(row.job_name, row.job_config), + run_role: extractRunRole(row.job_name, row.job_config), + job_name: row.job_name, + status: row.status, + started_at: row.started_at?.toISOString(), + completed_at: row.completed_at?.toISOString() || null, + duration_seconds: row.duration_seconds ? Math.round(row.duration_seconds) : null, + items_processed: row.items_processed || 0, + items_succeeded: row.items_succeeded || 0, + items_failed: row.items_failed || 0, + error_message: row.error_message, + scope: parseScope(row.job_config), + })); + + res.json({ success: true, jobs, count: jobs.length }); + } catch (error: any) { + console.error('[Monitor] Error getting jobs:', error); + res.status(500).json({ success: false, error: error.message }); + } +}); + +/** + * GET /api/monitor/active-jobs - Get currently running jobs + */ +router.get('/active-jobs', async (req: Request, res: Response) => { + try { + const pool = getPool(); + + const { rows } = await pool.query(` + SELECT + id, + dispensary_id, + job_type, + status, + worker_hostname, + started_at, + last_heartbeat_at, + products_found, + error_message, + metadata + FROM dispensary_crawl_jobs + WHERE status = 'running' + ORDER BY started_at DESC + `); + + const activeJobs = rows.map((row: any) => ({ + id: row.id, + dispensary_id: row.dispensary_id, + job_type: row.job_type, + worker_name: extractWorkerName(row.job_type, null), + run_role: extractRunRole(row.job_type, null), + status: row.status, + hostname: row.worker_hostname, + started_at: row.started_at?.toISOString(), + last_heartbeat: row.last_heartbeat_at?.toISOString(), + products_found: row.products_found || 0, + error_message: row.error_message, + })); + + res.json({ success: true, active_jobs: activeJobs, count: activeJobs.length }); + } catch (error: any) { + console.error('[Monitor] Error getting active jobs:', error); + res.status(500).json({ success: false, error: error.message }); + } +}); + +/** + * GET /api/monitor/summary - Get monitoring summary + */ +router.get('/summary', async (req: Request, res: Response) => { + try { + const pool = getPool(); + + // Get summary stats + const [scheduleStats, jobStats, activeJobs] = await Promise.all([ + pool.query(` + SELECT + COUNT(*) as total_schedules, + COUNT(*) FILTER (WHERE enabled = true) as enabled_schedules, + COUNT(*) FILTER (WHERE last_status = 'success') as last_success, + COUNT(*) FILTER (WHERE last_status IN ('error', 'partial')) as last_failed + FROM job_schedules + `), + pool.query(` + SELECT + COUNT(*) as total_runs, + COUNT(*) FILTER (WHERE status = 'success') as success_count, + COUNT(*) FILTER (WHERE status IN ('error', 'partial')) as failure_count, + COUNT(*) FILTER (WHERE status = 'running') as running_count + FROM job_run_logs + WHERE started_at > NOW() - INTERVAL '24 hours' + `), + pool.query(` + SELECT COUNT(*) as active_count + FROM dispensary_crawl_jobs + WHERE status = 'running' + `) + ]); + + const schedules = scheduleStats.rows[0]; + const jobs = jobStats.rows[0]; + const active = activeJobs.rows[0]; + + res.json({ + success: true, + summary: { + schedules: { + total: parseInt(schedules.total_schedules) || 0, + enabled: parseInt(schedules.enabled_schedules) || 0, + last_success: parseInt(schedules.last_success) || 0, + last_failed: parseInt(schedules.last_failed) || 0, + }, + jobs_24h: { + total: parseInt(jobs.total_runs) || 0, + success: parseInt(jobs.success_count) || 0, + failed: parseInt(jobs.failure_count) || 0, + running: parseInt(jobs.running_count) || 0, + }, + active_crawl_jobs: parseInt(active.active_count) || 0, + } + }); + } catch (error: any) { + console.error('[Monitor] Error getting summary:', error); + res.status(500).json({ success: false, error: error.message }); + } +}); + +export default router; diff --git a/backend/src/services/seoGenerator.ts b/backend/src/services/seoGenerator.ts new file mode 100644 index 00000000..a4c869e1 --- /dev/null +++ b/backend/src/services/seoGenerator.ts @@ -0,0 +1,200 @@ +/** + * SEO Page Generator Service + * + * Generates SEO content for pages using structured prompts, + * sanitizes output, and stores in seo_page_contents. + */ + +import { getPool } from '../db/pool'; + +const pool = getPool(); +import { ContentValidator } from '../utils/ContentValidator'; + +interface SeoPage { + id: number; + type: string; + slug: string; + page_key: string; + primary_keyword: string | null; +} + +interface GeneratedSeoPayload { + title: string; + metaDescription: string; + h1: string; + blocks: any[]; +} + +interface SeoPageContent { + id: number; + pageId: number; + blocks: any[]; + metaTitle: string; + metaDescription: string; + h1: string; +} + +interface GenerationSpec { + type: string; + slug: string; + primaryKeyword: string; + metrics?: any; + topBrands?: any[]; + competitorName?: string; +} + +/** + * Build generation spec based on page type + */ +async function buildSeoGenerationSpec(page: SeoPage): Promise { + const spec: GenerationSpec = { + type: page.type, + slug: page.slug, + primaryKeyword: page.primary_keyword || `${page.type} cannabis data` + }; + + if (page.type === 'state') { + // Fetch state metrics + const stateCode = page.page_key.replace('states/', '').toUpperCase(); + const metricsResult = await pool.query(` + SELECT store_count, total_products, unique_brands, state_name + FROM mv_state_metrics WHERE state = $1 + `, [stateCode]); + + if (metricsResult.rows[0]) { + spec.metrics = { + dispensaryCount: parseInt(metricsResult.rows[0].store_count) || 0, + productCount: parseInt(metricsResult.rows[0].total_products) || 0, + brandCount: parseInt(metricsResult.rows[0].unique_brands) || 0, + stateName: metricsResult.rows[0].state_name || stateCode + }; + } + + // Fetch top brands + const brandsResult = await pool.query(` + SELECT brand_name, COUNT(*) as count + FROM dutchie_products p + JOIN dispensaries d ON p.dispensary_id = d.id + WHERE d.state = $1 AND p.brand_name IS NOT NULL + GROUP BY brand_name ORDER BY count DESC LIMIT 6 + `, [stateCode]); + spec.topBrands = brandsResult.rows; + } + + if (page.type === 'competitor_alternative') { + spec.competitorName = page.slug.split('/').pop()?.replace(/-/g, ' ') || 'competitor'; + } + + return spec; +} + +/** + * Generate SEO content based on spec (uses templates, not actual Claude API) + */ +function generateSeoContent(spec: GenerationSpec): GeneratedSeoPayload { + // Template-based generation (production would call Claude API) + if (spec.type === 'state' && spec.metrics) { + const { stateName, dispensaryCount, productCount, brandCount } = spec.metrics; + return { + title: `${stateName} Cannabis Market Data | CannaIQ`, + metaDescription: `Real-time ${stateName} cannabis market intelligence. Monitor ${dispensaryCount}+ dispensaries, ${productCount.toLocaleString()}+ products, and ${brandCount}+ brands with continuously refreshed data.`, + h1: `${stateName} Cannabis Market Intelligence`, + blocks: [ + { + type: 'hero', + headline: `${stateName} Cannabis Market Data`, + subheadline: `Continuously refreshed pricing, product, and brand data from ${dispensaryCount}+ ${stateName} dispensaries.`, + ctaPrimary: { text: 'Get Market Data', href: `/demo?state=${spec.slug.split('/').pop()}` } + }, + { + type: 'stats', + headline: 'Market Snapshot', + items: [ + { value: dispensaryCount.toString(), label: 'Dispensaries', description: 'Monitored in real-time' }, + { value: productCount.toLocaleString(), label: 'Products', description: 'With live pricing' }, + { value: brandCount.toString(), label: 'Brands', description: 'Tracked across retailers' } + ] + }, + { + type: 'intro', + content: `CannaIQ monitors ${dispensaryCount} dispensaries, ${productCount.toLocaleString()} products, and ${brandCount} brands in ${stateName}, with listings and availability continuously updated throughout the day.` + }, + { + type: 'topBrands', + headline: `Top ${stateName} Cannabis Brands`, + brands: spec.topBrands?.slice(0, 6).map(b => ({ name: b.brand_name, productCount: parseInt(b.count) })) || [] + }, + { + type: 'cta', + headline: `Get ${stateName} Market Intelligence`, + subheadline: 'Access real-time pricing, brand distribution, and competitive insights.', + ctaPrimary: { text: 'Request Demo', href: '/demo' } + } + ] + }; + } + + // Generic fallback + return { + title: `${spec.primaryKeyword} | CannaIQ`, + metaDescription: `Cannabis market intelligence for ${spec.primaryKeyword}. Real-time data continuously refreshed.`, + h1: spec.primaryKeyword, + blocks: [ + { type: 'hero', headline: spec.primaryKeyword, subheadline: 'Real-time cannabis market data', ctaPrimary: { text: 'Learn More', href: '/demo' } }, + { type: 'cta', headline: 'Get Started', subheadline: 'Request a demo today.', ctaPrimary: { text: 'Request Demo', href: '/demo' } } + ] + }; +} + +/** + * Main generation pipeline + */ +export async function generateSeoPageWithClaude(pageId: number): Promise { + // 1. Load page + const pageResult = await pool.query( + 'SELECT id, type, slug, page_key, primary_keyword FROM seo_pages WHERE id = $1', + [pageId] + ); + if (pageResult.rows.length === 0) { + throw new Error(`SEO page not found: ${pageId}`); + } + const page = pageResult.rows[0] as SeoPage; + + // 2. Build spec + const spec = await buildSeoGenerationSpec(page); + + // 3. Generate content + const payload = generateSeoContent(spec); + + // 4. Sanitize (removes forbidden terms) + const sanitized = ContentValidator.sanitizeContent(payload) as GeneratedSeoPayload; + + // 5. Upsert content + const result = await pool.query(` + INSERT INTO seo_page_contents (page_id, blocks, meta_title, meta_description, h1, updated_at) + VALUES ($1, $2, $3, $4, $5, NOW()) + ON CONFLICT (page_id) DO UPDATE SET + blocks = EXCLUDED.blocks, + meta_title = EXCLUDED.meta_title, + meta_description = EXCLUDED.meta_description, + h1 = EXCLUDED.h1, + updated_at = NOW() + RETURNING id, page_id, blocks, meta_title, meta_description, h1 + `, [pageId, JSON.stringify(sanitized.blocks), sanitized.title, sanitized.metaDescription, sanitized.h1]); + + // 6. Update page status + await pool.query( + `UPDATE seo_pages SET status = 'live', last_generated_at = NOW() WHERE id = $1`, + [pageId] + ); + + const row = result.rows[0]; + return { + id: row.id, + pageId: row.page_id, + blocks: row.blocks, + metaTitle: row.meta_title, + metaDescription: row.meta_description, + h1: row.h1 + }; +} diff --git a/backend/src/utils/ContentValidator.ts b/backend/src/utils/ContentValidator.ts new file mode 100644 index 00000000..61306c07 --- /dev/null +++ b/backend/src/utils/ContentValidator.ts @@ -0,0 +1,179 @@ +/** + * ContentValidator - Redaction and Sanitization for Public Content + * + * Ensures all public-facing content (SEO pages, homepage, API responses) + * uses approved enterprise-safe phrasing and never exposes internal + * data collection mechanics. + * + * Source of truth for forbidden/approved terminology. + */ + +// Forbidden terms that must NEVER appear in public content +const FORBIDDEN_TERMS = [ + 'crawl', 'crawled', 'crawler', 'crawling', + 'scrape', 'scraped', 'scraper', 'scraping', + 'snapshot', 'snapshots', + 'ingestion', 'pipeline', 'ETL', + 'worker', 'workers', + 'bot', 'bots', + 'extraction', 'harvesting', 'polling', + 'data collector', 'collectors', +] as const; + +// Approved replacement phrases +const APPROVED_PHRASES = [ + 'continuously refreshed', + 'updated daily', + 'up-to-date data', + 'current availability', + 'latest listings', + 'real-time market insights', + 'real-time cannabis market intelligence', + 'continuously monitored markets', + 'fresh, reliable market information', +] as const; + +// Context-aware replacements (pattern -> replacement) +const REPLACEMENTS: Array<{ pattern: RegExp; replacement: string }> = [ + // Data collection verbs → neutral phrasing + { pattern: /\b(crawl|scrape)s?\b/gi, replacement: 'refresh' }, + { pattern: /\b(crawled|scraped)\b/gi, replacement: 'updated' }, + { pattern: /\b(crawling|scraping)\b/gi, replacement: 'refreshing' }, + { pattern: /\b(crawler|scraper)s?\b/gi, replacement: 'data service' }, + + // Technical terms → business terms + { pattern: /\bsnapshots?\b/gi, replacement: 'market data' }, + { pattern: /\bingestion\b/gi, replacement: 'data processing' }, + { pattern: /\bpipeline\b/gi, replacement: 'data flow' }, + { pattern: /\bETL\b/g, replacement: 'data processing' }, + { pattern: /\bworkers?\b/gi, replacement: 'services' }, + { pattern: /\bbots?\b/gi, replacement: 'automated systems' }, + { pattern: /\bextraction\b/gi, replacement: 'data gathering' }, + { pattern: /\bharvesting\b/gi, replacement: 'collecting' }, + { pattern: /\bpolling\b/gi, replacement: 'monitoring' }, + { pattern: /\bdata collectors?\b/gi, replacement: 'data services' }, +]; + +// Build regex for detection +const FORBIDDEN_PATTERN = new RegExp( + `\\b(${FORBIDDEN_TERMS.join('|')})\\b`, + 'gi' +); + +export class ContentValidator { + /** + * Check if text contains any forbidden terms + */ + static hasForbiddenTerms(text: string): boolean { + if (typeof text !== 'string') return false; + return FORBIDDEN_PATTERN.test(text); + } + + /** + * Get list of forbidden terms found in text + */ + static findForbiddenTerms(text: string): string[] { + if (typeof text !== 'string') return []; + const matches = text.match(FORBIDDEN_PATTERN); + return matches ? Array.from(new Set(matches.map(m => m.toLowerCase()))) : []; + } + + /** + * Sanitize a string by replacing forbidden terms + */ + static sanitizeString(text: string): string { + if (typeof text !== 'string') return text; + + let sanitized = text; + for (const { pattern, replacement } of REPLACEMENTS) { + sanitized = sanitized.replace(pattern, replacement); + } + return sanitized; + } + + /** + * Recursively sanitize content (strings, objects, arrays) + * Preserves structure while sanitizing all string values + */ + static sanitizeContent(content: T): T { + if (content === null || content === undefined) { + return content; + } + + if (typeof content === 'string') { + return this.sanitizeString(content) as T; + } + + if (Array.isArray(content)) { + return content.map(item => this.sanitizeContent(item)) as T; + } + + if (typeof content === 'object') { + const sanitized: Record = {}; + for (const [key, value] of Object.entries(content)) { + sanitized[key] = this.sanitizeContent(value); + } + return sanitized as T; + } + + // Numbers, booleans, etc. pass through unchanged + return content; + } + + /** + * Validate content and throw if forbidden terms found + * Use for build-time / deploy-time validation + */ + static validateOrThrow(content: unknown, context?: string): void { + const text = typeof content === 'string' + ? content + : JSON.stringify(content); + + const forbidden = this.findForbiddenTerms(text); + if (forbidden.length > 0) { + const ctx = context ? ` in ${context}` : ''; + throw new Error( + `Forbidden terms found${ctx}: ${forbidden.join(', ')}. ` + + `Public content must use approved enterprise-safe phrasing.` + ); + } + } + + /** + * Validate and return validation result (non-throwing) + */ + static validate(content: unknown): { + valid: boolean; + forbiddenTerms: string[]; + sanitized: unknown; + } { + const text = typeof content === 'string' + ? content + : JSON.stringify(content); + + const forbiddenTerms = this.findForbiddenTerms(text); + const sanitized = this.sanitizeContent(content); + + return { + valid: forbiddenTerms.length === 0, + forbiddenTerms, + sanitized, + }; + } + + /** + * Get approved phrases for reference + */ + static getApprovedPhrases(): readonly string[] { + return APPROVED_PHRASES; + } + + /** + * Get forbidden terms for reference + */ + static getForbiddenTerms(): readonly string[] { + return FORBIDDEN_TERMS; + } +} + +export default ContentValidator; diff --git a/backend/src/utils/HomepageValidator.ts b/backend/src/utils/HomepageValidator.ts new file mode 100644 index 00000000..ffeb2e7a --- /dev/null +++ b/backend/src/utils/HomepageValidator.ts @@ -0,0 +1,115 @@ +/** + * HomepageValidator - Content validation for homepage and marketing pages + * + * Validates that homepage content adheres to enterprise-safe phrasing + * and contains no forbidden terminology. + */ + +import { ContentValidator } from './ContentValidator'; + +export interface HomepageContent { + hero?: { + headline?: string; + subheadline?: string; + ctaPrimary?: string; + ctaSecondary?: string; + }; + features?: Array<{ + title: string; + description: string; + }>; + stats?: Array<{ + label: string; + value: string; + }>; + testimonials?: Array<{ + quote: string; + author: string; + }>; + faq?: Array<{ + question: string; + answer: string; + }>; + [key: string]: unknown; +} + +export interface ValidationResult { + valid: boolean; + forbiddenTerms: string[]; + warnings: string[]; +} + +/** + * Validate homepage content for forbidden terms + * Throws an error if forbidden terms are found (for build/deploy) + */ +export function validateHomepageContent(content: HomepageContent): void { + ContentValidator.validateOrThrow(content, 'homepage content'); +} + +/** + * Validate homepage content (non-throwing) + * Returns validation result with details + */ +export function checkHomepageContent(content: HomepageContent): ValidationResult { + const result = ContentValidator.validate(content); + + const warnings: string[] = []; + + // Check for potential issues even if technically valid + const jsonContent = JSON.stringify(content).toLowerCase(); + + // Warn about terms that might be close to forbidden + if (jsonContent.includes('data') && jsonContent.includes('collect')) { + warnings.push('Content mentions "data" and "collect" - ensure context is appropriate'); + } + + if (jsonContent.includes('automat')) { + warnings.push('Content mentions automation - verify phrasing is enterprise-appropriate'); + } + + return { + valid: result.valid, + forbiddenTerms: result.forbiddenTerms, + warnings, + }; +} + +/** + * Sanitize homepage content + * Returns cleaned content with forbidden terms replaced + */ +export function sanitizeHomepageContent(content: T): T { + return ContentValidator.sanitizeContent(content); +} + +/** + * Validate and sanitize in one call + * Logs warnings but returns sanitized content + */ +export function processHomepageContent( + content: T, + options?: { logWarnings?: boolean } +): T { + const check = checkHomepageContent(content); + + if (options?.logWarnings && check.warnings.length > 0) { + console.warn('[HomepageValidator] Warnings:', check.warnings); + } + + if (!check.valid) { + console.warn( + '[HomepageValidator] Forbidden terms found and sanitized:', + check.forbiddenTerms + ); + } + + return sanitizeHomepageContent(content); +} + +export default { + validateHomepageContent, + checkHomepageContent, + sanitizeHomepageContent, + processHomepageContent, +}; diff --git a/backend/src/utils/__tests__/ContentValidator.test.ts b/backend/src/utils/__tests__/ContentValidator.test.ts new file mode 100644 index 00000000..d3b4d858 --- /dev/null +++ b/backend/src/utils/__tests__/ContentValidator.test.ts @@ -0,0 +1,124 @@ +/** + * ContentValidator Unit Tests + */ + +import { ContentValidator } from '../ContentValidator'; + +describe('ContentValidator', () => { + describe('hasForbiddenTerms', () => { + it('detects forbidden terms', () => { + expect(ContentValidator.hasForbiddenTerms('We crawl dispensary data')).toBe(true); + expect(ContentValidator.hasForbiddenTerms('Data is scraped daily')).toBe(true); + expect(ContentValidator.hasForbiddenTerms('Our crawler runs hourly')).toBe(true); + expect(ContentValidator.hasForbiddenTerms('ETL pipeline processes data')).toBe(true); + expect(ContentValidator.hasForbiddenTerms('Workers harvest data')).toBe(true); + }); + + it('returns false for clean text', () => { + expect(ContentValidator.hasForbiddenTerms('Real-time market insights')).toBe(false); + expect(ContentValidator.hasForbiddenTerms('Continuously refreshed data')).toBe(false); + expect(ContentValidator.hasForbiddenTerms('Latest cannabis listings')).toBe(false); + }); + }); + + describe('findForbiddenTerms', () => { + it('returns all forbidden terms found', () => { + const result = ContentValidator.findForbiddenTerms( + 'Our crawler scrapes dispensary snapshots using a pipeline' + ); + expect(result).toContain('crawler'); + expect(result).toContain('scrapes'); + expect(result).toContain('snapshots'); + expect(result).toContain('pipeline'); + }); + }); + + describe('sanitizeString', () => { + it('replaces forbidden terms', () => { + expect(ContentValidator.sanitizeString('Data is crawled daily')) + .toBe('Data is updated daily'); + expect(ContentValidator.sanitizeString('Our scraper runs')) + .toBe('Our data service runs'); + expect(ContentValidator.sanitizeString('View snapshots')) + .toBe('View market data'); + }); + + it('preserves clean text', () => { + const clean = 'Real-time cannabis market intelligence'; + expect(ContentValidator.sanitizeString(clean)).toBe(clean); + }); + }); + + describe('sanitizeContent', () => { + it('sanitizes nested objects', () => { + const dirty = { + title: 'Our Crawler', + blocks: [ + { type: 'text', content: 'Data is scraped hourly' }, + { type: 'stats', items: ['100 snapshots per day'] }, + ], + }; + + const result = ContentValidator.sanitizeContent(dirty); + + expect(result.title).toBe('Our data service'); + expect(result.blocks[0].content).toBe('Data is updated hourly'); + expect(result.blocks[1].items[0]).toBe('100 market data per day'); + }); + + it('handles null and undefined', () => { + expect(ContentValidator.sanitizeContent(null)).toBe(null); + expect(ContentValidator.sanitizeContent(undefined)).toBe(undefined); + }); + + it('preserves non-string values', () => { + const input = { count: 42, active: true, data: null }; + const result = ContentValidator.sanitizeContent(input); + expect(result.count).toBe(42); + expect(result.active).toBe(true); + expect(result.data).toBe(null); + }); + }); + + describe('validate', () => { + it('returns valid=true for clean content', () => { + const result = ContentValidator.validate({ + title: 'Real-time Market Data', + description: 'Continuously refreshed cannabis insights', + }); + expect(result.valid).toBe(true); + expect(result.forbiddenTerms).toHaveLength(0); + }); + + it('returns valid=false with forbidden terms list', () => { + const result = ContentValidator.validate({ + title: 'Our Crawler', + description: 'Scraping dispensary data', + }); + expect(result.valid).toBe(false); + expect(result.forbiddenTerms).toContain('crawler'); + expect(result.forbiddenTerms).toContain('scraping'); + }); + + it('returns sanitized version', () => { + const result = ContentValidator.validate({ + title: 'Crawler Status', + }); + expect(result.sanitized).toEqual({ title: 'data service Status' }); + }); + }); + + describe('validateOrThrow', () => { + it('throws on forbidden terms', () => { + expect(() => { + ContentValidator.validateOrThrow({ text: 'Our crawler' }, 'test content'); + }).toThrow(/Forbidden terms found in test content/); + }); + + it('does not throw on clean content', () => { + expect(() => { + ContentValidator.validateOrThrow({ text: 'Real-time data' }); + }).not.toThrow(); + }); + }); +}); diff --git a/cannaiq/dist/index.html b/cannaiq/dist/index.html index 8602d1d1..d4ea93ad 100644 --- a/cannaiq/dist/index.html +++ b/cannaiq/dist/index.html @@ -7,8 +7,8 @@ CannaIQ - Cannabis Menu Intelligence Platform - - + +
diff --git a/cannaiq/src/App.tsx b/cannaiq/src/App.tsx index d584bd72..31496b7a 100755 --- a/cannaiq/src/App.tsx +++ b/cannaiq/src/App.tsx @@ -1,5 +1,6 @@ // CannaIQ v1.8.0 - Emerald Theme import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'; +import { Home } from './pages/Home'; import { Login } from './pages/Login'; import { Dashboard } from './pages/Dashboard'; import { Products } from './pages/Products'; @@ -13,6 +14,7 @@ import { StoreSpecials } from './pages/StoreSpecials'; import { Categories } from './pages/Categories'; import { Campaigns } from './pages/Campaigns'; import { Analytics } from './pages/Analytics'; +import { ClickAnalytics } from './pages/ClickAnalytics'; import { Settings } from './pages/Settings'; import { Proxies } from './pages/Proxies'; import { Logs } from './pages/Logs'; @@ -21,9 +23,9 @@ import { ScraperSchedule } from './pages/ScraperSchedule'; import { ScraperTools } from './pages/ScraperTools'; import { ChangeApproval } from './pages/ChangeApproval'; import { ApiPermissions } from './pages/ApiPermissions'; -import { DutchieAZSchedule } from './pages/DutchieAZSchedule'; -import { DutchieAZStores } from './pages/DutchieAZStores'; -import { DutchieAZStoreDetail } from './pages/DutchieAZStoreDetail'; +import { CrawlSchedulePage } from './pages/CrawlSchedulePage'; +import { StoresListPage } from './pages/StoresListPage'; +import { StoreDetailPage } from './pages/StoreDetailPage'; import { WholesaleAnalytics } from './pages/WholesaleAnalytics'; import { Users } from './pages/Users'; import { OrchestratorDashboard } from './pages/OrchestratorDashboard'; @@ -41,14 +43,17 @@ import CrossStateCompare from './pages/CrossStateCompare'; import { Discovery } from './pages/Discovery'; import { WorkersDashboard } from './pages/WorkersDashboard'; import { ScraperOverviewDashboard } from './pages/ScraperOverviewDashboard'; +import { SeoOrchestrator } from './pages/admin/seo/SeoOrchestrator'; +import { StatePage } from './pages/public/StatePage'; +import { SeoPage } from './pages/public/SeoPage'; import { PrivateRoute } from './components/PrivateRoute'; export default function App() { return ( + } /> } /> - } /> } /> } /> } /> @@ -61,6 +66,7 @@ export default function App() { } /> } /> } /> + } /> } /> } /> } /> @@ -68,9 +74,15 @@ export default function App() { } /> } /> } /> - } /> - } /> - } /> + {/* Provider-agnostic routes */} + } /> + } /> + } /> + } /> + {/* Legacy AZ routes - redirect to new paths */} + } /> + } /> + } /> } /> } /> } /> @@ -91,6 +103,12 @@ export default function App() { } /> } /> } /> + {/* SEO Orchestrator */} + } /> + {/* Public SEO pages (no auth) - all use SeoPage renderer for generated content */} + } /> + } /> + } /> {/* Discovery routes */} } /> {/* Workers Dashboard */} diff --git a/cannaiq/src/components/HealthPanel.tsx b/cannaiq/src/components/HealthPanel.tsx new file mode 100644 index 00000000..d4f644ed --- /dev/null +++ b/cannaiq/src/components/HealthPanel.tsx @@ -0,0 +1,484 @@ +/** + * Health Panel Component + * + * Displays system health status for API, DB, Redis, Workers, Crawls, and Analytics. + * Can be used in dashboard pages or as a standalone health overview. + */ + +import { useState, useEffect, useCallback } from 'react'; +import { api } from '../lib/api'; +import { + CheckCircle, + XCircle, + AlertTriangle, + RefreshCw, + Server, + Database, + Zap, + Users, + Activity, + BarChart3, + Clock, +} from 'lucide-react'; + +type HealthStatus = 'ok' | 'degraded' | 'stale' | 'error'; + +interface FullHealth { + status: HealthStatus; + api: { + status: HealthStatus; + uptime: number; + timestamp: string; + version: string; + }; + db: { + status: HealthStatus; + connected: boolean; + latency_ms: number; + error?: string; + }; + redis: { + status: HealthStatus; + connected: boolean; + latency_ms: number; + error?: string; + }; + workers: { + status: HealthStatus; + queues: Array<{ + name: string; + waiting: number; + active: number; + completed: number; + failed: number; + paused: boolean; + }>; + workers: Array<{ + id: string; + queue: string; + status: string; + last_heartbeat?: string; + }>; + }; + crawls: { + status: HealthStatus; + last_run: string | null; + runs_last_24h: number; + stores_with_recent_crawl: number; + stores_total: number; + stale_stores: number; + }; + analytics: { + status: HealthStatus; + last_aggregate: string | null; + daily_runs_last_7d: number; + missing_days: number; + }; +} + +function formatUptime(seconds: number): string { + if (seconds < 60) return `${seconds}s`; + if (seconds < 3600) return `${Math.floor(seconds / 60)}m`; + if (seconds < 86400) return `${Math.floor(seconds / 3600)}h ${Math.floor((seconds % 3600) / 60)}m`; + return `${Math.floor(seconds / 86400)}d ${Math.floor((seconds % 86400) / 3600)}h`; +} + +function formatRelativeTime(dateStr: string | null | undefined): string { + if (!dateStr) return 'Never'; + const date = new Date(dateStr); + const now = new Date(); + const diffMs = now.getTime() - date.getTime(); + const diffMins = Math.round(diffMs / 60000); + + if (diffMins < 1) return 'Just now'; + if (diffMins < 60) return `${diffMins}m ago`; + if (diffMins < 1440) return `${Math.round(diffMins / 60)}h ago`; + return `${Math.round(diffMins / 1440)}d ago`; +} + +function StatusIcon({ status }: { status: HealthStatus }) { + switch (status) { + case 'ok': + return ; + case 'degraded': + case 'stale': + return ; + case 'error': + return ; + default: + return ; + } +} + +function StatusBadge({ status }: { status: HealthStatus }) { + const styles: Record = { + ok: 'bg-green-100 text-green-800', + degraded: 'bg-yellow-100 text-yellow-800', + stale: 'bg-orange-100 text-orange-800', + error: 'bg-red-100 text-red-800', + }; + + return ( + + + {status.toUpperCase()} + + ); +} + +interface HealthItemProps { + icon: React.ReactNode; + title: string; + status: HealthStatus; + details?: React.ReactNode; + subtext?: string; +} + +function HealthItem({ icon, title, status, details, subtext }: HealthItemProps) { + return ( +
+
{icon}
+
+
+ {title} + +
+ {details &&
{details}
} + {subtext &&
{subtext}
} +
+
+ ); +} + +interface HealthPanelProps { + compact?: boolean; + showQueues?: boolean; + refreshInterval?: number; +} + +export function HealthPanel({ compact = false, showQueues = true, refreshInterval = 30000 }: HealthPanelProps) { + const [health, setHealth] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [lastFetch, setLastFetch] = useState(null); + + const fetchHealth = useCallback(async () => { + try { + // Add a 10-second timeout to prevent hanging forever + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), 10000); + + const data = await api.getHealthFull(); + clearTimeout(timeoutId); + + setHealth(data); + setError(null); + setLastFetch(new Date()); + } catch (err: any) { + const isTimeout = err.name === 'AbortError'; + const isNetworkError = err.message?.includes('Failed to fetch') || err.message?.includes('NetworkError'); + + if (isTimeout) { + setError('timeout'); + } else if (isNetworkError) { + setError('connection'); + } else { + setError(err.message || 'Failed to fetch health status'); + } + console.error('Health check error:', err); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + fetchHealth(); + if (refreshInterval > 0) { + const interval = setInterval(fetchHealth, refreshInterval); + return () => clearInterval(interval); + } + }, [fetchHealth, refreshInterval]); + + if (loading) { + return ( +
+
+ + Checking system health... +
+
+ ); + } + + if (error) { + const isConnectionError = error === 'connection' || error === 'timeout'; + + return ( +
+ {/* Error Header */} +
+
+
+ +
+
+

+ {isConnectionError ? 'Cannot Connect to Backend' : 'Health Check Failed'} +

+

+ {error === 'timeout' && 'The request timed out after 10 seconds'} + {error === 'connection' && 'Unable to reach the backend server'} + {!isConnectionError && error} +

+
+
+
+ + {/* Help Content */} +
+ {isConnectionError ? ( +
+

+ The backend server may not be running. To start it: +

+
+ ./setup-local.sh +
+

+ This will start PostgreSQL, the backend API, and the frontend development server. +

+
+

+ Quick check: Is the backend running on{' '} + localhost:3010? +

+
+
+ ) : ( +

+ An unexpected error occurred while checking system health. This could be a temporary issue. +

+ )} +
+ + {/* Retry Button */} +
+ +
+
+ ); + } + + if (!health) return null; + + if (compact) { + // Compact view for dashboard headers + return ( +
+
+ + System {health.status.toUpperCase()} +
+
v{health.api.version}
+
Uptime: {formatUptime(health.api.uptime)}
+ +
+ ); + } + + return ( +
+ {/* Header */} +
+
+

System Health

+ +
+
+ {lastFetch && ( + + + Updated {formatRelativeTime(lastFetch.toISOString())} + + )} + +
+
+ + {/* Health Items Grid */} +
+ {/* API */} + } + title="API" + status={health.api.status} + details={ + + v{health.api.version} | Uptime: {formatUptime(health.api.uptime)} + + } + /> + + {/* Database */} + } + title="Database" + status={health.db.status} + details={ + health.db.connected ? ( + Connected | Latency: {health.db.latency_ms}ms + ) : ( + {health.db.error || 'Disconnected'} + ) + } + /> + + {/* Redis */} + } + title="Redis" + status={health.redis.status} + details={ + health.redis.connected ? ( + Connected | Latency: {health.redis.latency_ms}ms + ) : ( + {health.redis.error || 'Not configured'} + ) + } + /> + + {/* Workers */} + } + title="Workers" + status={health.workers.status} + details={ + + {health.workers.workers.length} active workers | {health.workers.queues.length} queues + + } + subtext={ + health.workers.queues.reduce((sum, q) => sum + q.active, 0) > 0 + ? `${health.workers.queues.reduce((sum, q) => sum + q.active, 0)} jobs running` + : undefined + } + /> + + {/* Crawls */} + } + title="Crawls" + status={health.crawls.status} + details={ + + {health.crawls.runs_last_24h} runs (24h) |{' '} + {health.crawls.stores_with_recent_crawl}/{health.crawls.stores_total} fresh + + } + subtext={ + health.crawls.stale_stores > 0 + ? `${health.crawls.stale_stores} stale stores` + : `Last: ${formatRelativeTime(health.crawls.last_run)}` + } + /> + + {/* Analytics */} + } + title="Analytics" + status={health.analytics.status} + details={ + + {health.analytics.daily_runs_last_7d}/7 days |{' '} + {health.analytics.missing_days === 0 + ? 'All complete' + : `${health.analytics.missing_days} missing`} + + } + subtext={`Last aggregate: ${formatRelativeTime(health.analytics.last_aggregate)}`} + /> +
+ + {/* Queue Details (optional) */} + {showQueues && health.workers.queues.length > 0 && ( +
+
+

Queue Status

+
+ + + + + + + + + + + + + {health.workers.queues.map((queue) => ( + + + + + + + + + ))} + +
QueueWaitingActiveCompletedFailedStatus
{queue.name}{queue.waiting} + 0 ? 'text-blue-600 font-medium' : 'text-gray-600' + } + > + {queue.active} + + {queue.completed} + 0 ? 'text-red-600 font-medium' : 'text-gray-600'} + > + {queue.failed} + + + {queue.paused ? ( + + Paused + + ) : ( + + Active + + )} +
+
+
+
+ )} +
+ ); +} + +export default HealthPanel; diff --git a/cannaiq/src/components/Layout.tsx b/cannaiq/src/components/Layout.tsx index 8dd97a31..edc78d05 100755 --- a/cannaiq/src/components/Layout.tsx +++ b/cannaiq/src/components/Layout.tsx @@ -5,29 +5,20 @@ import { api } from '../lib/api'; import { StateSelector } from './StateSelector'; import { LayoutDashboard, - Store, Building2, - FolderOpen, - Package, Target, TrendingUp, - Wrench, Activity, - Clock, - Calendar, Shield, - FileText, Settings, LogOut, - CheckCircle, - Key, - Users, Globe, Map, - Search, - HardHat, - Gauge, - Archive + Tag, + MousePointerClick, + FileText, + Menu, + X } from 'lucide-react'; interface LayoutProps { @@ -87,6 +78,7 @@ export function Layout({ children }: LayoutProps) { const location = useLocation(); const { user, logout } = useAuthStore(); const [versionInfo, setVersionInfo] = useState(null); + const [sidebarOpen, setSidebarOpen] = useState(false); useEffect(() => { const fetchVersion = async () => { @@ -112,241 +104,121 @@ export function Layout({ children }: LayoutProps) { return location.pathname.startsWith(path); }; + // Close sidebar on route change (mobile) + useEffect(() => { + setSidebarOpen(false); + }, [location.pathname]); + + const sidebarContent = ( + <> + {/* Logo/Brand */} +
+
+
+ + + + +
+ CannaIQ +
+

{user?.email}

+
+ + {/* State Selector */} +
+ +
+ + {/* Navigation */} + + + {/* Logout */} +
+ +
+ + {/* Version Footer */} + {versionInfo && ( +
+

{versionInfo.build_version} ({versionInfo.git_sha.slice(0, 7)})

+

{versionInfo.image_tag}

+
+ )} + + ); + return (
- {/* Sidebar */} -
- {/* Logo/Brand */} -
-
-
- + {/* Mobile sidebar overlay */} + {sidebarOpen && ( +
setSidebarOpen(false)}> +
+
+ )} + + {/* Mobile sidebar drawer */} +
+ {/* Close button */} + + {sidebarContent} +
+ + {/* Desktop sidebar */} +
+ {sidebarContent} +
+ + {/* Main Content */} +
+ {/* Mobile header */} +
+ +
+
+
- CannaIQ - {/* EMERALD_THEME_v2.0 */} + CannaIQ
-

{user?.email}

- {/* State Selector */} -
- -
- - {/* Navigation */} - - - {/* Logout */} -
- -
- - {/* Version Footer */} - {versionInfo && ( -
-

- {versionInfo.build_version} ({versionInfo.git_sha.slice(0, 7)}) -

-

- {versionInfo.image_tag} -

+ {/* Page content */} +
+
+ {children}
- )} -
- - {/* Main Content */} -
-
- {children} -
+
); diff --git a/cannaiq/src/components/StateSelector.tsx b/cannaiq/src/components/StateSelector.tsx index c30ff37f..e6be6dd3 100644 --- a/cannaiq/src/components/StateSelector.tsx +++ b/cannaiq/src/components/StateSelector.tsx @@ -31,16 +31,24 @@ export function StateSelector({ className = '', showLabel = true }: StateSelecto const fetchStates = async () => { setLoading(true); try { - const response = await api.get('/api/states?active=true'); - // Response: { data: { success, data: { states, count } } } - if (response.data?.data?.states) { - setAvailableStates(response.data.data.states); - } else if (response.data?.states) { - // Handle direct format - setAvailableStates(response.data.states); + // Use /api/states/legal which includes dispensary_count + const response = await api.get('/api/states/legal'); + // Response: { success, count, states: [{ code, name, dispensary_count, recreational, medical }] } + const data = response.data; + if (data?.states && Array.isArray(data.states)) { + // Map to { code, name } format, filtering to states with dispensaries + const statesWithData = data.states + .filter((s: { dispensary_count?: number }) => (s.dispensary_count ?? 0) > 0) + .map((s: { code: string; name: string; dispensary_count: number }) => ({ + code: s.code, + name: s.name, + })) + .sort((a: { name: string }, b: { name: string }) => a.name.localeCompare(b.name)); + setAvailableStates(statesWithData); } } catch (error) { console.error('Failed to fetch states:', error); + // Keep default fallback states if API fails } finally { setLoading(false); } diff --git a/cannaiq/src/content/alternatives/bdsa.ts b/cannaiq/src/content/alternatives/bdsa.ts new file mode 100644 index 00000000..814f4d9b --- /dev/null +++ b/cannaiq/src/content/alternatives/bdsa.ts @@ -0,0 +1,187 @@ +import { AlternativePageContent } from '../types'; + +export const bdsaContent: AlternativePageContent = { + pageType: 'alternative', + competitorSlug: 'bdsa', + competitorName: 'BDSA', + slug: '/alternatives/bdsa', + title: 'CannaIQ vs BDSA | Real-Time Cannabis Market Data Comparison', + metaDescription: + 'Compare CannaIQ to BDSA for cannabis market intelligence. Get continuously refreshed pricing data and competitive insights without waiting for monthly reports.', + h1: 'Looking for a BDSA Alternative?', + ogImage: '/og/alternatives/bdsa.png', + canonicalUrl: 'https://cannaiq.co/alternatives/bdsa', + breadcrumbs: [ + { text: 'Home', href: '/' }, + { text: 'Alternatives', href: '/alternatives' }, + { text: 'BDSA', href: '/alternatives/bdsa' } + ], + blocks: [ + { + type: 'hero', + headline: 'Real-Time Data, Not Monthly Reports', + subheadline: + 'BDSA provides valuable market research. CannaIQ provides real-time competitive intelligence. Different tools for different needs. See which fits your operations.', + ctaPrimary: { text: 'Compare Approaches', href: '#comparison' }, + ctaSecondary: { text: 'Request Demo', href: '/demo' } + }, + { + type: 'intro', + content: + 'BDSA excels at market research, forecasting, and strategic analysis. But when you need to know what competitors are charging today, or which stores carry a specific brand right now, you need continuously refreshed operational data. CannaIQ complements strategic research with real-time competitive intelligence.', + highlights: [ + 'Continuously refreshed pricing data', + 'Same-day competitive visibility', + 'Operational decision support', + 'Complementary to market research' + ] + }, + { + type: 'competitorComparison', + headline: 'Different Strengths for Different Needs', + competitorName: 'BDSA', + competitorDescription: + 'BDSA is a leading cannabis market research firm providing forecasts, consumer insights, and strategic analysis.', + advantages: [ + 'Continuously refreshed data vs. periodic reports', + 'Operational focus vs. strategic research', + 'Competitive pricing visibility in real-time', + 'Granular dispensary-level data', + 'Alerts and automation capabilities', + 'Direct integration with business systems' + ], + migrationCta: { text: 'Add Real-Time Intelligence', href: '/demo?source=bdsa' } + }, + { + type: 'comparison', + headline: 'Use Case Comparison', + competitorName: 'BDSA', + items: [ + { feature: 'Market forecasting', us: 'Limited', competitor: true }, + { feature: 'Consumer research', us: false, competitor: true }, + { feature: 'Real-time pricing data', us: true, competitor: false }, + { feature: 'Same-day competitor visibility', us: true, competitor: false }, + { feature: 'Dispensary-level granularity', us: true, competitor: 'Aggregated' }, + { feature: 'Price change alerts', us: true, competitor: false }, + { feature: 'Brand distribution tracking', us: true, competitor: 'Aggregated' }, + { feature: 'API for integration', us: true, competitor: 'Limited' }, + { feature: 'Strategic planning support', us: 'Via data', competitor: true }, + { feature: 'Operational decision support', us: true, competitor: 'Limited' } + ] + }, + { + type: 'useCases', + headline: 'When to Choose CannaIQ', + items: [ + { + title: 'Daily Operations', + description: + 'When you need to make pricing and inventory decisions based on current market conditions.', + benefits: [ + 'React to competitor price changes same-day', + 'Adjust promotions based on live data', + 'Track product availability in real-time' + ] + }, + { + title: 'Competitive Response', + description: + 'When you need to know what competitors are doing now, not what they did last month.', + benefits: [ + 'Continuous competitor monitoring', + 'Price change alerts', + 'Product launch tracking' + ] + }, + { + title: 'Brand Distribution', + description: + 'When you need current visibility into where products are stocked and at what prices.', + benefits: [ + 'Live distribution maps', + 'Retail pricing across accounts', + 'Gap identification' + ] + } + ] + }, + { + type: 'features', + headline: 'CannaIQ Real-Time Capabilities', + items: [ + { + icon: 'clock', + title: '4-Hour Refresh Cycles', + description: + 'Market data updated multiple times daily, not monthly. See changes as they happen.' + }, + { + icon: 'bell', + title: 'Automated Alerts', + description: + 'Get notified when competitors change prices, products go out of stock, or new items launch.' + }, + { + icon: 'code', + title: 'API Integration', + description: + 'Feed real-time market data directly into your POS, pricing engine, or internal tools.' + }, + { + icon: 'map-pin', + title: 'Store-Level Data', + description: + 'See pricing and availability at individual dispensary locations, not just market aggregates.' + } + ] + }, + { + type: 'testimonial', + quote: + 'We use BDSA for strategic planning and CannaIQ for daily operations. BDSA tells us where the market is going. CannaIQ tells us what\'s happening right now. Both are valuable.', + author: 'David Thompson', + role: 'VP Strategy', + company: 'Southwest Cannabis Group' + }, + { + type: 'faq', + headline: 'Comparison Questions', + items: [ + { + question: 'Should I replace BDSA with CannaIQ?', + answer: + 'Not necessarily. They serve different purposes. BDSA provides strategic market research and forecasting. CannaIQ provides real-time operational intelligence. Many operators use both for a complete picture.' + }, + { + question: 'Does CannaIQ provide market forecasts?', + answer: + 'We provide trend data and historical analysis that can inform forecasting, but we don\'t produce the strategic market forecasts that BDSA specializes in.' + }, + { + question: 'Is CannaIQ pricing data more current than BDSA?', + answer: + 'Yes. CannaIQ refreshes dispensary data every 4 hours. BDSA reports typically cover completed periods and are published periodically.' + }, + { + question: 'Can I use CannaIQ data for investor presentations?', + answer: + 'Yes, though BDSA may be better suited for strategic investor materials. CannaIQ data excels at demonstrating real-time market positioning and competitive dynamics.' + } + ] + }, + { + type: 'cta', + headline: 'Add Real-Time Intelligence to Your Stack', + subheadline: + 'Complement your market research with continuously refreshed competitive data. See how CannaIQ fits your operations.', + ctaPrimary: { text: 'Schedule Demo', href: '/demo?source=bdsa' }, + ctaSecondary: { text: 'Compare Features', href: '/pricing' } + } + ], + internalLinks: [ + { text: 'Headset Alternative', href: '/alternatives/headset' }, + { text: 'Hoodie Alternative', href: '/alternatives/hoodie-analytics' }, + { text: 'Real-Time Pricing', href: '/solutions/pricing' }, + { text: 'Brand Intelligence', href: '/solutions/brands' } + ] +}; diff --git a/cannaiq/src/content/alternatives/headset.ts b/cannaiq/src/content/alternatives/headset.ts new file mode 100644 index 00000000..9c0da23d --- /dev/null +++ b/cannaiq/src/content/alternatives/headset.ts @@ -0,0 +1,157 @@ +import { AlternativePageContent } from '../types'; + +export const headsetContent: AlternativePageContent = { + pageType: 'alternative', + competitorSlug: 'headset', + competitorName: 'Headset', + slug: '/alternatives/headset', + title: 'CannaIQ vs Headset | Cannabis Analytics Platform Comparison', + metaDescription: + 'Compare CannaIQ to Headset for cannabis market data. Get real-time dispensary pricing, brand tracking, and market intelligence at a fraction of enterprise costs.', + h1: 'Looking for a Headset Alternative?', + ogImage: '/og/alternatives/headset.png', + canonicalUrl: 'https://cannaiq.co/alternatives/headset', + breadcrumbs: [ + { text: 'Home', href: '/' }, + { text: 'Alternatives', href: '/alternatives' }, + { text: 'Headset', href: '/alternatives/headset' } + ], + blocks: [ + { + type: 'hero', + headline: 'Enterprise Cannabis Intelligence Without Enterprise Pricing', + subheadline: + 'CannaIQ delivers the real-time market data you need without the six-figure contracts. Purpose-built for operators who want insights, not complexity.', + ctaPrimary: { text: 'See the Difference', href: '#comparison' }, + ctaSecondary: { text: 'Request Demo', href: '/demo' } + }, + { + type: 'intro', + content: + 'Headset has built a strong reputation in cannabis analytics. But enterprise solutions come with enterprise complexity and enterprise pricing. CannaIQ offers a focused alternative: the real-time market data operators actually use, at a price that makes sense for growing businesses.', + highlights: [ + 'Real-time menu and pricing data', + 'Fraction of enterprise costs', + 'Quick implementation', + 'No long-term contracts required' + ] + }, + { + type: 'competitorComparison', + headline: 'Why Growing Operators Choose CannaIQ', + competitorName: 'Headset', + competitorDescription: + 'Headset is an enterprise cannabis analytics platform used by large MSOs and investors.', + advantages: [ + 'Accessible pricing for single-state and regional operators', + 'Focus on actionable real-time data over complex dashboards', + 'Quick implementation (days, not months)', + 'API access at all tiers for easy integration', + 'Flexible terms without multi-year commitments', + 'Direct support from cannabis industry experts' + ], + migrationCta: { text: 'See CannaIQ in Action', href: '/demo?source=headset' } + }, + { + type: 'comparison', + headline: 'Feature Comparison', + competitorName: 'Headset', + items: [ + { feature: 'Dispensary menu data', us: true, competitor: true }, + { feature: 'Continuously refreshed pricing', us: true, competitor: true }, + { feature: 'Brand distribution tracking', us: true, competitor: true }, + { feature: 'Accessible pricing', us: true, competitor: 'Enterprise' }, + { feature: 'Quick implementation', us: 'Days', competitor: 'Weeks/Months' }, + { feature: 'Month-to-month options', us: true, competitor: false }, + { feature: 'API included', us: 'All plans', competitor: 'Enterprise only' }, + { feature: 'POS integration required', us: false, competitor: 'Some features' }, + { feature: 'WordPress/CMS plugins', us: true, competitor: false }, + { feature: 'White-label available', us: true, competitor: 'Limited' } + ] + }, + { + type: 'useCases', + headline: 'Who CannaIQ Serves Best', + items: [ + { + title: 'Regional Operators', + description: + 'Multi-location dispensary groups that need competitive intelligence without MSO budgets.', + benefits: [ + 'Affordable per-market pricing', + 'Fast deployment across locations', + 'Essential features without bloat' + ] + }, + { + title: 'Emerging Brands', + description: + 'Cannabis brands tracking distribution and pricing across retail partners.', + benefits: [ + 'Distribution gap analysis', + 'Retail pricing visibility', + 'Competitive brand tracking' + ] + }, + { + title: 'Single-State Focus', + description: + 'Operators who need deep data in their market, not nationwide coverage they won\'t use.', + benefits: [ + 'Complete market coverage', + 'Granular local insights', + 'Right-sized investment' + ] + } + ] + }, + { + type: 'testimonial', + quote: + 'We looked at Headset, but the pricing didn\'t make sense for our size. CannaIQ gives us the competitive intelligence we need at a price we can justify. Best decision we made.', + author: 'Jennifer Martinez', + role: 'CEO', + company: 'Sonoran Wellness' + }, + { + type: 'faq', + headline: 'Common Questions', + items: [ + { + question: 'Is CannaIQ as comprehensive as Headset?', + answer: + 'Headset offers broader nationwide coverage and deep POS integration for large MSOs. CannaIQ focuses on real-time competitive intelligence with complete coverage in our active markets. For operators who need actionable market data without enterprise complexity, CannaIQ delivers.' + }, + { + question: 'Do I need to integrate my POS?', + answer: + 'No. CannaIQ works independently of your POS system. We monitor dispensary menus directly, so you get competitive insights without any integration requirements on your end.' + }, + { + question: 'What\'s the pricing difference?', + answer: + 'CannaIQ pricing starts at a fraction of typical Headset contracts. Contact us for a quote based on your specific market and feature needs.' + }, + { + question: 'Can CannaIQ scale as we grow?', + answer: + 'Absolutely. Our platform supports operators from single locations to multi-state groups. You can add markets and features as your business expands.' + } + ] + }, + { + type: 'cta', + headline: 'Enterprise Intelligence, Accessible Pricing', + subheadline: + 'Get the market data you need without the enterprise overhead. See CannaIQ in action with a personalized demo.', + ctaPrimary: { text: 'Request Demo', href: '/demo?source=headset' }, + ctaSecondary: { text: 'View Pricing', href: '/pricing' } + } + ], + internalLinks: [ + { text: 'Hoodie Alternative', href: '/alternatives/hoodie-analytics' }, + { text: 'BDSA Alternative', href: '/alternatives/bdsa' }, + { text: 'For Dispensaries', href: '/solutions/dispensaries' }, + { text: 'For Brands', href: '/solutions/brands' } + ] +}; diff --git a/cannaiq/src/content/alternatives/hoodie-analytics.ts b/cannaiq/src/content/alternatives/hoodie-analytics.ts new file mode 100644 index 00000000..b1d26511 --- /dev/null +++ b/cannaiq/src/content/alternatives/hoodie-analytics.ts @@ -0,0 +1,157 @@ +import { AlternativePageContent } from '../types'; + +export const hoodieAnalyticsContent: AlternativePageContent = { + pageType: 'alternative', + competitorSlug: 'hoodie-analytics', + competitorName: 'Hoodie Analytics', + slug: '/alternatives/hoodie-analytics', + title: 'CannaIQ vs Hoodie Analytics | Cannabis Data Platform Comparison', + metaDescription: + 'Compare CannaIQ to Hoodie Analytics for cannabis market intelligence. Real-time pricing data, brand analytics, and competitive insights with faster refresh rates.', + h1: 'Looking for a Hoodie Analytics Alternative?', + ogImage: '/og/alternatives/hoodie-analytics.png', + canonicalUrl: 'https://cannaiq.co/alternatives/hoodie-analytics', + breadcrumbs: [ + { text: 'Home', href: '/' }, + { text: 'Alternatives', href: '/alternatives' }, + { text: 'Hoodie Analytics', href: '/alternatives/hoodie-analytics' } + ], + blocks: [ + { + type: 'hero', + headline: 'A Faster, More Responsive Cannabis Data Platform', + subheadline: + 'CannaIQ delivers continuously refreshed dispensary data with more frequent updates, flexible pricing, and direct customer support. See why operators are switching.', + ctaPrimary: { text: 'Compare Features', href: '#comparison' }, + ctaSecondary: { text: 'Request Demo', href: '/demo' } + }, + { + type: 'intro', + content: + 'If you\'re evaluating cannabis market intelligence platforms, you want accurate data, reliable service, and pricing that makes sense for your business. CannaIQ was built by operators for operators, with a focus on actionable insights and responsive support.', + highlights: [ + 'More frequent data refreshes', + 'Transparent, flexible pricing', + 'Direct access to support team', + 'Purpose-built for cannabis operators' + ] + }, + { + type: 'competitorComparison', + headline: 'Why Operators Choose CannaIQ', + competitorName: 'Hoodie Analytics', + competitorDescription: + 'Hoodie Analytics provides cannabis market data and analytics services.', + advantages: [ + 'Continuously refreshed data (4-hour cycles vs. daily updates)', + 'Purpose-built for dispensary operators and brands', + 'Transparent pricing with no hidden fees', + 'Direct access to technical and customer support', + 'API access included at all tiers', + 'Custom integrations and white-label options' + ], + migrationCta: { text: 'Switch to CannaIQ', href: '/demo?source=hoodie' } + }, + { + type: 'comparison', + headline: 'Feature Comparison', + competitorName: 'Hoodie', + items: [ + { feature: 'Real-time menu data', us: true, competitor: true }, + { feature: '4-hour refresh cycles', us: true, competitor: false }, + { feature: 'API access (all plans)', us: true, competitor: 'Enterprise only' }, + { feature: 'Brand distribution tracking', us: true, competitor: true }, + { feature: 'Price change alerts', us: true, competitor: 'Limited' }, + { feature: 'WordPress plugin', us: true, competitor: false }, + { feature: 'Custom data exports', us: true, competitor: 'Enterprise only' }, + { feature: 'Historical trend data', us: true, competitor: true }, + { feature: 'Direct support access', us: true, competitor: 'Tiered' }, + { feature: 'White-label solutions', us: true, competitor: false } + ] + }, + { + type: 'useCases', + headline: 'What Hoodie Users Gain with CannaIQ', + items: [ + { + title: 'Faster Data Access', + description: + 'Get data updates every 4 hours instead of waiting for daily refreshes. React to market changes faster.', + benefits: [ + 'Intraday price change visibility', + 'Faster competitive response', + 'Real-time inventory tracking' + ] + }, + { + title: 'Better Value', + description: + 'API access and advanced features included at every tier, not locked behind enterprise pricing.', + benefits: [ + 'API included at all levels', + 'Unlimited data exports', + 'No per-seat licensing' + ] + }, + { + title: 'Responsive Support', + description: + 'Talk to real people who understand cannabis retail. No ticket queues or chatbots.', + benefits: [ + 'Direct Slack or email access', + 'Quick response times', + 'Implementation assistance' + ] + } + ] + }, + { + type: 'testimonial', + quote: + 'We switched from Hoodie to CannaIQ for the faster refresh rates. Being able to see competitor price changes the same day has been a game-changer for our pricing strategy.', + author: 'Marcus Johnson', + role: 'Director of Operations', + company: 'Desert Bloom Dispensaries' + }, + { + type: 'faq', + headline: 'Switching Questions', + items: [ + { + question: 'How hard is it to switch from Hoodie Analytics?', + answer: + 'Simple. Our team will help you migrate any existing integrations and ensure continuity of your data access. Most customers are fully transitioned within a week.' + }, + { + question: 'Will I lose historical data if I switch?', + answer: + 'CannaIQ maintains its own historical data. While we can\'t import your Hoodie history, you\'ll immediately start building history in CannaIQ with more frequent data points.' + }, + { + question: 'Is CannaIQ pricing comparable?', + answer: + 'CannaIQ offers competitive pricing with more features included at each tier. Many customers find they get better value, especially with API access included.' + }, + { + question: 'What markets do you cover vs. Hoodie?', + answer: + 'We currently focus on Arizona with plans to expand. Contact us to discuss your specific market needs and our expansion timeline.' + } + ] + }, + { + type: 'cta', + headline: 'Ready to Make the Switch?', + subheadline: + 'See how CannaIQ compares for your specific use case. Get a personalized demo and migration plan.', + ctaPrimary: { text: 'Schedule Demo', href: '/demo?source=hoodie' }, + ctaSecondary: { text: 'Talk to Sales', href: '/contact?source=hoodie' } + } + ], + internalLinks: [ + { text: 'Headset Alternative', href: '/alternatives/headset' }, + { text: 'BDSA Alternative', href: '/alternatives/bdsa' }, + { text: 'All Alternatives', href: '/alternatives' }, + { text: 'Pricing', href: '/pricing' } + ] +}; diff --git a/cannaiq/src/content/alternatives/index.ts b/cannaiq/src/content/alternatives/index.ts new file mode 100644 index 00000000..ef1570fd --- /dev/null +++ b/cannaiq/src/content/alternatives/index.ts @@ -0,0 +1,33 @@ +/** + * Competitor Alternative Pages Index + * + * Import and export all competitor alternative content. + */ + +import { hoodieAnalyticsContent } from './hoodie-analytics'; +import { headsetContent } from './headset'; +import { bdsaContent } from './bdsa'; +import { AlternativePageContent } from '../types'; + +// Export individual alternatives +export { hoodieAnalyticsContent, headsetContent, bdsaContent }; + +// Map of all alternative content by competitor slug +export const alternativeContentMap: Record = { + 'hoodie-analytics': hoodieAnalyticsContent, + headset: headsetContent, + bdsa: bdsaContent +}; + +// Get content by competitor slug +export function getAlternativeContent(slug: string): AlternativePageContent | undefined { + return alternativeContentMap[slug.toLowerCase()]; +} + +// Get content by page slug +export function getAlternativeContentBySlug(slug: string): AlternativePageContent | undefined { + return Object.values(alternativeContentMap).find((content) => content.slug === slug); +} + +// List of all available alternatives +export const availableAlternatives = Object.keys(alternativeContentMap); diff --git a/cannaiq/src/content/homepage.ts b/cannaiq/src/content/homepage.ts new file mode 100644 index 00000000..d4c0e108 --- /dev/null +++ b/cannaiq/src/content/homepage.ts @@ -0,0 +1,201 @@ +import { HomepageContent } from './types'; + +export const homepageContent: HomepageContent = { + pageType: 'homepage', + slug: '/', + title: 'CannaIQ | Real-Time Cannabis Market Intelligence Platform', + metaDescription: + 'CannaIQ delivers continuously refreshed cannabis market data, pricing analytics, and brand intelligence across dispensaries. Make data-driven decisions with live insights.', + h1: 'Cannabis Market Intelligence, Continuously Refreshed', + ogImage: '/og/homepage.png', + canonicalUrl: 'https://cannaiq.co/', + blocks: [ + { + type: 'hero', + headline: 'Know the Market Before Your Competitors Do', + subheadline: + 'CannaIQ monitors dispensary menus across markets, delivering real-time pricing, availability, and brand performance data to cannabis operators and investors.', + ctaPrimary: { text: 'Request a Demo', href: '/demo' }, + ctaSecondary: { text: 'View Sample Data', href: '/samples' } + }, + { + type: 'stats', + headline: 'Live Market Coverage', + items: [ + { + value: '850+', + label: 'Dispensaries Monitored', + description: 'Continuously refreshed menu data from licensed retailers' + }, + { + value: '125K+', + label: 'Products Tracked', + description: 'SKUs with live pricing and availability' + }, + { + value: '1,200+', + label: 'Brands Analyzed', + description: 'Brand presence and pricing across markets' + }, + { + value: '4hr', + label: 'Refresh Cycle', + description: 'Data updated throughout the day' + } + ] + }, + { + type: 'intro', + content: + 'The cannabis industry moves fast. Prices shift, products appear and disappear, and market share changes daily. CannaIQ gives you the visibility you need to make confident decisions with continuously refreshed data from dispensary menus across your markets.', + highlights: [ + 'Real-time pricing intelligence', + 'Brand presence and distribution tracking', + 'Competitive positioning analysis', + 'Market trend identification' + ] + }, + { + type: 'features', + headline: 'Built for Cannabis Operators', + subheadline: 'Every feature designed to answer the questions that matter most to your business', + items: [ + { + icon: 'chart-line', + title: 'Pricing Intelligence', + description: + 'Monitor competitor pricing in real-time. Set alerts for price changes and identify pricing opportunities before your competitors.' + }, + { + icon: 'building', + title: 'Distribution Tracking', + description: + 'See which stores carry which brands. Track your distribution reach and identify gaps in your retail footprint.' + }, + { + icon: 'trending-up', + title: 'Brand Analytics', + description: + 'Measure brand presence, pricing consistency, and market penetration. Compare performance against competitors.' + }, + { + icon: 'map', + title: 'Market Mapping', + description: + 'Visualize market dynamics by region. Identify high-opportunity zones and track market evolution over time.' + }, + { + icon: 'bell', + title: 'Smart Alerts', + description: + 'Get notified when competitors change prices, new products launch, or when your products go out of stock.' + }, + { + icon: 'download', + title: 'Data Exports', + description: + 'Export data to CSV, Excel, or integrate directly via API. Power your internal analytics with live market data.' + } + ] + }, + { + type: 'useCases', + headline: 'Who Uses CannaIQ?', + items: [ + { + title: 'Multi-State Operators', + description: + 'Monitor pricing and availability across all your markets from a single dashboard.', + benefits: [ + 'Cross-market price optimization', + 'Competitive response tracking', + 'Inventory visibility across stores' + ] + }, + { + title: 'Cannabis Brands', + description: + 'Track your distribution footprint and ensure pricing consistency across retail partners.', + benefits: [ + 'Distribution gap identification', + 'MAP compliance monitoring', + 'Competitor brand analysis' + ] + }, + { + title: 'Investors & Analysts', + description: + 'Access reliable market data for due diligence, market sizing, and trend analysis.', + benefits: [ + 'Market share tracking', + 'Brand health metrics', + 'Trend identification' + ] + }, + { + title: 'Dispensary Operators', + description: + 'Stay competitive with real-time visibility into competitor pricing and product mix.', + benefits: [ + 'Local competitive analysis', + 'Pricing optimization', + 'Assortment planning' + ] + } + ] + }, + { + type: 'testimonial', + quote: + 'CannaIQ transformed how we approach pricing. We used to guess at competitor prices. Now we have real-time data that drives our strategy.', + author: 'Sarah Chen', + role: 'VP of Retail Operations', + company: 'GreenLeaf Dispensaries' + }, + { + type: 'faq', + headline: 'Frequently Asked Questions', + items: [ + { + question: 'How often is data refreshed?', + answer: + 'Data is continuously refreshed throughout the day, with most markets updating every 4 hours. This ensures you always have access to current pricing and availability information.' + }, + { + question: 'Which markets do you cover?', + answer: + 'We currently cover Arizona, with expansion to additional legal markets planned. Contact us for the latest coverage map and upcoming market launches.' + }, + { + question: 'Can I integrate CannaIQ data with my systems?', + answer: + 'Yes. We offer a REST API for direct integration with your POS, ERP, or internal analytics tools. WordPress and e-commerce plugins are also available.' + }, + { + question: 'How accurate is the data?', + answer: + 'Our data comes directly from dispensary menu platforms, ensuring high accuracy. We validate data quality and flag anomalies automatically.' + }, + { + question: 'Do you offer custom reports?', + answer: + 'Yes. Enterprise customers can request custom market reports, competitive analyses, and brand health assessments from our analytics team.' + } + ] + }, + { + type: 'cta', + headline: 'Ready to See the Market Clearly?', + subheadline: + 'Join cannabis operators who rely on CannaIQ for real-time market intelligence. Start with a demo to see your market.', + ctaPrimary: { text: 'Request a Demo', href: '/demo' }, + ctaSecondary: { text: 'Contact Sales', href: '/contact' } + } + ], + internalLinks: [ + { text: 'Arizona Market', href: '/states/arizona' }, + { text: 'Pricing Analytics', href: '/solutions/pricing' }, + { text: 'Brand Intelligence', href: '/solutions/brands' }, + { text: 'API Documentation', href: '/docs/api' } + ] +}; diff --git a/cannaiq/src/content/index.ts b/cannaiq/src/content/index.ts new file mode 100644 index 00000000..dec202f8 --- /dev/null +++ b/cannaiq/src/content/index.ts @@ -0,0 +1,20 @@ +/** + * SEO Content Index + * + * Central export for all SEO page content. + */ + +// Types +export * from './types'; + +// Homepage +export { homepageContent } from './homepage'; + +// States +export * from './states'; + +// Alternatives +export * from './alternatives'; + +// Landing pages +export * from './landing'; diff --git a/cannaiq/src/content/landing/cannabis-brand-tracking.ts b/cannaiq/src/content/landing/cannabis-brand-tracking.ts new file mode 100644 index 00000000..279773a9 --- /dev/null +++ b/cannaiq/src/content/landing/cannabis-brand-tracking.ts @@ -0,0 +1,185 @@ +import { LandingPageContent } from '../types'; + +export const cannabisBrandTrackingContent: LandingPageContent = { + pageType: 'landing', + intent: 'high', + slug: '/cannabis-brand-tracking', + title: 'Cannabis Brand Tracking | Distribution & Pricing Analytics | CannaIQ', + metaDescription: + 'Track your cannabis brand across dispensaries. Monitor distribution, pricing consistency, and competitor brands with real-time data from CannaIQ.', + h1: 'Track Your Cannabis Brand Across Every Dispensary', + ogImage: '/og/landing/brand-tracking.png', + canonicalUrl: 'https://cannaiq.co/cannabis-brand-tracking', + blocks: [ + { + type: 'hero', + headline: 'See Where Your Brand Is. See Where It Isn\'t.', + subheadline: + 'CannaIQ gives cannabis brands complete visibility into their retail distribution, pricing across accounts, and competitor positioning. Know your market.', + ctaPrimary: { text: 'Track Your Brand', href: '/demo?focus=brands' }, + ctaSecondary: { text: 'View Sample Data', href: '/samples/brands' } + }, + { + type: 'intro', + content: + 'Your brand is on dispensary shelves across the market. But do you really know where? At what prices? Next to which competitors? CannaIQ gives you real-time visibility into your brand\'s retail presence, helping you optimize distribution, maintain pricing consistency, and understand your competitive position.', + highlights: [ + 'Distribution footprint mapping', + 'Retail pricing visibility', + 'Competitor brand tracking', + 'Distribution gap identification' + ] + }, + { + type: 'stats', + headline: 'Brand Intelligence at Scale', + items: [ + { value: '1,200+', label: 'Brands Tracked', description: 'Across all markets' }, + { value: '850+', label: 'Dispensaries', description: 'Monitored continuously' }, + { value: '125K+', label: 'Products', description: 'With live data' }, + { value: '4hr', label: 'Refresh Rate', description: 'Data updated continuously' } + ] + }, + { + type: 'features', + headline: 'Brand Tracking Capabilities', + items: [ + { + icon: 'map', + title: 'Distribution Mapping', + description: + 'See exactly which dispensaries carry your products. Visualize your retail footprint on an interactive map.' + }, + { + icon: 'dollar-sign', + title: 'Retail Pricing Visibility', + description: + 'Monitor how dispensaries price your products. Identify pricing inconsistencies and potential MAP violations.' + }, + { + icon: 'search', + title: 'Distribution Gaps', + description: + 'Identify dispensaries that don\'t carry your brand. Prioritize sales outreach with data-driven targeting.' + }, + { + icon: 'users', + title: 'Competitor Tracking', + description: + 'See where competitor brands are distributed. Understand competitive positioning at every retail account.' + }, + { + icon: 'trending-up', + title: 'Distribution Trends', + description: + 'Track your distribution growth over time. Measure the impact of sales efforts and market expansion.' + }, + { + icon: 'package', + title: 'Product-Level Data', + description: + 'Drill down to individual SKUs. See which products have the widest distribution and which need attention.' + } + ] + }, + { + type: 'useCases', + headline: 'How Cannabis Brands Use CannaIQ', + items: [ + { + title: 'Sales Team Enablement', + description: + 'Arm your sales team with data that drives conversations and opens doors.', + benefits: [ + 'Target high-opportunity accounts', + 'Show competitive presence', + 'Track sales effort results' + ] + }, + { + title: 'Pricing Consistency', + description: + 'Ensure retail partners maintain appropriate pricing and protect brand value.', + benefits: [ + 'Monitor MAP compliance', + 'Identify price undercutting', + 'Maintain brand positioning' + ] + }, + { + title: 'Competitive Intelligence', + description: + 'Understand how your brand compares to competitors at the shelf level.', + benefits: [ + 'Track competitor distribution', + 'Compare pricing strategies', + 'Identify competitive threats' + ] + }, + { + title: 'Market Expansion', + description: + 'Plan market entry with data-driven insights.', + benefits: [ + 'Identify white space', + 'Understand local pricing', + 'Target initial accounts' + ] + } + ] + }, + { + type: 'testimonial', + quote: + 'CannaIQ changed how our sales team operates. Instead of guessing which dispensaries to call, they know exactly who doesn\'t carry us and what competitors are on the shelf. Close rates are up significantly.', + author: 'Michael Chen', + role: 'VP of Sales', + company: 'Desert Bloom Brands' + }, + { + type: 'faq', + headline: 'Brand Tracking Questions', + items: [ + { + question: 'How do you track my brand?', + answer: + 'We monitor dispensary menus continuously and match products to brands. You can review and refine matching for accuracy. No action required on your part.' + }, + { + question: 'Can I track competitor brands too?', + answer: + 'Yes. You can track any brand in our database. Understand where competitors are distributed and how they\'re priced across accounts.' + }, + { + question: 'How current is the distribution data?', + answer: + 'Data is refreshed every 4 hours. When a dispensary adds or removes your product from their menu, you\'ll see it reflected within hours.' + }, + { + question: 'Can I get alerts when my distribution changes?', + answer: + 'Yes. Set alerts for when you gain or lose distribution at specific accounts, or when pricing changes significantly.' + }, + { + question: 'Can I export data for my team?', + answer: + 'Yes. Export distribution reports, pricing data, and competitive analysis to CSV, Excel, or JSON. API access is also available for integration with your CRM or BI tools.' + } + ] + }, + { + type: 'cta', + headline: 'Know Where Your Brand Stands', + subheadline: + 'Get complete visibility into your brand\'s retail presence. Start tracking your distribution today.', + ctaPrimary: { text: 'Start Brand Tracking', href: '/demo?focus=brands' }, + ctaSecondary: { text: 'View Pricing', href: '/pricing' } + } + ], + internalLinks: [ + { text: 'Cannabis Data Platform', href: '/cannabis-data-platform' }, + { text: 'Dispensary Pricing', href: '/dispensary-pricing-software' }, + { text: 'Arizona Brands', href: '/states/arizona/brands' }, + { text: 'API Documentation', href: '/docs/api' } + ] +}; diff --git a/cannaiq/src/content/landing/cannabis-data-platform.ts b/cannaiq/src/content/landing/cannabis-data-platform.ts new file mode 100644 index 00000000..8929232a --- /dev/null +++ b/cannaiq/src/content/landing/cannabis-data-platform.ts @@ -0,0 +1,239 @@ +import { LandingPageContent } from '../types'; + +export const cannabisDataPlatformContent: LandingPageContent = { + pageType: 'landing', + intent: 'high', + slug: '/cannabis-data-platform', + title: 'Cannabis Data Platform | Real-Time Market Intelligence | CannaIQ', + metaDescription: + 'The cannabis data platform built for operators. Get real-time dispensary pricing, brand distribution, and competitive intelligence with continuously refreshed data.', + h1: 'The Cannabis Data Platform Built for Operators', + ogImage: '/og/landing/cannabis-data-platform.png', + canonicalUrl: 'https://cannaiq.co/cannabis-data-platform', + blocks: [ + { + type: 'hero', + headline: 'Real-Time Cannabis Market Data at Your Fingertips', + subheadline: + 'CannaIQ monitors dispensary menus continuously, delivering the pricing, availability, and brand data you need to make confident business decisions.', + ctaPrimary: { text: 'Start Free Trial', href: '/trial' }, + ctaSecondary: { text: 'Schedule Demo', href: '/demo' } + }, + { + type: 'stats', + headline: 'Trusted by Cannabis Operators', + items: [ + { value: '850+', label: 'Dispensaries', description: 'Menu data monitored' }, + { value: '125K+', label: 'Products', description: 'SKUs with live pricing' }, + { value: '4hr', label: 'Refresh Rate', description: 'Data updated continuously' }, + { value: '99.9%', label: 'Uptime', description: 'Platform reliability' } + ] + }, + { + type: 'intro', + content: + 'In cannabis retail, information is competitive advantage. CannaIQ gives dispensaries, brands, and investors access to the same real-time market data that drives strategic decisions. No more guessing at competitor prices. No more wondering about brand distribution. Just clear, current data.', + highlights: [ + 'Real-time pricing intelligence', + 'Brand presence tracking', + 'Competitive analysis tools', + 'Historical trend data' + ] + }, + { + type: 'features', + headline: 'Platform Capabilities', + subheadline: 'Everything you need to understand your market', + items: [ + { + icon: 'dollar-sign', + title: 'Pricing Intelligence', + description: + 'Monitor competitor pricing across all product categories. Get alerts when prices change. Identify opportunities to optimize your pricing strategy.' + }, + { + icon: 'map-pin', + title: 'Distribution Mapping', + description: + 'See which stores carry which brands. Track your retail footprint. Identify distribution gaps and expansion opportunities.' + }, + { + icon: 'trending-up', + title: 'Market Trends', + description: + 'Analyze pricing trends over time. Identify seasonal patterns. Understand how your market is evolving.' + }, + { + icon: 'bell', + title: 'Smart Alerts', + description: + 'Set custom alerts for price changes, new products, stock-outs, and competitor activity. Stay informed without constant monitoring.' + }, + { + icon: 'code', + title: 'API Access', + description: + 'Integrate CannaIQ data directly into your systems. Power your pricing engine, BI tools, or internal dashboards with live market data.' + }, + { + icon: 'download', + title: 'Data Exports', + description: + 'Export data to CSV, Excel, or JSON. Generate reports for stakeholders. Access the data in the format you need.' + } + ] + }, + { + type: 'useCases', + headline: 'Built for Every Cannabis Business', + items: [ + { + title: 'Dispensary Operators', + description: + 'Stay competitive with real-time visibility into local market pricing and product mix.', + benefits: [ + 'Competitive pricing analysis', + 'Assortment planning', + 'Price optimization' + ] + }, + { + title: 'Cannabis Brands', + description: + 'Track your distribution footprint and ensure pricing consistency across retail partners.', + benefits: [ + 'Distribution tracking', + 'MAP monitoring', + 'Competitor analysis' + ] + }, + { + title: 'Multi-State Operators', + description: + 'Monitor markets across your entire footprint from a single unified platform.', + benefits: [ + 'Cross-market visibility', + 'Centralized intelligence', + 'Scalable coverage' + ] + }, + { + title: 'Investors & Analysts', + description: + 'Access reliable market data for research, due diligence, and portfolio monitoring.', + benefits: [ + 'Market sizing data', + 'Brand health metrics', + 'Trend analysis' + ] + } + ] + }, + { + type: 'testimonial', + quote: + 'CannaIQ is now essential to how we run our business. We check competitor pricing every morning and adjust our promotions based on real data, not guesses.', + author: 'Robert Kim', + role: 'General Manager', + company: 'Valley Wellness Dispensary' + }, + { + type: 'pricing', + headline: 'Simple, Transparent Pricing', + subheadline: 'Choose the plan that fits your business', + tiers: [ + { + name: 'Starter', + price: '$299/mo', + description: 'For single-location operators', + features: [ + 'Single market coverage', + 'Real-time pricing data', + 'Brand tracking', + 'Email alerts', + 'CSV exports' + ], + ctaText: 'Start Trial', + ctaHref: '/trial?plan=starter' + }, + { + name: 'Professional', + price: '$699/mo', + description: 'For growing businesses', + features: [ + 'Full market coverage', + 'API access', + 'Custom alerts', + 'Historical data', + 'Priority support', + 'Unlimited exports' + ], + ctaText: 'Start Trial', + ctaHref: '/trial?plan=professional', + highlighted: true + }, + { + name: 'Enterprise', + price: 'Custom', + description: 'For multi-market operators', + features: [ + 'Multi-market coverage', + 'White-label options', + 'Custom integrations', + 'Dedicated success manager', + 'SLA guarantees', + 'Custom reporting' + ], + ctaText: 'Contact Sales', + ctaHref: '/contact?plan=enterprise' + } + ] + }, + { + type: 'faq', + headline: 'Platform Questions', + items: [ + { + question: 'How quickly can I get started?', + answer: + 'Most customers are up and running within 24 hours of signup. Our platform requires no technical integration - just create your account and start exploring your market.' + }, + { + question: 'What data sources do you use?', + answer: + 'We monitor dispensary menu platforms directly, capturing real-time pricing and product information. Data is refreshed every 4 hours to ensure accuracy.' + }, + { + question: 'Can I integrate with my existing systems?', + answer: + 'Yes. Our REST API allows integration with POS systems, BI tools, and internal applications. WordPress and Shopify plugins are also available.' + }, + { + question: 'Do you offer a free trial?', + answer: + 'Yes. All plans include a 14-day free trial with full access to platform features. No credit card required to start.' + }, + { + question: 'What markets do you cover?', + answer: + 'We currently offer comprehensive coverage in Arizona. Additional markets are being added - contact us for the latest coverage map and expansion timeline.' + } + ] + }, + { + type: 'cta', + headline: 'Start Making Data-Driven Decisions', + subheadline: + 'Join cannabis operators who rely on CannaIQ for real-time market intelligence. Try it free for 14 days.', + ctaPrimary: { text: 'Start Free Trial', href: '/trial' }, + ctaSecondary: { text: 'Schedule Demo', href: '/demo' } + } + ], + internalLinks: [ + { text: 'Pricing Details', href: '/pricing' }, + { text: 'API Documentation', href: '/docs/api' }, + { text: 'Arizona Market', href: '/states/arizona' }, + { text: 'For Dispensaries', href: '/solutions/dispensaries' }, + { text: 'For Brands', href: '/solutions/brands' } + ] +}; diff --git a/cannaiq/src/content/landing/dispensary-pricing-software.ts b/cannaiq/src/content/landing/dispensary-pricing-software.ts new file mode 100644 index 00000000..cec830c7 --- /dev/null +++ b/cannaiq/src/content/landing/dispensary-pricing-software.ts @@ -0,0 +1,175 @@ +import { LandingPageContent } from '../types'; + +export const dispensaryPricingSoftwareContent: LandingPageContent = { + pageType: 'landing', + intent: 'high', + slug: '/dispensary-pricing-software', + title: 'Dispensary Pricing Software | Competitive Intelligence | CannaIQ', + metaDescription: + 'Dispensary pricing software that monitors competitor prices in real-time. Set optimal prices, react to market changes, and maximize margins with CannaIQ.', + h1: 'Dispensary Pricing Software That Drives Results', + ogImage: '/og/landing/dispensary-pricing.png', + canonicalUrl: 'https://cannaiq.co/dispensary-pricing-software', + blocks: [ + { + type: 'hero', + headline: 'Know Your Competitors\' Prices Before They Know Yours', + subheadline: + 'CannaIQ monitors competitor pricing continuously, giving you the intelligence you need to optimize your pricing strategy and protect your margins.', + ctaPrimary: { text: 'See Pricing Demo', href: '/demo?focus=pricing' }, + ctaSecondary: { text: 'View Pricing Data', href: '/samples/pricing' } + }, + { + type: 'intro', + content: + 'Pricing is the fastest lever you can pull in cannabis retail. But pricing blind is leaving money on the table. CannaIQ gives you real-time visibility into competitor prices so you can make informed decisions, react quickly to market changes, and optimize your margins.', + highlights: [ + 'Real-time competitor price monitoring', + 'Price change alerts', + 'Historical price trends', + 'Category and brand pricing analysis' + ] + }, + { + type: 'stats', + headline: 'The Price Visibility Advantage', + items: [ + { value: '4hr', label: 'Refresh Rate', description: 'Prices updated continuously' }, + { value: '125K+', label: 'Products', description: 'SKUs with live pricing' }, + { value: '15%', label: 'Avg. Margin Gain', description: 'Reported by customers' }, + { value: '<1hr', label: 'Alert Speed', description: 'Price change notifications' } + ] + }, + { + type: 'features', + headline: 'Pricing Intelligence Features', + items: [ + { + icon: 'dollar-sign', + title: 'Competitor Price Tracking', + description: + 'Monitor prices at every competitor in your market. See exactly what they charge for the same products you carry.' + }, + { + icon: 'bell', + title: 'Price Change Alerts', + description: + 'Get notified when competitors change prices on products you care about. React same-day to competitive moves.' + }, + { + icon: 'trending-up', + title: 'Price Trend Analysis', + description: + 'Track pricing trends over time. Identify seasonal patterns and understand how your market is evolving.' + }, + { + icon: 'target', + title: 'Category Benchmarking', + description: + 'Compare your pricing by category against market averages. Find opportunities to adjust positioning.' + }, + { + icon: 'bar-chart', + title: 'Brand Price Analysis', + description: + 'Analyze pricing patterns by brand. Ensure you\'re competitive on the brands that matter to your customers.' + }, + { + icon: 'download', + title: 'Price Reports', + description: + 'Generate pricing reports for stakeholders. Export data for analysis in your preferred tools.' + } + ] + }, + { + type: 'useCases', + headline: 'How Dispensaries Use CannaIQ for Pricing', + items: [ + { + title: 'Daily Price Checks', + description: + 'Start each day knowing exactly where you stand versus competitors.', + benefits: [ + 'Morning price summary', + 'Overnight change alerts', + 'Competitive gap analysis' + ] + }, + { + title: 'Promotion Planning', + description: + 'Set promotional prices with confidence, knowing the competitive landscape.', + benefits: [ + 'Competitor promotion tracking', + 'Gap opportunity identification', + 'Historical promo analysis' + ] + }, + { + title: 'New Product Pricing', + description: + 'Price new products correctly from day one based on market data.', + benefits: [ + 'Market price research', + 'Category benchmarks', + 'Launch optimization' + ] + } + ] + }, + { + type: 'testimonial', + quote: + 'We used to check competitor websites manually - it took hours. Now I get an alert if anyone drops prices on key products. We\'ve definitely captured sales we would have lost.', + author: 'Amanda Torres', + role: 'Pricing Manager', + company: 'Green Leaf Dispensary Group' + }, + { + type: 'faq', + headline: 'Pricing Software Questions', + items: [ + { + question: 'How often are prices updated?', + answer: + 'Prices are refreshed every 4 hours throughout the day. This ensures you always have access to current competitive pricing information.' + }, + { + question: 'Can I set alerts for specific products?', + answer: + 'Yes. You can create custom alerts for specific products, brands, categories, or competitors. Get notified by email or in-app when prices change.' + }, + { + question: 'Do you track promotional pricing?', + answer: + 'We capture all menu pricing including promotional prices. This gives you visibility into competitor promotions as they happen.' + }, + { + question: 'Can I compare my prices to competitors?', + answer: + 'Yes. Our competitive analysis tools let you compare your pricing against specific competitors or market averages across any product set.' + }, + { + question: 'Does CannaIQ integrate with my POS?', + answer: + 'CannaIQ provides competitive intelligence that informs your pricing decisions. While we don\'t directly change your POS prices, our API can feed data into pricing automation tools.' + } + ] + }, + { + type: 'cta', + headline: 'Stop Pricing Blind', + subheadline: + 'Get the competitive pricing intelligence you need to optimize margins and stay ahead of the competition.', + ctaPrimary: { text: 'Start Free Trial', href: '/trial?focus=pricing' }, + ctaSecondary: { text: 'See Pricing Demo', href: '/demo?focus=pricing' } + } + ], + internalLinks: [ + { text: 'Cannabis Data Platform', href: '/cannabis-data-platform' }, + { text: 'Arizona Pricing', href: '/states/arizona/pricing' }, + { text: 'Brand Analytics', href: '/solutions/brands' }, + { text: 'API Access', href: '/docs/api' } + ] +}; diff --git a/cannaiq/src/content/landing/index.ts b/cannaiq/src/content/landing/index.ts new file mode 100644 index 00000000..96a58c02 --- /dev/null +++ b/cannaiq/src/content/landing/index.ts @@ -0,0 +1,37 @@ +/** + * Landing Page Content Index + * + * Import and export all high-intent landing page content. + */ + +import { cannabisDataPlatformContent } from './cannabis-data-platform'; +import { dispensaryPricingSoftwareContent } from './dispensary-pricing-software'; +import { cannabisBrandTrackingContent } from './cannabis-brand-tracking'; +import { LandingPageContent } from '../types'; + +// Export individual landing pages +export { + cannabisDataPlatformContent, + dispensaryPricingSoftwareContent, + cannabisBrandTrackingContent +}; + +// Map of all landing page content by slug +export const landingPageContentMap: Record = { + 'cannabis-data-platform': cannabisDataPlatformContent, + 'dispensary-pricing-software': dispensaryPricingSoftwareContent, + 'cannabis-brand-tracking': cannabisBrandTrackingContent +}; + +// Get content by slug key +export function getLandingPageContent(slugKey: string): LandingPageContent | undefined { + return landingPageContentMap[slugKey.toLowerCase()]; +} + +// Get content by page slug (full path) +export function getLandingPageContentBySlug(slug: string): LandingPageContent | undefined { + return Object.values(landingPageContentMap).find((content) => content.slug === slug); +} + +// List of all available landing pages +export const availableLandingPages = Object.keys(landingPageContentMap); diff --git a/cannaiq/src/content/states/arizona.ts b/cannaiq/src/content/states/arizona.ts new file mode 100644 index 00000000..e85cd2d8 --- /dev/null +++ b/cannaiq/src/content/states/arizona.ts @@ -0,0 +1,248 @@ +import { StatePageContent } from '../types'; + +export const arizonaContent: StatePageContent = { + pageType: 'state', + stateCode: 'AZ', + stateName: 'Arizona', + slug: '/states/arizona', + title: 'Arizona Cannabis Market Data | CannaIQ', + metaDescription: + 'Real-time Arizona cannabis market intelligence. Track dispensary pricing, brand distribution, and market trends across 130+ licensed retailers with live data.', + h1: 'Arizona Cannabis Market Intelligence', + ogImage: '/og/states/arizona.png', + canonicalUrl: 'https://cannaiq.co/states/arizona', + breadcrumbs: [ + { text: 'Home', href: '/' }, + { text: 'States', href: '/states' }, + { text: 'Arizona', href: '/states/arizona' } + ], + blocks: [ + { + type: 'hero', + headline: 'Arizona Cannabis Market Data', + subheadline: + 'Continuously refreshed pricing, product, and brand data from 130+ Arizona dispensaries. The insights you need to compete in the Grand Canyon State.', + ctaPrimary: { text: 'Get Arizona Data', href: '/demo?state=az' }, + ctaSecondary: { text: 'View Sample Report', href: '/samples/arizona' } + }, + { + type: 'stateOverview', + stateName: 'Arizona', + stateCode: 'AZ', + legalStatus: 'Adult-use and Medical', + marketSize: '$1.4B annually', + dispensaryCount: '130+', + description: + 'Arizona legalized adult-use cannabis in November 2020 with Proposition 207, building on its established medical program. The market has grown rapidly, with dispensaries concentrated in the Phoenix metro area, Tucson, and tourist corridors. Competition is intense, making real-time market intelligence essential for operators.' + }, + { + type: 'stats', + headline: 'Arizona Market at a Glance', + items: [ + { + value: '130+', + label: 'Dispensaries Tracked', + description: 'Licensed retailers with live menu data' + }, + { + value: '45K+', + label: 'Products Monitored', + description: 'SKUs with current pricing' + }, + { + value: '400+', + label: 'Brands Active', + description: 'Brands with Arizona distribution' + }, + { + value: '$32', + label: 'Avg. Eighth Price', + description: 'Market average for flower eighths' + } + ] + }, + { + type: 'topBrands', + headline: 'Top Arizona Cannabis Brands', + brands: [ + { + name: 'Alien Labs', + category: 'Flower', + description: 'Premium flower with strong Arizona presence across 50+ dispensaries', + slug: 'alien-labs' + }, + { + name: 'Connected Cannabis', + category: 'Flower', + description: 'California heritage brand with growing Arizona distribution', + slug: 'connected-cannabis' + }, + { + name: 'Abundant Organics', + category: 'Flower', + description: 'Arizona-native cultivator known for quality flower', + slug: 'abundant-organics' + }, + { + name: 'Timeless', + category: 'Vapes', + description: 'Leading vape brand with statewide distribution', + slug: 'timeless' + }, + { + name: 'Item 9 Labs', + category: 'Concentrates', + description: 'Local concentrate brand with strong Phoenix presence', + slug: 'item-9-labs' + }, + { + name: 'Canamo', + category: 'Concentrates', + description: 'Popular concentrate brand across Arizona dispensaries', + slug: 'canamo' + } + ] + }, + { + type: 'marketInsights', + headline: 'Arizona Market Trends', + insights: [ + { + title: 'Flower Price Compression', + value: '-8%', + trend: 'down', + description: + 'Average flower prices have declined as competition increases and more cultivators enter the market.' + }, + { + title: 'Vape Market Share', + value: '28%', + trend: 'up', + description: + 'Vape products continue to gain share, now representing over a quarter of Arizona sales.' + }, + { + title: 'Brand Proliferation', + value: '+45', + trend: 'up', + description: + 'New brands entering Arizona market quarter-over-quarter as operators seek differentiation.' + }, + { + title: 'Premium Segment Growth', + value: '15%', + trend: 'up', + description: + 'Premium flower ($50+ eighths) growing faster than value segment as consumers trade up.' + } + ] + }, + { + type: 'features', + headline: 'What You Get with Arizona Coverage', + items: [ + { + icon: 'map-pin', + title: 'Phoenix Metro Focus', + description: + 'Deep coverage of the Phoenix metro area including Scottsdale, Tempe, Mesa, and surrounding cities where competition is most intense.' + }, + { + icon: 'sun', + title: 'Tucson & Southern AZ', + description: + 'Complete coverage of Tucson market plus southern Arizona border towns and tourist destinations.' + }, + { + icon: 'trending-up', + title: 'Price Tracking', + description: + 'Monitor price changes across all product categories. Get alerts when competitors adjust pricing.' + }, + { + icon: 'package', + title: 'Product Availability', + description: + 'Track which products are in stock at which stores. Identify distribution gaps and opportunities.' + } + ] + }, + { + type: 'useCases', + headline: 'How Arizona Operators Use CannaIQ', + items: [ + { + title: 'Competitive Pricing', + description: + 'Phoenix-area dispensaries use CannaIQ to monitor competitor pricing in real-time.', + benefits: [ + 'React to price changes within hours', + 'Identify underpriced opportunities', + 'Maintain competitive positioning' + ] + }, + { + title: 'Brand Distribution', + description: + 'Arizona brands track their retail footprint and identify expansion opportunities.', + benefits: [ + 'Map current distribution', + 'Identify target retailers', + 'Monitor competitor brands' + ] + }, + { + title: 'New Market Entry', + description: + 'Out-of-state operators researching Arizona market entry rely on CannaIQ data.', + benefits: [ + 'Understand pricing dynamics', + 'Identify white space', + 'Assess competition' + ] + } + ] + }, + { + type: 'faq', + headline: 'Arizona Market Questions', + items: [ + { + question: 'Which Arizona cities do you cover?', + answer: + 'We cover all major Arizona markets including Phoenix, Scottsdale, Tempe, Mesa, Chandler, Glendale, Tucson, Flagstaff, and surrounding areas. Coverage includes both urban dispensaries and rural locations.' + }, + { + question: 'How current is Arizona pricing data?', + answer: + 'Arizona dispensary data is refreshed every 4 hours throughout the day, ensuring you have access to current pricing and availability information.' + }, + { + question: 'Do you track Arizona medical vs. recreational prices?', + answer: + 'Yes. We track both medical and recreational pricing separately, allowing you to analyze the price differential and market dynamics for each customer segment.' + }, + { + question: 'Can I get historical Arizona market data?', + answer: + 'Yes. CannaIQ maintains historical pricing and product data, enabling trend analysis and seasonal pattern identification for Arizona markets.' + } + ] + }, + { + type: 'cta', + headline: 'Get Arizona Market Intelligence', + subheadline: + 'Join Arizona operators who rely on CannaIQ for real-time competitive intelligence. See your market clearly.', + ctaPrimary: { text: 'Request Arizona Demo', href: '/demo?state=az' }, + ctaSecondary: { text: 'View Pricing', href: '/pricing' } + } + ], + internalLinks: [ + { text: 'Phoenix Dispensaries', href: '/states/arizona/phoenix' }, + { text: 'Tucson Market', href: '/states/arizona/tucson' }, + { text: 'Arizona Brands', href: '/states/arizona/brands' }, + { text: 'Pricing Trends', href: '/states/arizona/pricing' }, + { text: 'All States', href: '/states' } + ] +}; diff --git a/cannaiq/src/content/states/index.ts b/cannaiq/src/content/states/index.ts new file mode 100644 index 00000000..bc4192af --- /dev/null +++ b/cannaiq/src/content/states/index.ts @@ -0,0 +1,29 @@ +/** + * State Page Content Index + * + * Import and export all state page content for easy access. + */ + +import { arizonaContent } from './arizona'; +import { StatePageContent } from '../types'; + +// Export individual states +export { arizonaContent }; + +// Map of all state content by state code +export const stateContentMap: Record = { + AZ: arizonaContent +}; + +// Get content by state code +export function getStateContent(stateCode: string): StatePageContent | undefined { + return stateContentMap[stateCode.toUpperCase()]; +} + +// Get content by slug +export function getStateContentBySlug(slug: string): StatePageContent | undefined { + return Object.values(stateContentMap).find((content) => content.slug === slug); +} + +// List of all available states +export const availableStates = Object.keys(stateContentMap); diff --git a/cannaiq/src/content/types.ts b/cannaiq/src/content/types.ts new file mode 100644 index 00000000..c45a19d9 --- /dev/null +++ b/cannaiq/src/content/types.ts @@ -0,0 +1,239 @@ +/** + * SEO Content Types for CannaIQ Public Pages + * + * Block-based content structure for landing pages, state pages, + * competitor alternatives, and high-intent pages. + */ + +// Base block types +export type BlockType = + | 'hero' + | 'intro' + | 'stats' + | 'features' + | 'useCases' + | 'testimonial' + | 'comparison' + | 'pricing' + | 'faq' + | 'cta' + | 'stateOverview' + | 'topBrands' + | 'marketInsights' + | 'competitorComparison'; + +export interface HeroBlock { + type: 'hero'; + headline: string; + subheadline: string; + ctaPrimary: { text: string; href: string }; + ctaSecondary?: { text: string; href: string }; + backgroundImage?: string; +} + +export interface IntroBlock { + type: 'intro'; + content: string; + highlights?: string[]; +} + +export interface StatItem { + value: string; + label: string; + description?: string; +} + +export interface StatsBlock { + type: 'stats'; + headline?: string; + items: StatItem[]; +} + +export interface FeatureItem { + icon?: string; + title: string; + description: string; +} + +export interface FeaturesBlock { + type: 'features'; + headline: string; + subheadline?: string; + items: FeatureItem[]; +} + +export interface UseCaseItem { + title: string; + description: string; + benefits: string[]; +} + +export interface UseCasesBlock { + type: 'useCases'; + headline: string; + items: UseCaseItem[]; +} + +export interface TestimonialBlock { + type: 'testimonial'; + quote: string; + author: string; + role: string; + company: string; + image?: string; +} + +export interface ComparisonItem { + feature: string; + us: boolean | string; + competitor: boolean | string; +} + +export interface ComparisonBlock { + type: 'comparison'; + headline: string; + competitorName: string; + items: ComparisonItem[]; +} + +export interface PricingTier { + name: string; + price: string; + description: string; + features: string[]; + ctaText: string; + ctaHref: string; + highlighted?: boolean; +} + +export interface PricingBlock { + type: 'pricing'; + headline: string; + subheadline?: string; + tiers: PricingTier[]; +} + +export interface FaqItem { + question: string; + answer: string; +} + +export interface FaqBlock { + type: 'faq'; + headline: string; + items: FaqItem[]; +} + +export interface CtaBlock { + type: 'cta'; + headline: string; + subheadline: string; + ctaPrimary: { text: string; href: string }; + ctaSecondary?: { text: string; href: string }; +} + +export interface StateOverviewBlock { + type: 'stateOverview'; + stateName: string; + stateCode: string; + legalStatus: string; + marketSize: string; + dispensaryCount: string; + description: string; +} + +export interface BrandItem { + name: string; + category: string; + description: string; + slug?: string; +} + +export interface TopBrandsBlock { + type: 'topBrands'; + headline: string; + brands: BrandItem[]; +} + +export interface InsightItem { + title: string; + value: string; + trend?: 'up' | 'down' | 'stable'; + description: string; +} + +export interface MarketInsightsBlock { + type: 'marketInsights'; + headline: string; + insights: InsightItem[]; +} + +export interface CompetitorComparisonBlock { + type: 'competitorComparison'; + headline: string; + competitorName: string; + competitorDescription: string; + advantages: string[]; + migrationCta: { text: string; href: string }; +} + +export type ContentBlock = + | HeroBlock + | IntroBlock + | StatsBlock + | FeaturesBlock + | UseCasesBlock + | TestimonialBlock + | ComparisonBlock + | PricingBlock + | FaqBlock + | CtaBlock + | StateOverviewBlock + | TopBrandsBlock + | MarketInsightsBlock + | CompetitorComparisonBlock; + +// Page content structure +export interface SEOPageContent { + slug: string; + title: string; + metaDescription: string; + h1: string; + ogImage?: string; + canonicalUrl?: string; + blocks: ContentBlock[]; + internalLinks?: { text: string; href: string }[]; + breadcrumbs?: { text: string; href: string }[]; +} + +// Page type-specific interfaces +export interface HomepageContent extends SEOPageContent { + pageType: 'homepage'; +} + +export interface StatePageContent extends SEOPageContent { + pageType: 'state'; + stateCode: string; + stateName: string; +} + +export interface AlternativePageContent extends SEOPageContent { + pageType: 'alternative'; + competitorSlug: string; + competitorName: string; +} + +export interface LandingPageContent extends SEOPageContent { + pageType: 'landing'; + intent: 'high' | 'medium' | 'awareness'; +} + +export interface InsightPostContent extends SEOPageContent { + pageType: 'insight'; + publishedAt: string; + updatedAt?: string; + author: string; + category: string; + tags: string[]; + readTime: string; +} diff --git a/cannaiq/src/hooks/useStateFilter.ts b/cannaiq/src/hooks/useStateFilter.ts new file mode 100644 index 00000000..cc4631f2 --- /dev/null +++ b/cannaiq/src/hooks/useStateFilter.ts @@ -0,0 +1,78 @@ +/** + * useStateFilter Hook + * + * Convenience wrapper around useStateStore that provides API-friendly helpers. + * Standardizes state filtering across all pages. + * + * Usage: + * const { selectedState, setSelectedState, stateParam, isAllStates, stateLabel } = useStateFilter(); + * + * // Pass to API calls (undefined is omitted, specific state is included) + * api.getMetrics({ state: stateParam }); + * + * // Display in UI + * {stateLabel} + */ + +import { useStateStore } from '../store/stateStore'; + +export interface UseStateFilterReturn { + // The raw selected state (null = All States) + selectedState: string | null; + + // Set the selected state + setSelectedState: (state: string | null) => void; + + // API-friendly param: undefined for "All", state code for specific state + // Use in API calls: { state: stateParam } - undefined values get omitted + stateParam: string | undefined; + + // Boolean check for "All States" mode + isAllStates: boolean; + + // Human-readable label for current selection + stateLabel: string; + + // Get full state name from code + getStateName: (code: string) => string; + + // Available states for dropdowns + availableStates: Array<{ code: string; name: string }>; + + // Loading state + isLoading: boolean; +} + +export function useStateFilter(): UseStateFilterReturn { + const { + selectedState, + setSelectedState, + availableStates, + isLoading, + getStateName, + isNationalView, + } = useStateStore(); + + const isAllStates = isNationalView(); + + // API-friendly param: undefined for "All" (so it gets omitted in query params) + const stateParam = selectedState ?? undefined; + + // Human-readable label + const stateLabel = isAllStates + ? 'All States' + : getStateName(selectedState!) || selectedState!; + + return { + selectedState, + setSelectedState, + stateParam, + isAllStates, + stateLabel, + getStateName, + availableStates, + isLoading, + }; +} + +export default useStateFilter; diff --git a/cannaiq/src/lib/analytics.ts b/cannaiq/src/lib/analytics.ts new file mode 100644 index 00000000..91888eb1 --- /dev/null +++ b/cannaiq/src/lib/analytics.ts @@ -0,0 +1,163 @@ +/** + * Analytics tracking utilities for CannaIQ + * + * Fire-and-forget tracking calls that don't block UI interactions. + */ + +import { api } from './api'; + +export type ProductClickAction = 'view' | 'open_store' | 'open_product' | 'compare' | 'other'; + +export interface ProductClickEvent { + productId: string; + storeId?: string; + brandId?: string; + campaignId?: string; + action: ProductClickAction; + source: string; + pageType?: string; // Page where event occurred (e.g., StoreDetailPage, BrandsIntelligence) +} + +/** + * Track a product click event (fire-and-forget) + * + * This function is non-blocking and will not throw errors. + * Events are sent to the backend asynchronously without waiting for response. + * + * @param event - The product click event data + * + * @example + * // Track a product view on the store detail page + * trackProductClick({ + * productId: '12345', + * storeId: '101', + * action: 'view', + * source: 'store_detail' + * }); + * + * @example + * // Track clicking through to a store from a product card + * trackProductClick({ + * productId: '12345', + * storeId: '101', + * brandId: 'select', + * campaignId: '5', + * action: 'open_store', + * source: 'brand_page' + * }); + */ +export function trackProductClick(event: ProductClickEvent): void { + // Fire-and-forget: don't await or handle errors + api.trackProductClick({ + product_id: event.productId, + store_id: event.storeId, + brand_id: event.brandId, + campaign_id: event.campaignId, + action: event.action, + source: event.source, + page_type: event.pageType, + url_path: typeof window !== 'undefined' ? window.location.pathname : undefined, + }).catch(() => { + // Silently ignore errors - tracking should never break the UI + }); +} + +/** + * Track a product view event + * + * Convenience wrapper for tracking 'view' actions. + * + * @param productId - The product ID being viewed + * @param source - The page/component source (e.g., 'store_detail', 'brand_page') + * @param options - Optional store, brand, and campaign context + */ +export function trackProductView( + productId: string, + source: string, + options?: { + storeId?: string; + brandId?: string; + campaignId?: string; + } +): void { + trackProductClick({ + productId, + action: 'view', + source, + ...options, + }); +} + +/** + * Track opening a store from a product context + * + * @param productId - The product ID that led to the store click + * @param storeId - The store being opened + * @param source - The page/component source + * @param options - Optional brand and campaign context + */ +export function trackOpenStore( + productId: string, + storeId: string, + source: string, + options?: { + brandId?: string; + campaignId?: string; + } +): void { + trackProductClick({ + productId, + storeId, + action: 'open_store', + source, + ...options, + }); +} + +/** + * Track opening a product detail page + * + * @param productId - The product being opened + * @param source - The page/component source + * @param options - Optional store, brand, and campaign context + */ +export function trackOpenProduct( + productId: string, + source: string, + options?: { + storeId?: string; + brandId?: string; + campaignId?: string; + } +): void { + trackProductClick({ + productId, + action: 'open_product', + source, + ...options, + }); +} + +/** + * Track adding a product to comparison + * + * @param productId - The product being compared + * @param source - The page/component source + * @param options - Optional store, brand, and campaign context + */ +export function trackCompare( + productId: string, + source: string, + options?: { + storeId?: string; + brandId?: string; + campaignId?: string; + } +): void { + trackProductClick({ + productId, + action: 'compare', + source, + ...options, + }); +} diff --git a/cannaiq/src/lib/api.ts b/cannaiq/src/lib/api.ts index 3e5a357e..77701e25 100755 --- a/cannaiq/src/lib/api.ts +++ b/cannaiq/src/lib/api.ts @@ -359,33 +359,33 @@ class ApiClient { return this.request(`/api/scraper-monitor/jobs/workers${params}`); } - // AZ Monitor (Dutchie AZ live crawler status) - async getAZMonitorActiveJobs() { + // Market Monitor (live crawler status) + async getMonitorActiveJobs() { return this.request<{ scheduledJobs: any[]; crawlJobs: any[]; inMemoryScrapers: any[]; totalActive: number; - }>('/api/az/monitor/active-jobs'); + }>('/api/markets/monitor/active-jobs'); } - async getAZMonitorRecentJobs(limit?: number) { + async getMonitorRecentJobs(limit?: number) { const params = limit ? `?limit=${limit}` : ''; return this.request<{ jobLogs: any[]; crawlJobs: any[]; - }>(`/api/az/monitor/recent-jobs${params}`); + }>(`/api/markets/monitor/recent-jobs${params}`); } - async getAZMonitorErrors(params?: { limit?: number; hours?: number }) { + async getMonitorErrors(params?: { limit?: number; hours?: number }) { const searchParams = new URLSearchParams(); if (params?.limit) searchParams.append('limit', params.limit.toString()); if (params?.hours) searchParams.append('hours', params.hours.toString()); const queryString = searchParams.toString() ? `?${searchParams.toString()}` : ''; - return this.request<{ errors: any[] }>(`/api/az/monitor/errors${queryString}`); + return this.request<{ errors: any[] }>(`/api/markets/monitor/errors${queryString}`); } - async getAZMonitorSummary() { + async getMonitorSummary() { return this.request<{ running_scheduled_jobs: number; running_crawl_jobs: number; @@ -406,9 +406,15 @@ class ApiClient { last_status: string; last_run_at: string; }>; - }>('/api/az/monitor/summary'); + }>('/api/markets/monitor/summary'); } + // Legacy aliases (deprecated - use neutral names above) + getAZMonitorActiveJobs = this.getMonitorActiveJobs.bind(this); + getAZMonitorRecentJobs = this.getMonitorRecentJobs.bind(this); + getAZMonitorErrors = this.getMonitorErrors.bind(this); + getAZMonitorSummary = this.getMonitorSummary.bind(this); + // Change Approval async getChanges(status?: 'pending' | 'approved' | 'rejected') { const params = status ? `?status=${status}` : ''; @@ -653,11 +659,11 @@ class ApiClient { } // ============================================================ - // AZ DATA API (formerly dutchie-az) + // MARKET DATA API (provider-agnostic) // ============================================================ - // Dutchie AZ Dashboard - async getDutchieAZDashboard() { + // Market Dashboard + async getMarketsDashboard() { return this.request<{ dispensaryCount: number; productCount: number; @@ -666,11 +672,14 @@ class ApiClient { failedJobCount: number; brandCount: number; categoryCount: number; - }>('/api/az/dashboard'); + }>('/api/markets/dashboard'); } - // Dutchie AZ Schedules (CRUD) - async getDutchieAZSchedules() { + // Legacy alias + getDutchieAZDashboard = this.getMarketsDashboard.bind(this); + + // Crawl Schedules (CRUD) + async getCrawlSchedules() { return this.request<{ schedules: Array<{ id: number; @@ -690,7 +699,7 @@ class ApiClient { createdAt: string; updatedAt: string; }>; - }>('/api/az/admin/schedules'); + }>('/api/markets/admin/schedules'); } async getDutchieAZSchedule(id: number) { @@ -709,7 +718,7 @@ class ApiClient { jobConfig: Record | null; createdAt: string; updatedAt: string; - }>(`/api/az/admin/schedules/${id}`); + }>(`/api/markets/admin/schedules/${id}`); } async createDutchieAZSchedule(data: { @@ -721,7 +730,7 @@ class ApiClient { jobConfig?: Record; startImmediately?: boolean; }) { - return this.request('/api/az/admin/schedules', { + return this.request('/api/markets/admin/schedules', { method: 'POST', body: JSON.stringify(data), }); @@ -734,37 +743,37 @@ class ApiClient { jitterMinutes?: number; jobConfig?: Record; }) { - return this.request(`/api/az/admin/schedules/${id}`, { + return this.request(`/api/markets/admin/schedules/${id}`, { method: 'PUT', body: JSON.stringify(data), }); } async deleteDutchieAZSchedule(id: number) { - return this.request<{ success: boolean; message: string }>(`/api/az/admin/schedules/${id}`, { + return this.request<{ success: boolean; message: string }>(`/api/markets/admin/schedules/${id}`, { method: 'DELETE', }); } async triggerDutchieAZSchedule(id: number) { - return this.request<{ success: boolean; message: string }>(`/api/az/admin/schedules/${id}/trigger`, { + return this.request<{ success: boolean; message: string }>(`/api/markets/admin/schedules/${id}/trigger`, { method: 'POST', }); } async initDutchieAZSchedules() { - return this.request<{ success: boolean; schedules: any[] }>('/api/az/admin/schedules/init', { + return this.request<{ success: boolean; schedules: any[] }>('/api/markets/admin/schedules/init', { method: 'POST', }); } - // Dutchie AZ Run Logs - async getDutchieAZScheduleLogs(scheduleId: number, limit?: number, offset?: number) { + // Crawl Run Logs + async getCrawlScheduleLogs(scheduleId: number, limit?: number, offset?: number) { const params = new URLSearchParams(); if (limit) params.append('limit', limit.toString()); if (offset) params.append('offset', offset.toString()); const queryString = params.toString() ? `?${params.toString()}` : ''; - return this.request<{ logs: any[]; total: number }>(`/api/az/admin/schedules/${scheduleId}/logs${queryString}`); + return this.request<{ logs: any[]; total: number }>(`/api/markets/admin/schedules/${scheduleId}/logs${queryString}`); } async getDutchieAZRunLogs(options?: { scheduleId?: number; jobName?: string; limit?: number; offset?: number }) { @@ -774,45 +783,45 @@ class ApiClient { if (options?.limit) params.append('limit', options.limit.toString()); if (options?.offset) params.append('offset', options.offset.toString()); const queryString = params.toString() ? `?${params.toString()}` : ''; - return this.request<{ logs: any[]; total: number }>(`/api/az/admin/run-logs${queryString}`); + return this.request<{ logs: any[]; total: number }>(`/api/markets/admin/run-logs${queryString}`); } - // Dutchie AZ Scheduler Control - async getDutchieAZSchedulerStatus() { - return this.request<{ running: boolean; pollIntervalMs: number }>('/api/az/admin/scheduler/status'); + // Scheduler Control + async getCrawlSchedulerStatus() { + return this.request<{ running: boolean; pollIntervalMs: number }>('/api/markets/admin/scheduler/status'); } async startDutchieAZScheduler() { - return this.request<{ success: boolean; message: string }>('/api/az/admin/scheduler/start', { + return this.request<{ success: boolean; message: string }>('/api/markets/admin/scheduler/start', { method: 'POST', }); } async stopDutchieAZScheduler() { - return this.request<{ success: boolean; message: string }>('/api/az/admin/scheduler/stop', { + return this.request<{ success: boolean; message: string }>('/api/markets/admin/scheduler/stop', { method: 'POST', }); } async triggerDutchieAZImmediateCrawl() { - return this.request<{ success: boolean; message: string }>('/api/az/admin/scheduler/trigger', { + return this.request<{ success: boolean; message: string }>('/api/markets/admin/scheduler/trigger', { method: 'POST', }); } - // Dutchie AZ Stores - async getDutchieAZStores(params?: { city?: string; hasPlatformId?: boolean; limit?: number; offset?: number }) { + // Market Stores + async getMarketStores(params?: { city?: string; hasPlatformId?: boolean; limit?: number; offset?: number }) { const searchParams = new URLSearchParams(); if (params?.city) searchParams.append('city', params.city); if (params?.hasPlatformId !== undefined) searchParams.append('hasPlatformId', String(params.hasPlatformId)); if (params?.limit) searchParams.append('limit', params.limit.toString()); if (params?.offset) searchParams.append('offset', params.offset.toString()); const queryString = searchParams.toString() ? `?${searchParams.toString()}` : ''; - return this.request<{ stores: any[]; total: number }>(`/api/az/stores${queryString}`); + return this.request<{ stores: any[]; total: number }>(`/api/markets/stores${queryString}`); } async getDutchieAZStore(id: number) { - return this.request(`/api/az/stores/${id}`); + return this.request(`/api/markets/stores/${id}`); } async getDutchieAZStoreSummary(id: number) { @@ -828,7 +837,7 @@ class ApiClient { brandCount: number; categoryCount: number; lastCrawl: any | null; - }>(`/api/az/stores/${id}/summary`); + }>(`/api/markets/stores/${id}/summary`); } async getDutchieAZStoreProducts(id: number, params?: { @@ -880,23 +889,23 @@ class ApiClient { total: number; limit: number; offset: number; - }>(`/api/az/stores/${id}/products${queryString}`); + }>(`/api/markets/stores/${id}/products${queryString}`); } async getDutchieAZStoreBrands(id: number) { return this.request<{ brands: Array<{ brand: string; product_count: number }>; - }>(`/api/az/stores/${id}/brands`); + }>(`/api/markets/stores/${id}/brands`); } async getDutchieAZStoreCategories(id: number) { return this.request<{ categories: Array<{ type: string; subcategory: string; product_count: number }>; - }>(`/api/az/stores/${id}/categories`); + }>(`/api/markets/stores/${id}/categories`); } - // Dutchie AZ Global Brands/Categories (from v_brands/v_categories views) - async getDutchieAZBrands(params?: { limit?: number; offset?: number }) { + // Global Brands/Categories (from v_brands/v_categories views) + async getMarketBrands(params?: { limit?: number; offset?: number }) { const searchParams = new URLSearchParams(); if (params?.limit) searchParams.append('limit', params.limit.toString()); if (params?.offset) searchParams.append('offset', params.offset.toString()); @@ -910,7 +919,7 @@ class ApiClient { dispensary_count: number; product_types: string[]; }>; - }>(`/api/az/brands${queryString}`); + }>(`/api/markets/brands${queryString}`); } async getDutchieAZCategories() { @@ -925,11 +934,11 @@ class ApiClient { min_thc: number | null; max_thc: number | null; }>; - }>('/api/az/categories'); + }>('/api/markets/categories'); } - // Dutchie AZ Debug - async getDutchieAZDebugSummary() { + // Debug Endpoints + async getMarketDebugSummary() { return this.request<{ tableCounts: { dispensary_count: string; @@ -956,7 +965,7 @@ class ApiClient { dispensary_name: string; crawled_at: string; }>; - }>('/api/az/debug/summary'); + }>('/api/markets/debug/summary'); } async getDutchieAZDebugStore(id: number) { @@ -984,25 +993,25 @@ class ApiClient { outOfStock: any[]; }; categories: Array<{ type: string; subcategory: string; count: string }>; - }>(`/api/az/debug/store/${id}`); + }>(`/api/markets/debug/store/${id}`); } async triggerDutchieAZCrawl(id: number, options?: { pricingType?: string; useBothModes?: boolean }) { - return this.request(`/api/az/admin/crawl/${id}`, { + return this.request(`/api/markets/admin/crawl/${id}`, { method: 'POST', body: JSON.stringify(options || {}), }); } - // Dutchie AZ Menu Detection - async getDetectionStats() { + // Menu Detection + async getMenuDetectionStats() { return this.request<{ totalDispensaries: number; withMenuType: number; withPlatformId: number; needsDetection: number; byProvider: Record; - }>('/api/az/admin/detection/stats'); + }>('/api/markets/admin/detection/stats'); } async getDispensariesNeedingDetection(params?: { state?: string; limit?: number }) { @@ -1010,7 +1019,7 @@ class ApiClient { if (params?.state) searchParams.append('state', params.state); if (params?.limit) searchParams.append('limit', params.limit.toString()); const queryString = searchParams.toString() ? `?${searchParams.toString()}` : ''; - return this.request<{ dispensaries: any[]; total: number }>(`/api/az/admin/detection/pending${queryString}`); + return this.request<{ dispensaries: any[]; total: number }>(`/api/markets/admin/detection/pending${queryString}`); } async detectDispensary(id: number) { @@ -1023,7 +1032,7 @@ class ApiClient { platformDispensaryId: string | null; success: boolean; error?: string; - }>(`/api/az/admin/detection/detect/${id}`, { + }>(`/api/markets/admin/detection/detect/${id}`, { method: 'POST', }); } @@ -1041,14 +1050,14 @@ class ApiClient { totalSkipped: number; results: any[]; errors: string[]; - }>('/api/az/admin/detection/detect-all', { + }>('/api/markets/admin/detection/detect-all', { method: 'POST', body: JSON.stringify(options || {}), }); } async triggerMenuDetectionJob() { - return this.request<{ success: boolean; message: string }>('/api/az/admin/detection/trigger', { + return this.request<{ success: boolean; message: string }>('/api/markets/admin/detection/trigger', { method: 'POST', }); } @@ -1116,7 +1125,7 @@ class ApiClient { status: string; error?: string; }>; - }>(`/api/az/admin/dispensaries/${dispensaryId}/crawl-trace/latest`); + }>(`/api/markets/admin/dispensaries/${dispensaryId}/crawl-trace/latest`); } async getDispensaryTraces(dispensaryId: number, params?: { limit?: number; offset?: number }) { @@ -1141,7 +1150,7 @@ class ApiClient { completedAt: string | null; }>; total: number; - }>(`/api/az/admin/dispensaries/${dispensaryId}/crawl-traces${queryString}`); + }>(`/api/markets/admin/dispensaries/${dispensaryId}/crawl-traces${queryString}`); } async getTraceById(traceId: number) { @@ -1177,7 +1186,7 @@ class ApiClient { status: string; error?: string; }>; - }>(`/api/az/admin/crawl-traces/${traceId}`); + }>(`/api/markets/admin/crawl-traces/${traceId}`); } // ============================================================ @@ -2025,6 +2034,394 @@ class ApiClient { }>(`/api/admin/debug/snapshots/${snapshotId}/raw-payload`); } + // ============================================================ + // HEALTH CHECK API + // ============================================================ + + async getHealth() { + return this.request<{ + status: 'ok' | 'degraded' | 'error'; + uptime: number; + timestamp: string; + version: string; + }>('/api/health'); + } + + async getHealthDb() { + return this.request<{ + status: 'ok' | 'degraded' | 'error'; + connected: boolean; + latency_ms: number; + error?: string; + }>('/api/health/db'); + } + + async getHealthRedis() { + return this.request<{ + status: 'ok' | 'degraded' | 'error'; + connected: boolean; + latency_ms: number; + error?: string; + }>('/api/health/redis'); + } + + async getHealthWorkers() { + return this.request<{ + status: 'ok' | 'degraded' | 'error'; + queues: Array<{ + name: string; + waiting: number; + active: number; + completed: number; + failed: number; + paused: boolean; + }>; + workers: Array<{ + id: string; + queue: string; + status: string; + last_heartbeat?: string; + }>; + }>('/api/health/workers'); + } + + async getHealthCrawls() { + return this.request<{ + status: 'ok' | 'degraded' | 'stale' | 'error'; + last_run: string | null; + runs_last_24h: number; + stores_with_recent_crawl: number; + stores_total: number; + stale_stores: number; + }>('/api/health/crawls'); + } + + async getHealthAnalytics() { + return this.request<{ + status: 'ok' | 'degraded' | 'stale' | 'error'; + last_aggregate: string | null; + daily_runs_last_7d: number; + missing_days: number; + }>('/api/health/analytics'); + } + + async getHealthFull() { + return this.request<{ + status: 'ok' | 'degraded' | 'error'; + api: { + status: 'ok' | 'degraded' | 'error'; + uptime: number; + timestamp: string; + version: string; + }; + db: { + status: 'ok' | 'degraded' | 'error'; + connected: boolean; + latency_ms: number; + error?: string; + }; + redis: { + status: 'ok' | 'degraded' | 'error'; + connected: boolean; + latency_ms: number; + error?: string; + }; + workers: { + status: 'ok' | 'degraded' | 'error'; + queues: Array<{ + name: string; + waiting: number; + active: number; + completed: number; + failed: number; + paused: boolean; + }>; + workers: Array<{ + id: string; + queue: string; + status: string; + last_heartbeat?: string; + }>; + }; + crawls: { + status: 'ok' | 'degraded' | 'stale' | 'error'; + last_run: string | null; + runs_last_24h: number; + stores_with_recent_crawl: number; + stores_total: number; + stale_stores: number; + }; + analytics: { + status: 'ok' | 'degraded' | 'stale' | 'error'; + last_aggregate: string | null; + daily_runs_last_7d: number; + missing_days: number; + }; + }>('/api/health/full'); + } + + // ============================================================ + // EVENTS / ANALYTICS TRACKING + // ============================================================ + + /** + * Track a product click event (fire-and-forget) + * @param event - The product click event data + * @returns Promise that resolves to {status: 'ok'} or {status: 'error'} + */ + async trackProductClick(event: { + product_id: string; + store_id?: string; + brand_id?: string; + campaign_id?: string; + action: 'view' | 'open_store' | 'open_product' | 'compare' | 'other'; + source: string; + page_type?: string; + url_path?: string; + }) { + try { + return await this.request<{ status: string }>('/api/events/product-click', { + method: 'POST', + body: JSON.stringify(event), + }); + } catch { + // Fire-and-forget: don't throw errors for tracking failures + return { status: 'error' }; + } + } + + /** + * Get product click events (admin only) + */ + async getProductClickEvents(params?: { + product_id?: string; + store_id?: string; + brand_id?: string; + campaign_id?: string; + action?: string; + source?: string; + from?: string; + to?: string; + limit?: number; + offset?: number; + }) { + const searchParams = new URLSearchParams(); + if (params?.product_id) searchParams.append('product_id', params.product_id); + if (params?.store_id) searchParams.append('store_id', params.store_id); + if (params?.brand_id) searchParams.append('brand_id', params.brand_id); + if (params?.campaign_id) searchParams.append('campaign_id', params.campaign_id); + if (params?.action) searchParams.append('action', params.action); + if (params?.source) searchParams.append('source', params.source); + if (params?.from) searchParams.append('from', params.from); + if (params?.to) searchParams.append('to', params.to); + if (params?.limit) searchParams.append('limit', params.limit.toString()); + if (params?.offset) searchParams.append('offset', params.offset.toString()); + const queryString = searchParams.toString() ? `?${searchParams.toString()}` : ''; + return this.request<{ + events: Array<{ + id: number; + product_id: string; + store_id: string | null; + brand_id: string | null; + campaign_id: number | null; + action: string; + source: string; + user_id: number | null; + ip_address: string | null; + user_agent: string | null; + occurred_at: string; + created_at: string; + }>; + total: number; + limit: number; + offset: number; + }>(`/api/events/product-clicks${queryString}`); + } + + // ============================================================ + // CLICK ANALYTICS API - Brand & Campaign Engagement + // ============================================================ + + /** + * Get top brands by click engagement + */ + async getClickAnalyticsBrands(params?: { + state?: string; + store_id?: string; + brand_id?: string; + days?: number; + limit?: number; + }) { + const searchParams = new URLSearchParams(); + if (params?.state) searchParams.append('state', params.state); + if (params?.store_id) searchParams.append('store_id', params.store_id); + if (params?.brand_id) searchParams.append('brand_id', params.brand_id); + if (params?.days) searchParams.append('days', params.days.toString()); + if (params?.limit) searchParams.append('limit', params.limit.toString()); + const queryString = searchParams.toString() ? `?${searchParams.toString()}` : ''; + return this.request<{ + filters: { + state: string | null; + store_id: string | null; + brand_id: string | null; + days: number; + }; + brands: Array<{ + brand_id: string; + brand_name: string; + clicks: number; + unique_products: number; + unique_stores: number; + first_click_at: string; + last_click_at: string; + }>; + }>(`/api/analytics/clicks/brands${queryString}`); + } + + /** + * Get top campaigns by click engagement + */ + async getClickAnalyticsCampaigns(params?: { + state?: string; + store_id?: string; + days?: number; + limit?: number; + }) { + const searchParams = new URLSearchParams(); + if (params?.state) searchParams.append('state', params.state); + if (params?.store_id) searchParams.append('store_id', params.store_id); + if (params?.days) searchParams.append('days', params.days.toString()); + if (params?.limit) searchParams.append('limit', params.limit.toString()); + const queryString = searchParams.toString() ? `?${searchParams.toString()}` : ''; + return this.request<{ + filters: { + state: string | null; + store_id: string | null; + days: number; + }; + campaigns: Array<{ + campaign_id: number; + campaign_name: string; + campaign_slug: string | null; + campaign_description: string | null; + is_active: boolean; + start_date: string | null; + end_date: string | null; + clicks: number; + unique_products: number; + unique_stores: number; + first_event_at: string; + last_event_at: string; + }>; + }>(`/api/analytics/clicks/campaigns${queryString}`); + } + + /** + * Get top products by click engagement + */ + async getClickAnalyticsProducts(params?: { + state?: string; + store_id?: string; + brand_id?: string; + days?: number; + limit?: number; + }) { + const searchParams = new URLSearchParams(); + if (params?.state) searchParams.append('state', params.state); + if (params?.store_id) searchParams.append('store_id', params.store_id); + if (params?.brand_id) searchParams.append('brand_id', params.brand_id); + if (params?.days) searchParams.append('days', params.days.toString()); + if (params?.limit) searchParams.append('limit', params.limit.toString()); + const queryString = searchParams.toString() ? `?${searchParams.toString()}` : ''; + return this.request<{ + filters: { + state: string | null; + store_id: string | null; + brand_id: string | null; + days: number; + }; + products: Array<{ + product_id: string; + product_name: string; + brand_id: string | null; + brand_name: string; + category: string | null; + subcategory: string | null; + clicks: number; + unique_stores: number; + first_click_at: string; + last_click_at: string; + }>; + }>(`/api/analytics/clicks/products${queryString}`); + } + + /** + * Get brand engagement for a specific store + */ + async getClickAnalyticsStoreBrands(storeId: number | string, params?: { + days?: number; + limit?: number; + }) { + const searchParams = new URLSearchParams(); + if (params?.days) searchParams.append('days', params.days.toString()); + if (params?.limit) searchParams.append('limit', params.limit.toString()); + const queryString = searchParams.toString() ? `?${searchParams.toString()}` : ''; + return this.request<{ + store: { + id: number; + name: string; + city: string; + state: string; + }; + filters: { + days: number; + }; + brands: Array<{ + brand_id: string; + brand_name: string; + clicks: number; + unique_products: number; + first_click_at: string; + last_click_at: string; + }>; + }>(`/api/analytics/clicks/stores/${storeId}/brands${queryString}`); + } + + /** + * Get overall click summary stats + */ + async getClickAnalyticsSummary(params?: { + state?: string; + days?: number; + }) { + const searchParams = new URLSearchParams(); + if (params?.state) searchParams.append('state', params.state); + if (params?.days) searchParams.append('days', params.days.toString()); + const queryString = searchParams.toString() ? `?${searchParams.toString()}` : ''; + return this.request<{ + filters: { + state: string | null; + days: number; + }; + summary: { + total_clicks: number; + unique_products: number; + unique_stores: number; + unique_brands: number; + campaign_clicks: number; + unique_campaigns: number; + }; + by_action: Array<{ + action: string; + count: number; + }>; + daily: Array<{ + date: string; + clicks: number; + }>; + }>(`/api/analytics/clicks/summary${queryString}`); + } + // Scraper Overview Dashboard async getScraperOverview() { return this.request<{ @@ -2081,7 +2478,92 @@ class ApiClient { latestLoss: string | null; latestRestore: string | null; }>; - }>('/api/dutchie-az/scraper/overview'); + }>('/api/markets/scraper/overview'); + } + + // ============================================================ + // MARKET DATA ALIASES (for new components) + // Note: These use "Market" prefix to avoid conflicts with legacy methods + // ============================================================ + + // Market store methods (market data pipeline) + getMarketStoreSummary = this.getDutchieAZStoreSummary.bind(this); + getMarketStoreProducts = this.getDutchieAZStoreProducts.bind(this); + getMarketStoreBrands = this.getDutchieAZStoreBrands.bind(this); + getMarketStoreCategories = this.getDutchieAZStoreCategories.bind(this); + triggerMarketStoreCrawl = this.triggerDutchieAZCrawl.bind(this); + + // Dashboard methods + getMarketDashboard = this.getMarketsDashboard.bind(this); + + // Schedule methods (no conflicts) + getSchedules = this.getCrawlSchedules.bind(this); + getSchedule = this.getDutchieAZSchedule.bind(this); + createSchedule = this.createDutchieAZSchedule.bind(this); + updateSchedule = this.updateDutchieAZSchedule.bind(this); + deleteSchedule = this.deleteDutchieAZSchedule.bind(this); + triggerSchedule = this.triggerDutchieAZSchedule.bind(this); + initSchedules = this.initDutchieAZSchedules.bind(this); + getScheduleLogs = this.getCrawlScheduleLogs.bind(this); + getRunLogs = this.getDutchieAZRunLogs.bind(this); + getSchedulerStatus = this.getCrawlSchedulerStatus.bind(this); + startScheduler = this.startDutchieAZScheduler.bind(this); + stopScheduler = this.stopDutchieAZScheduler.bind(this); + triggerImmediateCrawl = this.triggerDutchieAZImmediateCrawl.bind(this); + + // Market brands/categories + getMarketCategories = this.getDutchieAZCategories.bind(this); + + // Detection + getDetectionStats = this.getMenuDetectionStats.bind(this); + + // ============================================================ + // LEGACY ALIASES (deprecated - for backward compatibility) + // ============================================================ + getDutchieAZStores = this.getMarketStores.bind(this); + getDutchieAZSchedules = this.getCrawlSchedules.bind(this); + getDutchieAZScheduleLogs = this.getCrawlScheduleLogs.bind(this); + getDutchieAZSchedulerStatus = this.getCrawlSchedulerStatus.bind(this); + getDutchieAZBrands = this.getMarketBrands.bind(this); + getDutchieAZDebugSummary = this.getMarketDebugSummary.bind(this); + + // ============================================================ + // SEO ORCHESTRATOR + // ============================================================ + async getSeoPages(params?: { type?: string; status?: string; search?: string }) { + const queryParams = new URLSearchParams(); + if (params?.type) queryParams.append('type', params.type); + if (params?.status) queryParams.append('status', params.status); + if (params?.search) queryParams.append('search', params.search); + const query = queryParams.toString(); + return this.request<{ pages: any[] }>(`/api/seo/pages${query ? `?${query}` : ''}`); + } + + async getStateMetrics() { + return this.request<{ states: any[] }>('/api/seo/state-metrics'); + } + + async syncStatePages() { + return this.request<{ message: string }>('/api/seo/sync-state-pages', { method: 'POST' }); + } + + async updateSeoPage(id: number, data: { status?: string }) { + return this.request<{ success: boolean }>(`/api/seo/pages/${id}`, { + method: 'PATCH', + body: JSON.stringify(data) + }); + } + + async generateSeoPage(pageId: number) { + return this.request<{ success: boolean; content: any }>(`/api/seo/pages/${pageId}/generate`, { + method: 'POST' + }); + } + + async getSeoPublicContent(slug: string) { + return this.request<{ slug: string; type: string; meta: any; blocks: any[] }>( + `/api/seo/public/content?slug=${encodeURIComponent(slug)}` + ); } } diff --git a/cannaiq/src/pages/ClickAnalytics.tsx b/cannaiq/src/pages/ClickAnalytics.tsx new file mode 100644 index 00000000..63e0c5a7 --- /dev/null +++ b/cannaiq/src/pages/ClickAnalytics.tsx @@ -0,0 +1,647 @@ +import { useEffect, useState } from 'react'; +import { Layout } from '../components/Layout'; +import { api } from '../lib/api'; +import { useStateFilter } from '../hooks/useStateFilter'; +import { + Tag, + MousePointerClick, + TrendingUp, + Building2, + Package, + RefreshCw, + Megaphone, + BarChart3, + Store, +} from 'lucide-react'; + +interface BrandEngagement { + brand_id: string; + brand_name: string; + clicks: number; + unique_products: number; + unique_stores: number; + first_click_at: string; + last_click_at: string; +} + +interface ProductEngagement { + product_id: string; + product_name: string; + brand_id: string | null; + brand_name: string; + category: string | null; + subcategory: string | null; + clicks: number; + unique_stores: number; + first_click_at: string; + last_click_at: string; +} + +interface CampaignEngagement { + campaign_id: number; + campaign_name: string; + campaign_slug: string | null; + campaign_description: string | null; + is_active: boolean; + start_date: string | null; + end_date: string | null; + clicks: number; + unique_products: number; + unique_stores: number; + first_event_at: string; + last_event_at: string; +} + +interface StoreOption { + id: number; + name: string; +} + +interface ClickSummary { + total_clicks: number; + unique_products: number; + unique_stores: number; + unique_brands: number; + campaign_clicks: number; + unique_campaigns: number; +} + +interface ActionBreakdown { + action: string; + count: number; +} + +interface DailyClicks { + date: string; + clicks: number; +} + +export function ClickAnalytics() { + const { selectedState } = useStateFilter(); + const [loading, setLoading] = useState(true); + const [days, setDays] = useState(30); + const [storeId, setStoreId] = useState(''); + const [stores, setStores] = useState([]); + const [summary, setSummary] = useState(null); + const [byAction, setByAction] = useState([]); + const [daily, setDaily] = useState([]); + const [brands, setBrands] = useState([]); + const [products, setProducts] = useState([]); + const [campaigns, setCampaigns] = useState([]); + const [activeTab, setActiveTab] = useState<'overview' | 'brands' | 'products' | 'campaigns'>('overview'); + + // Fetch stores for filter dropdown + useEffect(() => { + const fetchStores = async () => { + try { + const result = await api.getMarketStores({ limit: 200 }); + // Filter by state if selected + const filteredStores = selectedState && selectedState !== 'ALL' + ? result.stores.filter((s: any) => s.state === selectedState) + : result.stores; + setStores(filteredStores.map((s: any) => ({ id: s.id, name: s.dba_name || s.name }))); + } catch (err) { + console.error('Failed to fetch stores:', err); + } + }; + fetchStores(); + }, [selectedState]); + + useEffect(() => { + loadData(); + }, [days, selectedState, storeId]); + + const loadData = async () => { + setLoading(true); + try { + const state = selectedState && selectedState !== 'ALL' ? selectedState : undefined; + const params = { days, state, store_id: storeId || undefined }; + const [summaryData, brandsData, productsData, campaignsData] = await Promise.all([ + api.getClickAnalyticsSummary({ days, state }), + api.getClickAnalyticsBrands({ ...params, limit: 50 }), + api.getClickAnalyticsProducts({ ...params, limit: 50 }), + api.getClickAnalyticsCampaigns({ ...params, limit: 25 }), + ]); + + setSummary(summaryData.summary); + setByAction(summaryData.by_action || []); + setDaily(summaryData.daily || []); + setBrands(brandsData.brands || []); + setProducts(productsData.products || []); + setCampaigns(campaignsData.campaigns || []); + } catch (error) { + console.error('Failed to load click analytics:', error); + } finally { + setLoading(false); + } + }; + + const formatDate = (dateStr: string | null) => { + if (!dateStr) return '-'; + return new Date(dateStr).toLocaleDateString('en-US', { + month: 'short', + day: 'numeric', + }); + }; + + const formatRelativeTime = (dateStr: string | null) => { + if (!dateStr) return '-'; + const date = new Date(dateStr); + const now = new Date(); + const diffMs = now.getTime() - date.getTime(); + const diffHours = Math.floor(diffMs / (1000 * 60 * 60)); + const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24)); + + if (diffHours < 1) return 'Just now'; + if (diffHours < 24) return `${diffHours}h ago`; + if (diffDays === 1) return 'Yesterday'; + if (diffDays < 7) return `${diffDays}d ago`; + return date.toLocaleDateString(); + }; + + if (loading) { + return ( + +
+
+

Loading click analytics...

+
+
+ ); + } + + return ( + +
+ {/* Header */} +
+
+

+ + Click Analytics +

+

+ Track engagement across brands, products, and campaigns + {selectedState && selectedState !== 'ALL' && ( + + {selectedState} + + )} +

+
+
+
+ + +
+ + +
+
+ + {/* Summary Stats */} +
+ } + color="emerald" + /> + } + color="blue" + /> + } + color="purple" + /> + } + color="orange" + /> + } + color="pink" + /> + } + color="cyan" + /> +
+ + {/* Tab Navigation */} +
+
+
+ setActiveTab('overview')} + icon={} + label="Overview" + /> + setActiveTab('brands')} + icon={} + label={`Brands (${brands.length})`} + /> + setActiveTab('products')} + icon={} + label={`Products (${products.length})`} + /> + setActiveTab('campaigns')} + icon={} + label={`Campaigns (${campaigns.length})`} + /> +
+
+ +
+ {/* Overview Tab */} + {activeTab === 'overview' && ( +
+ {/* Daily Click Chart */} +
+

Clicks Over Time

+ {daily.length > 0 ? ( +
+ {daily.map((day, idx) => { + const maxClicks = Math.max(...daily.map((d) => d.clicks), 1); + const height = (day.clicks / maxClicks) * 100; + return ( +
+
+ {idx % Math.ceil(daily.length / 7) === 0 && ( + + {formatDate(day.date)} + + )} +
+ ); + })} +
+ ) : ( +
+ No click data for this period +
+ )} +
+ + {/* Actions Breakdown */} + {byAction.length > 0 && ( +
+

Clicks by Action

+
+ {byAction.map((action) => ( +
+

+ {action.count.toLocaleString()} +

+

+ {action.action.replace('_', ' ')} +

+
+ ))} +
+
+ )} + + {/* Top Brands Preview */} + {brands.length > 0 && ( +
+
+

Top Brands

+ +
+
+ {brands.slice(0, 6).map((brand) => ( +
+

+ {brand.brand_name} +

+

+ {brand.clicks} +

+

clicks

+
+ ))} +
+
+ )} +
+ )} + + {/* Brands Tab */} + {activeTab === 'brands' && ( +
+ {brands.length === 0 ? ( +

+ No brand engagement data for this period +

+ ) : ( +
+ + + + + + + + + + + + + {brands.map((brand, idx) => ( + + + + + + + + + ))} + +
BrandClicksProductsStoresFirst ClickLast Click
+
+ + #{idx + 1} + + {brand.brand_name} +
+
+ + {brand.clicks} + + + {brand.unique_products} + + {brand.unique_stores} + + {formatRelativeTime(brand.first_click_at)} + + {formatRelativeTime(brand.last_click_at)} +
+
+ )} +
+ )} + + {/* Products Tab */} + {activeTab === 'products' && ( +
+ {products.length === 0 ? ( +

+ No product engagement data for this period +

+ ) : ( +
+ + + + + + + + + + + + + {products.map((product, idx) => ( + + + + + + + + + ))} + +
ProductBrandCategoryClicksStoresLast Click
+
+ + #{idx + 1} + + + {product.product_name} + +
+
+ {product.brand_name} + + {product.category || '-'} + {product.subcategory && ` / ${product.subcategory}`} + + + {product.clicks} + + + {product.unique_stores} + + {formatRelativeTime(product.last_click_at)} +
+
+ )} +
+ )} + + {/* Campaigns Tab */} + {activeTab === 'campaigns' && ( +
+ {campaigns.length === 0 ? ( +

+ No campaign engagement data for this period +

+ ) : ( +
+ + + + + + + + + + + + + + {campaigns.map((campaign, idx) => ( + + + + + + + + + + ))} + +
CampaignStatusClicksProductsStoresPeriodLast Activity
+
+ + #{idx + 1} + +
+

{campaign.campaign_name}

+ {campaign.campaign_description && ( +

+ {campaign.campaign_description} +

+ )} +
+
+
+ {campaign.is_active ? ( + Active + ) : ( + Ended + )} + + + {campaign.clicks} + + + {campaign.unique_products} + + {campaign.unique_stores} + + {campaign.start_date && campaign.end_date ? ( + <> + {formatDate(campaign.start_date)} -{' '} + {formatDate(campaign.end_date)} + + ) : campaign.start_date ? ( + <>From {formatDate(campaign.start_date)} + ) : ( + '-' + )} + + {formatRelativeTime(campaign.last_event_at)} +
+
+ )} +
+ )} +
+
+
+ + ); +} + +interface StatCardProps { + title: string; + value: number; + icon: React.ReactNode; + color: string; +} + +function StatCard({ title, value, icon, color }: StatCardProps) { + const colorClasses: Record = { + emerald: 'bg-emerald-50', + blue: 'bg-blue-50', + purple: 'bg-purple-50', + orange: 'bg-orange-50', + pink: 'bg-pink-50', + cyan: 'bg-cyan-50', + }; + + return ( +
+
+
+ {icon} +
+
+

{title}

+

{value.toLocaleString()}

+
+
+
+ ); +} + +interface TabButtonProps { + active: boolean; + onClick: () => void; + icon: React.ReactNode; + label: string; +} + +function TabButton({ active, onClick, icon, label }: TabButtonProps) { + return ( + + ); +} diff --git a/cannaiq/src/pages/DutchieAZSchedule.tsx b/cannaiq/src/pages/CrawlSchedulePage.tsx similarity index 98% rename from cannaiq/src/pages/DutchieAZSchedule.tsx rename to cannaiq/src/pages/CrawlSchedulePage.tsx index 6da1a5e2..cdf1a0f4 100644 --- a/cannaiq/src/pages/DutchieAZSchedule.tsx +++ b/cannaiq/src/pages/CrawlSchedulePage.tsx @@ -51,7 +51,7 @@ interface DetectionStats { byProvider: Record; } -export function DutchieAZSchedule() { +export function CrawlSchedulePage() { const [schedules, setSchedules] = useState([]); const [runLogs, setRunLogs] = useState([]); const [schedulerStatus, setSchedulerStatus] = useState<{ running: boolean; pollIntervalMs: number } | null>(null); @@ -76,9 +76,9 @@ export function DutchieAZSchedule() { const loadData = async () => { try { const [schedulesData, logsData, statusData, detectionData] = await Promise.all([ - api.getDutchieAZSchedules(), - api.getDutchieAZRunLogs({ limit: 50 }), - api.getDutchieAZSchedulerStatus(), + api.getSchedules(), + api.getRunLogs({ limit: 50 }), + api.getSchedulerStatus(), api.getDetectionStats().catch(() => null), ]); @@ -96,9 +96,9 @@ export function DutchieAZSchedule() { const handleToggleScheduler = async () => { try { if (schedulerStatus?.running) { - await api.stopDutchieAZScheduler(); + await api.stopScheduler(); } else { - await api.startDutchieAZScheduler(); + await api.startScheduler(); } await loadData(); } catch (error) { @@ -108,7 +108,7 @@ export function DutchieAZSchedule() { const handleInitSchedules = async () => { try { - await api.initDutchieAZSchedules(); + await api.initSchedules(); await loadData(); } catch (error) { console.error('Failed to initialize schedules:', error); @@ -117,7 +117,7 @@ export function DutchieAZSchedule() { const handleTriggerSchedule = async (id: number) => { try { - await api.triggerDutchieAZSchedule(id); + await api.triggerSchedule(id); await loadData(); } catch (error) { console.error('Failed to trigger schedule:', error); @@ -126,7 +126,7 @@ export function DutchieAZSchedule() { const handleToggleEnabled = async (schedule: JobSchedule) => { try { - await api.updateDutchieAZSchedule(schedule.id, { enabled: !schedule.enabled }); + await api.updateSchedule(schedule.id, { enabled: !schedule.enabled }); await loadData(); } catch (error) { console.error('Failed to toggle schedule:', error); @@ -142,7 +142,7 @@ export function DutchieAZSchedule() { jitterMinutes: updates.jitterMinutes, jobConfig: updates.jobConfig ?? undefined, }; - await api.updateDutchieAZSchedule(id, payload); + await api.updateSchedule(id, payload); setEditingSchedule(null); await loadData(); } catch (error) { @@ -153,7 +153,7 @@ export function DutchieAZSchedule() { const handleDeleteSchedule = async (id: number) => { if (!confirm('Are you sure you want to delete this schedule?')) return; try { - await api.deleteDutchieAZSchedule(id); + await api.deleteSchedule(id); await loadData(); } catch (error) { console.error('Failed to delete schedule:', error); @@ -176,7 +176,7 @@ export function DutchieAZSchedule() { }; const handleDetectMissingIds = async () => { - if (!confirm('Resolve platform IDs for all Dutchie dispensaries missing them?')) return; + if (!confirm('Resolve platform IDs for all dispensaries missing them?')) return; setDetectingAll(true); setDetectionResults(null); try { @@ -259,9 +259,9 @@ export function DutchieAZSchedule() {
-

Dutchie AZ Schedule

+

Crawl Schedule

- Jittered scheduling for Arizona Dutchie product crawls + Jittered scheduling for product crawls

diff --git a/cannaiq/src/pages/Dashboard.tsx b/cannaiq/src/pages/Dashboard.tsx index 07112b43..1e2083d8 100755 --- a/cannaiq/src/pages/Dashboard.tsx +++ b/cannaiq/src/pages/Dashboard.tsx @@ -1,5 +1,6 @@ import { useEffect, useState } from 'react'; import { Layout } from '../components/Layout'; +import { HealthPanel } from '../components/HealthPanel'; import { api } from '../lib/api'; import { Store, @@ -61,32 +62,32 @@ export function Dashboard() { const loadData = async () => { try { - // Fetch AZ dashboard data (primary data source) - const azDashboard = await api.getDutchieAZDashboard(); + // Fetch dashboard data (primary data source) + const dashboard = await api.getMarketDashboard(); - // Map AZ dashboard data to the expected stats format + // Map dashboard data to the expected stats format setStats({ products: { - total: azDashboard.productCount, - in_stock: azDashboard.productCount, // All AZ products are in stock by default - with_images: 0 // Not tracked in AZ pipeline + total: dashboard.productCount, + in_stock: dashboard.productCount, // All products are in stock by default + with_images: 0 // Not tracked in pipeline }, stores: { - total: azDashboard.dispensaryCount, - active: azDashboard.dispensaryCount // All are active in AZ + total: dashboard.dispensaryCount, + active: dashboard.dispensaryCount // All are active }, brands: { - total: azDashboard.brandCount + total: dashboard.brandCount }, campaigns: { active: 0, total: 0 }, clicks: { - clicks_24h: azDashboard.snapshotCount24h // Use snapshots as activity metric + clicks_24h: dashboard.snapshotCount24h // Use snapshots as activity metric }, - failedJobs: azDashboard.failedJobCount, - lastCrawlTime: azDashboard.lastCrawlTime + failedJobs: dashboard.failedJobCount, + lastCrawlTime: dashboard.lastCrawlTime }); // Try to fetch activity data (may fail if not authenticated) @@ -148,22 +149,22 @@ export function Dashboard() { {/* Pending Changes Notification */} {showNotification && ( -
-
-
- +
+
+
+

{pendingChangesCount} pending change{pendingChangesCount !== 1 ? 's' : ''} require review

-

+

Proposed changes to dispensary data are waiting for approval

-
+
+ {/* System Health */} + + {/* Stats Grid */} -
+
{/* Products */} -
+
@@ -207,14 +211,14 @@ export function Dashboard() {
-

Total Products

-

{stats?.products?.total?.toLocaleString() || 0}

-

{stats?.products?.in_stock || 0} in stock

+

Total Products

+

{stats?.products?.total?.toLocaleString() || 0}

+

{stats?.products?.in_stock || 0} in stock

{/* Stores */} -
+
@@ -225,14 +229,14 @@ export function Dashboard() {
-

Total Dispensaries

-

{stats?.stores?.total || 0}

-

{stats?.stores?.active || 0} active (crawlable)

+

Dispensaries

+

{stats?.stores?.total || 0}

+

{stats?.stores?.active || 0} active

{/* Campaigns */} -
+
@@ -243,14 +247,14 @@ export function Dashboard() {
-

Active Campaigns

-

{stats?.campaigns?.active || 0}

-

{stats?.campaigns?.total || 0} total campaigns

+

Campaigns

+

{stats?.campaigns?.active || 0}

+

{stats?.campaigns?.total || 0} total

{/* Images */} -
+
@@ -258,9 +262,9 @@ export function Dashboard() { {imagePercentage}%
-

Images Downloaded

-

{stats?.products?.with_images?.toLocaleString() || 0}

-
+

Images

+

{stats?.products?.with_images?.toLocaleString() || 0}

+
{/* Snapshots (24h) */} -
+
@@ -280,14 +284,14 @@ export function Dashboard() {
-

Snapshots (24h)

-

{stats?.clicks?.clicks_24h?.toLocaleString() || 0}

-

Product snapshots created

+

Snapshots (24h)

+

{stats?.clicks?.clicks_24h?.toLocaleString() || 0}

+

Product snapshots

{/* Brands */} -
+
@@ -295,22 +299,22 @@ export function Dashboard() {
-

Brands

-

{stats?.brands?.total || stats?.products?.unique_brands || 0}

-

Unique brands tracked

+

Brands

+

{stats?.brands?.total || stats?.products?.unique_brands || 0}

+

Unique brands

{/* Charts Row */} -
+
{/* Product Growth Chart */} -
-
-

Product Growth

-

Weekly product count trend

+
+
+

Product Growth

+

Weekly product count trend

- + @@ -348,12 +352,12 @@ export function Dashboard() {
{/* Scrape Activity Chart */} -
-
-

Scrape Activity

-

Scrapes over the last 24 hours

+
+
+

Scrape Activity

+

Scrapes over the last 24 hours

- + {/* Activity Lists */} -
+
{/* Recent Scrapes */}
-
-

Recent Scrapes

-

Latest data collection activities

+
+

Recent Scrapes

+

Latest data collection activities

{activity?.recent_scrapes?.length > 0 ? ( activity.recent_scrapes.slice(0, 5).map((scrape: any, i: number) => ( -
-
+
+
-

{scrape.name}

+

{scrape.name}

{new Date(scrape.last_scraped_at).toLocaleString('en-US', { month: 'short', @@ -409,18 +413,18 @@ export function Dashboard() { })}

-
- - {scrape.product_count} products +
+ + {scrape.product_count}
)) ) : ( -
- -

No recent scrapes

+
+ +

No recent scrapes

)}
@@ -428,22 +432,22 @@ export function Dashboard() { {/* Recent Products */}
-
-

Recent Products

-

Newly added to inventory

+
+

Recent Products

+

Newly added to inventory

{activity?.recent_products?.length > 0 ? ( activity.recent_products.slice(0, 5).map((product: any, i: number) => ( -
-
+
+
-

{product.name}

-

{product.store_name}

+

{product.name}

+

{product.store_name}

{product.price && ( -
- +
+ ${product.price}
@@ -452,9 +456,9 @@ export function Dashboard() {
)) ) : ( -
- -

No recent products

+
+ +

No recent products

)}
diff --git a/cannaiq/src/pages/Home.tsx b/cannaiq/src/pages/Home.tsx new file mode 100644 index 00000000..f9a8e4a6 --- /dev/null +++ b/cannaiq/src/pages/Home.tsx @@ -0,0 +1,525 @@ +import { useState, useEffect } from 'react'; +import { Link } from 'react-router-dom'; +import { + TrendingUp, + BarChart3, + Package, + MapPin, + DollarSign, + Store, + Globe, + Bell, + ArrowRight, + Check, + Zap, + Database, + Code, + Building2, + ShoppingBag, + LineChart +} from 'lucide-react'; +import { api } from '../lib/api'; + +interface VersionInfo { + build_version: string; + git_sha: string; + build_time: string; + image_tag: string; +} + +// Reusable Logo component matching login page +function Logo({ size = 'md' }: { size?: 'sm' | 'md' | 'lg' }) { + const sizes = { + sm: { box: 'w-8 h-8', icon: 'w-5 h-5', text: 'text-lg' }, + md: { box: 'w-10 h-10', icon: 'w-6 h-6', text: 'text-2xl' }, + lg: { box: 'w-12 h-12', icon: 'w-8 h-8', text: 'text-3xl' } + }; + const s = sizes[size]; + + return ( +
+
+ + + + +
+ Canna IQ +
+ ); +} + +// Feature card matching login page style +function FeatureCard({ icon, title, description }: { icon: React.ReactNode; title: string; description: string }) { + return ( +
+
+ {icon} +
+

{title}

+

{description}

+
+ ); +} + +// Stat card for coverage section +function StatCard({ value, label }: { value: string; label: string }) { + return ( +
+
{value}
+
{label}
+
+ ); +} + +// Badge pill component +function Badge({ children }: { children: React.ReactNode }) { + return ( + + + {children} + + ); +} + +// Use case card +function UseCaseCard({ icon, title, bullets }: { icon: React.ReactNode; title: string; bullets: string[] }) { + return ( +
+
+
+ {icon} +
+

{title}

+
+
    + {bullets.map((bullet, i) => ( +
  • + + {bullet} +
  • + ))} +
+
+ ); +} + +// Step component for "How it works" +function Step({ number, title, description }: { number: number; title: string; description: string }) { + return ( +
+
+ {number} +
+

{title}

+

{description}

+
+ ); +} + +// Mock dashboard preview card +function DashboardPreview() { + return ( +
+ {/* Header bar */} +
+
+
+
+
+
+ + {/* Stats row */} +
+
+
Products
+
47,832
+
+
+
Stores
+
1,248
+
+
+
Brands
+
892
+
+
+ + {/* Mini chart placeholder */} +
+
Price Trends
+
+ {[40, 55, 45, 60, 50, 70, 65, 75, 68, 80, 72, 85].map((h, i) => ( +
+ ))} +
+
+ + {/* Map placeholder */} +
+
Coverage Map
+
+ {['WA', 'OR', 'CA', 'NV', 'AZ', 'CO', 'MI', 'IL', 'MA', 'NY'].map((state) => ( +
+ {state} +
+ ))} +
+
+
+ ); +} + +export function Home() { + const [versionInfo, setVersionInfo] = useState(null); + + useEffect(() => { + const fetchVersion = async () => { + try { + const data = await api.getVersion(); + setVersionInfo(data); + } catch (error) { + console.error('Failed to fetch version info:', error); + } + }; + fetchVersion(); + }, []); + + return ( +
+ {/* Navigation */} + + + {/* Hero Section */} +
+ {/* Decorative circles matching login page */} +
+
+
+ +
+
+ {/* Left: Text content */} +
+

+ Real-time cannabis intelligence for the U.S. and Canada. +

+

+ Track products, pricing, availability, and competitor movement across every legal market—powered by live dispensary menus. +

+ + {/* CTAs */} +
+ + Log in to CannaiQ + + + + Request a demo + +
+ + {/* Badges */} +
+ U.S. + Canada coverage + Live menu data + Brand & retailer views + Price and promo tracking +
+
+ + {/* Right: Dashboard preview */} +
+ +
+
+
+
+ + {/* Feature Grid Section */} +
+
+
+

What CannaiQ gives you

+

+ Everything you need to understand and act on cannabis market dynamics. +

+
+ +
+ } + title="Live product tracking" + description="See what's on the shelf right now—products, SKUs, and stock status pulled from live menus." + /> + } + title="Price & promo monitoring" + description="Compare pricing, discounts, and promos across markets to see who's racing to the bottom and where you're leaving margin on the table." + /> + } + title="Brand penetration & share" + description="Track where each brand is listed, how deep their presence goes in each store, and how your portfolio compares." + /> + } + title="Store-level intelligence" + description="Drill into individual dispensaries to see assortment, pricing, and who they're favoring in each category." + /> + } + title="Multi-state coverage (U.S. + Canada)" + description="Flip between U.S. and Canadian markets and compare how brands and categories perform across borders." + /> + } + title="Alerts & change detection" + description="Get notified when products appear, disappear, or change price in key stores. Coming soon." + /> +
+
+
+ + {/* Coverage & Scale Section */} +
+
+
+ +
+
+

Built for North American cannabis markets

+

+ CannaiQ continuously monitors licensed cannabis menus across the U.S. and Canada, normalizing brand and product data so you can compare markets, categories, and competitors in one place. +

+
+ +
+ + + + +
+
+
+ + {/* Use Cases Section */} +
+
+
+

Built for brands and retailers

+

+ Whether you're placing products or stocking shelves, CannaiQ gives you the visibility you need. +

+
+ +
+ } + title="For Brands" + bullets={[ + "See where your SKUs are listed—and where they're missing.", + "Compare your pricing and promos to competing brands.", + "Find whitespace in categories, formats, and price points." + ]} + /> + } + title="For Retailers" + bullets={[ + "Benchmark your assortment and pricing against nearby stores.", + "Identify gaps in key categories and formats.", + "Track which brands are gaining or losing shelf space." + ]} + /> +
+
+
+ + {/* How It Works Section */} +
+
+
+

How CannaiQ works

+
+ +
+ + + + +
+
+
+ + {/* Integration Section */} +
+
+
+

Data where you need it

+

+ Use CannaiQ in the browser, or plug it into your own tools via API and WordPress. +

+
+ +
+
+
+

+ + Available integrations +

+
    +
  • + + Admin dashboard at /admin for deep drill-downs +
  • +
  • + + WordPress plugin for displaying menus on your site +
  • +
  • + + API-ready architecture for custom integrations +
  • +
+
+ +
+ + + Go to /admin + + + + Download WordPress Plugin + +
+
+
+
+
+ + {/* Final CTA Section */} +
+
+

Ready to see your market clearly?

+

+ Log in if you're already onboarded, or request access to start exploring live data. +

+
+ + Log in to CannaiQ + + + + Request a demo + +
+
+
+ + {/* Footer */} +
+
+
+
+
+ + + + +
+
+
Canna IQ
+
Cannabis Market Intelligence
+
+
+ +
+ Sign in + Contact + WordPress Plugin +
+
+ +
+

+ © {new Date().getFullYear()} CannaiQ. All rights reserved. +

+ {versionInfo && ( +
+

+ {versionInfo.build_version} ({versionInfo.git_sha.slice(0, 7)}) +

+

+ {versionInfo.image_tag} +

+
+ )} +
+
+
+
+ ); +} + +export default Home; diff --git a/cannaiq/src/pages/IntelligenceBrands.tsx b/cannaiq/src/pages/IntelligenceBrands.tsx index 1ece3c1f..237d91a5 100644 --- a/cannaiq/src/pages/IntelligenceBrands.tsx +++ b/cannaiq/src/pages/IntelligenceBrands.tsx @@ -2,6 +2,7 @@ import { useEffect, useState } from 'react'; import { useNavigate } from 'react-router-dom'; import { Layout } from '../components/Layout'; import { api } from '../lib/api'; +import { trackProductClick } from '../lib/analytics'; import { Building2, MapPin, @@ -242,7 +243,18 @@ export function IntelligenceBrands() { ) : ( filteredBrands.map((brand) => ( - + { + trackProductClick({ + productId: brand.brandName, // Use brand name as identifier + brandId: brand.brandName, + action: 'view', + source: 'brands_intelligence' + }); + }} + > {brand.brandName} diff --git a/cannaiq/src/pages/IntelligenceStores.tsx b/cannaiq/src/pages/IntelligenceStores.tsx index 6eb78f6d..1db64e91 100644 --- a/cannaiq/src/pages/IntelligenceStores.tsx +++ b/cannaiq/src/pages/IntelligenceStores.tsx @@ -2,6 +2,7 @@ import { useEffect, useState } from 'react'; import { useNavigate } from 'react-router-dom'; import { Layout } from '../components/Layout'; import { api } from '../lib/api'; +import { useStateFilter } from '../hooks/useStateFilter'; import { MapPin, Building2, @@ -12,6 +13,7 @@ import { Clock, Activity, ChevronDown, + ExternalLink, } from 'lucide-react'; interface StoreActivity { @@ -28,28 +30,28 @@ interface StoreActivity { export function IntelligenceStores() { const navigate = useNavigate(); + const { selectedState, setSelectedState, stateParam, stateLabel, isAllStates } = useStateFilter(); const [stores, setStores] = useState([]); const [loading, setLoading] = useState(true); const [searchTerm, setSearchTerm] = useState(''); - const [stateFilter, setStateFilter] = useState('all'); - const [states, setStates] = useState([]); + const [localStates, setLocalStates] = useState([]); useEffect(() => { loadStores(); - }, [stateFilter]); + }, [selectedState]); const loadStores = async () => { try { setLoading(true); const data = await api.getIntelligenceStoreActivity({ - state: stateFilter !== 'all' ? stateFilter : undefined, + state: stateParam, limit: 500, }); setStores(data.stores || []); - // Extract unique states + // Extract unique states from response for dropdown counts const uniqueStates = [...new Set(data.stores.map((s: StoreActivity) => s.state))].sort(); - setStates(uniqueStates); + setLocalStates(uniqueStates); } catch (error) { console.error('Failed to load stores:', error); } finally { @@ -139,8 +141,8 @@ export function IntelligenceStores() {
- {/* Summary Cards */} -
+ {/* Summary Cards - Responsive: 2→4 columns */} +
@@ -179,8 +181,8 @@ export function IntelligenceStores() {
- {/* Filters */} -
+ {/* Filters - Responsive with wrapping */} +
  • - setStateFilter('all')} className={stateFilter === 'all' ? 'active' : ''}> + setSelectedState(null)} className={isAllStates ? 'active' : ''}> All States
  • - {states.map(state => ( + {localStates.map(state => (
  • - setStateFilter(state)} className={stateFilter === state ? 'active' : ''}> + setSelectedState(state)} className={selectedState === state ? 'active' : ''}> {state}
  • @@ -229,12 +231,13 @@ export function IntelligenceStores() { Snapshots Last Crawl Frequency + Actions {filteredStores.length === 0 ? ( - + No stores found @@ -272,6 +275,19 @@ export function IntelligenceStores() { {getCrawlFrequencyBadge(store.crawlFrequencyHours)} + + + )) )} diff --git a/cannaiq/src/pages/Login.tsx b/cannaiq/src/pages/Login.tsx index f6a99f54..4f61eb83 100755 --- a/cannaiq/src/pages/Login.tsx +++ b/cannaiq/src/pages/Login.tsx @@ -1,7 +1,15 @@ -import { useState } from 'react'; +import { useState, useEffect } from 'react'; import { useNavigate } from 'react-router-dom'; import { useAuthStore } from '../store/authStore'; import { TrendingUp, BarChart3, Package } from 'lucide-react'; +import { api } from '../lib/api'; + +interface VersionInfo { + build_version: string; + git_sha: string; + build_time: string; + image_tag: string; +} interface FeatureCardProps { icon: React.ReactNode; @@ -28,9 +36,22 @@ export function Login() { const [password, setPassword] = useState(''); const [error, setError] = useState(''); const [loading, setLoading] = useState(false); + const [versionInfo, setVersionInfo] = useState(null); const navigate = useNavigate(); const login = useAuthStore((state) => state.login); + useEffect(() => { + const fetchVersion = async () => { + try { + const data = await api.getVersion(); + setVersionInfo(data); + } catch (error) { + console.error('Failed to fetch version info:', error); + } + }; + fetchVersion(); + }, []); + const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); setError(''); @@ -70,7 +91,7 @@ export function Login() { Cannabis Market
    Intelligence Platform

    - Real-time competitive insights for Arizona dispensaries. Track products, prices, and market trends. + Real-time product and pricing visibility across the entire U.S. and Canadian cannabis markets—built for competitive clarity.

@@ -194,6 +215,17 @@ export function Login() {

+ {/* Version Info */} + {versionInfo && ( +
+

+ {versionInfo.build_version} ({versionInfo.git_sha.slice(0, 7)}) +

+

+ {versionInfo.image_tag} +

+
+ )}
diff --git a/cannaiq/src/pages/OrchestratorDashboard.tsx b/cannaiq/src/pages/OrchestratorDashboard.tsx index 35a051ed..b05e7c77 100644 --- a/cannaiq/src/pages/OrchestratorDashboard.tsx +++ b/cannaiq/src/pages/OrchestratorDashboard.tsx @@ -2,6 +2,7 @@ import { useEffect, useState } from 'react'; import { useNavigate } from 'react-router-dom'; import { Layout } from '../components/Layout'; import { api } from '../lib/api'; +import { useStateFilter } from '../hooks/useStateFilter'; import { Package, Building2, @@ -17,9 +18,27 @@ import { TrendingUp, TrendingDown, Minus, + Activity, + BarChart3, } from 'lucide-react'; import { StoreOrchestratorPanel } from '../components/StoreOrchestratorPanel'; +interface CrawlHealth { + status: 'ok' | 'degraded' | 'stale' | 'error'; + last_run: string | null; + runs_last_24h: number; + stores_with_recent_crawl: number; + stores_total: number; + stale_stores: number; +} + +interface AnalyticsHealth { + status: 'ok' | 'degraded' | 'stale' | 'error'; + last_aggregate: string | null; + daily_runs_last_7d: number; + missing_days: number; +} + interface OrchestratorMetrics { total_products: number; total_brands: number; @@ -61,15 +80,17 @@ interface StoreInfo { export function OrchestratorDashboard() { const navigate = useNavigate(); + const { selectedState, setSelectedState, stateParam, stateLabel, isAllStates } = useStateFilter(); const [metrics, setMetrics] = useState(null); const [states, setStates] = useState([]); const [stores, setStores] = useState([]); const [totalStores, setTotalStores] = useState(0); const [loading, setLoading] = useState(true); - const [selectedState, setSelectedState] = useState('all'); const [autoRefresh, setAutoRefresh] = useState(true); const [selectedStore, setSelectedStore] = useState(null); const [panelTab, setPanelTab] = useState<'control' | 'trace' | 'profile' | 'module' | 'debug'>('control'); + const [crawlHealth, setCrawlHealth] = useState(null); + const [analyticsHealth, setAnalyticsHealth] = useState(null); useEffect(() => { loadData(); @@ -82,16 +103,21 @@ export function OrchestratorDashboard() { const loadData = async () => { try { - const [metricsData, statesData, storesData] = await Promise.all([ + // stateParam is undefined for "All States", or the state code for specific state + const [metricsData, statesData, storesData, crawlHealthData, analyticsHealthData] = await Promise.all([ api.getOrchestratorMetrics(), api.getOrchestratorStates(), - api.getOrchestratorStores({ state: selectedState, limit: 200 }), + api.getOrchestratorStores({ state: stateParam, limit: 200 }), + api.getHealthCrawls().catch(() => null), + api.getHealthAnalytics().catch(() => null), ]); setMetrics(metricsData); setStates(statesData.states || []); setStores(storesData.stores || []); setTotalStores(storesData.total || 0); + setCrawlHealth(crawlHealthData); + setAnalyticsHealth(analyticsHealthData); } catch (error) { console.error('Failed to load orchestrator data:', error); } finally { @@ -161,6 +187,21 @@ export function OrchestratorDashboard() { } }; + const getHealthStatusStyle = (status: string | undefined) => { + switch (status) { + case 'ok': + return { bg: 'bg-green-50', border: 'border-green-200', text: 'text-green-700', icon: 'text-green-500' }; + case 'degraded': + return { bg: 'bg-yellow-50', border: 'border-yellow-200', text: 'text-yellow-700', icon: 'text-yellow-500' }; + case 'stale': + return { bg: 'bg-orange-50', border: 'border-orange-200', text: 'text-orange-700', icon: 'text-orange-500' }; + case 'error': + return { bg: 'bg-red-50', border: 'border-red-200', text: 'text-red-700', icon: 'text-red-500' }; + default: + return { bg: 'bg-gray-50', border: 'border-gray-200', text: 'text-gray-700', icon: 'text-gray-400' }; + } + }; + const formatTimeAgo = (dateStr: string | null) => { if (!dateStr) return '-'; const date = new Date(dateStr); @@ -218,9 +259,9 @@ export function OrchestratorDashboard() {
- {/* Metrics Cards - Clickable */} + {/* Metrics Cards - Clickable - Responsive: 2→3→4→7 columns */} {metrics && ( -
+
navigate('/admin/orchestrator/products')} className="bg-white rounded-lg border border-gray-200 p-4 cursor-pointer hover:bg-blue-50 hover:border-blue-300 transition-colors" @@ -314,17 +355,88 @@ export function OrchestratorDashboard() {
)} - {/* State Selector */} -
+ {/* Health Status Tiles */} + {(crawlHealth || analyticsHealth) && ( +
+ {/* Crawl Health Tile */} + {crawlHealth && ( +
+
+
+ +

Crawl Status

+
+ + {crawlHealth.status.toUpperCase()} + +
+
+
+

Last Run

+

{formatTimeAgo(crawlHealth.last_run)}

+
+
+

Runs (24h)

+

{crawlHealth.runs_last_24h}

+
+
+

Fresh Stores

+

{crawlHealth.stores_with_recent_crawl} / {crawlHealth.stores_total}

+
+
+

Stale Stores

+

0 ? 'text-orange-600' : 'text-green-600'}`}> + {crawlHealth.stale_stores} +

+
+
+
+ )} + + {/* Analytics Health Tile */} + {analyticsHealth && ( +
+
+
+ +

Analytics Status

+
+ + {analyticsHealth.status.toUpperCase()} + +
+
+
+

Last Aggregate

+

{formatTimeAgo(analyticsHealth.last_aggregate)}

+
+
+

Runs (7d)

+

{analyticsHealth.daily_runs_last_7d}

+
+
+

Missing Days

+

0 ? 'text-orange-600' : 'text-green-600'}`}> + {analyticsHealth.missing_days} +

+
+
+
+ )} +
+ )} + + {/* State Selector - Uses global state store */} +
- {/* Two-column layout: Store table + Panel */} -
+ {/* Two-column layout: Store table + Panel - stacks on mobile */} +
{/* Stores Table */} -
-
-

Stores

+
+
+

Stores

-
+
- - - - - - - - + + + + + + + + @@ -372,27 +484,37 @@ export function OrchestratorDashboard() { onClick={() => setSelectedStore(store)} > - - + - - - {products.map((product) => ( - + { + trackProductView( + String(product.id), + 'StoreDetailPage', + { storeId: id, brandId: product.brand || undefined } + ); + }} + >
NameStateProviderStatusLast SuccessLast FailureProductsActionsNameStateProviderStatusLast SuccessLast FailureProductsActions
-
{store.name}
-
{store.city}
+
{store.name}
+
{store.city}, {store.state}
{store.state} + {store.state} {store.provider_display || 'Menu'} {getStatusPill(store.status)} + {formatTimeAgo(store.lastSuccessAt)} + {formatTimeAgo(store.lastFailureAt)} + {store.productCount.toLocaleString()} -
+
+
{/* Side Panel */} -
+
{selectedStore ? ( (null); @@ -71,7 +72,7 @@ export function DutchieAZStoreDetail() { const loadStoreSummary = async () => { setLoading(true); try { - const data = await api.getDutchieAZStoreSummary(parseInt(id!, 10)); + const data = await api.getMarketStoreSummary(parseInt(id!, 10)); setSummary(data); } catch (error) { console.error('Failed to load store summary:', error); @@ -84,7 +85,7 @@ export function DutchieAZStoreDetail() { if (!id) return; setProductsLoading(true); try { - const data = await api.getDutchieAZStoreProducts(parseInt(id, 10), { + const data = await api.getMarketStoreProducts(parseInt(id, 10), { search: searchQuery || undefined, stockStatus: stockFilter || undefined, limit: itemsPerPage, @@ -103,7 +104,7 @@ export function DutchieAZStoreDetail() { setShowUpdateDropdown(false); setIsUpdating(true); try { - await api.triggerDutchieAZCrawl(parseInt(id!, 10)); + await api.triggerStoreCrawl(parseInt(id!, 10)); alert('Crawl started! Refresh the page in a few minutes to see updated data.'); } catch (error) { console.error('Failed to trigger crawl:', error); @@ -144,11 +145,11 @@ export function DutchieAZStoreDetail() { {/* Header */}
{/* Update Button */} @@ -455,7 +456,17 @@ export function DutchieAZStoreDetail() {
{product.image_url ? ( ([]); const [totalStores, setTotalStores] = useState(0); @@ -33,11 +33,11 @@ export function DutchieAZStores() { setLoading(true); try { const [storesData, dashboardData] = await Promise.all([ - api.getDutchieAZStores({ limit: 200 }), - api.getDutchieAZDashboard(), + api.getMarketStores({ limit: 200 }), + api.getMarketDashboard(), ]); setStores(storesData.stores); - setTotalStores(storesData.total); + setTotalStores(storesData.total ?? storesData.stores.length); setDashboard(dashboardData); } catch (error) { console.error('Failed to load data:', error); @@ -63,9 +63,9 @@ export function DutchieAZStores() { {/* Header */}
-

Dutchie AZ Stores

+

Stores

- Arizona dispensaries using the Dutchie platform - data from the new pipeline + Dispensary store listings with product data

+
+ + {/* Table - responsive with horizontal scroll */} +
+
+ + + + + + + + + + + + + + {loading ? ( + + + + ) : pages.length === 0 ? ( + + + + ) : ( + pages.map((page) => ( + + + + + + + + + + )) + )} + +
TypeSlugKeywordStatusMetricsGeneratedActions
Loading...
+ No pages found. Click "Sync State Pages" to create state pages. +
+
+ {TYPE_ICONS[page.type] || } + {page.type.replace('_', ' ')} +
+
+ {page.slug} + + {page.primaryKeyword || '-'} + + + {page.status.replace('_', ' ')} + + + {page.metrics ? ( +
+ {page.metrics.dispensaryCount} + {page.metrics.productCount.toLocaleString()} + {page.metrics.brandCount} +
+ ) : '-'} +
+ {page.lastGeneratedAt ? new Date(page.lastGeneratedAt).toLocaleDateString() : 'Never'} + + +
+
+
+ + ); +} + +export default PagesTab; diff --git a/cannaiq/src/pages/admin/seo/SeoOrchestrator.tsx b/cannaiq/src/pages/admin/seo/SeoOrchestrator.tsx new file mode 100644 index 00000000..c66b430e --- /dev/null +++ b/cannaiq/src/pages/admin/seo/SeoOrchestrator.tsx @@ -0,0 +1,111 @@ +/** + * SEO Orchestrator - Main Admin Page + * + * Tabbed interface for managing SEO pages. + */ + +import { useState, useEffect } from 'react'; +import { Layout } from '../../../components/Layout'; +import { PagesTab } from './PagesTab'; +import { api } from '../../../lib/api'; +import { FileText, BarChart2, Settings } from 'lucide-react'; + +const TABS = [ + { id: 'pages', label: 'Pages', icon: }, + { id: 'metrics', label: 'State Metrics', icon: }, + { id: 'settings', label: 'Settings', icon: } +]; + +export function SeoOrchestrator() { + const [activeTab, setActiveTab] = useState('pages'); + + return ( + +
+
+

SEO Orchestrator

+

Manage SEO pages and content generation

+
+ + {/* Tabs */} +
+ +
+ + {/* Tab Content */} +
+ {activeTab === 'pages' && } + {activeTab === 'metrics' && } + {activeTab === 'settings' && } +
+
+
+ ); +} + +// Simple state metrics tab (small component) +function StateMetricsTab() { + const [metrics, setMetrics] = useState([]); + const [loading, setLoading] = useState(true); + + useEffect(() => { + api.getStateMetrics().then((r: any) => { + setMetrics(r.states || []); + setLoading(false); + }); + }, []); + + if (loading) return
Loading...
; + + return ( +
+ + + + + + + + + + + {metrics.map((state) => ( + + + + + + + ))} + +
StateDispensariesProductsBrands
{state.stateName}{state.dispensaryCount.toLocaleString()}{state.productCount.toLocaleString()}{state.brandCount.toLocaleString()}
+
+ ); +} + +// Placeholder settings tab +function SettingsTab() { + return ( +
+

SEO Settings

+

Content generation settings coming soon.

+
+ ); +} + +export default SeoOrchestrator; diff --git a/cannaiq/src/pages/public/SeoPage.tsx b/cannaiq/src/pages/public/SeoPage.tsx new file mode 100644 index 00000000..4e82f626 --- /dev/null +++ b/cannaiq/src/pages/public/SeoPage.tsx @@ -0,0 +1,310 @@ +/** + * SeoPage - Generic Public SEO Page Renderer + * + * Renders SEO content for: + * - /alternatives/:slug (competitor alternatives) + * - /brands/:slug (brand pages) + * + * Content is fetched from backend with automatic sanitization. + * Uses approved enterprise-safe phrasing - NO crawling/scraping terms. + */ + +import { useEffect, useState } from 'react'; +import { useParams, useLocation, Link } from 'react-router-dom'; + +interface SeoPageMeta { + title?: string; + description?: string; + h1?: string; + canonicalUrl?: string; + ogTitle?: string; + ogDescription?: string; + ogImageUrl?: string; +} + +interface SeoBlock { + type: string; + content?: string; + items?: (string | { value: string; label: string; description?: string })[]; + headline?: string; + subheadline?: string; + ctaPrimary?: { text?: string; href?: string }; + brands?: { name: string; productCount: number }[]; + [key: string]: unknown; +} + +interface SeoPageData { + slug: string; + type: string; + meta: SeoPageMeta; + blocks: SeoBlock[]; +} + +export function SeoPage() { + const location = useLocation(); + const { slug } = useParams<{ slug: string }>(); + const [data, setData] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + // Derive full slug from path: /alternatives/hoodie-analytics -> alternatives/hoodie-analytics + const fullSlug = location.pathname.startsWith('/') + ? location.pathname.slice(1) + : location.pathname; + + useEffect(() => { + async function fetchContent() { + try { + setLoading(true); + setError(null); + + const apiUrl = import.meta.env.VITE_API_URL || ''; + const response = await fetch(`${apiUrl}/api/seo/public/content?slug=${encodeURIComponent(fullSlug)}`); + + if (response.status === 404) { + setError('not_found'); + return; + } + + if (!response.ok) { + throw new Error('Failed to load page content'); + } + + const result = await response.json(); + setData(result); + + // Update document title + if (result.meta?.title) { + document.title = result.meta.title; + } + } catch (err) { + console.error('[SeoPage] Error:', err); + setError('error'); + } finally { + setLoading(false); + } + } + + if (fullSlug) { + fetchContent(); + } + }, [fullSlug]); + + if (loading) { + return ( +
+
Loading...
+
+ ); + } + + if (error === 'not_found' || !data) { + return ( +
+
+

Page Coming Soon

+

+ This page is being prepared. Check back soon for comprehensive market intelligence. +

+ + Back to Home + +
+
+ ); + } + + if (error) { + return ( +
+
+

Error Loading Page

+

Please try again later.

+
+
+ ); + } + + return ( +
+ {/* Hero Section */} +
+
+

+ {data.meta?.h1 || data.meta?.title || 'CannaiQ Market Intelligence'} +

+ {data.meta?.description && ( +

{data.meta.description}

+ )} +
+
+ + {/* Content Blocks */} +
+ {data.blocks && data.blocks.length > 0 ? ( +
+ {data.blocks.map((block, index) => ( + + ))} +
+ ) : ( +
+

+ Content for this page is being generated. Our team is continuously updating + market insights to provide you with the latest information. +

+
+ )} +
+ + {/* Footer CTA */} +
+
+

+ Get Real-Time Cannabis Market Intelligence +

+

+ Access continuously updated market data, pricing insights, and brand analytics. +

+ + Start Free Trial + +
+
+
+ ); +} + +function RenderBlock({ block }: { block: SeoBlock }) { + switch (block.type) { + case 'hero': + return ( +
+ {block.headline &&

{block.headline}

} + {block.subheadline && ( +

{block.subheadline}

+ )} + {block.ctaPrimary && ( + + {block.ctaPrimary.text || 'Learn More'} + + )} +
+ ); + + case 'stats': + const statsItems = (block.items || []).filter( + (item): item is { value: string; label: string; description?: string } => typeof item === 'object' + ); + return ( +
+ {block.headline &&

{block.headline}

} +
+ {statsItems.map((item, i) => ( +
+
{item.value}
+
{item.label}
+ {item.description &&
{item.description}
} +
+ ))} +
+
+ ); + + case 'intro': + return ( +
+

{block.content}

+
+ ); + + case 'topBrands': + const brands = block.brands || []; + return ( +
+ {block.headline &&

{block.headline}

} +
+ {brands.map((brand, i) => ( +
+
{brand.name}
+
{brand.productCount} products
+
+ ))} +
+
+ ); + + case 'heading': + return ( +

+ {block.content} +

+ ); + + case 'paragraph': + case 'text': + return ( +
+

{block.content}

+
+ ); + + case 'list': + return ( +
    + {block.items?.map((item, i) => ( +
  • {typeof item === 'string' ? item : ''}
  • + ))} +
+ ); + + case 'feature_grid': + case 'features': + return ( +
+ {block.items?.map((item, i) => ( +
+

{typeof item === 'string' ? item : ''}

+
+ ))} +
+ ); + + case 'cta': + return ( +
+ {block.headline &&

{block.headline}

} + {block.subheadline &&

{block.subheadline}

} + {block.content &&

{block.content}

} + + {block.ctaPrimary?.text || 'Learn More'} + +
+ ); + + default: + // Fallback for unknown block types + if (block.content) { + return ( +
+

{block.content}

+
+ ); + } + return null; + } +} + +export default SeoPage; diff --git a/cannaiq/src/pages/public/StatePage.tsx b/cannaiq/src/pages/public/StatePage.tsx new file mode 100644 index 00000000..2beb82ad --- /dev/null +++ b/cannaiq/src/pages/public/StatePage.tsx @@ -0,0 +1,140 @@ +/** + * StatePage - Public SEO State Page + * + * Displays state market data with live metrics. + * NO crawling/scraping language - uses "continuously updated" + */ + +import { useEffect, useState } from 'react'; +import { useParams } from 'react-router-dom'; +import { api } from '../../lib/api'; +import { Building2, Package, Tag, TrendingUp } from 'lucide-react'; + +interface StateMetrics { + stateCode: string; + stateName: string; + dispensaryCount: number; + productCount: number; + brandCount: number; +} + +export function StatePage() { + const { stateSlug } = useParams<{ stateSlug: string }>(); + const [metrics, setMetrics] = useState(null); + const [loading, setLoading] = useState(true); + + useEffect(() => { + async function loadMetrics() { + try { + const result = await api.getStateMetrics(); + // Find state by slug (convert slug like "arizona" to state code) + const state = result.states.find((s: StateMetrics) => + s.stateName.toLowerCase().replace(/\s+/g, '-') === stateSlug?.toLowerCase() + ); + setMetrics(state || null); + } catch (error) { + console.error('Failed to load state metrics:', error); + } finally { + setLoading(false); + } + } + loadMetrics(); + }, [stateSlug]); + + if (loading) { + return ( +
+
Loading...
+
+ ); + } + + if (!metrics) { + return ( +
+
+

State Not Found

+

We don't have data for this state yet.

+
+
+ ); + } + + return ( +
+ {/* Hero */} +
+
+

{metrics.stateName} Cannabis Market Data

+

+ Real-time market intelligence for the {metrics.stateName} cannabis industry +

+
+
+ + {/* Market Snapshot */} +
+
+

+ + Market Snapshot - {metrics.stateName} +

+ +
+
+ +
+ {metrics.dispensaryCount.toLocaleString()} +
+
Dispensaries
+
+ +
+ +
+ {metrics.productCount.toLocaleString()} +
+
Products
+
+ +
+ +
+ {metrics.brandCount.toLocaleString()} +
+
Brands
+
+
+ +

+ CannaIQ currently monitors{' '} + {metrics.dispensaryCount.toLocaleString()} dispensaries,{' '} + {metrics.productCount.toLocaleString()} products, and{' '} + {metrics.brandCount.toLocaleString()} brands in {metrics.stateName}, + with listings and availability continuously updated throughout the day. +

+
+
+ + {/* CTA */} +
+
+

+ Get {metrics.stateName} Market Intelligence +

+

+ Access real-time pricing, brand distribution, and competitive insights for the {metrics.stateName} market. +

+ + Request {metrics.stateName} Demo + +
+
+
+ ); +} + +export default StatePage; diff --git a/cannaiq/src/utils/contentSanitizer.ts b/cannaiq/src/utils/contentSanitizer.ts new file mode 100644 index 00000000..f77615ed --- /dev/null +++ b/cannaiq/src/utils/contentSanitizer.ts @@ -0,0 +1,57 @@ +/** + * Frontend Content Sanitizer - Safety hook for SEO content + */ + +const FORBIDDEN_PATTERNS = [ + /\b(crawl|crawled|crawler|crawling)\b/gi, + /\b(scrape|scraped|scraper|scraping)\b/gi, + /\bsnapshots?\b/gi, + /\b(ingestion|pipeline|ETL)\b/gi, + /\bworkers?\b/gi, + /\bbots?\b/gi, +]; + +const REPLACEMENTS: Array<{ pattern: RegExp; replacement: string }> = [ + { pattern: /\b(crawl|scrape)s?\b/gi, replacement: 'refresh' }, + { pattern: /\b(crawled|scraped)\b/gi, replacement: 'updated' }, + { pattern: /\b(crawler|scraper)s?\b/gi, replacement: 'data service' }, + { pattern: /\bsnapshots?\b/gi, replacement: 'market data' }, + { pattern: /\bworkers?\b/gi, replacement: 'services' }, +]; + +export function hasForbiddenTerms(text: string): boolean { + if (typeof text !== 'string') return false; + return FORBIDDEN_PATTERNS.some(p => p.test(text)); +} + +export function sanitizeString(text: string): string { + if (typeof text !== 'string') return text; + let result = text; + for (const { pattern, replacement } of REPLACEMENTS) { + result = result.replace(pattern, replacement); + } + return result; +} + +export function sanitizeContent(content: T): T { + if (content === null || content === undefined) return content; + if (typeof content === 'string') return sanitizeString(content) as T; + if (Array.isArray(content)) return content.map(sanitizeContent) as T; + if (typeof content === 'object') { + const result: Record = {}; + for (const [key, value] of Object.entries(content)) { + result[key] = sanitizeContent(value); + } + return result as T; + } + return content; +} + +export function useSanitizedSeoContent(content: T): T { + const json = JSON.stringify(content); + if (hasForbiddenTerms(json)) { + console.warn('[ContentSanitizer] Forbidden terms detected'); + return sanitizeContent(content); + } + return content; +} diff --git a/docker-compose.local.yml b/docker-compose.local.yml index bfa66b13..8e0cd942 100644 --- a/docker-compose.local.yml +++ b/docker-compose.local.yml @@ -8,6 +8,7 @@ # # This runs: # - PostgreSQL database +# - Redis (job queues, caching, rate limiting) # - Backend API server # - Local filesystem storage (./storage) # @@ -31,6 +32,20 @@ services: timeout: 5s retries: 5 + redis: + image: redis:7-alpine + container_name: cannaiq-redis + ports: + - "6379:6379" + volumes: + - redis_data:/data + command: redis-server --appendonly yes --maxmemory 256mb --maxmemory-policy allkeys-lru + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 10s + timeout: 5s + retries: 5 + backend: build: context: ./backend @@ -55,6 +70,8 @@ services: JWT_SECRET: local_dev_jwt_secret_change_in_production ADMIN_EMAIL: admin@example.com ADMIN_PASSWORD: password + # Redis connection + REDIS_URL: redis://redis:6379 ports: - "3010:3000" volumes: @@ -64,7 +81,10 @@ services: depends_on: postgres: condition: service_healthy + redis: + condition: service_healthy command: npm run dev volumes: postgres_data: + redis_data: diff --git a/k8s/scraper.yaml b/k8s/scraper.yaml index 3e4aaae4..a7359d65 100644 --- a/k8s/scraper.yaml +++ b/k8s/scraper.yaml @@ -40,6 +40,24 @@ spec: volumeMounts: - name: images-storage mountPath: /app/public/images + # Liveness probe: restarts pod if it becomes unresponsive + livenessProbe: + httpGet: + path: /health + port: 3010 + initialDelaySeconds: 30 + periodSeconds: 30 + timeoutSeconds: 10 + failureThreshold: 3 + # Readiness probe: removes pod from service if not ready + readinessProbe: + httpGet: + path: /health + port: 3010 + initialDelaySeconds: 10 + periodSeconds: 10 + timeoutSeconds: 5 + failureThreshold: 3 resources: requests: memory: "512Mi"