Compare commits
27 Commits
feat/steal
...
fix/ci-bui
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
56a5f00015 | ||
|
|
a96d50c481 | ||
|
|
4806212f46 | ||
|
|
2486f3c6b2 | ||
|
|
03eab66d35 | ||
|
|
97b1ab23d8 | ||
|
|
9fff0ba430 | ||
|
|
7d3e91b2e6 | ||
|
|
74957a9ec5 | ||
|
|
2d035c46cf | ||
|
|
53445fe72a | ||
|
|
37cc8956c5 | ||
|
|
197c82f921 | ||
|
|
2c52493a9c | ||
|
|
2ee2ba6b8c | ||
|
|
bafcf1694a | ||
|
|
95792aab15 | ||
|
|
38ae2c3a3e | ||
|
|
249d3c1b7f | ||
|
|
9647f94f89 | ||
|
|
afc288d2cf | ||
|
|
df01ce6aad | ||
|
|
aea93bc96b | ||
|
|
4e84f30f8b | ||
|
|
b20a0a4fa5 | ||
|
|
6eb1babc86 | ||
|
|
9a9c2f76a2 |
@@ -45,6 +45,31 @@ steps:
|
||||
when:
|
||||
event: pull_request
|
||||
|
||||
# ===========================================
|
||||
# AUTO-MERGE: Merge PR after all checks pass
|
||||
# ===========================================
|
||||
auto-merge:
|
||||
image: alpine:latest
|
||||
environment:
|
||||
GITEA_TOKEN:
|
||||
from_secret: gitea_token
|
||||
commands:
|
||||
- apk add --no-cache curl
|
||||
- |
|
||||
echo "Merging PR #${CI_COMMIT_PULL_REQUEST}..."
|
||||
curl -s -X POST \
|
||||
-H "Authorization: token $GITEA_TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"Do":"merge"}' \
|
||||
"https://code.cannabrands.app/api/v1/repos/Creationshop/dispensary-scraper/pulls/${CI_COMMIT_PULL_REQUEST}/merge"
|
||||
depends_on:
|
||||
- typecheck-backend
|
||||
- typecheck-cannaiq
|
||||
- typecheck-findadispo
|
||||
- typecheck-findagram
|
||||
when:
|
||||
event: pull_request
|
||||
|
||||
# ===========================================
|
||||
# MASTER DEPLOY: Parallel Docker builds
|
||||
# ===========================================
|
||||
@@ -65,10 +90,10 @@ steps:
|
||||
platforms: linux/amd64
|
||||
provenance: false
|
||||
build_args:
|
||||
- APP_BUILD_VERSION=${CI_COMMIT_SHA}
|
||||
- APP_GIT_SHA=${CI_COMMIT_SHA}
|
||||
- APP_BUILD_TIME=${CI_PIPELINE_CREATED}
|
||||
- CONTAINER_IMAGE_TAG=${CI_COMMIT_SHA:0:8}
|
||||
APP_BUILD_VERSION: ${CI_COMMIT_SHA:0:8}
|
||||
APP_GIT_SHA: ${CI_COMMIT_SHA}
|
||||
APP_BUILD_TIME: ${CI_PIPELINE_CREATED}
|
||||
CONTAINER_IMAGE_TAG: ${CI_COMMIT_SHA:0:8}
|
||||
depends_on: []
|
||||
when:
|
||||
branch: master
|
||||
|
||||
@@ -5,7 +5,7 @@ FROM code.cannabrands.app/creationshop/node:20-slim AS builder
|
||||
WORKDIR /app
|
||||
|
||||
COPY package*.json ./
|
||||
RUN npm ci
|
||||
RUN npm install
|
||||
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
@@ -43,10 +43,13 @@ ENV PUPPETEER_EXECUTABLE_PATH=/usr/bin/chromium
|
||||
WORKDIR /app
|
||||
|
||||
COPY package*.json ./
|
||||
RUN npm ci --omit=dev
|
||||
RUN npm install --omit=dev
|
||||
|
||||
COPY --from=builder /app/dist ./dist
|
||||
|
||||
# Copy migrations for auto-migrate on startup
|
||||
COPY migrations ./migrations
|
||||
|
||||
# Create local images directory for when MinIO is not configured
|
||||
RUN mkdir -p /app/public/images/products
|
||||
|
||||
|
||||
394
backend/docs/BRAND_INTELLIGENCE_API.md
Normal file
394
backend/docs/BRAND_INTELLIGENCE_API.md
Normal file
@@ -0,0 +1,394 @@
|
||||
# Brand Intelligence API
|
||||
|
||||
## Endpoint
|
||||
|
||||
```
|
||||
GET /api/analytics/v2/brand/:name/intelligence
|
||||
```
|
||||
|
||||
## Query Parameters
|
||||
|
||||
| Param | Type | Default | Description |
|
||||
|-------|------|---------|-------------|
|
||||
| `window` | `7d\|30d\|90d` | `30d` | Time window for trend calculations |
|
||||
| `state` | string | - | Filter by state code (e.g., `AZ`) |
|
||||
| `category` | string | - | Filter by category (e.g., `Flower`) |
|
||||
|
||||
## Response Payload Schema
|
||||
|
||||
```typescript
|
||||
interface BrandIntelligenceResult {
|
||||
brand_name: string;
|
||||
window: '7d' | '30d' | '90d';
|
||||
generated_at: string; // ISO timestamp when data was computed
|
||||
|
||||
performance_snapshot: PerformanceSnapshot;
|
||||
alerts: Alerts;
|
||||
sku_performance: SkuPerformance[];
|
||||
retail_footprint: RetailFootprint;
|
||||
competitive_landscape: CompetitiveLandscape;
|
||||
inventory_health: InventoryHealth;
|
||||
promo_performance: PromoPerformance;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Section 1: Performance Snapshot
|
||||
|
||||
Summary cards with key brand metrics.
|
||||
|
||||
```typescript
|
||||
interface PerformanceSnapshot {
|
||||
active_skus: number; // Total products in catalog
|
||||
total_revenue_30d: number | null; // Estimated from qty × price
|
||||
total_stores: number; // Active retail partners
|
||||
new_stores_30d: number; // New distribution in window
|
||||
market_share: number | null; // % of category SKUs
|
||||
avg_wholesale_price: number | null;
|
||||
price_position: 'premium' | 'value' | 'competitive';
|
||||
}
|
||||
```
|
||||
|
||||
**UI Label Mapping:**
|
||||
| Field | User-Facing Label | Helper Text |
|
||||
|-------|-------------------|-------------|
|
||||
| `active_skus` | Active Products | X total in catalog |
|
||||
| `total_revenue_30d` | Monthly Revenue | Estimated from sales |
|
||||
| `total_stores` | Retail Distribution | Active retail partners |
|
||||
| `new_stores_30d` | New Opportunities | X new in last 30 days |
|
||||
| `market_share` | Category Position | % of category |
|
||||
| `avg_wholesale_price` | Avg Wholesale | Per unit |
|
||||
| `price_position` | Pricing Tier | Premium/Value/Market Rate |
|
||||
|
||||
---
|
||||
|
||||
## Section 2: Alerts
|
||||
|
||||
Issues requiring attention.
|
||||
|
||||
```typescript
|
||||
interface Alerts {
|
||||
lost_stores_30d_count: number;
|
||||
lost_skus_30d_count: number;
|
||||
competitor_takeover_count: number;
|
||||
avg_oos_duration_days: number | null;
|
||||
avg_reorder_lag_days: number | null;
|
||||
items: AlertItem[];
|
||||
}
|
||||
|
||||
interface AlertItem {
|
||||
type: 'lost_store' | 'delisted_sku' | 'shelf_loss' | 'extended_oos';
|
||||
severity: 'critical' | 'warning';
|
||||
store_name?: string;
|
||||
product_name?: string;
|
||||
competitor_brand?: string;
|
||||
days_since?: number;
|
||||
state_code?: string;
|
||||
}
|
||||
```
|
||||
|
||||
**UI Label Mapping:**
|
||||
| Field | User-Facing Label |
|
||||
|-------|-------------------|
|
||||
| `lost_stores_30d_count` | Accounts at Risk |
|
||||
| `lost_skus_30d_count` | Delisted SKUs |
|
||||
| `competitor_takeover_count` | Shelf Losses |
|
||||
| `avg_oos_duration_days` | Avg Stockout Length |
|
||||
| `avg_reorder_lag_days` | Avg Restock Time |
|
||||
| `severity: critical` | Urgent |
|
||||
| `severity: warning` | Watch |
|
||||
|
||||
---
|
||||
|
||||
## Section 3: SKU Performance (Product Velocity)
|
||||
|
||||
How fast each SKU sells.
|
||||
|
||||
```typescript
|
||||
interface SkuPerformance {
|
||||
store_product_id: number;
|
||||
product_name: string;
|
||||
category: string | null;
|
||||
daily_velocity: number; // Units/day estimate
|
||||
velocity_status: 'hot' | 'steady' | 'slow' | 'stale';
|
||||
retail_price: number | null;
|
||||
on_sale: boolean;
|
||||
stores_carrying: number;
|
||||
stock_status: 'in_stock' | 'low_stock' | 'out_of_stock';
|
||||
}
|
||||
```
|
||||
|
||||
**UI Label Mapping:**
|
||||
| Field | User-Facing Label |
|
||||
|-------|-------------------|
|
||||
| `daily_velocity` | Daily Rate |
|
||||
| `velocity_status` | Momentum |
|
||||
| `velocity_status: hot` | Hot |
|
||||
| `velocity_status: steady` | Steady |
|
||||
| `velocity_status: slow` | Slow |
|
||||
| `velocity_status: stale` | Stale |
|
||||
| `retail_price` | Retail Price |
|
||||
| `on_sale` | Promo (badge) |
|
||||
|
||||
**Velocity Thresholds:**
|
||||
- `hot`: >= 5 units/day
|
||||
- `steady`: >= 1 unit/day
|
||||
- `slow`: >= 0.1 units/day
|
||||
- `stale`: < 0.1 units/day
|
||||
|
||||
---
|
||||
|
||||
## Section 4: Retail Footprint
|
||||
|
||||
Store placement and coverage.
|
||||
|
||||
```typescript
|
||||
interface RetailFootprint {
|
||||
total_stores: number;
|
||||
in_stock_count: number;
|
||||
out_of_stock_count: number;
|
||||
penetration_by_region: RegionPenetration[];
|
||||
whitespace_stores: WhitespaceStore[];
|
||||
}
|
||||
|
||||
interface RegionPenetration {
|
||||
state_code: string;
|
||||
store_count: number;
|
||||
percent_reached: number; // % of state's dispensaries
|
||||
in_stock: number;
|
||||
out_of_stock: number;
|
||||
}
|
||||
|
||||
interface WhitespaceStore {
|
||||
store_id: number;
|
||||
store_name: string;
|
||||
state_code: string;
|
||||
city: string | null;
|
||||
category_fit: number; // How many competing brands they carry
|
||||
competitor_brands: string[];
|
||||
}
|
||||
```
|
||||
|
||||
**UI Label Mapping:**
|
||||
| Field | User-Facing Label |
|
||||
|-------|-------------------|
|
||||
| `penetration_by_region` | Market Coverage by Region |
|
||||
| `percent_reached` | X% reached |
|
||||
| `in_stock` | X stocked |
|
||||
| `out_of_stock` | X out |
|
||||
| `whitespace_stores` | Expansion Opportunities |
|
||||
| `category_fit` | X fit |
|
||||
|
||||
---
|
||||
|
||||
## Section 5: Competitive Landscape
|
||||
|
||||
Market positioning vs competitors.
|
||||
|
||||
```typescript
|
||||
interface CompetitiveLandscape {
|
||||
brand_price_position: 'premium' | 'value' | 'competitive';
|
||||
market_share_trend: MarketSharePoint[];
|
||||
competitors: Competitor[];
|
||||
head_to_head_skus: HeadToHead[];
|
||||
}
|
||||
|
||||
interface MarketSharePoint {
|
||||
date: string;
|
||||
share_percent: number;
|
||||
}
|
||||
|
||||
interface Competitor {
|
||||
brand_name: string;
|
||||
store_overlap_percent: number;
|
||||
price_position: 'premium' | 'value' | 'competitive';
|
||||
avg_price: number | null;
|
||||
sku_count: number;
|
||||
}
|
||||
|
||||
interface HeadToHead {
|
||||
product_name: string;
|
||||
brand_price: number;
|
||||
competitor_brand: string;
|
||||
competitor_price: number;
|
||||
price_diff_percent: number;
|
||||
}
|
||||
```
|
||||
|
||||
**UI Label Mapping:**
|
||||
| Field | User-Facing Label |
|
||||
|-------|-------------------|
|
||||
| `price_position: premium` | Premium Tier |
|
||||
| `price_position: value` | Value Leader |
|
||||
| `price_position: competitive` | Market Rate |
|
||||
| `market_share_trend` | Share of Shelf Trend |
|
||||
| `head_to_head_skus` | Price Comparison |
|
||||
| `store_overlap_percent` | X% store overlap |
|
||||
|
||||
---
|
||||
|
||||
## Section 6: Inventory Health
|
||||
|
||||
Stock projections and risk levels.
|
||||
|
||||
```typescript
|
||||
interface InventoryHealth {
|
||||
critical_count: number; // <7 days stock
|
||||
warning_count: number; // 7-14 days stock
|
||||
healthy_count: number; // 14-90 days stock
|
||||
overstocked_count: number; // >90 days stock
|
||||
skus: InventorySku[];
|
||||
overstock_alert: OverstockItem[];
|
||||
}
|
||||
|
||||
interface InventorySku {
|
||||
store_product_id: number;
|
||||
product_name: string;
|
||||
store_name: string;
|
||||
days_of_stock: number | null;
|
||||
risk_level: 'critical' | 'elevated' | 'moderate' | 'healthy';
|
||||
current_quantity: number | null;
|
||||
daily_sell_rate: number | null;
|
||||
}
|
||||
|
||||
interface OverstockItem {
|
||||
product_name: string;
|
||||
store_name: string;
|
||||
excess_units: number;
|
||||
days_of_stock: number;
|
||||
}
|
||||
```
|
||||
|
||||
**UI Label Mapping:**
|
||||
| Field | User-Facing Label |
|
||||
|-------|-------------------|
|
||||
| `risk_level: critical` | Reorder Now |
|
||||
| `risk_level: elevated` | Low Stock |
|
||||
| `risk_level: moderate` | Monitor |
|
||||
| `risk_level: healthy` | Healthy |
|
||||
| `critical_count` | Urgent (<7 days) |
|
||||
| `warning_count` | Low (7-14 days) |
|
||||
| `overstocked_count` | Excess (>90 days) |
|
||||
| `days_of_stock` | X days remaining |
|
||||
| `overstock_alert` | Overstock Alert |
|
||||
| `excess_units` | X excess units |
|
||||
|
||||
---
|
||||
|
||||
## Section 7: Promotion Effectiveness
|
||||
|
||||
How promotions impact sales.
|
||||
|
||||
```typescript
|
||||
interface PromoPerformance {
|
||||
avg_baseline_velocity: number | null;
|
||||
avg_promo_velocity: number | null;
|
||||
avg_velocity_lift: number | null; // % increase during promo
|
||||
avg_efficiency_score: number | null; // ROI proxy
|
||||
promotions: Promotion[];
|
||||
}
|
||||
|
||||
interface Promotion {
|
||||
product_name: string;
|
||||
store_name: string;
|
||||
status: 'active' | 'scheduled' | 'ended';
|
||||
start_date: string;
|
||||
end_date: string | null;
|
||||
regular_price: number;
|
||||
promo_price: number;
|
||||
discount_percent: number;
|
||||
baseline_velocity: number | null;
|
||||
promo_velocity: number | null;
|
||||
velocity_lift: number | null;
|
||||
efficiency_score: number | null;
|
||||
}
|
||||
```
|
||||
|
||||
**UI Label Mapping:**
|
||||
| Field | User-Facing Label |
|
||||
|-------|-------------------|
|
||||
| `avg_baseline_velocity` | Normal Rate |
|
||||
| `avg_promo_velocity` | During Promos |
|
||||
| `avg_velocity_lift` | Avg Sales Lift |
|
||||
| `avg_efficiency_score` | ROI Score |
|
||||
| `velocity_lift` | Sales Lift |
|
||||
| `efficiency_score` | ROI Score |
|
||||
| `status: active` | Live |
|
||||
| `status: scheduled` | Scheduled |
|
||||
| `status: ended` | Ended |
|
||||
|
||||
---
|
||||
|
||||
## Example Queries
|
||||
|
||||
### Get full payload
|
||||
```javascript
|
||||
const response = await fetch('/api/analytics/v2/brand/Wyld/intelligence?window=30d');
|
||||
const data = await response.json();
|
||||
```
|
||||
|
||||
### Extract summary cards (flattened)
|
||||
```javascript
|
||||
const { performance_snapshot: ps, alerts } = data;
|
||||
|
||||
const summaryCards = {
|
||||
activeProducts: ps.active_skus,
|
||||
monthlyRevenue: ps.total_revenue_30d,
|
||||
retailDistribution: ps.total_stores,
|
||||
newOpportunities: ps.new_stores_30d,
|
||||
categoryPosition: ps.market_share,
|
||||
avgWholesale: ps.avg_wholesale_price,
|
||||
pricingTier: ps.price_position,
|
||||
accountsAtRisk: alerts.lost_stores_30d_count,
|
||||
delistedSkus: alerts.lost_skus_30d_count,
|
||||
shelfLosses: alerts.competitor_takeover_count,
|
||||
};
|
||||
```
|
||||
|
||||
### Get top 10 fastest selling SKUs
|
||||
```javascript
|
||||
const topSkus = data.sku_performance
|
||||
.filter(sku => sku.velocity_status === 'hot' || sku.velocity_status === 'steady')
|
||||
.sort((a, b) => b.daily_velocity - a.daily_velocity)
|
||||
.slice(0, 10);
|
||||
```
|
||||
|
||||
### Get critical inventory alerts only
|
||||
```javascript
|
||||
const criticalInventory = data.inventory_health.skus
|
||||
.filter(sku => sku.risk_level === 'critical');
|
||||
```
|
||||
|
||||
### Get states with <50% penetration
|
||||
```javascript
|
||||
const underPenetrated = data.retail_footprint.penetration_by_region
|
||||
.filter(region => region.percent_reached < 50)
|
||||
.sort((a, b) => a.percent_reached - b.percent_reached);
|
||||
```
|
||||
|
||||
### Get active promotions with positive lift
|
||||
```javascript
|
||||
const effectivePromos = data.promo_performance.promotions
|
||||
.filter(p => p.status === 'active' && p.velocity_lift > 0)
|
||||
.sort((a, b) => b.velocity_lift - a.velocity_lift);
|
||||
```
|
||||
|
||||
### Build chart data for market share trend
|
||||
```javascript
|
||||
const chartData = data.competitive_landscape.market_share_trend.map(point => ({
|
||||
x: new Date(point.date),
|
||||
y: point.share_percent,
|
||||
}));
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Notes for Frontend Implementation
|
||||
|
||||
1. **All fields are snake_case** - transform to camelCase if needed
|
||||
2. **Null values are possible** - handle gracefully in UI
|
||||
3. **Arrays may be empty** - show appropriate empty states
|
||||
4. **Timestamps are ISO format** - parse with `new Date()`
|
||||
5. **Percentages are already computed** - no need to multiply by 100
|
||||
6. **The `window` parameter affects trend calculations** - 7d/30d/90d
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "dutchie-menus-backend",
|
||||
"version": "1.5.1",
|
||||
"version": "1.6.0",
|
||||
"description": "Backend API for Dutchie Menus scraper and management",
|
||||
"main": "dist/index.js",
|
||||
"scripts": {
|
||||
|
||||
141
backend/src/db/auto-migrate.ts
Normal file
141
backend/src/db/auto-migrate.ts
Normal file
@@ -0,0 +1,141 @@
|
||||
/**
|
||||
* Auto-Migration System
|
||||
*
|
||||
* Runs SQL migration files from the migrations/ folder automatically on server startup.
|
||||
* Uses a schema_migrations table to track which migrations have been applied.
|
||||
*
|
||||
* Safe to run multiple times - only applies new migrations.
|
||||
*/
|
||||
|
||||
import { Pool } from 'pg';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
const MIGRATIONS_DIR = path.join(__dirname, '../../migrations');
|
||||
|
||||
/**
|
||||
* Ensure schema_migrations table exists
|
||||
*/
|
||||
async function ensureMigrationsTable(pool: Pool): Promise<void> {
|
||||
await pool.query(`
|
||||
CREATE TABLE IF NOT EXISTS schema_migrations (
|
||||
id SERIAL PRIMARY KEY,
|
||||
name VARCHAR(255) UNIQUE NOT NULL,
|
||||
applied_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
||||
)
|
||||
`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get list of already-applied migrations
|
||||
*/
|
||||
async function getAppliedMigrations(pool: Pool): Promise<Set<string>> {
|
||||
const result = await pool.query('SELECT name FROM schema_migrations');
|
||||
return new Set(result.rows.map(row => row.name));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get list of migration files from disk
|
||||
*/
|
||||
function getMigrationFiles(): string[] {
|
||||
if (!fs.existsSync(MIGRATIONS_DIR)) {
|
||||
console.log('[AutoMigrate] No migrations directory found');
|
||||
return [];
|
||||
}
|
||||
|
||||
return fs.readdirSync(MIGRATIONS_DIR)
|
||||
.filter(f => f.endsWith('.sql'))
|
||||
.sort(); // Sort alphabetically (001_, 002_, etc.)
|
||||
}
|
||||
|
||||
/**
|
||||
* Run a single migration file
|
||||
*/
|
||||
async function runMigration(pool: Pool, filename: string): Promise<void> {
|
||||
const filepath = path.join(MIGRATIONS_DIR, filename);
|
||||
const sql = fs.readFileSync(filepath, 'utf8');
|
||||
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
await client.query('BEGIN');
|
||||
|
||||
// Run the migration SQL
|
||||
await client.query(sql);
|
||||
|
||||
// Record that this migration was applied
|
||||
await client.query(
|
||||
'INSERT INTO schema_migrations (name) VALUES ($1) ON CONFLICT (name) DO NOTHING',
|
||||
[filename]
|
||||
);
|
||||
|
||||
await client.query('COMMIT');
|
||||
console.log(`[AutoMigrate] ✓ Applied: ${filename}`);
|
||||
} catch (error: any) {
|
||||
await client.query('ROLLBACK');
|
||||
console.error(`[AutoMigrate] ✗ Failed: ${filename}`);
|
||||
throw error;
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Run all pending migrations
|
||||
*
|
||||
* @param pool - Database connection pool
|
||||
* @returns Number of migrations applied
|
||||
*/
|
||||
export async function runAutoMigrations(pool: Pool): Promise<number> {
|
||||
console.log('[AutoMigrate] Checking for pending migrations...');
|
||||
|
||||
try {
|
||||
// Ensure migrations table exists
|
||||
await ensureMigrationsTable(pool);
|
||||
|
||||
// Get applied and available migrations
|
||||
const applied = await getAppliedMigrations(pool);
|
||||
const available = getMigrationFiles();
|
||||
|
||||
// Find pending migrations
|
||||
const pending = available.filter(f => !applied.has(f));
|
||||
|
||||
if (pending.length === 0) {
|
||||
console.log('[AutoMigrate] No pending migrations');
|
||||
return 0;
|
||||
}
|
||||
|
||||
console.log(`[AutoMigrate] Found ${pending.length} pending migrations`);
|
||||
|
||||
// Run each pending migration in order
|
||||
for (const filename of pending) {
|
||||
await runMigration(pool, filename);
|
||||
}
|
||||
|
||||
console.log(`[AutoMigrate] Successfully applied ${pending.length} migrations`);
|
||||
return pending.length;
|
||||
|
||||
} catch (error: any) {
|
||||
console.error('[AutoMigrate] Migration failed:', error.message);
|
||||
// Don't crash the server - log and continue
|
||||
// The specific failing migration will have been rolled back
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check migration status without running anything
|
||||
*/
|
||||
export async function checkMigrationStatus(pool: Pool): Promise<{
|
||||
applied: string[];
|
||||
pending: string[];
|
||||
}> {
|
||||
await ensureMigrationsTable(pool);
|
||||
|
||||
const applied = await getAppliedMigrations(pool);
|
||||
const available = getMigrationFiles();
|
||||
|
||||
return {
|
||||
applied: available.filter(f => applied.has(f)),
|
||||
pending: available.filter(f => !applied.has(f)),
|
||||
};
|
||||
}
|
||||
@@ -90,7 +90,7 @@ export async function upsertStoreProducts(
|
||||
name_raw, brand_name_raw, category_raw, subcategory_raw,
|
||||
price_rec, price_med, price_rec_special, price_med_special,
|
||||
is_on_special, discount_percent,
|
||||
is_in_stock, stock_status,
|
||||
is_in_stock, stock_status, stock_quantity, total_quantity_available,
|
||||
thc_percent, cbd_percent,
|
||||
image_url,
|
||||
first_seen_at, last_seen_at, updated_at
|
||||
@@ -99,9 +99,9 @@ export async function upsertStoreProducts(
|
||||
$5, $6, $7, $8,
|
||||
$9, $10, $11, $12,
|
||||
$13, $14,
|
||||
$15, $16,
|
||||
$17, $18,
|
||||
$19,
|
||||
$15, $16, $17, $17,
|
||||
$18, $19,
|
||||
$20,
|
||||
NOW(), NOW(), NOW()
|
||||
)
|
||||
ON CONFLICT (dispensary_id, provider, provider_product_id)
|
||||
@@ -118,6 +118,8 @@ export async function upsertStoreProducts(
|
||||
discount_percent = EXCLUDED.discount_percent,
|
||||
is_in_stock = EXCLUDED.is_in_stock,
|
||||
stock_status = EXCLUDED.stock_status,
|
||||
stock_quantity = EXCLUDED.stock_quantity,
|
||||
total_quantity_available = EXCLUDED.total_quantity_available,
|
||||
thc_percent = EXCLUDED.thc_percent,
|
||||
cbd_percent = EXCLUDED.cbd_percent,
|
||||
image_url = EXCLUDED.image_url,
|
||||
@@ -141,6 +143,7 @@ export async function upsertStoreProducts(
|
||||
productPricing?.discountPercent,
|
||||
productAvailability?.inStock ?? true,
|
||||
productAvailability?.stockStatus || 'unknown',
|
||||
productAvailability?.quantity ?? null, // stock_quantity and total_quantity_available
|
||||
// Clamp THC/CBD to valid percentage range (0-100) - some products report mg as %
|
||||
product.thcPercent !== null && product.thcPercent <= 100 ? product.thcPercent : null,
|
||||
product.cbdPercent !== null && product.cbdPercent <= 100 ? product.cbdPercent : null,
|
||||
|
||||
@@ -6,6 +6,8 @@ import { initializeMinio, isMinioEnabled } from './utils/minio';
|
||||
import { initializeImageStorage } from './utils/image-storage';
|
||||
import { logger } from './services/logger';
|
||||
import { cleanupOrphanedJobs } from './services/proxyTestQueue';
|
||||
import { runAutoMigrations } from './db/auto-migrate';
|
||||
import { getPool } from './db/pool';
|
||||
import healthRoutes from './routes/health';
|
||||
import imageProxyRoutes from './routes/image-proxy';
|
||||
|
||||
@@ -127,7 +129,6 @@ import { createStatesRouter } from './routes/states';
|
||||
import { createAnalyticsV2Router } from './routes/analytics-v2';
|
||||
import { createDiscoveryRoutes } from './discovery';
|
||||
import pipelineRoutes from './routes/pipeline';
|
||||
import { getPool } from './db/pool';
|
||||
|
||||
// Consumer API routes (findadispo.com, findagram.co)
|
||||
import consumerAuthRoutes from './routes/consumer-auth';
|
||||
@@ -307,6 +308,17 @@ async function startServer() {
|
||||
try {
|
||||
logger.info('system', 'Starting server...');
|
||||
|
||||
// Run auto-migrations before anything else
|
||||
const pool = getPool();
|
||||
const migrationsApplied = await runAutoMigrations(pool);
|
||||
if (migrationsApplied > 0) {
|
||||
logger.info('system', `Applied ${migrationsApplied} database migrations`);
|
||||
} else if (migrationsApplied === 0) {
|
||||
logger.info('system', 'Database schema up to date');
|
||||
} else {
|
||||
logger.warn('system', 'Some migrations failed - check logs');
|
||||
}
|
||||
|
||||
await initializeMinio();
|
||||
await initializeImageStorage();
|
||||
logger.info('system', isMinioEnabled() ? 'MinIO storage initialized' : 'Local filesystem storage initialized');
|
||||
|
||||
@@ -534,7 +534,8 @@ export async function executeGraphQL(
|
||||
}
|
||||
|
||||
if (response.status === 403 && retryOn403) {
|
||||
console.warn(`[Dutchie Client] 403 blocked - rotating fingerprint...`);
|
||||
console.warn(`[Dutchie Client] 403 blocked - rotating proxy and fingerprint...`);
|
||||
await rotateProxyOn403('403 Forbidden on GraphQL');
|
||||
rotateFingerprint();
|
||||
attempt++;
|
||||
await sleep(1000 * attempt);
|
||||
@@ -617,7 +618,8 @@ export async function fetchPage(
|
||||
}
|
||||
|
||||
if (response.status === 403 && retryOn403) {
|
||||
console.warn(`[Dutchie Client] 403 blocked - rotating fingerprint...`);
|
||||
console.warn(`[Dutchie Client] 403 blocked - rotating proxy and fingerprint...`);
|
||||
await rotateProxyOn403('403 Forbidden on page fetch');
|
||||
rotateFingerprint();
|
||||
attempt++;
|
||||
await sleep(1000 * attempt);
|
||||
|
||||
@@ -16,6 +16,7 @@ import { BrandPenetrationService } from '../services/analytics/BrandPenetrationS
|
||||
import { CategoryAnalyticsService } from '../services/analytics/CategoryAnalyticsService';
|
||||
import { StoreAnalyticsService } from '../services/analytics/StoreAnalyticsService';
|
||||
import { StateAnalyticsService } from '../services/analytics/StateAnalyticsService';
|
||||
import { BrandIntelligenceService } from '../services/analytics/BrandIntelligenceService';
|
||||
import { TimeWindow, LegalType } from '../services/analytics/types';
|
||||
|
||||
function parseTimeWindow(window?: string): TimeWindow {
|
||||
@@ -41,6 +42,7 @@ export function createAnalyticsV2Router(pool: Pool): Router {
|
||||
const categoryService = new CategoryAnalyticsService(pool);
|
||||
const storeService = new StoreAnalyticsService(pool);
|
||||
const stateService = new StateAnalyticsService(pool);
|
||||
const brandIntelligenceService = new BrandIntelligenceService(pool);
|
||||
|
||||
// ============================================================
|
||||
// PRICE ANALYTICS
|
||||
@@ -231,6 +233,76 @@ export function createAnalyticsV2Router(pool: Pool): Router {
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /brand/:name/promotions
|
||||
* Get brand promotional history - tracks specials, discounts, duration, and sales estimates
|
||||
*
|
||||
* Query params:
|
||||
* - window: 7d|30d|90d (default: 90d)
|
||||
* - state: state code filter (e.g., AZ)
|
||||
* - category: category filter (e.g., Flower)
|
||||
*/
|
||||
router.get('/brand/:name/promotions', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const brandName = decodeURIComponent(req.params.name);
|
||||
const window = parseTimeWindow(req.query.window as string) || '90d';
|
||||
const stateCode = req.query.state as string | undefined;
|
||||
const category = req.query.category as string | undefined;
|
||||
|
||||
const result = await brandService.getBrandPromotionalHistory(brandName, {
|
||||
window,
|
||||
stateCode,
|
||||
category,
|
||||
});
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
console.error('[AnalyticsV2] Brand promotions error:', error);
|
||||
res.status(500).json({ error: 'Failed to fetch brand promotional history' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /brand/:name/intelligence
|
||||
* Get comprehensive B2B brand intelligence dashboard data
|
||||
*
|
||||
* Returns all brand metrics in a single unified response:
|
||||
* - Performance Snapshot (active SKUs, revenue, stores, market share)
|
||||
* - Alerts/Slippage (lost stores, delisted SKUs, competitor takeovers)
|
||||
* - Product Velocity (daily rates, velocity status)
|
||||
* - Retail Footprint (penetration, whitespace opportunities)
|
||||
* - Competitive Landscape (price position, market share trend)
|
||||
* - Inventory Health (days of stock, risk levels)
|
||||
* - Promotion Effectiveness (baseline vs promo velocity, ROI)
|
||||
*
|
||||
* Query params:
|
||||
* - window: 7d|30d|90d (default: 30d)
|
||||
* - state: state code filter (e.g., AZ)
|
||||
* - category: category filter (e.g., Flower)
|
||||
*/
|
||||
router.get('/brand/:name/intelligence', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const brandName = decodeURIComponent(req.params.name);
|
||||
const window = parseTimeWindow(req.query.window as string);
|
||||
const stateCode = req.query.state as string | undefined;
|
||||
const category = req.query.category as string | undefined;
|
||||
|
||||
const result = await brandIntelligenceService.getBrandIntelligence(brandName, {
|
||||
window,
|
||||
stateCode,
|
||||
category,
|
||||
});
|
||||
|
||||
if (!result) {
|
||||
return res.status(404).json({ error: 'Brand not found' });
|
||||
}
|
||||
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
console.error('[AnalyticsV2] Brand intelligence error:', error);
|
||||
res.status(500).json({ error: 'Failed to fetch brand intelligence' });
|
||||
}
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// CATEGORY ANALYTICS
|
||||
// ============================================================
|
||||
@@ -400,6 +472,31 @@ export function createAnalyticsV2Router(pool: Pool): Router {
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /store/:id/quantity-changes
|
||||
* Get quantity changes for a store (increases/decreases)
|
||||
* Useful for estimating sales (decreases) or restocks (increases)
|
||||
*
|
||||
* Query params:
|
||||
* - window: 7d|30d|90d (default: 7d)
|
||||
* - direction: increase|decrease|all (default: all)
|
||||
* - limit: number (default: 100)
|
||||
*/
|
||||
router.get('/store/:id/quantity-changes', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const dispensaryId = parseInt(req.params.id);
|
||||
const window = parseTimeWindow(req.query.window as string);
|
||||
const direction = (req.query.direction as 'increase' | 'decrease' | 'all') || 'all';
|
||||
const limit = req.query.limit ? parseInt(req.query.limit as string) : 100;
|
||||
|
||||
const result = await storeService.getQuantityChanges(dispensaryId, { window, direction, limit });
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
console.error('[AnalyticsV2] Store quantity changes error:', error);
|
||||
res.status(500).json({ error: 'Failed to fetch store quantity changes' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /store/:id/inventory
|
||||
* Get store inventory composition
|
||||
|
||||
@@ -27,8 +27,8 @@ router.get('/brands', async (req: Request, res: Response) => {
|
||||
array_agg(DISTINCT d.state) FILTER (WHERE d.state IS NOT NULL) as states,
|
||||
COUNT(DISTINCT d.id) as store_count,
|
||||
COUNT(DISTINCT sp.id) as sku_count,
|
||||
ROUND(AVG(sp.price_rec)::numeric, 2) FILTER (WHERE sp.price_rec > 0) as avg_price_rec,
|
||||
ROUND(AVG(sp.price_med)::numeric, 2) FILTER (WHERE sp.price_med > 0) as avg_price_med
|
||||
ROUND(AVG(sp.price_rec) FILTER (WHERE sp.price_rec > 0)::numeric, 2) as avg_price_rec,
|
||||
ROUND(AVG(sp.price_med) FILTER (WHERE sp.price_med > 0)::numeric, 2) as avg_price_med
|
||||
FROM store_products sp
|
||||
JOIN dispensaries d ON sp.dispensary_id = d.id
|
||||
WHERE sp.brand_name_raw IS NOT NULL AND sp.brand_name_raw != ''
|
||||
@@ -154,10 +154,9 @@ router.get('/pricing', async (req: Request, res: Response) => {
|
||||
SELECT
|
||||
sp.category_raw as category,
|
||||
ROUND(AVG(sp.price_rec)::numeric, 2) as avg_price,
|
||||
MIN(sp.price_rec) FILTER (WHERE sp.price_rec > 0) as min_price,
|
||||
MIN(sp.price_rec) as min_price,
|
||||
MAX(sp.price_rec) as max_price,
|
||||
ROUND(PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY sp.price_rec)::numeric, 2)
|
||||
FILTER (WHERE sp.price_rec > 0) as median_price,
|
||||
ROUND(PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY sp.price_rec)::numeric, 2) as median_price,
|
||||
COUNT(*) as product_count
|
||||
FROM store_products sp
|
||||
WHERE sp.category_raw IS NOT NULL AND sp.price_rec > 0
|
||||
@@ -169,7 +168,7 @@ router.get('/pricing', async (req: Request, res: Response) => {
|
||||
SELECT
|
||||
d.state,
|
||||
ROUND(AVG(sp.price_rec)::numeric, 2) as avg_price,
|
||||
MIN(sp.price_rec) FILTER (WHERE sp.price_rec > 0) as min_price,
|
||||
MIN(sp.price_rec) as min_price,
|
||||
MAX(sp.price_rec) as max_price,
|
||||
COUNT(DISTINCT sp.id) as product_count
|
||||
FROM store_products sp
|
||||
|
||||
@@ -2,7 +2,7 @@ import { Router } from 'express';
|
||||
import { authMiddleware, requireRole } from '../auth/middleware';
|
||||
import { pool } from '../db/pool';
|
||||
import { testProxy, addProxy, addProxiesFromList } from '../services/proxy';
|
||||
import { createProxyTestJob, getProxyTestJob, getActiveProxyTestJob, cancelProxyTestJob } from '../services/proxyTestQueue';
|
||||
import { createProxyTestJob, getProxyTestJob, getActiveProxyTestJob, cancelProxyTestJob, ProxyTestMode } from '../services/proxyTestQueue';
|
||||
|
||||
const router = Router();
|
||||
router.use(authMiddleware);
|
||||
@@ -11,9 +11,10 @@ router.use(authMiddleware);
|
||||
router.get('/', async (req, res) => {
|
||||
try {
|
||||
const result = await pool.query(`
|
||||
SELECT id, host, port, protocol, active, is_anonymous,
|
||||
SELECT id, host, port, protocol, username, password, active, is_anonymous,
|
||||
last_tested_at, test_result, response_time_ms, created_at,
|
||||
city, state, country, country_code, location_updated_at
|
||||
city, state, country, country_code, location_updated_at,
|
||||
COALESCE(max_connections, 1) as max_connections
|
||||
FROM proxies
|
||||
ORDER BY created_at DESC
|
||||
`);
|
||||
@@ -166,13 +167,39 @@ router.post('/:id/test', requireRole('superadmin', 'admin'), async (req, res) =>
|
||||
});
|
||||
|
||||
// Start proxy test job
|
||||
// Query params: mode=all|failed|inactive, concurrency=10
|
||||
router.post('/test-all', requireRole('superadmin', 'admin'), async (req, res) => {
|
||||
try {
|
||||
const jobId = await createProxyTestJob();
|
||||
res.json({ jobId, message: 'Proxy test job started' });
|
||||
} catch (error) {
|
||||
const mode = (req.query.mode as ProxyTestMode) || 'all';
|
||||
const concurrency = parseInt(req.query.concurrency as string) || 10;
|
||||
|
||||
// Validate mode
|
||||
if (!['all', 'failed', 'inactive'].includes(mode)) {
|
||||
return res.status(400).json({ error: 'Invalid mode. Use: all, failed, or inactive' });
|
||||
}
|
||||
|
||||
// Validate concurrency (1-50)
|
||||
if (concurrency < 1 || concurrency > 50) {
|
||||
return res.status(400).json({ error: 'Concurrency must be between 1 and 50' });
|
||||
}
|
||||
|
||||
const { jobId, totalProxies } = await createProxyTestJob(mode, concurrency);
|
||||
res.json({ jobId, total: totalProxies, mode, concurrency, message: `Proxy test job started (mode: ${mode}, concurrency: ${concurrency})` });
|
||||
} catch (error: any) {
|
||||
console.error('Error starting proxy test job:', error);
|
||||
res.status(500).json({ error: 'Failed to start proxy test job' });
|
||||
res.status(500).json({ error: error.message || 'Failed to start proxy test job' });
|
||||
}
|
||||
});
|
||||
|
||||
// Convenience endpoint: Test only failed proxies
|
||||
router.post('/test-failed', requireRole('superadmin', 'admin'), async (req, res) => {
|
||||
try {
|
||||
const concurrency = parseInt(req.query.concurrency as string) || 10;
|
||||
const { jobId, totalProxies } = await createProxyTestJob('failed', concurrency);
|
||||
res.json({ jobId, total: totalProxies, mode: 'failed', concurrency, message: 'Retesting failed proxies...' });
|
||||
} catch (error: any) {
|
||||
console.error('Error starting failed proxy test:', error);
|
||||
res.status(500).json({ error: error.message || 'Failed to start proxy test job' });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -197,8 +224,8 @@ router.post('/test-job/:jobId/cancel', requireRole('superadmin', 'admin'), async
|
||||
router.put('/:id', requireRole('superadmin', 'admin'), async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const { host, port, protocol, username, password, active } = req.body;
|
||||
|
||||
const { host, port, protocol, username, password, active, max_connections } = req.body;
|
||||
|
||||
const result = await pool.query(`
|
||||
UPDATE proxies
|
||||
SET host = COALESCE($1, host),
|
||||
@@ -207,10 +234,11 @@ router.put('/:id', requireRole('superadmin', 'admin'), async (req, res) => {
|
||||
username = COALESCE($4, username),
|
||||
password = COALESCE($5, password),
|
||||
active = COALESCE($6, active),
|
||||
max_connections = COALESCE($7, max_connections),
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = $7
|
||||
WHERE id = $8
|
||||
RETURNING *
|
||||
`, [host, port, protocol, username, password, active, id]);
|
||||
`, [host, port, protocol, username, password, active, max_connections, id]);
|
||||
|
||||
if (result.rows.length === 0) {
|
||||
return res.status(404).json({ error: 'Proxy not found' });
|
||||
|
||||
@@ -145,6 +145,36 @@ router.get('/:id', async (req: Request, res: Response) => {
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* DELETE /api/tasks/:id
|
||||
* Delete a specific task by ID
|
||||
* Only allows deletion of failed, completed, or pending tasks (not running)
|
||||
*/
|
||||
router.delete('/:id', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const taskId = parseInt(req.params.id, 10);
|
||||
|
||||
// First check if task exists and its status
|
||||
const task = await taskService.getTask(taskId);
|
||||
if (!task) {
|
||||
return res.status(404).json({ error: 'Task not found' });
|
||||
}
|
||||
|
||||
// Don't allow deleting running tasks
|
||||
if (task.status === 'running' || task.status === 'claimed') {
|
||||
return res.status(400).json({ error: 'Cannot delete a running or claimed task' });
|
||||
}
|
||||
|
||||
// Delete the task
|
||||
await pool.query('DELETE FROM worker_tasks WHERE id = $1', [taskId]);
|
||||
|
||||
res.json({ success: true, message: `Task ${taskId} deleted` });
|
||||
} catch (error: unknown) {
|
||||
console.error('Error deleting task:', error);
|
||||
res.status(500).json({ error: 'Failed to delete task' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/tasks
|
||||
* Create a new task
|
||||
|
||||
@@ -14,23 +14,36 @@ router.get('/', async (req: AuthRequest, res) => {
|
||||
try {
|
||||
const { search, domain } = req.query;
|
||||
|
||||
let query = `
|
||||
SELECT id, email, role, first_name, last_name, phone, domain, created_at, updated_at
|
||||
FROM users
|
||||
WHERE 1=1
|
||||
`;
|
||||
// Check which columns exist (schema-tolerant)
|
||||
const columnsResult = await pool.query(`
|
||||
SELECT column_name FROM information_schema.columns
|
||||
WHERE table_name = 'users' AND column_name IN ('first_name', 'last_name', 'phone', 'domain')
|
||||
`);
|
||||
const existingColumns = new Set(columnsResult.rows.map((r: any) => r.column_name));
|
||||
|
||||
// Build column list based on what exists
|
||||
const selectCols = ['id', 'email', 'role', 'created_at', 'updated_at'];
|
||||
if (existingColumns.has('first_name')) selectCols.push('first_name');
|
||||
if (existingColumns.has('last_name')) selectCols.push('last_name');
|
||||
if (existingColumns.has('phone')) selectCols.push('phone');
|
||||
if (existingColumns.has('domain')) selectCols.push('domain');
|
||||
|
||||
let query = `SELECT ${selectCols.join(', ')} FROM users WHERE 1=1`;
|
||||
const params: any[] = [];
|
||||
let paramIndex = 1;
|
||||
|
||||
// Search by email, first_name, or last_name
|
||||
// Search by email (and optionally first_name, last_name if they exist)
|
||||
if (search && typeof search === 'string') {
|
||||
query += ` AND (email ILIKE $${paramIndex} OR first_name ILIKE $${paramIndex} OR last_name ILIKE $${paramIndex})`;
|
||||
const searchClauses = ['email ILIKE $' + paramIndex];
|
||||
if (existingColumns.has('first_name')) searchClauses.push('first_name ILIKE $' + paramIndex);
|
||||
if (existingColumns.has('last_name')) searchClauses.push('last_name ILIKE $' + paramIndex);
|
||||
query += ` AND (${searchClauses.join(' OR ')})`;
|
||||
params.push(`%${search}%`);
|
||||
paramIndex++;
|
||||
}
|
||||
|
||||
// Filter by domain
|
||||
if (domain && typeof domain === 'string') {
|
||||
// Filter by domain (if column exists)
|
||||
if (domain && typeof domain === 'string' && existingColumns.has('domain')) {
|
||||
query += ` AND domain = $${paramIndex}`;
|
||||
params.push(domain);
|
||||
paramIndex++;
|
||||
@@ -50,8 +63,22 @@ router.get('/', async (req: AuthRequest, res) => {
|
||||
router.get('/:id', async (req: AuthRequest, res) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
|
||||
// Check which columns exist (schema-tolerant)
|
||||
const columnsResult = await pool.query(`
|
||||
SELECT column_name FROM information_schema.columns
|
||||
WHERE table_name = 'users' AND column_name IN ('first_name', 'last_name', 'phone', 'domain')
|
||||
`);
|
||||
const existingColumns = new Set(columnsResult.rows.map((r: any) => r.column_name));
|
||||
|
||||
const selectCols = ['id', 'email', 'role', 'created_at', 'updated_at'];
|
||||
if (existingColumns.has('first_name')) selectCols.push('first_name');
|
||||
if (existingColumns.has('last_name')) selectCols.push('last_name');
|
||||
if (existingColumns.has('phone')) selectCols.push('phone');
|
||||
if (existingColumns.has('domain')) selectCols.push('domain');
|
||||
|
||||
const result = await pool.query(`
|
||||
SELECT id, email, role, first_name, last_name, phone, domain, created_at, updated_at
|
||||
SELECT ${selectCols.join(', ')}
|
||||
FROM users
|
||||
WHERE id = $1
|
||||
`, [id]);
|
||||
|
||||
@@ -273,6 +273,29 @@ router.post('/deregister', async (req: Request, res: Response) => {
|
||||
*/
|
||||
router.get('/workers', async (req: Request, res: Response) => {
|
||||
try {
|
||||
// Check if worker_registry table exists
|
||||
const tableCheck = await pool.query(`
|
||||
SELECT EXISTS (
|
||||
SELECT FROM information_schema.tables
|
||||
WHERE table_name = 'worker_registry'
|
||||
) as exists
|
||||
`);
|
||||
|
||||
if (!tableCheck.rows[0].exists) {
|
||||
// Return empty result if table doesn't exist yet
|
||||
return res.json({
|
||||
success: true,
|
||||
workers: [],
|
||||
summary: {
|
||||
active_count: 0,
|
||||
idle_count: 0,
|
||||
offline_count: 0,
|
||||
total_count: 0,
|
||||
active_roles: 0
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const { status, role, include_terminated = 'false' } = req.query;
|
||||
|
||||
let whereClause = include_terminated === 'true' ? 'WHERE 1=1' : "WHERE status != 'terminated'";
|
||||
|
||||
1202
backend/src/services/analytics/BrandIntelligenceService.ts
Normal file
1202
backend/src/services/analytics/BrandIntelligenceService.ts
Normal file
File diff suppressed because it is too large
Load Diff
@@ -26,6 +26,8 @@ import {
|
||||
PenetrationDataPoint,
|
||||
BrandMarketPosition,
|
||||
BrandRecVsMedFootprint,
|
||||
BrandPromotionalSummary,
|
||||
BrandPromotionalEvent,
|
||||
} from './types';
|
||||
|
||||
export class BrandPenetrationService {
|
||||
@@ -44,16 +46,17 @@ export class BrandPenetrationService {
|
||||
// Get current brand presence
|
||||
const currentResult = await this.pool.query(`
|
||||
SELECT
|
||||
sp.brand_name,
|
||||
sp.brand_name_raw AS brand_name,
|
||||
COUNT(DISTINCT sp.dispensary_id) AS total_dispensaries,
|
||||
COUNT(*) AS total_skus,
|
||||
ROUND(COUNT(*)::NUMERIC / NULLIF(COUNT(DISTINCT sp.dispensary_id), 0), 2) AS avg_skus_per_dispensary,
|
||||
ARRAY_AGG(DISTINCT s.code) FILTER (WHERE s.code IS NOT NULL) AS states_present
|
||||
FROM store_products sp
|
||||
LEFT JOIN states s ON s.id = sp.state_id
|
||||
WHERE sp.brand_name = $1
|
||||
JOIN dispensaries d ON d.id = sp.dispensary_id
|
||||
LEFT JOIN states s ON s.id = d.state_id
|
||||
WHERE sp.brand_name_raw = $1
|
||||
AND sp.is_in_stock = TRUE
|
||||
GROUP BY sp.brand_name
|
||||
GROUP BY sp.brand_name_raw
|
||||
`, [brandName]);
|
||||
|
||||
if (currentResult.rows.length === 0) {
|
||||
@@ -72,7 +75,7 @@ export class BrandPenetrationService {
|
||||
DATE(sps.captured_at) AS date,
|
||||
COUNT(DISTINCT sps.dispensary_id) AS dispensary_count
|
||||
FROM store_product_snapshots sps
|
||||
WHERE sps.brand_name = $1
|
||||
WHERE sps.brand_name_raw = $1
|
||||
AND sps.captured_at >= $2
|
||||
AND sps.captured_at <= $3
|
||||
AND sps.is_in_stock = TRUE
|
||||
@@ -123,8 +126,9 @@ export class BrandPenetrationService {
|
||||
COUNT(DISTINCT sp.dispensary_id) AS dispensary_count,
|
||||
COUNT(*) AS sku_count
|
||||
FROM store_products sp
|
||||
JOIN states s ON s.id = sp.state_id
|
||||
WHERE sp.brand_name = $1
|
||||
JOIN dispensaries d ON d.id = sp.dispensary_id
|
||||
JOIN states s ON s.id = d.state_id
|
||||
WHERE sp.brand_name_raw = $1
|
||||
AND sp.is_in_stock = TRUE
|
||||
GROUP BY s.code, s.name, s.recreational_legal, s.medical_legal
|
||||
),
|
||||
@@ -133,7 +137,8 @@ export class BrandPenetrationService {
|
||||
s.code AS state_code,
|
||||
COUNT(DISTINCT sp.dispensary_id) AS total_dispensaries
|
||||
FROM store_products sp
|
||||
JOIN states s ON s.id = sp.state_id
|
||||
JOIN dispensaries d ON d.id = sp.dispensary_id
|
||||
JOIN states s ON s.id = d.state_id
|
||||
WHERE sp.is_in_stock = TRUE
|
||||
GROUP BY s.code
|
||||
)
|
||||
@@ -169,7 +174,7 @@ export class BrandPenetrationService {
|
||||
let filters = '';
|
||||
|
||||
if (options.category) {
|
||||
filters += ` AND sp.category = $${paramIdx}`;
|
||||
filters += ` AND sp.category_raw = $${paramIdx}`;
|
||||
params.push(options.category);
|
||||
paramIdx++;
|
||||
}
|
||||
@@ -183,31 +188,33 @@ export class BrandPenetrationService {
|
||||
const result = await this.pool.query(`
|
||||
WITH brand_metrics AS (
|
||||
SELECT
|
||||
sp.brand_name,
|
||||
sp.category,
|
||||
sp.brand_name_raw AS brand_name,
|
||||
sp.category_raw AS category,
|
||||
s.code AS state_code,
|
||||
COUNT(*) AS sku_count,
|
||||
COUNT(DISTINCT sp.dispensary_id) AS dispensary_count,
|
||||
AVG(sp.price_rec) AS avg_price
|
||||
FROM store_products sp
|
||||
JOIN states s ON s.id = sp.state_id
|
||||
WHERE sp.brand_name = $1
|
||||
JOIN dispensaries d ON d.id = sp.dispensary_id
|
||||
JOIN states s ON s.id = d.state_id
|
||||
WHERE sp.brand_name_raw = $1
|
||||
AND sp.is_in_stock = TRUE
|
||||
AND sp.category IS NOT NULL
|
||||
AND sp.category_raw IS NOT NULL
|
||||
${filters}
|
||||
GROUP BY sp.brand_name, sp.category, s.code
|
||||
GROUP BY sp.brand_name_raw, sp.category_raw, s.code
|
||||
),
|
||||
category_totals AS (
|
||||
SELECT
|
||||
sp.category,
|
||||
sp.category_raw AS category,
|
||||
s.code AS state_code,
|
||||
COUNT(*) AS total_skus,
|
||||
AVG(sp.price_rec) AS category_avg_price
|
||||
FROM store_products sp
|
||||
JOIN states s ON s.id = sp.state_id
|
||||
JOIN dispensaries d ON d.id = sp.dispensary_id
|
||||
JOIN states s ON s.id = d.state_id
|
||||
WHERE sp.is_in_stock = TRUE
|
||||
AND sp.category IS NOT NULL
|
||||
GROUP BY sp.category, s.code
|
||||
AND sp.category_raw IS NOT NULL
|
||||
GROUP BY sp.category_raw, s.code
|
||||
)
|
||||
SELECT
|
||||
bm.*,
|
||||
@@ -243,8 +250,9 @@ export class BrandPenetrationService {
|
||||
COUNT(DISTINCT sp.dispensary_id) AS dispensary_count,
|
||||
ROUND(COUNT(*)::NUMERIC / NULLIF(COUNT(DISTINCT sp.dispensary_id), 0), 2) AS avg_skus
|
||||
FROM store_products sp
|
||||
JOIN states s ON s.id = sp.state_id
|
||||
WHERE sp.brand_name = $1
|
||||
JOIN dispensaries d ON d.id = sp.dispensary_id
|
||||
JOIN states s ON s.id = d.state_id
|
||||
WHERE sp.brand_name_raw = $1
|
||||
AND sp.is_in_stock = TRUE
|
||||
AND s.recreational_legal = TRUE
|
||||
),
|
||||
@@ -255,8 +263,9 @@ export class BrandPenetrationService {
|
||||
COUNT(DISTINCT sp.dispensary_id) AS dispensary_count,
|
||||
ROUND(COUNT(*)::NUMERIC / NULLIF(COUNT(DISTINCT sp.dispensary_id), 0), 2) AS avg_skus
|
||||
FROM store_products sp
|
||||
JOIN states s ON s.id = sp.state_id
|
||||
WHERE sp.brand_name = $1
|
||||
JOIN dispensaries d ON d.id = sp.dispensary_id
|
||||
JOIN states s ON s.id = d.state_id
|
||||
WHERE sp.brand_name_raw = $1
|
||||
AND sp.is_in_stock = TRUE
|
||||
AND s.medical_legal = TRUE
|
||||
AND (s.recreational_legal = FALSE OR s.recreational_legal IS NULL)
|
||||
@@ -311,23 +320,24 @@ export class BrandPenetrationService {
|
||||
}
|
||||
|
||||
if (category) {
|
||||
filters += ` AND sp.category = $${paramIdx}`;
|
||||
filters += ` AND sp.category_raw = $${paramIdx}`;
|
||||
params.push(category);
|
||||
paramIdx++;
|
||||
}
|
||||
|
||||
const result = await this.pool.query(`
|
||||
SELECT
|
||||
sp.brand_name,
|
||||
sp.brand_name_raw AS brand_name,
|
||||
COUNT(DISTINCT sp.dispensary_id) AS dispensary_count,
|
||||
COUNT(*) AS sku_count,
|
||||
COUNT(DISTINCT s.code) AS state_count
|
||||
FROM store_products sp
|
||||
LEFT JOIN states s ON s.id = sp.state_id
|
||||
WHERE sp.brand_name IS NOT NULL
|
||||
JOIN dispensaries d ON d.id = sp.dispensary_id
|
||||
LEFT JOIN states s ON s.id = d.state_id
|
||||
WHERE sp.brand_name_raw IS NOT NULL
|
||||
AND sp.is_in_stock = TRUE
|
||||
${filters}
|
||||
GROUP BY sp.brand_name
|
||||
GROUP BY sp.brand_name_raw
|
||||
ORDER BY dispensary_count DESC, sku_count DESC
|
||||
LIMIT $1
|
||||
`, params);
|
||||
@@ -358,23 +368,23 @@ export class BrandPenetrationService {
|
||||
const result = await this.pool.query(`
|
||||
WITH start_counts AS (
|
||||
SELECT
|
||||
brand_name,
|
||||
brand_name_raw AS brand_name,
|
||||
COUNT(DISTINCT dispensary_id) AS dispensary_count
|
||||
FROM store_product_snapshots
|
||||
WHERE captured_at >= $1 AND captured_at < $1 + INTERVAL '1 day'
|
||||
AND brand_name IS NOT NULL
|
||||
AND brand_name_raw IS NOT NULL
|
||||
AND is_in_stock = TRUE
|
||||
GROUP BY brand_name
|
||||
GROUP BY brand_name_raw
|
||||
),
|
||||
end_counts AS (
|
||||
SELECT
|
||||
brand_name,
|
||||
brand_name_raw AS brand_name,
|
||||
COUNT(DISTINCT dispensary_id) AS dispensary_count
|
||||
FROM store_product_snapshots
|
||||
WHERE captured_at >= $2 - INTERVAL '1 day' AND captured_at <= $2
|
||||
AND brand_name IS NOT NULL
|
||||
AND brand_name_raw IS NOT NULL
|
||||
AND is_in_stock = TRUE
|
||||
GROUP BY brand_name
|
||||
GROUP BY brand_name_raw
|
||||
)
|
||||
SELECT
|
||||
COALESCE(sc.brand_name, ec.brand_name) AS brand_name,
|
||||
@@ -401,6 +411,225 @@ export class BrandPenetrationService {
|
||||
change_percent: row.change_percent ? parseFloat(row.change_percent) : 0,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get brand promotional history
|
||||
*
|
||||
* Tracks when products went on special, how long, what discount,
|
||||
* and estimated quantity sold during the promotion.
|
||||
*/
|
||||
async getBrandPromotionalHistory(
|
||||
brandName: string,
|
||||
options: { window?: TimeWindow; customRange?: DateRange; stateCode?: string; category?: string } = {}
|
||||
): Promise<BrandPromotionalSummary> {
|
||||
const { window = '90d', customRange, stateCode, category } = options;
|
||||
const { start, end } = getDateRangeFromWindow(window, customRange);
|
||||
|
||||
// Build filters
|
||||
const params: any[] = [brandName, start, end];
|
||||
let paramIdx = 4;
|
||||
let filters = '';
|
||||
|
||||
if (stateCode) {
|
||||
filters += ` AND s.code = $${paramIdx}`;
|
||||
params.push(stateCode);
|
||||
paramIdx++;
|
||||
}
|
||||
|
||||
if (category) {
|
||||
filters += ` AND sp.category_raw = $${paramIdx}`;
|
||||
params.push(category);
|
||||
paramIdx++;
|
||||
}
|
||||
|
||||
// Find promotional events by detecting when is_on_special transitions to TRUE
|
||||
// and tracking until it transitions back to FALSE
|
||||
const eventsResult = await this.pool.query(`
|
||||
WITH snapshot_with_lag AS (
|
||||
SELECT
|
||||
sps.id,
|
||||
sps.store_product_id,
|
||||
sps.dispensary_id,
|
||||
sps.brand_name_raw,
|
||||
sps.name_raw,
|
||||
sps.category_raw,
|
||||
sps.is_on_special,
|
||||
sps.price_rec,
|
||||
sps.price_rec_special,
|
||||
sps.stock_quantity,
|
||||
sps.captured_at,
|
||||
LAG(sps.is_on_special) OVER (
|
||||
PARTITION BY sps.store_product_id
|
||||
ORDER BY sps.captured_at
|
||||
) AS prev_is_on_special,
|
||||
LAG(sps.stock_quantity) OVER (
|
||||
PARTITION BY sps.store_product_id
|
||||
ORDER BY sps.captured_at
|
||||
) AS prev_stock_quantity
|
||||
FROM store_product_snapshots sps
|
||||
JOIN store_products sp ON sp.id = sps.store_product_id
|
||||
JOIN dispensaries dd ON dd.id = sp.dispensary_id
|
||||
LEFT JOIN states s ON s.id = dd.state_id
|
||||
WHERE sps.brand_name_raw = $1
|
||||
AND sps.captured_at >= $2
|
||||
AND sps.captured_at <= $3
|
||||
${filters}
|
||||
),
|
||||
special_starts AS (
|
||||
-- Find when specials START (transition from not-on-special to on-special)
|
||||
SELECT
|
||||
store_product_id,
|
||||
dispensary_id,
|
||||
name_raw,
|
||||
category_raw,
|
||||
captured_at AS special_start,
|
||||
price_rec AS regular_price,
|
||||
price_rec_special AS special_price,
|
||||
stock_quantity AS quantity_at_start
|
||||
FROM snapshot_with_lag
|
||||
WHERE is_on_special = TRUE
|
||||
AND (prev_is_on_special = FALSE OR prev_is_on_special IS NULL)
|
||||
AND price_rec_special IS NOT NULL
|
||||
AND price_rec IS NOT NULL
|
||||
),
|
||||
special_ends AS (
|
||||
-- Find when specials END (transition from on-special to not-on-special)
|
||||
SELECT
|
||||
store_product_id,
|
||||
captured_at AS special_end,
|
||||
prev_stock_quantity AS quantity_at_end
|
||||
FROM snapshot_with_lag
|
||||
WHERE is_on_special = FALSE
|
||||
AND prev_is_on_special = TRUE
|
||||
),
|
||||
matched_events AS (
|
||||
SELECT
|
||||
ss.store_product_id,
|
||||
ss.dispensary_id,
|
||||
ss.name_raw AS product_name,
|
||||
ss.category_raw AS category,
|
||||
ss.special_start,
|
||||
se.special_end,
|
||||
ss.regular_price,
|
||||
ss.special_price,
|
||||
ss.quantity_at_start,
|
||||
COALESCE(se.quantity_at_end, ss.quantity_at_start) AS quantity_at_end
|
||||
FROM special_starts ss
|
||||
LEFT JOIN special_ends se ON se.store_product_id = ss.store_product_id
|
||||
AND se.special_end > ss.special_start
|
||||
AND se.special_end = (
|
||||
SELECT MIN(se2.special_end)
|
||||
FROM special_ends se2
|
||||
WHERE se2.store_product_id = ss.store_product_id
|
||||
AND se2.special_end > ss.special_start
|
||||
)
|
||||
)
|
||||
SELECT
|
||||
me.store_product_id,
|
||||
me.dispensary_id,
|
||||
d.name AS dispensary_name,
|
||||
s.code AS state_code,
|
||||
me.product_name,
|
||||
me.category,
|
||||
me.special_start,
|
||||
me.special_end,
|
||||
EXTRACT(DAY FROM COALESCE(me.special_end, NOW()) - me.special_start)::INT AS duration_days,
|
||||
me.regular_price,
|
||||
me.special_price,
|
||||
ROUND(((me.regular_price - me.special_price) / NULLIF(me.regular_price, 0)) * 100, 1) AS discount_percent,
|
||||
me.quantity_at_start,
|
||||
me.quantity_at_end,
|
||||
GREATEST(0, COALESCE(me.quantity_at_start, 0) - COALESCE(me.quantity_at_end, 0)) AS quantity_sold_estimate
|
||||
FROM matched_events me
|
||||
JOIN dispensaries d ON d.id = me.dispensary_id
|
||||
LEFT JOIN states s ON s.id = d.state_id
|
||||
ORDER BY me.special_start DESC
|
||||
`, params);
|
||||
|
||||
const events: BrandPromotionalEvent[] = eventsResult.rows.map((row: any) => ({
|
||||
product_name: row.product_name,
|
||||
store_product_id: parseInt(row.store_product_id),
|
||||
dispensary_id: parseInt(row.dispensary_id),
|
||||
dispensary_name: row.dispensary_name,
|
||||
state_code: row.state_code || 'Unknown',
|
||||
category: row.category,
|
||||
special_start: row.special_start.toISOString().split('T')[0],
|
||||
special_end: row.special_end ? row.special_end.toISOString().split('T')[0] : null,
|
||||
duration_days: row.duration_days ? parseInt(row.duration_days) : null,
|
||||
regular_price: parseFloat(row.regular_price) || 0,
|
||||
special_price: parseFloat(row.special_price) || 0,
|
||||
discount_percent: parseFloat(row.discount_percent) || 0,
|
||||
quantity_at_start: row.quantity_at_start ? parseInt(row.quantity_at_start) : null,
|
||||
quantity_at_end: row.quantity_at_end ? parseInt(row.quantity_at_end) : null,
|
||||
quantity_sold_estimate: row.quantity_sold_estimate ? parseInt(row.quantity_sold_estimate) : null,
|
||||
}));
|
||||
|
||||
// Calculate summary stats
|
||||
const totalEvents = events.length;
|
||||
const uniqueProducts = new Set(events.map(e => e.store_product_id)).size;
|
||||
const uniqueDispensaries = new Set(events.map(e => e.dispensary_id)).size;
|
||||
const uniqueStates = [...new Set(events.map(e => e.state_code))];
|
||||
|
||||
const avgDiscount = totalEvents > 0
|
||||
? events.reduce((sum, e) => sum + e.discount_percent, 0) / totalEvents
|
||||
: 0;
|
||||
|
||||
const durations = events.filter(e => e.duration_days !== null).map(e => e.duration_days!);
|
||||
const avgDuration = durations.length > 0
|
||||
? durations.reduce((sum, d) => sum + d, 0) / durations.length
|
||||
: null;
|
||||
|
||||
const totalQuantitySold = events
|
||||
.filter(e => e.quantity_sold_estimate !== null)
|
||||
.reduce((sum, e) => sum + (e.quantity_sold_estimate || 0), 0);
|
||||
|
||||
// Calculate frequency
|
||||
const windowDays = Math.ceil((end.getTime() - start.getTime()) / (1000 * 60 * 60 * 24));
|
||||
const weeklyAvg = windowDays > 0 ? (totalEvents / windowDays) * 7 : 0;
|
||||
const monthlyAvg = windowDays > 0 ? (totalEvents / windowDays) * 30 : 0;
|
||||
|
||||
// Group by category
|
||||
const categoryMap = new Map<string, { count: number; discounts: number[]; quantity: number }>();
|
||||
for (const event of events) {
|
||||
const cat = event.category || 'Uncategorized';
|
||||
if (!categoryMap.has(cat)) {
|
||||
categoryMap.set(cat, { count: 0, discounts: [], quantity: 0 });
|
||||
}
|
||||
const entry = categoryMap.get(cat)!;
|
||||
entry.count++;
|
||||
entry.discounts.push(event.discount_percent);
|
||||
if (event.quantity_sold_estimate !== null) {
|
||||
entry.quantity += event.quantity_sold_estimate;
|
||||
}
|
||||
}
|
||||
|
||||
const byCategory = Array.from(categoryMap.entries()).map(([category, data]) => ({
|
||||
category,
|
||||
event_count: data.count,
|
||||
avg_discount_percent: data.discounts.length > 0
|
||||
? Math.round((data.discounts.reduce((a, b) => a + b, 0) / data.discounts.length) * 10) / 10
|
||||
: 0,
|
||||
quantity_sold_estimate: data.quantity > 0 ? data.quantity : null,
|
||||
})).sort((a, b) => b.event_count - a.event_count);
|
||||
|
||||
return {
|
||||
brand_name: brandName,
|
||||
window,
|
||||
total_promotional_events: totalEvents,
|
||||
total_products_on_special: uniqueProducts,
|
||||
total_dispensaries_with_specials: uniqueDispensaries,
|
||||
states_with_specials: uniqueStates,
|
||||
avg_discount_percent: Math.round(avgDiscount * 10) / 10,
|
||||
avg_duration_days: avgDuration !== null ? Math.round(avgDuration * 10) / 10 : null,
|
||||
total_quantity_sold_estimate: totalQuantitySold > 0 ? totalQuantitySold : null,
|
||||
promotional_frequency: {
|
||||
weekly_avg: Math.round(weeklyAvg * 10) / 10,
|
||||
monthly_avg: Math.round(monthlyAvg * 10) / 10,
|
||||
},
|
||||
by_category: byCategory,
|
||||
events,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export default BrandPenetrationService;
|
||||
|
||||
@@ -259,6 +259,122 @@ export class StoreAnalyticsService {
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get quantity changes for a store (increases/decreases)
|
||||
* Useful for estimating sales (decreases) or restocks (increases)
|
||||
*
|
||||
* @param direction - 'decrease' for likely sales, 'increase' for restocks, 'all' for both
|
||||
*/
|
||||
async getQuantityChanges(
|
||||
dispensaryId: number,
|
||||
options: {
|
||||
window?: TimeWindow;
|
||||
customRange?: DateRange;
|
||||
direction?: 'increase' | 'decrease' | 'all';
|
||||
limit?: number;
|
||||
} = {}
|
||||
): Promise<{
|
||||
dispensary_id: number;
|
||||
window: TimeWindow;
|
||||
direction: string;
|
||||
total_changes: number;
|
||||
total_units_decreased: number;
|
||||
total_units_increased: number;
|
||||
changes: Array<{
|
||||
store_product_id: number;
|
||||
product_name: string;
|
||||
brand_name: string | null;
|
||||
category: string | null;
|
||||
old_quantity: number;
|
||||
new_quantity: number;
|
||||
quantity_delta: number;
|
||||
direction: 'increase' | 'decrease';
|
||||
captured_at: string;
|
||||
}>;
|
||||
}> {
|
||||
const { window = '7d', customRange, direction = 'all', limit = 100 } = options;
|
||||
const { start, end } = getDateRangeFromWindow(window, customRange);
|
||||
|
||||
// Build direction filter
|
||||
let directionFilter = '';
|
||||
if (direction === 'decrease') {
|
||||
directionFilter = 'AND qty_delta < 0';
|
||||
} else if (direction === 'increase') {
|
||||
directionFilter = 'AND qty_delta > 0';
|
||||
}
|
||||
|
||||
const result = await this.pool.query(`
|
||||
WITH qty_changes AS (
|
||||
SELECT
|
||||
sps.store_product_id,
|
||||
sp.name_raw AS product_name,
|
||||
sp.brand_name_raw AS brand_name,
|
||||
sp.category_raw AS category,
|
||||
LAG(sps.stock_quantity) OVER w AS old_quantity,
|
||||
sps.stock_quantity AS new_quantity,
|
||||
sps.stock_quantity - LAG(sps.stock_quantity) OVER w AS qty_delta,
|
||||
sps.captured_at
|
||||
FROM store_product_snapshots sps
|
||||
JOIN store_products sp ON sp.id = sps.store_product_id
|
||||
WHERE sps.dispensary_id = $1
|
||||
AND sps.captured_at >= $2
|
||||
AND sps.captured_at <= $3
|
||||
AND sps.stock_quantity IS NOT NULL
|
||||
WINDOW w AS (PARTITION BY sps.store_product_id ORDER BY sps.captured_at)
|
||||
)
|
||||
SELECT *
|
||||
FROM qty_changes
|
||||
WHERE old_quantity IS NOT NULL
|
||||
AND qty_delta != 0
|
||||
${directionFilter}
|
||||
ORDER BY captured_at DESC
|
||||
LIMIT $4
|
||||
`, [dispensaryId, start, end, limit]);
|
||||
|
||||
// Calculate totals
|
||||
const totalsResult = await this.pool.query(`
|
||||
WITH qty_changes AS (
|
||||
SELECT
|
||||
sps.stock_quantity - LAG(sps.stock_quantity) OVER w AS qty_delta
|
||||
FROM store_product_snapshots sps
|
||||
WHERE sps.dispensary_id = $1
|
||||
AND sps.captured_at >= $2
|
||||
AND sps.captured_at <= $3
|
||||
AND sps.stock_quantity IS NOT NULL
|
||||
AND sps.store_product_id IS NOT NULL
|
||||
WINDOW w AS (PARTITION BY sps.store_product_id ORDER BY sps.captured_at)
|
||||
)
|
||||
SELECT
|
||||
COUNT(*) FILTER (WHERE qty_delta != 0) AS total_changes,
|
||||
COALESCE(SUM(ABS(qty_delta)) FILTER (WHERE qty_delta < 0), 0) AS units_decreased,
|
||||
COALESCE(SUM(qty_delta) FILTER (WHERE qty_delta > 0), 0) AS units_increased
|
||||
FROM qty_changes
|
||||
WHERE qty_delta IS NOT NULL
|
||||
`, [dispensaryId, start, end]);
|
||||
|
||||
const totals = totalsResult.rows[0] || {};
|
||||
|
||||
return {
|
||||
dispensary_id: dispensaryId,
|
||||
window,
|
||||
direction,
|
||||
total_changes: parseInt(totals.total_changes) || 0,
|
||||
total_units_decreased: parseInt(totals.units_decreased) || 0,
|
||||
total_units_increased: parseInt(totals.units_increased) || 0,
|
||||
changes: result.rows.map((row: any) => ({
|
||||
store_product_id: row.store_product_id,
|
||||
product_name: row.product_name,
|
||||
brand_name: row.brand_name,
|
||||
category: row.category,
|
||||
old_quantity: row.old_quantity,
|
||||
new_quantity: row.new_quantity,
|
||||
quantity_delta: row.qty_delta,
|
||||
direction: row.qty_delta > 0 ? 'increase' : 'decrease',
|
||||
captured_at: row.captured_at?.toISOString() || null,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get store inventory composition (categories and brands breakdown)
|
||||
*/
|
||||
|
||||
@@ -11,3 +11,4 @@ export { BrandPenetrationService } from './BrandPenetrationService';
|
||||
export { CategoryAnalyticsService } from './CategoryAnalyticsService';
|
||||
export { StoreAnalyticsService } from './StoreAnalyticsService';
|
||||
export { StateAnalyticsService } from './StateAnalyticsService';
|
||||
export { BrandIntelligenceService } from './BrandIntelligenceService';
|
||||
|
||||
@@ -322,3 +322,48 @@ export interface RecVsMedPriceComparison {
|
||||
};
|
||||
price_diff_percent: number | null;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// BRAND PROMOTIONAL ANALYTICS TYPES
|
||||
// ============================================================
|
||||
|
||||
export interface BrandPromotionalEvent {
|
||||
product_name: string;
|
||||
store_product_id: number;
|
||||
dispensary_id: number;
|
||||
dispensary_name: string;
|
||||
state_code: string;
|
||||
category: string | null;
|
||||
special_start: string; // ISO date when special started
|
||||
special_end: string | null; // ISO date when special ended (null if ongoing)
|
||||
duration_days: number | null;
|
||||
regular_price: number;
|
||||
special_price: number;
|
||||
discount_percent: number;
|
||||
quantity_at_start: number | null;
|
||||
quantity_at_end: number | null;
|
||||
quantity_sold_estimate: number | null; // quantity_at_start - quantity_at_end
|
||||
}
|
||||
|
||||
export interface BrandPromotionalSummary {
|
||||
brand_name: string;
|
||||
window: TimeWindow;
|
||||
total_promotional_events: number;
|
||||
total_products_on_special: number;
|
||||
total_dispensaries_with_specials: number;
|
||||
states_with_specials: string[];
|
||||
avg_discount_percent: number;
|
||||
avg_duration_days: number | null;
|
||||
total_quantity_sold_estimate: number | null;
|
||||
promotional_frequency: {
|
||||
weekly_avg: number;
|
||||
monthly_avg: number;
|
||||
};
|
||||
by_category: Array<{
|
||||
category: string;
|
||||
event_count: number;
|
||||
avg_discount_percent: number;
|
||||
quantity_sold_estimate: number | null;
|
||||
}>;
|
||||
events: BrandPromotionalEvent[];
|
||||
}
|
||||
|
||||
@@ -61,6 +61,13 @@ export interface Proxy {
|
||||
failureCount: number;
|
||||
successCount: number;
|
||||
avgResponseTimeMs: number | null;
|
||||
maxConnections: number; // Number of concurrent connections allowed (for rotating proxies)
|
||||
// Location info (if known)
|
||||
city?: string;
|
||||
state?: string;
|
||||
country?: string;
|
||||
countryCode?: string;
|
||||
timezone?: string;
|
||||
}
|
||||
|
||||
export interface ProxyStats {
|
||||
@@ -113,14 +120,23 @@ export class ProxyRotator {
|
||||
last_tested_at as "lastUsedAt",
|
||||
failure_count as "failureCount",
|
||||
0 as "successCount",
|
||||
response_time_ms as "avgResponseTimeMs"
|
||||
response_time_ms as "avgResponseTimeMs",
|
||||
COALESCE(max_connections, 1) as "maxConnections",
|
||||
city,
|
||||
state,
|
||||
country,
|
||||
country_code as "countryCode",
|
||||
timezone
|
||||
FROM proxies
|
||||
WHERE active = true
|
||||
ORDER BY failure_count ASC, last_tested_at ASC NULLS FIRST
|
||||
`);
|
||||
|
||||
this.proxies = result.rows;
|
||||
console.log(`[ProxyRotator] Loaded ${this.proxies.length} active proxies`);
|
||||
|
||||
// Calculate total concurrent capacity
|
||||
const totalCapacity = this.proxies.reduce((sum, p) => sum + p.maxConnections, 0);
|
||||
console.log(`[ProxyRotator] Loaded ${this.proxies.length} active proxies (${totalCapacity} max concurrent connections)`);
|
||||
} catch (error) {
|
||||
// Table might not exist - that's okay
|
||||
console.warn(`[ProxyRotator] Could not load proxies: ${error}`);
|
||||
@@ -256,7 +272,7 @@ export class ProxyRotator {
|
||||
*/
|
||||
getStats(): ProxyStats {
|
||||
const totalProxies = this.proxies.length;
|
||||
const activeProxies = this.proxies.filter(p => p.isActive).length;
|
||||
const activeProxies = this.proxies.reduce((sum, p) => sum + p.maxConnections, 0); // Total concurrent capacity
|
||||
const blockedProxies = this.proxies.filter(p => p.failureCount >= 5).length;
|
||||
|
||||
const successRates = this.proxies
|
||||
@@ -269,7 +285,7 @@ export class ProxyRotator {
|
||||
|
||||
return {
|
||||
totalProxies,
|
||||
activeProxies,
|
||||
activeProxies, // Total concurrent capacity across all proxies
|
||||
blockedProxies,
|
||||
avgSuccessRate,
|
||||
};
|
||||
@@ -403,6 +419,26 @@ export class CrawlRotator {
|
||||
await this.proxy.markFailed(current.id, error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current proxy location info (for reporting)
|
||||
* Note: For rotating proxies (like IPRoyal), the actual exit location varies per request
|
||||
*/
|
||||
getProxyLocation(): { city?: string; state?: string; country?: string; timezone?: string; isRotating: boolean } | null {
|
||||
const current = this.proxy.getCurrent();
|
||||
if (!current) return null;
|
||||
|
||||
// Check if this is a rotating proxy (max_connections > 1 usually indicates rotating)
|
||||
const isRotating = current.maxConnections > 1;
|
||||
|
||||
return {
|
||||
city: current.city,
|
||||
state: current.state,
|
||||
country: current.country,
|
||||
timezone: current.timezone,
|
||||
isRotating
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
|
||||
@@ -276,7 +276,6 @@ export async function addProxiesFromList(proxies: Array<{
|
||||
await pool.query(`
|
||||
INSERT INTO proxies (host, port, protocol, username, password, active)
|
||||
VALUES ($1, $2, $3, $4, $5, false)
|
||||
ON CONFLICT (host, port, protocol) DO NOTHING
|
||||
`, [
|
||||
proxy.host,
|
||||
proxy.port,
|
||||
@@ -285,27 +284,9 @@ export async function addProxiesFromList(proxies: Array<{
|
||||
proxy.password
|
||||
]);
|
||||
|
||||
// Check if it was actually inserted
|
||||
const result = await pool.query(`
|
||||
SELECT id FROM proxies
|
||||
WHERE host = $1 AND port = $2 AND protocol = $3
|
||||
`, [proxy.host, proxy.port, proxy.protocol]);
|
||||
|
||||
if (result.rows.length > 0) {
|
||||
// Check if it was just inserted (no last_tested_at means new)
|
||||
const checkResult = await pool.query(`
|
||||
SELECT last_tested_at FROM proxies
|
||||
WHERE host = $1 AND port = $2 AND protocol = $3
|
||||
`, [proxy.host, proxy.port, proxy.protocol]);
|
||||
|
||||
if (checkResult.rows[0].last_tested_at === null) {
|
||||
added++;
|
||||
if (added % 100 === 0) {
|
||||
console.log(`📥 Imported ${added} proxies...`);
|
||||
}
|
||||
} else {
|
||||
duplicates++;
|
||||
}
|
||||
added++;
|
||||
if (added % 100 === 0) {
|
||||
console.log(`📥 Imported ${added} proxies...`);
|
||||
}
|
||||
} catch (error: any) {
|
||||
failed++;
|
||||
|
||||
@@ -8,8 +8,12 @@ interface ProxyTestJob {
|
||||
tested_proxies: number;
|
||||
passed_proxies: number;
|
||||
failed_proxies: number;
|
||||
mode?: string; // 'all' | 'failed' | 'inactive'
|
||||
}
|
||||
|
||||
// Concurrency settings
|
||||
const DEFAULT_CONCURRENCY = 10; // Test 10 proxies at a time
|
||||
|
||||
// Simple in-memory queue - could be replaced with Bull/Bee-Queue for production
|
||||
const activeJobs = new Map<number, { cancelled: boolean }>();
|
||||
|
||||
@@ -33,18 +37,40 @@ export async function cleanupOrphanedJobs(): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
export async function createProxyTestJob(): Promise<number> {
|
||||
export type ProxyTestMode = 'all' | 'failed' | 'inactive';
|
||||
|
||||
export interface CreateJobResult {
|
||||
jobId: number;
|
||||
totalProxies: number;
|
||||
}
|
||||
|
||||
export async function createProxyTestJob(mode: ProxyTestMode = 'all', concurrency: number = DEFAULT_CONCURRENCY): Promise<CreateJobResult> {
|
||||
// Check for existing running jobs first
|
||||
const existingJob = await getActiveProxyTestJob();
|
||||
if (existingJob) {
|
||||
throw new Error('A proxy test job is already running. Please cancel it first.');
|
||||
}
|
||||
const result = await pool.query(`
|
||||
SELECT COUNT(*) as count FROM proxies
|
||||
`);
|
||||
|
||||
// Get count based on mode
|
||||
let countQuery: string;
|
||||
switch (mode) {
|
||||
case 'failed':
|
||||
countQuery = `SELECT COUNT(*) as count FROM proxies WHERE test_result = 'failed' OR active = false`;
|
||||
break;
|
||||
case 'inactive':
|
||||
countQuery = `SELECT COUNT(*) as count FROM proxies WHERE active = false`;
|
||||
break;
|
||||
default:
|
||||
countQuery = `SELECT COUNT(*) as count FROM proxies`;
|
||||
}
|
||||
|
||||
const result = await pool.query(countQuery);
|
||||
const totalProxies = parseInt(result.rows[0].count);
|
||||
|
||||
if (totalProxies === 0) {
|
||||
throw new Error(`No proxies to test with mode '${mode}'`);
|
||||
}
|
||||
|
||||
const jobResult = await pool.query(`
|
||||
INSERT INTO proxy_test_jobs (status, total_proxies)
|
||||
VALUES ('pending', $1)
|
||||
@@ -53,12 +79,12 @@ export async function createProxyTestJob(): Promise<number> {
|
||||
|
||||
const jobId = jobResult.rows[0].id;
|
||||
|
||||
// Start job in background
|
||||
runProxyTestJob(jobId).catch(err => {
|
||||
// Start job in background with mode and concurrency
|
||||
runProxyTestJob(jobId, mode, concurrency).catch(err => {
|
||||
console.error(`❌ Proxy test job ${jobId} failed:`, err);
|
||||
});
|
||||
|
||||
return jobId;
|
||||
return { jobId, totalProxies };
|
||||
}
|
||||
|
||||
export async function getProxyTestJob(jobId: number): Promise<ProxyTestJob | null> {
|
||||
@@ -111,7 +137,7 @@ export async function cancelProxyTestJob(jobId: number): Promise<boolean> {
|
||||
return result.rows.length > 0;
|
||||
}
|
||||
|
||||
async function runProxyTestJob(jobId: number): Promise<void> {
|
||||
async function runProxyTestJob(jobId: number, mode: ProxyTestMode = 'all', concurrency: number = DEFAULT_CONCURRENCY): Promise<void> {
|
||||
// Register job as active
|
||||
activeJobs.set(jobId, { cancelled: false });
|
||||
|
||||
@@ -125,20 +151,30 @@ async function runProxyTestJob(jobId: number): Promise<void> {
|
||||
WHERE id = $1
|
||||
`, [jobId]);
|
||||
|
||||
console.log(`🔍 Starting proxy test job ${jobId}...`);
|
||||
console.log(`🔍 Starting proxy test job ${jobId} (mode: ${mode}, concurrency: ${concurrency})...`);
|
||||
|
||||
// Get all proxies
|
||||
const result = await pool.query(`
|
||||
SELECT id, host, port, protocol, username, password
|
||||
FROM proxies
|
||||
ORDER BY id
|
||||
`);
|
||||
// Get proxies based on mode
|
||||
let query: string;
|
||||
switch (mode) {
|
||||
case 'failed':
|
||||
query = `SELECT id, host, port, protocol, username, password FROM proxies WHERE test_result = 'failed' OR active = false ORDER BY id`;
|
||||
break;
|
||||
case 'inactive':
|
||||
query = `SELECT id, host, port, protocol, username, password FROM proxies WHERE active = false ORDER BY id`;
|
||||
break;
|
||||
default:
|
||||
query = `SELECT id, host, port, protocol, username, password FROM proxies ORDER BY id`;
|
||||
}
|
||||
|
||||
const result = await pool.query(query);
|
||||
const proxies = result.rows;
|
||||
|
||||
let tested = 0;
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
|
||||
for (const proxy of result.rows) {
|
||||
// Process proxies in batches for parallel testing
|
||||
for (let i = 0; i < proxies.length; i += concurrency) {
|
||||
// Check if job was cancelled
|
||||
const jobControl = activeJobs.get(jobId);
|
||||
if (jobControl?.cancelled) {
|
||||
@@ -146,23 +182,34 @@ async function runProxyTestJob(jobId: number): Promise<void> {
|
||||
break;
|
||||
}
|
||||
|
||||
// Test the proxy
|
||||
const testResult = await testProxy(
|
||||
proxy.host,
|
||||
proxy.port,
|
||||
proxy.protocol,
|
||||
proxy.username,
|
||||
proxy.password
|
||||
const batch = proxies.slice(i, i + concurrency);
|
||||
|
||||
// Test batch in parallel
|
||||
const batchResults = await Promise.all(
|
||||
batch.map(async (proxy) => {
|
||||
const testResult = await testProxy(
|
||||
proxy.host,
|
||||
proxy.port,
|
||||
proxy.protocol,
|
||||
proxy.username,
|
||||
proxy.password
|
||||
);
|
||||
|
||||
// Save result
|
||||
await saveProxyTestResult(proxy.id, testResult);
|
||||
|
||||
return testResult.success;
|
||||
})
|
||||
);
|
||||
|
||||
// Save result
|
||||
await saveProxyTestResult(proxy.id, testResult);
|
||||
|
||||
tested++;
|
||||
if (testResult.success) {
|
||||
passed++;
|
||||
} else {
|
||||
failed++;
|
||||
// Count results
|
||||
for (const success of batchResults) {
|
||||
tested++;
|
||||
if (success) {
|
||||
passed++;
|
||||
} else {
|
||||
failed++;
|
||||
}
|
||||
}
|
||||
|
||||
// Update job progress
|
||||
@@ -175,10 +222,8 @@ async function runProxyTestJob(jobId: number): Promise<void> {
|
||||
WHERE id = $4
|
||||
`, [tested, passed, failed, jobId]);
|
||||
|
||||
// Log progress every 10 proxies
|
||||
if (tested % 10 === 0) {
|
||||
console.log(`📊 Job ${jobId}: ${tested}/${result.rows.length} proxies tested (${passed} passed, ${failed} failed)`);
|
||||
}
|
||||
// Log progress
|
||||
console.log(`📊 Job ${jobId}: ${tested}/${proxies.length} proxies tested (${passed} passed, ${failed} failed)`);
|
||||
}
|
||||
|
||||
// Mark job as completed
|
||||
|
||||
@@ -10,6 +10,17 @@
|
||||
|
||||
import { pool } from '../db/pool';
|
||||
|
||||
// Helper to check if a table exists
|
||||
async function tableExists(tableName: string): Promise<boolean> {
|
||||
const result = await pool.query(`
|
||||
SELECT EXISTS (
|
||||
SELECT FROM information_schema.tables
|
||||
WHERE table_name = $1
|
||||
) as exists
|
||||
`, [tableName]);
|
||||
return result.rows[0].exists;
|
||||
}
|
||||
|
||||
export type TaskRole =
|
||||
| 'store_discovery'
|
||||
| 'entry_point_discovery'
|
||||
@@ -206,15 +217,53 @@ class TaskService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark a task as failed
|
||||
* Mark a task as failed, with auto-retry if under max_retries
|
||||
* Returns true if task was re-queued for retry, false if permanently failed
|
||||
*/
|
||||
async failTask(taskId: number, errorMessage: string): Promise<void> {
|
||||
async failTask(taskId: number, errorMessage: string): Promise<boolean> {
|
||||
// Get current retry state
|
||||
const result = await pool.query(
|
||||
`SELECT retry_count, max_retries FROM worker_tasks WHERE id = $1`,
|
||||
[taskId]
|
||||
);
|
||||
|
||||
if (result.rows.length === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const { retry_count, max_retries } = result.rows[0];
|
||||
const newRetryCount = (retry_count || 0) + 1;
|
||||
|
||||
if (newRetryCount < (max_retries || 3)) {
|
||||
// Re-queue for retry - reset to pending with incremented retry_count
|
||||
await pool.query(
|
||||
`UPDATE worker_tasks
|
||||
SET status = 'pending',
|
||||
worker_id = NULL,
|
||||
claimed_at = NULL,
|
||||
started_at = NULL,
|
||||
retry_count = $2,
|
||||
error_message = $3,
|
||||
updated_at = NOW()
|
||||
WHERE id = $1`,
|
||||
[taskId, newRetryCount, `Retry ${newRetryCount}: ${errorMessage}`]
|
||||
);
|
||||
console.log(`[TaskService] Task ${taskId} queued for retry ${newRetryCount}/${max_retries || 3}`);
|
||||
return true;
|
||||
}
|
||||
|
||||
// Max retries exceeded - mark as permanently failed
|
||||
await pool.query(
|
||||
`UPDATE worker_tasks
|
||||
SET status = 'failed', completed_at = NOW(), error_message = $2
|
||||
SET status = 'failed',
|
||||
completed_at = NOW(),
|
||||
retry_count = $2,
|
||||
error_message = $3
|
||||
WHERE id = $1`,
|
||||
[taskId, errorMessage]
|
||||
[taskId, newRetryCount, `Failed after ${newRetryCount} attempts: ${errorMessage}`]
|
||||
);
|
||||
console.log(`[TaskService] Task ${taskId} permanently failed after ${newRetryCount} attempts`);
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -232,6 +281,11 @@ class TaskService {
|
||||
* List tasks with filters
|
||||
*/
|
||||
async listTasks(filter: TaskFilter = {}): Promise<WorkerTask[]> {
|
||||
// Return empty list if table doesn't exist
|
||||
if (!await tableExists('worker_tasks')) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const conditions: string[] = [];
|
||||
const params: (string | number | string[])[] = [];
|
||||
let paramIndex = 1;
|
||||
@@ -285,21 +339,41 @@ class TaskService {
|
||||
* Get capacity metrics for all roles
|
||||
*/
|
||||
async getCapacityMetrics(): Promise<CapacityMetrics[]> {
|
||||
const result = await pool.query(
|
||||
`SELECT * FROM v_worker_capacity`
|
||||
);
|
||||
return result.rows as CapacityMetrics[];
|
||||
// Return empty metrics if worker_tasks table doesn't exist
|
||||
if (!await tableExists('worker_tasks')) {
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await pool.query(
|
||||
`SELECT * FROM v_worker_capacity`
|
||||
);
|
||||
return result.rows as CapacityMetrics[];
|
||||
} catch {
|
||||
// View may not exist
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get capacity metrics for a specific role
|
||||
*/
|
||||
async getRoleCapacity(role: TaskRole): Promise<CapacityMetrics | null> {
|
||||
const result = await pool.query(
|
||||
`SELECT * FROM v_worker_capacity WHERE role = $1`,
|
||||
[role]
|
||||
);
|
||||
return (result.rows[0] as CapacityMetrics) || null;
|
||||
// Return null if worker_tasks table doesn't exist
|
||||
if (!await tableExists('worker_tasks')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await pool.query(
|
||||
`SELECT * FROM v_worker_capacity WHERE role = $1`,
|
||||
[role]
|
||||
);
|
||||
return (result.rows[0] as CapacityMetrics) || null;
|
||||
} catch {
|
||||
// View may not exist
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -425,12 +499,6 @@ class TaskService {
|
||||
* Get task counts by status for dashboard
|
||||
*/
|
||||
async getTaskCounts(): Promise<Record<TaskStatus, number>> {
|
||||
const result = await pool.query(
|
||||
`SELECT status, COUNT(*) as count
|
||||
FROM worker_tasks
|
||||
GROUP BY status`
|
||||
);
|
||||
|
||||
const counts: Record<TaskStatus, number> = {
|
||||
pending: 0,
|
||||
claimed: 0,
|
||||
@@ -440,6 +508,17 @@ class TaskService {
|
||||
stale: 0,
|
||||
};
|
||||
|
||||
// Return empty counts if table doesn't exist
|
||||
if (!await tableExists('worker_tasks')) {
|
||||
return counts;
|
||||
}
|
||||
|
||||
const result = await pool.query(
|
||||
`SELECT status, COUNT(*) as count
|
||||
FROM worker_tasks
|
||||
GROUP BY status`
|
||||
);
|
||||
|
||||
for (const row of result.rows) {
|
||||
const typedRow = row as { status: TaskStatus; count: string };
|
||||
counts[typedRow.status] = parseInt(typedRow.count, 10);
|
||||
|
||||
@@ -182,12 +182,13 @@ export class TaskWorker {
|
||||
}
|
||||
|
||||
/**
|
||||
* Send heartbeat to registry with resource usage
|
||||
* Send heartbeat to registry with resource usage and proxy location
|
||||
*/
|
||||
private async sendRegistryHeartbeat(): Promise<void> {
|
||||
try {
|
||||
const memUsage = process.memoryUsage();
|
||||
const cpuUsage = process.cpuUsage();
|
||||
const proxyLocation = this.crawlRotator.getProxyLocation();
|
||||
|
||||
await fetch(`${API_BASE_URL}/api/worker-registry/heartbeat`, {
|
||||
method: 'POST',
|
||||
@@ -202,6 +203,7 @@ export class TaskWorker {
|
||||
memory_rss_mb: Math.round(memUsage.rss / 1024 / 1024),
|
||||
cpu_user_ms: Math.round(cpuUsage.user / 1000),
|
||||
cpu_system_ms: Math.round(cpuUsage.system / 1000),
|
||||
proxy_location: proxyLocation,
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
@@ -6,8 +6,8 @@ WORKDIR /app
|
||||
# Copy package files
|
||||
COPY package*.json ./
|
||||
|
||||
# Install dependencies
|
||||
RUN npm ci
|
||||
# Install dependencies (npm install is more forgiving than npm ci)
|
||||
RUN npm install
|
||||
|
||||
# Copy source files
|
||||
COPY . .
|
||||
|
||||
@@ -69,6 +69,13 @@ class ApiClient {
|
||||
return { data };
|
||||
}
|
||||
|
||||
async delete<T = any>(endpoint: string): Promise<{ data: T }> {
|
||||
const data = await this.request<T>(endpoint, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
return { data };
|
||||
}
|
||||
|
||||
// Auth
|
||||
async login(email: string, password: string) {
|
||||
return this.request<{ token: string; user: any }>('/api/auth/login', {
|
||||
@@ -313,7 +320,7 @@ class ApiClient {
|
||||
}
|
||||
|
||||
async testAllProxies() {
|
||||
return this.request<{ jobId: number; message: string }>('/api/proxies/test-all', {
|
||||
return this.request<{ jobId: number; total: number; message: string }>('/api/proxies/test-all', {
|
||||
method: 'POST',
|
||||
});
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useEffect, useState, useRef } from 'react';
|
||||
import { Layout } from '../components/Layout';
|
||||
import { api } from '../lib/api';
|
||||
import { Toast } from '../components/Toast';
|
||||
import { Key, Plus, Copy, Check, X, Trash2, Power, PowerOff, Store, Globe, Shield, Clock, Eye, EyeOff, Search, ChevronDown } from 'lucide-react';
|
||||
import { Key, Plus, Copy, Check, X, Trash2, Power, PowerOff, Store, Globe, Shield, Clock, Eye, EyeOff, Search, ChevronDown, Pencil } from 'lucide-react';
|
||||
|
||||
interface ApiPermission {
|
||||
id: number;
|
||||
@@ -161,6 +161,12 @@ export function ApiPermissions() {
|
||||
allowed_ips: '',
|
||||
allowed_domains: '',
|
||||
});
|
||||
const [editingPermission, setEditingPermission] = useState<ApiPermission | null>(null);
|
||||
const [editForm, setEditForm] = useState({
|
||||
user_name: '',
|
||||
allowed_ips: '',
|
||||
allowed_domains: '',
|
||||
});
|
||||
const [notification, setNotification] = useState<{ message: string; type: 'success' | 'error' | 'info' } | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -240,6 +246,33 @@ export function ApiPermissions() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleEdit = (perm: ApiPermission) => {
|
||||
setEditingPermission(perm);
|
||||
setEditForm({
|
||||
user_name: perm.user_name,
|
||||
allowed_ips: perm.allowed_ips || '',
|
||||
allowed_domains: perm.allowed_domains || '',
|
||||
});
|
||||
};
|
||||
|
||||
const handleSaveEdit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!editingPermission) return;
|
||||
|
||||
try {
|
||||
await api.updateApiPermission(editingPermission.id, {
|
||||
user_name: editForm.user_name,
|
||||
allowed_ips: editForm.allowed_ips || undefined,
|
||||
allowed_domains: editForm.allowed_domains || undefined,
|
||||
});
|
||||
setNotification({ message: 'API key updated successfully', type: 'success' });
|
||||
setEditingPermission(null);
|
||||
loadPermissions();
|
||||
} catch (error: any) {
|
||||
setNotification({ message: 'Failed to update permission: ' + error.message, type: 'error' });
|
||||
}
|
||||
};
|
||||
|
||||
const copyToClipboard = async (text: string, id: number) => {
|
||||
await navigator.clipboard.writeText(text);
|
||||
setCopiedId(id);
|
||||
@@ -494,21 +527,36 @@ export function ApiPermissions() {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Restrictions */}
|
||||
{(perm.allowed_ips || perm.allowed_domains) && (
|
||||
<div className="flex gap-4 mt-3 text-xs text-gray-500">
|
||||
{perm.allowed_ips && (
|
||||
<span>IPs: {perm.allowed_ips.split('\n').length} allowed</span>
|
||||
{/* Allowed Domains - Always show */}
|
||||
<div className="mt-3 text-xs">
|
||||
<span className="text-gray-500 flex items-center gap-1">
|
||||
<Globe className="w-3 h-3" />
|
||||
Domains:{' '}
|
||||
{perm.allowed_domains ? (
|
||||
<span className="text-gray-700 font-mono">
|
||||
{perm.allowed_domains.split('\n').filter(d => d.trim()).join(', ')}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-amber-600">Any domain (no restriction)</span>
|
||||
)}
|
||||
{perm.allowed_domains && (
|
||||
<span>Domains: {perm.allowed_domains.split('\n').length} allowed</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</span>
|
||||
{perm.allowed_ips && (
|
||||
<span className="text-gray-500 ml-4">
|
||||
IPs: {perm.allowed_ips.split('\n').filter(ip => ip.trim()).length} allowed
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center gap-2 ml-4">
|
||||
<button
|
||||
onClick={() => handleEdit(perm)}
|
||||
className="p-2 text-blue-600 hover:bg-blue-50 rounded-lg transition-colors"
|
||||
title="Edit"
|
||||
>
|
||||
<Pencil className="w-5 h-5" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleToggle(perm.id)}
|
||||
className={`p-2 rounded-lg transition-colors ${
|
||||
@@ -534,6 +582,86 @@ export function ApiPermissions() {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Edit Modal */}
|
||||
{editingPermission && (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
||||
<div className="bg-white rounded-xl shadow-xl max-w-lg w-full mx-4 max-h-[90vh] overflow-y-auto">
|
||||
<div className="px-6 py-4 border-b border-gray-200">
|
||||
<h2 className="text-lg font-semibold text-gray-900 flex items-center gap-2">
|
||||
<Pencil className="w-5 h-5 text-blue-600" />
|
||||
Edit API Key
|
||||
</h2>
|
||||
<p className="text-sm text-gray-500 mt-1">
|
||||
{editingPermission.store_name}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSaveEdit} className="p-6 space-y-5">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Label / Website Name
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={editForm.user_name}
|
||||
onChange={(e) => setEditForm({ ...editForm, user_name: e.target.value })}
|
||||
className="w-full px-4 py-2.5 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
<Globe className="w-4 h-4 inline mr-1" />
|
||||
Allowed Domains
|
||||
</label>
|
||||
<textarea
|
||||
value={editForm.allowed_domains}
|
||||
onChange={(e) => setEditForm({ ...editForm, allowed_domains: e.target.value })}
|
||||
rows={4}
|
||||
className="w-full px-4 py-2.5 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent font-mono text-sm"
|
||||
placeholder="example.com *.example.com subdomain.example.com"
|
||||
/>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
One domain per line. Use * for wildcards (e.g., *.example.com). Leave empty to allow any domain.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
<Shield className="w-4 h-4 inline mr-1" />
|
||||
Allowed IP Addresses
|
||||
</label>
|
||||
<textarea
|
||||
value={editForm.allowed_ips}
|
||||
onChange={(e) => setEditForm({ ...editForm, allowed_ips: e.target.value })}
|
||||
rows={3}
|
||||
className="w-full px-4 py-2.5 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent font-mono text-sm"
|
||||
placeholder="192.168.1.1 10.0.0.0/8"
|
||||
/>
|
||||
<p className="text-xs text-gray-500 mt-1">One per line. CIDR notation supported. Leave empty to allow any IP.</p>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3 pt-2">
|
||||
<button
|
||||
type="submit"
|
||||
className="flex-1 px-5 py-2.5 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
|
||||
>
|
||||
Save Changes
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setEditingPermission(null)}
|
||||
className="px-5 py-2.5 bg-gray-100 text-gray-700 rounded-lg hover:bg-gray-200 transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
X,
|
||||
Search,
|
||||
Calendar,
|
||||
Trash2,
|
||||
} from 'lucide-react';
|
||||
|
||||
// Worker from registry
|
||||
@@ -61,6 +62,9 @@ interface Task {
|
||||
started_at: string | null;
|
||||
completed_at: string | null;
|
||||
error: string | null;
|
||||
error_message: string | null;
|
||||
retry_count: number;
|
||||
max_retries: number;
|
||||
result: any;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
@@ -499,7 +503,7 @@ function WorkerStatusBadge({ status, healthStatus }: { status: string; healthSta
|
||||
);
|
||||
}
|
||||
|
||||
function TaskStatusBadge({ status }: { status: string }) {
|
||||
function TaskStatusBadge({ status, error, retryCount }: { status: string; error?: string | null; retryCount?: number }) {
|
||||
const config: Record<string, { bg: string; text: string; icon: any }> = {
|
||||
pending: { bg: 'bg-yellow-100', text: 'text-yellow-700', icon: Clock },
|
||||
running: { bg: 'bg-blue-100', text: 'text-blue-700', icon: Activity },
|
||||
@@ -510,10 +514,25 @@ function TaskStatusBadge({ status }: { status: string }) {
|
||||
const cfg = config[status] || { bg: 'bg-gray-100', text: 'text-gray-700', icon: Clock };
|
||||
const Icon = cfg.icon;
|
||||
|
||||
// Build tooltip text
|
||||
let tooltip = '';
|
||||
if (error) {
|
||||
tooltip = error;
|
||||
}
|
||||
if (retryCount && retryCount > 0) {
|
||||
tooltip = `Attempt ${retryCount + 1}${error ? `: ${error}` : ''}`;
|
||||
}
|
||||
|
||||
return (
|
||||
<span className={`inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium ${cfg.bg} ${cfg.text}`}>
|
||||
<span
|
||||
className={`inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium ${cfg.bg} ${cfg.text} ${error ? 'cursor-help' : ''}`}
|
||||
title={tooltip || undefined}
|
||||
>
|
||||
<Icon className="w-3 h-3" />
|
||||
{status}
|
||||
{retryCount && retryCount > 0 && status !== 'failed' && (
|
||||
<span className="text-[10px] opacity-75">({retryCount})</span>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -735,6 +754,18 @@ export function JobQueue() {
|
||||
return () => clearInterval(interval);
|
||||
}, [fetchWorkers]);
|
||||
|
||||
// Delete a task
|
||||
const handleDeleteTask = async (taskId: number) => {
|
||||
if (!confirm('Delete this task?')) return;
|
||||
try {
|
||||
await api.delete(`/api/tasks/${taskId}`);
|
||||
fetchTasks();
|
||||
} catch (err: any) {
|
||||
console.error('Delete error:', err);
|
||||
alert(err.response?.data?.error || 'Failed to delete task');
|
||||
}
|
||||
};
|
||||
|
||||
// Get active workers (for display)
|
||||
const activeWorkers = workers.filter(w => w.status !== 'offline' && w.status !== 'terminated');
|
||||
const busyWorkers = workers.filter(w => w.current_task_id !== null);
|
||||
@@ -910,12 +941,13 @@ export function JobQueue() {
|
||||
<th className="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase">Assigned To</th>
|
||||
<th className="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase">Created</th>
|
||||
<th className="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase">Duration</th>
|
||||
<th className="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase w-16"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-200">
|
||||
{tasks.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={7} className="px-4 py-8 text-center text-gray-500">
|
||||
<td colSpan={8} className="px-4 py-8 text-center text-gray-500">
|
||||
<Inbox className="w-8 h-8 mx-auto mb-2 text-gray-300" />
|
||||
<p>No tasks found</p>
|
||||
</td>
|
||||
@@ -958,7 +990,7 @@ export function JobQueue() {
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<TaskStatusBadge status={task.status} />
|
||||
<TaskStatusBadge status={task.status} error={task.error_message || task.error} retryCount={task.retry_count} />
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm">
|
||||
{assignedWorker ? (
|
||||
@@ -986,6 +1018,17 @@ export function JobQueue() {
|
||||
'-'
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
{(task.status === 'failed' || task.status === 'completed' || task.status === 'pending') && (
|
||||
<button
|
||||
onClick={() => handleDeleteTask(task.id)}
|
||||
className="p-1 text-gray-400 hover:text-red-500 hover:bg-red-50 rounded transition-colors"
|
||||
title="Delete task"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})
|
||||
|
||||
@@ -2,12 +2,13 @@ import { useEffect, useState } from 'react';
|
||||
import { Layout } from '../components/Layout';
|
||||
import { api } from '../lib/api';
|
||||
import { Toast } from '../components/Toast';
|
||||
import { Shield, CheckCircle, XCircle, RefreshCw, Plus, MapPin, Clock, TrendingUp, Trash2, AlertCircle, Upload, FileText, X } from 'lucide-react';
|
||||
import { Shield, CheckCircle, XCircle, RefreshCw, Plus, MapPin, Clock, TrendingUp, Trash2, AlertCircle, Upload, FileText, X, Edit2 } from 'lucide-react';
|
||||
|
||||
export function Proxies() {
|
||||
const [proxies, setProxies] = useState<any[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [showAddForm, setShowAddForm] = useState(false);
|
||||
const [editingProxy, setEditingProxy] = useState<any>(null);
|
||||
const [testing, setTesting] = useState<{ [key: number]: boolean }>({});
|
||||
const [activeJob, setActiveJob] = useState<any>(null);
|
||||
const [notification, setNotification] = useState<{ message: string; type: 'success' | 'error' | 'info' } | null>(null);
|
||||
@@ -95,7 +96,8 @@ export function Proxies() {
|
||||
try {
|
||||
const response = await api.testAllProxies();
|
||||
setNotification({ message: 'Proxy testing job started', type: 'success' });
|
||||
setActiveJob({ id: response.jobId, status: 'pending', tested_proxies: 0, total_proxies: proxies.length, passed_proxies: 0, failed_proxies: 0 });
|
||||
// Use response.total if available, otherwise proxies.length, but immediately poll for accurate count
|
||||
setActiveJob({ id: response.jobId, status: 'pending', tested_proxies: 0, total_proxies: response.total || proxies.length || 0, passed_proxies: 0, failed_proxies: 0 });
|
||||
} catch (error: any) {
|
||||
setNotification({ message: 'Failed to start testing: ' + error.message, type: 'error' });
|
||||
}
|
||||
@@ -342,6 +344,18 @@ export function Proxies() {
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Edit Proxy Modal */}
|
||||
{editingProxy && (
|
||||
<EditProxyModal
|
||||
proxy={editingProxy}
|
||||
onClose={() => setEditingProxy(null)}
|
||||
onSuccess={() => {
|
||||
setEditingProxy(null);
|
||||
loadProxies();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Proxy List */}
|
||||
<div className="space-y-3">
|
||||
{proxies.map(proxy => (
|
||||
@@ -360,6 +374,9 @@ export function Proxies() {
|
||||
{proxy.is_anonymous && (
|
||||
<span className="px-2 py-1 text-xs font-medium bg-blue-50 text-blue-700 rounded">Anonymous</span>
|
||||
)}
|
||||
{proxy.max_connections > 1 && (
|
||||
<span className="px-2 py-1 text-xs font-medium bg-orange-50 text-orange-700 rounded">{proxy.max_connections} connections</span>
|
||||
)}
|
||||
{(proxy.city || proxy.state || proxy.country) && (
|
||||
<span className="px-2 py-1 text-xs font-medium bg-purple-50 text-purple-700 rounded flex items-center gap-1">
|
||||
<MapPin className="w-3 h-3" />
|
||||
@@ -394,6 +411,13 @@ export function Proxies() {
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => setEditingProxy(proxy)}
|
||||
className="inline-flex items-center gap-1 px-3 py-1.5 bg-gray-50 text-gray-700 rounded-lg hover:bg-gray-100 transition-colors text-sm font-medium"
|
||||
>
|
||||
<Edit2 className="w-4 h-4" />
|
||||
Edit
|
||||
</button>
|
||||
{!proxy.active ? (
|
||||
<button
|
||||
onClick={() => handleRetest(proxy.id)}
|
||||
@@ -762,3 +786,157 @@ function AddProxyForm({ onClose, onSuccess }: { onClose: () => void; onSuccess:
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function EditProxyModal({ proxy, onClose, onSuccess }: { proxy: any; onClose: () => void; onSuccess: () => void }) {
|
||||
const [host, setHost] = useState(proxy.host || '');
|
||||
const [port, setPort] = useState(proxy.port?.toString() || '');
|
||||
const [protocol, setProtocol] = useState(proxy.protocol || 'http');
|
||||
const [username, setUsername] = useState(proxy.username || '');
|
||||
const [password, setPassword] = useState(proxy.password || '');
|
||||
const [maxConnections, setMaxConnections] = useState(proxy.max_connections?.toString() || '1');
|
||||
const [loading, setSaving] = useState(false);
|
||||
const [notification, setNotification] = useState<{ message: string; type: 'success' | 'error' | 'info' } | null>(null);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setSaving(true);
|
||||
|
||||
try {
|
||||
await api.updateProxy(proxy.id, {
|
||||
host,
|
||||
port: parseInt(port),
|
||||
protocol,
|
||||
username: username || undefined,
|
||||
password: password || undefined,
|
||||
max_connections: parseInt(maxConnections) || 1,
|
||||
});
|
||||
onSuccess();
|
||||
} catch (error: any) {
|
||||
setNotification({ message: 'Failed to update proxy: ' + error.message, type: 'error' });
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
||||
<div className="bg-white rounded-xl shadow-xl max-w-md w-full mx-4">
|
||||
{notification && (
|
||||
<Toast
|
||||
message={notification.message}
|
||||
type={notification.type}
|
||||
onClose={() => setNotification(null)}
|
||||
/>
|
||||
)}
|
||||
<div className="p-6">
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<h2 className="text-xl font-semibold text-gray-900">Edit Proxy</h2>
|
||||
<button onClick={onClose} className="p-2 hover:bg-gray-100 rounded-lg transition-colors">
|
||||
<X className="w-5 h-5 text-gray-500" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">Host</label>
|
||||
<input
|
||||
type="text"
|
||||
value={host}
|
||||
onChange={(e) => setHost(e.target.value)}
|
||||
required
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">Port</label>
|
||||
<input
|
||||
type="number"
|
||||
value={port}
|
||||
onChange={(e) => setPort(e.target.value)}
|
||||
required
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">Protocol</label>
|
||||
<select
|
||||
value={protocol}
|
||||
onChange={(e) => setProtocol(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
>
|
||||
<option value="http">HTTP</option>
|
||||
<option value="https">HTTPS</option>
|
||||
<option value="socks5">SOCKS5</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">Username</label>
|
||||
<input
|
||||
type="text"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
placeholder="Optional"
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">Password</label>
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="Optional"
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">Max Connections</label>
|
||||
<input
|
||||
type="number"
|
||||
value={maxConnections}
|
||||
onChange={(e) => setMaxConnections(e.target.value)}
|
||||
min="1"
|
||||
max="500"
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
/>
|
||||
<p className="text-xs text-gray-500 mt-1">For rotating proxies - allows concurrent connections</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-3 mt-6 pt-6 border-t border-gray-200">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 text-gray-700 hover:bg-gray-50 rounded-lg transition-colors font-medium"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="inline-flex items-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors font-medium disabled:opacity-50"
|
||||
>
|
||||
{loading ? (
|
||||
<>
|
||||
<div className="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin"></div>
|
||||
Saving...
|
||||
</>
|
||||
) : (
|
||||
'Save Changes'
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -15,6 +15,9 @@ import {
|
||||
Cpu,
|
||||
Heart,
|
||||
Gauge,
|
||||
Server,
|
||||
MapPin,
|
||||
Trash2,
|
||||
} from 'lucide-react';
|
||||
|
||||
// Worker from registry
|
||||
@@ -26,6 +29,7 @@ interface Worker {
|
||||
status: string;
|
||||
pod_name: string | null;
|
||||
hostname: string | null;
|
||||
ip_address: string | null;
|
||||
started_at: string;
|
||||
last_heartbeat_at: string;
|
||||
last_task_at: string | null;
|
||||
@@ -34,6 +38,22 @@ interface Worker {
|
||||
current_task_id: number | null;
|
||||
health_status: string;
|
||||
seconds_since_heartbeat: number;
|
||||
metadata: {
|
||||
cpu?: number;
|
||||
memory?: number;
|
||||
memoryTotal?: number;
|
||||
memory_mb?: number;
|
||||
memory_total_mb?: number;
|
||||
cpu_user_ms?: number;
|
||||
cpu_system_ms?: number;
|
||||
proxy_location?: {
|
||||
city?: string;
|
||||
state?: string;
|
||||
country?: string;
|
||||
timezone?: string;
|
||||
isRotating?: boolean;
|
||||
};
|
||||
} | null;
|
||||
}
|
||||
|
||||
// Current task info
|
||||
@@ -140,7 +160,7 @@ function LiveTimer({ startedAt }: { startedAt: string | null }) {
|
||||
);
|
||||
}
|
||||
|
||||
function RoleBadge({ role }: { role: string }) {
|
||||
function RoleBadge({ role }: { role: string | null }) {
|
||||
const colors: Record<string, string> = {
|
||||
product_refresh: 'bg-emerald-100 text-emerald-700',
|
||||
product_discovery: 'bg-blue-100 text-blue-700',
|
||||
@@ -149,6 +169,14 @@ function RoleBadge({ role }: { role: string }) {
|
||||
analytics_refresh: 'bg-pink-100 text-pink-700',
|
||||
};
|
||||
|
||||
if (!role) {
|
||||
return (
|
||||
<span className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-gray-100 text-gray-700">
|
||||
any task
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<span className={`inline-flex items-center px-2 py-0.5 rounded text-xs font-medium ${colors[role] || 'bg-gray-100 text-gray-700'}`}>
|
||||
{role.replace(/_/g, ' ')}
|
||||
@@ -210,6 +238,27 @@ export function WorkersDashboard() {
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Cleanup stale workers
|
||||
const handleCleanupStale = async () => {
|
||||
try {
|
||||
await api.post('/api/worker-registry/cleanup', { stale_threshold_minutes: 2 });
|
||||
fetchData();
|
||||
} catch (err: any) {
|
||||
console.error('Cleanup error:', err);
|
||||
}
|
||||
};
|
||||
|
||||
// Remove a single worker
|
||||
const handleRemoveWorker = async (workerId: string) => {
|
||||
if (!confirm('Remove this worker from the registry?')) return;
|
||||
try {
|
||||
await api.delete(`/api/worker-registry/workers/${workerId}`);
|
||||
fetchData();
|
||||
} catch (err: any) {
|
||||
console.error('Remove error:', err);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
const interval = setInterval(fetchData, 5000);
|
||||
@@ -256,13 +305,23 @@ export function WorkersDashboard() {
|
||||
{workers.length} registered workers ({busyWorkers.length} busy, {idleWorkers.length} idle)
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => fetchData()}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-emerald-600 text-white rounded-lg hover:bg-emerald-700 transition-colors"
|
||||
>
|
||||
<RefreshCw className="w-4 h-4" />
|
||||
Refresh
|
||||
</button>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={handleCleanupStale}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-gray-100 text-gray-700 rounded-lg hover:bg-gray-200 transition-colors"
|
||||
title="Mark stale workers (no heartbeat > 2 min) as offline"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
Cleanup Stale
|
||||
</button>
|
||||
<button
|
||||
onClick={() => fetchData()}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-emerald-600 text-white rounded-lg hover:bg-emerald-700 transition-colors"
|
||||
>
|
||||
<RefreshCw className="w-4 h-4" />
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
@@ -372,11 +431,12 @@ export function WorkersDashboard() {
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Worker</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Role</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Status</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Exit Location</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Current Task</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Task Duration</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Duration</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Utilization</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Heartbeat</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Uptime</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-200">
|
||||
@@ -409,6 +469,35 @@ export function WorkersDashboard() {
|
||||
<td className="px-4 py-3">
|
||||
<HealthBadge status={worker.status} healthStatus={worker.health_status} />
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
{(() => {
|
||||
const loc = worker.metadata?.proxy_location;
|
||||
if (!loc) {
|
||||
return <span className="text-gray-400 text-sm">-</span>;
|
||||
}
|
||||
const parts = [loc.city, loc.state, loc.country].filter(Boolean);
|
||||
if (parts.length === 0) {
|
||||
return loc.isRotating ? (
|
||||
<span className="text-xs text-purple-600 font-medium" title="Rotating proxy - exit location varies per request">
|
||||
Rotating
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-gray-400 text-sm">Unknown</span>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="flex items-center gap-1.5" title={loc.timezone || ''}>
|
||||
<MapPin className="w-3 h-3 text-gray-400" />
|
||||
<span className="text-sm text-gray-700">
|
||||
{parts.join(', ')}
|
||||
</span>
|
||||
{loc.isRotating && (
|
||||
<span className="text-xs text-purple-500" title="Rotating proxy">*</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
{worker.current_task_id ? (
|
||||
<div>
|
||||
@@ -452,8 +541,14 @@ export function WorkersDashboard() {
|
||||
<span className="text-gray-600">{formatRelativeTime(worker.last_heartbeat_at)}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-gray-600">
|
||||
{formatUptime(worker.started_at)}
|
||||
<td className="px-4 py-3">
|
||||
<button
|
||||
onClick={() => handleRemoveWorker(worker.worker_id)}
|
||||
className="p-1.5 text-gray-400 hover:text-red-600 hover:bg-red-50 rounded transition-colors"
|
||||
title="Remove worker from registry"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { api } from '../../../lib/api';
|
||||
import { Building2, Tag, Globe, Target, FileText, RefreshCw, Sparkles, Loader2 } from 'lucide-react';
|
||||
import { Building2, Tag, Globe, Target, FileText, RefreshCw, Sparkles, Loader2, AlertCircle } from 'lucide-react';
|
||||
|
||||
interface SeoPage {
|
||||
id: number;
|
||||
@@ -47,11 +47,31 @@ export function PagesTab() {
|
||||
const [search, setSearch] = useState('');
|
||||
const [syncing, setSyncing] = useState(false);
|
||||
const [generatingId, setGeneratingId] = useState<number | null>(null);
|
||||
const [hasActiveAiProvider, setHasActiveAiProvider] = useState<boolean | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
loadPages();
|
||||
checkAiProvider();
|
||||
}, [typeFilter, search]);
|
||||
|
||||
async function checkAiProvider() {
|
||||
try {
|
||||
const data = await api.getSettings();
|
||||
const settings = data.settings || [];
|
||||
// Check if either Anthropic or OpenAI is configured with an API key AND enabled
|
||||
const anthropicKey = settings.find((s: any) => s.key === 'anthropic_api_key')?.value;
|
||||
const anthropicEnabled = settings.find((s: any) => s.key === 'anthropic_enabled')?.value === 'true';
|
||||
const openaiKey = settings.find((s: any) => s.key === 'openai_api_key')?.value;
|
||||
const openaiEnabled = settings.find((s: any) => s.key === 'openai_enabled')?.value === 'true';
|
||||
|
||||
const hasProvider = (anthropicKey && anthropicEnabled) || (openaiKey && openaiEnabled);
|
||||
setHasActiveAiProvider(!!hasProvider);
|
||||
} catch (error) {
|
||||
console.error('Failed to check AI provider:', error);
|
||||
setHasActiveAiProvider(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadPages() {
|
||||
setLoading(true);
|
||||
try {
|
||||
@@ -188,12 +208,18 @@ export function PagesTab() {
|
||||
<td className="px-3 sm:px-4 py-3">
|
||||
<button
|
||||
onClick={() => handleGenerate(page.id)}
|
||||
disabled={generatingId === page.id}
|
||||
className="flex items-center gap-1 px-2 sm:px-3 py-1.5 text-xs font-medium bg-purple-50 text-purple-700 rounded-lg hover:bg-purple-100 disabled:opacity-50"
|
||||
title="Generate content"
|
||||
disabled={generatingId === page.id || hasActiveAiProvider === false}
|
||||
className={`flex items-center gap-1 px-2 sm:px-3 py-1.5 text-xs font-medium rounded-lg disabled:cursor-not-allowed ${
|
||||
hasActiveAiProvider === false
|
||||
? 'bg-gray-100 text-gray-400'
|
||||
: 'bg-purple-50 text-purple-700 hover:bg-purple-100 disabled:opacity-50'
|
||||
}`}
|
||||
title={hasActiveAiProvider === false ? 'No Active AI Provider' : 'Generate content'}
|
||||
>
|
||||
{generatingId === page.id ? (
|
||||
<Loader2 className="w-3.5 h-3.5 animate-spin" />
|
||||
) : hasActiveAiProvider === false ? (
|
||||
<AlertCircle className="w-3.5 h-3.5" />
|
||||
) : (
|
||||
<Sparkles className="w-3.5 h-3.5" />
|
||||
)}
|
||||
|
||||
@@ -7,16 +7,6 @@
|
||||
"src": "favicon.ico",
|
||||
"sizes": "64x64 32x32 24x24 16x16",
|
||||
"type": "image/x-icon"
|
||||
},
|
||||
{
|
||||
"src": "logo192.png",
|
||||
"type": "image/png",
|
||||
"sizes": "192x192"
|
||||
},
|
||||
{
|
||||
"src": "logo512.png",
|
||||
"type": "image/png",
|
||||
"sizes": "512x512"
|
||||
}
|
||||
],
|
||||
"start_url": ".",
|
||||
|
||||
@@ -373,10 +373,12 @@ export function mapCategoryForUI(apiCategory) {
|
||||
* Map API brand to UI-compatible format
|
||||
*/
|
||||
export function mapBrandForUI(apiBrand) {
|
||||
// API returns 'brand' field (see /api/v1/brands endpoint)
|
||||
const brandName = apiBrand.brand || apiBrand.brand_name || '';
|
||||
return {
|
||||
id: apiBrand.brand_name,
|
||||
name: apiBrand.brand_name,
|
||||
slug: apiBrand.brand_name?.toLowerCase().replace(/\s+/g, '-'),
|
||||
id: brandName,
|
||||
name: brandName,
|
||||
slug: brandName ? brandName.toLowerCase().replace(/\s+/g, '-') : '',
|
||||
logo: apiBrand.brand_logo_url || null,
|
||||
productCount: parseInt(apiBrand.product_count || 0, 10),
|
||||
dispensaryCount: parseInt(apiBrand.dispensary_count || 0, 10),
|
||||
|
||||
@@ -27,7 +27,7 @@ const Brands = () => {
|
||||
}, []);
|
||||
|
||||
const filteredBrands = brands.filter((brand) =>
|
||||
brand.name.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
brand.name && brand.name.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
);
|
||||
|
||||
// Group brands alphabetically
|
||||
|
||||
@@ -1 +1 @@
|
||||
1.5.4
|
||||
1.6.0
|
||||
|
||||
@@ -312,3 +312,184 @@
|
||||
border-radius: 4px;
|
||||
border-left: 4px solid #c62828;
|
||||
}
|
||||
|
||||
/* ========================================
|
||||
Brand Grid Widget
|
||||
======================================== */
|
||||
.cannaiq-brand-grid {
|
||||
display: grid;
|
||||
gap: 20px;
|
||||
margin: 20px 0;
|
||||
}
|
||||
|
||||
.cannaiq-brand-card {
|
||||
background: #fff;
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
text-align: center;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
transition: transform 0.2s, box-shadow 0.2s;
|
||||
}
|
||||
|
||||
.cannaiq-brand-card:hover {
|
||||
transform: translateY(-4px);
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
.cannaiq-brand-name {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
margin: 0 0 8px 0;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.cannaiq-brand-count {
|
||||
font-size: 13px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
/* ========================================
|
||||
Category List Widget
|
||||
======================================== */
|
||||
.cannaiq-category-grid {
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
margin: 20px 0;
|
||||
}
|
||||
|
||||
.cannaiq-category-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
margin: 20px 0;
|
||||
}
|
||||
|
||||
.cannaiq-category-pills {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
margin: 20px 0;
|
||||
}
|
||||
|
||||
.cannaiq-category-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 12px 16px;
|
||||
background: #fff;
|
||||
border-radius: 8px;
|
||||
text-decoration: none;
|
||||
color: #333;
|
||||
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.08);
|
||||
transition: background 0.2s, transform 0.2s;
|
||||
}
|
||||
|
||||
.cannaiq-category-item:hover {
|
||||
background: #f3f4f6;
|
||||
transform: translateX(4px);
|
||||
}
|
||||
|
||||
.cannaiq-category-pills-item {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 8px 16px;
|
||||
background: #f3f4f6;
|
||||
border-radius: 20px;
|
||||
text-decoration: none;
|
||||
color: #333;
|
||||
font-size: 14px;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.cannaiq-category-pills-item:hover {
|
||||
background: #e5e7eb;
|
||||
}
|
||||
|
||||
.cannaiq-category-name {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.cannaiq-category-count {
|
||||
font-size: 13px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
/* ========================================
|
||||
Specials/Deals Grid Widget
|
||||
======================================== */
|
||||
.cannaiq-specials-grid {
|
||||
display: grid;
|
||||
gap: 24px;
|
||||
margin: 20px 0;
|
||||
}
|
||||
|
||||
.cannaiq-special-card {
|
||||
background: #fff;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
transition: transform 0.2s, box-shadow 0.2s;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.cannaiq-special-card:hover {
|
||||
transform: translateY(-4px);
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
.cannaiq-discount-badge {
|
||||
position: absolute;
|
||||
top: 12px;
|
||||
right: 12px;
|
||||
background: #ef4444;
|
||||
color: #fff;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
padding: 4px 10px;
|
||||
border-radius: 4px;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.cannaiq-special-image {
|
||||
width: 100%;
|
||||
aspect-ratio: 1;
|
||||
overflow: hidden;
|
||||
background: #f5f5f5;
|
||||
}
|
||||
|
||||
.cannaiq-special-image img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.cannaiq-special-content {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.cannaiq-special-title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
margin: 0 0 8px 0;
|
||||
color: #333;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.cannaiq-special-price {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.cannaiq-special-price .cannaiq-price-sale {
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
color: #16a34a;
|
||||
}
|
||||
|
||||
.cannaiq-special-price .cannaiq-price-regular {
|
||||
font-size: 14px;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
* Plugin Name: CannaIQ Menus
|
||||
* Plugin URI: https://cannaiq.co
|
||||
* Description: Display cannabis product menus from CannaIQ with Elementor integration. Real-time menu data updated daily.
|
||||
* Version: 1.5.4
|
||||
* Version: 1.6.0
|
||||
* Author: CannaIQ
|
||||
* Author URI: https://cannaiq.co
|
||||
* License: GPL v2 or later
|
||||
@@ -15,7 +15,7 @@ if (!defined('ABSPATH')) {
|
||||
exit; // Exit if accessed directly
|
||||
}
|
||||
|
||||
define('CANNAIQ_MENUS_VERSION', '1.5.4');
|
||||
define('CANNAIQ_MENUS_VERSION', '1.6.0');
|
||||
define('CANNAIQ_MENUS_API_URL', 'https://cannaiq.co/api/v1');
|
||||
define('CANNAIQ_MENUS_PLUGIN_DIR', plugin_dir_path(__FILE__));
|
||||
define('CANNAIQ_MENUS_PLUGIN_URL', plugin_dir_url(__FILE__));
|
||||
@@ -46,14 +46,17 @@ class CannaIQ_Menus_Plugin {
|
||||
// Initialize plugin
|
||||
load_plugin_textdomain('cannaiq-menus', false, dirname(plugin_basename(__FILE__)) . '/languages');
|
||||
|
||||
// Register shortcodes
|
||||
// Register shortcodes - primary CannaIQ shortcodes
|
||||
add_shortcode('cannaiq_products', [$this, 'products_shortcode']);
|
||||
add_shortcode('cannaiq_product', [$this, 'single_product_shortcode']);
|
||||
// Legacy shortcode support (backward compatibility)
|
||||
add_shortcode('crawlsy_products', [$this, 'products_shortcode']);
|
||||
add_shortcode('crawlsy_product', [$this, 'single_product_shortcode']);
|
||||
add_shortcode('dutchie_products', [$this, 'products_shortcode']);
|
||||
add_shortcode('dutchie_product', [$this, 'single_product_shortcode']);
|
||||
|
||||
// DEPRECATED: Legacy shortcode aliases for backward compatibility only
|
||||
// These allow sites that used the old plugin names to continue working
|
||||
// New implementations should use [cannaiq_products] and [cannaiq_product]
|
||||
add_shortcode('crawlsy_products', [$this, 'products_shortcode']); // deprecated
|
||||
add_shortcode('crawlsy_product', [$this, 'single_product_shortcode']); // deprecated
|
||||
add_shortcode('dutchie_products', [$this, 'products_shortcode']); // deprecated
|
||||
add_shortcode('dutchie_product', [$this, 'single_product_shortcode']); // deprecated
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -62,9 +65,15 @@ class CannaIQ_Menus_Plugin {
|
||||
public function register_elementor_widgets($widgets_manager) {
|
||||
require_once CANNAIQ_MENUS_PLUGIN_DIR . 'widgets/product-grid.php';
|
||||
require_once CANNAIQ_MENUS_PLUGIN_DIR . 'widgets/single-product.php';
|
||||
require_once CANNAIQ_MENUS_PLUGIN_DIR . 'widgets/brand-grid.php';
|
||||
require_once CANNAIQ_MENUS_PLUGIN_DIR . 'widgets/category-list.php';
|
||||
require_once CANNAIQ_MENUS_PLUGIN_DIR . 'widgets/specials-grid.php';
|
||||
|
||||
$widgets_manager->register(new \CannaIQ_Menus_Product_Grid_Widget());
|
||||
$widgets_manager->register(new \CannaIQ_Menus_Single_Product_Widget());
|
||||
$widgets_manager->register(new \CannaIQ_Menus_Brand_Grid_Widget());
|
||||
$widgets_manager->register(new \CannaIQ_Menus_Category_List_Widget());
|
||||
$widgets_manager->register(new \CannaIQ_Menus_Specials_Grid_Widget());
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -108,7 +117,9 @@ class CannaIQ_Menus_Plugin {
|
||||
public function register_settings() {
|
||||
register_setting('cannaiq_menus_settings', 'cannaiq_api_token');
|
||||
|
||||
// Migrate old settings if they exist
|
||||
// MIGRATION: Auto-migrate API tokens from old plugin versions
|
||||
// This runs once - if user had crawlsy or dutchie plugin, their token is preserved
|
||||
// Can be removed in a future major version once all users have migrated
|
||||
$old_crawlsy_token = get_option('crawlsy_api_token');
|
||||
$old_dutchie_token = get_option('dutchie_api_token');
|
||||
|
||||
@@ -392,6 +403,152 @@ class CannaIQ_Menus_Plugin {
|
||||
|
||||
return $data['product'] ?? false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch Categories from API
|
||||
*/
|
||||
public function fetch_categories($args = []) {
|
||||
$api_token = get_option('cannaiq_api_token');
|
||||
|
||||
if (!$api_token) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$query_args = http_build_query($args);
|
||||
$url = CANNAIQ_MENUS_API_URL . '/categories' . ($query_args ? '?' . $query_args : '');
|
||||
|
||||
$response = wp_remote_get($url, [
|
||||
'headers' => [
|
||||
'X-API-Key' => $api_token
|
||||
],
|
||||
'timeout' => 30
|
||||
]);
|
||||
|
||||
if (is_wp_error($response)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$body = wp_remote_retrieve_body($response);
|
||||
$data = json_decode($body, true);
|
||||
|
||||
return $data['categories'] ?? false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch Brands from API
|
||||
*/
|
||||
public function fetch_brands($args = []) {
|
||||
$api_token = get_option('cannaiq_api_token');
|
||||
|
||||
if (!$api_token) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$query_args = http_build_query($args);
|
||||
$url = CANNAIQ_MENUS_API_URL . '/brands' . ($query_args ? '?' . $query_args : '');
|
||||
|
||||
$response = wp_remote_get($url, [
|
||||
'headers' => [
|
||||
'X-API-Key' => $api_token
|
||||
],
|
||||
'timeout' => 30
|
||||
]);
|
||||
|
||||
if (is_wp_error($response)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$body = wp_remote_retrieve_body($response);
|
||||
$data = json_decode($body, true);
|
||||
|
||||
return $data['brands'] ?? false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch Specials/Deals from API
|
||||
*/
|
||||
public function fetch_specials($args = []) {
|
||||
$api_token = get_option('cannaiq_api_token');
|
||||
|
||||
if (!$api_token) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$query_args = http_build_query($args);
|
||||
$url = CANNAIQ_MENUS_API_URL . '/specials' . ($query_args ? '?' . $query_args : '');
|
||||
|
||||
$response = wp_remote_get($url, [
|
||||
'headers' => [
|
||||
'X-API-Key' => $api_token
|
||||
],
|
||||
'timeout' => 30
|
||||
]);
|
||||
|
||||
if (is_wp_error($response)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$body = wp_remote_retrieve_body($response);
|
||||
$data = json_decode($body, true);
|
||||
|
||||
return $data['products'] ?? false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get categories as options for Elementor select control
|
||||
* Returns cached results for performance
|
||||
*/
|
||||
public function get_category_options() {
|
||||
$cache_key = 'cannaiq_category_options';
|
||||
$cached = get_transient($cache_key);
|
||||
|
||||
if ($cached !== false) {
|
||||
return $cached;
|
||||
}
|
||||
|
||||
$categories = $this->fetch_categories();
|
||||
$options = ['' => __('All Categories', 'cannaiq-menus')];
|
||||
|
||||
if ($categories) {
|
||||
foreach ($categories as $cat) {
|
||||
$name = $cat['type'] ?? $cat['name'] ?? '';
|
||||
if ($name) {
|
||||
$options[$name] = ucwords(str_replace('_', ' ', $name));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
set_transient($cache_key, $options, 5 * MINUTE_IN_SECONDS);
|
||||
return $options;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get brands as options for Elementor select control
|
||||
* Returns cached results for performance
|
||||
*/
|
||||
public function get_brand_options() {
|
||||
$cache_key = 'cannaiq_brand_options';
|
||||
$cached = get_transient($cache_key);
|
||||
|
||||
if ($cached !== false) {
|
||||
return $cached;
|
||||
}
|
||||
|
||||
$brands = $this->fetch_brands(['limit' => 200]);
|
||||
$options = ['' => __('All Brands', 'cannaiq-menus')];
|
||||
|
||||
if ($brands) {
|
||||
foreach ($brands as $brand) {
|
||||
$name = $brand['brand'] ?? $brand['brand_name'] ?? '';
|
||||
if ($name) {
|
||||
$options[$name] = $name;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
set_transient($cache_key, $options, 5 * MINUTE_IN_SECONDS);
|
||||
return $options;
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize Plugin
|
||||
|
||||
184
wordpress-plugin/widgets/brand-grid.php
Normal file
184
wordpress-plugin/widgets/brand-grid.php
Normal file
@@ -0,0 +1,184 @@
|
||||
<?php
|
||||
/**
|
||||
* Elementor Brand Grid Widget
|
||||
*/
|
||||
|
||||
if (!defined('ABSPATH')) {
|
||||
exit;
|
||||
}
|
||||
|
||||
class CannaIQ_Menus_Brand_Grid_Widget extends \Elementor\Widget_Base {
|
||||
|
||||
public function get_name() {
|
||||
return 'cannaiq_brand_grid';
|
||||
}
|
||||
|
||||
public function get_title() {
|
||||
return __('CannaIQ Brand Grid', 'cannaiq-menus');
|
||||
}
|
||||
|
||||
public function get_icon() {
|
||||
return 'eicon-gallery-grid';
|
||||
}
|
||||
|
||||
public function get_categories() {
|
||||
return ['general'];
|
||||
}
|
||||
|
||||
protected function register_controls() {
|
||||
|
||||
// Content Section
|
||||
$this->start_controls_section(
|
||||
'content_section',
|
||||
[
|
||||
'label' => __('Content', 'cannaiq-menus'),
|
||||
'tab' => \Elementor\Controls_Manager::TAB_CONTENT,
|
||||
]
|
||||
);
|
||||
|
||||
$this->add_control(
|
||||
'limit',
|
||||
[
|
||||
'label' => __('Number of Brands', 'cannaiq-menus'),
|
||||
'type' => \Elementor\Controls_Manager::NUMBER,
|
||||
'default' => 12,
|
||||
'min' => 1,
|
||||
'max' => 100,
|
||||
]
|
||||
);
|
||||
|
||||
$this->add_control(
|
||||
'columns',
|
||||
[
|
||||
'label' => __('Columns', 'cannaiq-menus'),
|
||||
'type' => \Elementor\Controls_Manager::SELECT,
|
||||
'default' => '4',
|
||||
'options' => [
|
||||
'2' => __('2 Columns', 'cannaiq-menus'),
|
||||
'3' => __('3 Columns', 'cannaiq-menus'),
|
||||
'4' => __('4 Columns', 'cannaiq-menus'),
|
||||
'6' => __('6 Columns', 'cannaiq-menus'),
|
||||
],
|
||||
]
|
||||
);
|
||||
|
||||
$this->add_control(
|
||||
'show_product_count',
|
||||
[
|
||||
'label' => __('Show Product Count', 'cannaiq-menus'),
|
||||
'type' => \Elementor\Controls_Manager::SWITCHER,
|
||||
'label_on' => __('Yes', 'cannaiq-menus'),
|
||||
'label_off' => __('No', 'cannaiq-menus'),
|
||||
'return_value' => 'yes',
|
||||
'default' => 'yes',
|
||||
]
|
||||
);
|
||||
|
||||
$this->add_control(
|
||||
'link_to_products',
|
||||
[
|
||||
'label' => __('Link to Products Page', 'cannaiq-menus'),
|
||||
'type' => \Elementor\Controls_Manager::URL,
|
||||
'placeholder' => __('/products', 'cannaiq-menus'),
|
||||
'description' => __('Brand name will be appended as ?brand=Name', 'cannaiq-menus'),
|
||||
]
|
||||
);
|
||||
|
||||
$this->end_controls_section();
|
||||
|
||||
// Style Section
|
||||
$this->start_controls_section(
|
||||
'style_section',
|
||||
[
|
||||
'label' => __('Style', 'cannaiq-menus'),
|
||||
'tab' => \Elementor\Controls_Manager::TAB_STYLE,
|
||||
]
|
||||
);
|
||||
|
||||
$this->add_control(
|
||||
'card_background',
|
||||
[
|
||||
'label' => __('Card Background', 'cannaiq-menus'),
|
||||
'type' => \Elementor\Controls_Manager::COLOR,
|
||||
'default' => '#ffffff',
|
||||
'selectors' => [
|
||||
'{{WRAPPER}} .cannaiq-brand-card' => 'background-color: {{VALUE}};',
|
||||
],
|
||||
]
|
||||
);
|
||||
|
||||
$this->add_control(
|
||||
'card_border_radius',
|
||||
[
|
||||
'label' => __('Border Radius', 'cannaiq-menus'),
|
||||
'type' => \Elementor\Controls_Manager::SLIDER,
|
||||
'size_units' => ['px'],
|
||||
'range' => [
|
||||
'px' => [
|
||||
'min' => 0,
|
||||
'max' => 50,
|
||||
],
|
||||
],
|
||||
'default' => [
|
||||
'size' => 8,
|
||||
],
|
||||
'selectors' => [
|
||||
'{{WRAPPER}} .cannaiq-brand-card' => 'border-radius: {{SIZE}}{{UNIT}};',
|
||||
],
|
||||
]
|
||||
);
|
||||
|
||||
$this->add_control(
|
||||
'text_color',
|
||||
[
|
||||
'label' => __('Text Color', 'cannaiq-menus'),
|
||||
'type' => \Elementor\Controls_Manager::COLOR,
|
||||
'default' => '#333333',
|
||||
'selectors' => [
|
||||
'{{WRAPPER}} .cannaiq-brand-card' => 'color: {{VALUE}};',
|
||||
],
|
||||
]
|
||||
);
|
||||
|
||||
$this->end_controls_section();
|
||||
}
|
||||
|
||||
protected function render() {
|
||||
$settings = $this->get_settings_for_display();
|
||||
|
||||
$plugin = CannaIQ_Menus_Plugin::instance();
|
||||
$brands = $plugin->fetch_brands(['limit' => $settings['limit']]);
|
||||
|
||||
if (!$brands) {
|
||||
echo '<p>' . __('No brands found.', 'cannaiq-menus') . '</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
$columns = $settings['columns'];
|
||||
$link_base = $settings['link_to_products']['url'] ?? '';
|
||||
?>
|
||||
<div class="cannaiq-brand-grid cannaiq-grid-cols-<?php echo esc_attr($columns); ?>">
|
||||
<?php foreach ($brands as $brand):
|
||||
$brand_name = $brand['brand'] ?? $brand['brand_name'] ?? '';
|
||||
$product_count = $brand['product_count'] ?? 0;
|
||||
$brand_url = $link_base ? $link_base . '?brand=' . urlencode($brand_name) : '#';
|
||||
?>
|
||||
<div class="cannaiq-brand-card"
|
||||
<?php if ($brand_url !== '#'): ?>onclick="window.location.href='<?php echo esc_url($brand_url); ?>'"<?php endif; ?>
|
||||
style="cursor: <?php echo ($brand_url !== '#') ? 'pointer' : 'default'; ?>;">
|
||||
<div class="cannaiq-brand-content">
|
||||
<h3 class="cannaiq-brand-name">
|
||||
<?php echo esc_html($brand_name); ?>
|
||||
</h3>
|
||||
<?php if ($settings['show_product_count'] === 'yes' && $product_count > 0): ?>
|
||||
<span class="cannaiq-brand-count">
|
||||
<?php echo esc_html($product_count); ?> <?php _e('products', 'cannaiq-menus'); ?>
|
||||
</span>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
<?php
|
||||
}
|
||||
}
|
||||
205
wordpress-plugin/widgets/category-list.php
Normal file
205
wordpress-plugin/widgets/category-list.php
Normal file
@@ -0,0 +1,205 @@
|
||||
<?php
|
||||
/**
|
||||
* Elementor Category List Widget
|
||||
*/
|
||||
|
||||
if (!defined('ABSPATH')) {
|
||||
exit;
|
||||
}
|
||||
|
||||
class CannaIQ_Menus_Category_List_Widget extends \Elementor\Widget_Base {
|
||||
|
||||
public function get_name() {
|
||||
return 'cannaiq_category_list';
|
||||
}
|
||||
|
||||
public function get_title() {
|
||||
return __('CannaIQ Category List', 'cannaiq-menus');
|
||||
}
|
||||
|
||||
public function get_icon() {
|
||||
return 'eicon-bullet-list';
|
||||
}
|
||||
|
||||
public function get_categories() {
|
||||
return ['general'];
|
||||
}
|
||||
|
||||
protected function register_controls() {
|
||||
|
||||
// Content Section
|
||||
$this->start_controls_section(
|
||||
'content_section',
|
||||
[
|
||||
'label' => __('Content', 'cannaiq-menus'),
|
||||
'tab' => \Elementor\Controls_Manager::TAB_CONTENT,
|
||||
]
|
||||
);
|
||||
|
||||
$this->add_control(
|
||||
'layout',
|
||||
[
|
||||
'label' => __('Layout', 'cannaiq-menus'),
|
||||
'type' => \Elementor\Controls_Manager::SELECT,
|
||||
'default' => 'grid',
|
||||
'options' => [
|
||||
'grid' => __('Grid', 'cannaiq-menus'),
|
||||
'list' => __('List', 'cannaiq-menus'),
|
||||
'pills' => __('Pills/Tags', 'cannaiq-menus'),
|
||||
],
|
||||
]
|
||||
);
|
||||
|
||||
$this->add_control(
|
||||
'columns',
|
||||
[
|
||||
'label' => __('Columns', 'cannaiq-menus'),
|
||||
'type' => \Elementor\Controls_Manager::SELECT,
|
||||
'default' => '3',
|
||||
'options' => [
|
||||
'2' => __('2 Columns', 'cannaiq-menus'),
|
||||
'3' => __('3 Columns', 'cannaiq-menus'),
|
||||
'4' => __('4 Columns', 'cannaiq-menus'),
|
||||
'6' => __('6 Columns', 'cannaiq-menus'),
|
||||
],
|
||||
'condition' => [
|
||||
'layout' => 'grid',
|
||||
],
|
||||
]
|
||||
);
|
||||
|
||||
$this->add_control(
|
||||
'show_product_count',
|
||||
[
|
||||
'label' => __('Show Product Count', 'cannaiq-menus'),
|
||||
'type' => \Elementor\Controls_Manager::SWITCHER,
|
||||
'label_on' => __('Yes', 'cannaiq-menus'),
|
||||
'label_off' => __('No', 'cannaiq-menus'),
|
||||
'return_value' => 'yes',
|
||||
'default' => 'yes',
|
||||
]
|
||||
);
|
||||
|
||||
$this->add_control(
|
||||
'link_to_products',
|
||||
[
|
||||
'label' => __('Link to Products Page', 'cannaiq-menus'),
|
||||
'type' => \Elementor\Controls_Manager::URL,
|
||||
'placeholder' => __('/products', 'cannaiq-menus'),
|
||||
'description' => __('Category name will be appended as ?category=Name', 'cannaiq-menus'),
|
||||
]
|
||||
);
|
||||
|
||||
$this->end_controls_section();
|
||||
|
||||
// Style Section
|
||||
$this->start_controls_section(
|
||||
'style_section',
|
||||
[
|
||||
'label' => __('Style', 'cannaiq-menus'),
|
||||
'tab' => \Elementor\Controls_Manager::TAB_STYLE,
|
||||
]
|
||||
);
|
||||
|
||||
$this->add_control(
|
||||
'card_background',
|
||||
[
|
||||
'label' => __('Card Background', 'cannaiq-menus'),
|
||||
'type' => \Elementor\Controls_Manager::COLOR,
|
||||
'default' => '#ffffff',
|
||||
'selectors' => [
|
||||
'{{WRAPPER}} .cannaiq-category-item' => 'background-color: {{VALUE}};',
|
||||
],
|
||||
]
|
||||
);
|
||||
|
||||
$this->add_control(
|
||||
'card_border_radius',
|
||||
[
|
||||
'label' => __('Border Radius', 'cannaiq-menus'),
|
||||
'type' => \Elementor\Controls_Manager::SLIDER,
|
||||
'size_units' => ['px'],
|
||||
'range' => [
|
||||
'px' => [
|
||||
'min' => 0,
|
||||
'max' => 50,
|
||||
],
|
||||
],
|
||||
'default' => [
|
||||
'size' => 8,
|
||||
],
|
||||
'selectors' => [
|
||||
'{{WRAPPER}} .cannaiq-category-item' => 'border-radius: {{SIZE}}{{UNIT}};',
|
||||
],
|
||||
]
|
||||
);
|
||||
|
||||
$this->add_control(
|
||||
'text_color',
|
||||
[
|
||||
'label' => __('Text Color', 'cannaiq-menus'),
|
||||
'type' => \Elementor\Controls_Manager::COLOR,
|
||||
'default' => '#333333',
|
||||
'selectors' => [
|
||||
'{{WRAPPER}} .cannaiq-category-item' => 'color: {{VALUE}};',
|
||||
],
|
||||
]
|
||||
);
|
||||
|
||||
$this->add_control(
|
||||
'hover_background',
|
||||
[
|
||||
'label' => __('Hover Background', 'cannaiq-menus'),
|
||||
'type' => \Elementor\Controls_Manager::COLOR,
|
||||
'default' => '#f3f4f6',
|
||||
'selectors' => [
|
||||
'{{WRAPPER}} .cannaiq-category-item:hover' => 'background-color: {{VALUE}};',
|
||||
],
|
||||
]
|
||||
);
|
||||
|
||||
$this->end_controls_section();
|
||||
}
|
||||
|
||||
protected function render() {
|
||||
$settings = $this->get_settings_for_display();
|
||||
|
||||
$plugin = CannaIQ_Menus_Plugin::instance();
|
||||
$categories = $plugin->fetch_categories();
|
||||
|
||||
if (!$categories) {
|
||||
echo '<p>' . __('No categories found.', 'cannaiq-menus') . '</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
$layout = $settings['layout'];
|
||||
$columns = $settings['columns'];
|
||||
$link_base = $settings['link_to_products']['url'] ?? '';
|
||||
|
||||
$container_class = 'cannaiq-category-' . $layout;
|
||||
if ($layout === 'grid') {
|
||||
$container_class .= ' cannaiq-grid-cols-' . $columns;
|
||||
}
|
||||
?>
|
||||
<div class="<?php echo esc_attr($container_class); ?>">
|
||||
<?php foreach ($categories as $category):
|
||||
$cat_name = $category['type'] ?? $category['name'] ?? '';
|
||||
$display_name = ucwords(str_replace('_', ' ', $cat_name));
|
||||
$product_count = $category['product_count'] ?? 0;
|
||||
$cat_url = $link_base ? $link_base . '?category=' . urlencode($cat_name) : '#';
|
||||
?>
|
||||
<a href="<?php echo esc_url($cat_url); ?>" class="cannaiq-category-item cannaiq-category-<?php echo esc_attr($layout); ?>-item">
|
||||
<span class="cannaiq-category-name">
|
||||
<?php echo esc_html($display_name); ?>
|
||||
</span>
|
||||
<?php if ($settings['show_product_count'] === 'yes' && $product_count > 0): ?>
|
||||
<span class="cannaiq-category-count">
|
||||
(<?php echo esc_html($product_count); ?>)
|
||||
</span>
|
||||
<?php endif; ?>
|
||||
</a>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
<?php
|
||||
}
|
||||
}
|
||||
@@ -47,12 +47,37 @@ class CannaIQ_Menus_Product_Grid_Widget extends \Elementor\Widget_Base {
|
||||
);
|
||||
|
||||
$this->add_control(
|
||||
'category_id',
|
||||
'category',
|
||||
[
|
||||
'label' => __('Category ID', 'cannaiq-menus'),
|
||||
'type' => \Elementor\Controls_Manager::NUMBER,
|
||||
'label' => __('Category', 'cannaiq-menus'),
|
||||
'type' => \Elementor\Controls_Manager::SELECT,
|
||||
'default' => '',
|
||||
'description' => __('Leave empty to show all categories', 'cannaiq-menus'),
|
||||
'options' => CannaIQ_Menus_Plugin::instance()->get_category_options(),
|
||||
'description' => __('Filter by product category', 'cannaiq-menus'),
|
||||
]
|
||||
);
|
||||
|
||||
$this->add_control(
|
||||
'brand',
|
||||
[
|
||||
'label' => __('Brand', 'cannaiq-menus'),
|
||||
'type' => \Elementor\Controls_Manager::SELECT,
|
||||
'default' => '',
|
||||
'options' => CannaIQ_Menus_Plugin::instance()->get_brand_options(),
|
||||
'description' => __('Filter by brand', 'cannaiq-menus'),
|
||||
]
|
||||
);
|
||||
|
||||
$this->add_control(
|
||||
'on_special',
|
||||
[
|
||||
'label' => __('On Special Only', 'cannaiq-menus'),
|
||||
'type' => \Elementor\Controls_Manager::SWITCHER,
|
||||
'label_on' => __('Yes', 'cannaiq-menus'),
|
||||
'label_off' => __('No', 'cannaiq-menus'),
|
||||
'return_value' => 'yes',
|
||||
'default' => 'no',
|
||||
'description' => __('Show only products on sale', 'cannaiq-menus'),
|
||||
]
|
||||
);
|
||||
|
||||
@@ -243,8 +268,16 @@ class CannaIQ_Menus_Product_Grid_Widget extends \Elementor\Widget_Base {
|
||||
'in_stock' => $settings['in_stock_only'] === 'yes' ? 'true' : 'false',
|
||||
];
|
||||
|
||||
if (!empty($settings['category_id'])) {
|
||||
$args['category_id'] = $settings['category_id'];
|
||||
if (!empty($settings['category'])) {
|
||||
$args['type'] = $settings['category'];
|
||||
}
|
||||
|
||||
if (!empty($settings['brand'])) {
|
||||
$args['brandName'] = $settings['brand'];
|
||||
}
|
||||
|
||||
if ($settings['on_special'] === 'yes') {
|
||||
$args['on_special'] = 'true';
|
||||
}
|
||||
|
||||
if (!empty($settings['search'])) {
|
||||
|
||||
288
wordpress-plugin/widgets/specials-grid.php
Normal file
288
wordpress-plugin/widgets/specials-grid.php
Normal file
@@ -0,0 +1,288 @@
|
||||
<?php
|
||||
/**
|
||||
* Elementor Specials/Deals Grid Widget
|
||||
*/
|
||||
|
||||
if (!defined('ABSPATH')) {
|
||||
exit;
|
||||
}
|
||||
|
||||
class CannaIQ_Menus_Specials_Grid_Widget extends \Elementor\Widget_Base {
|
||||
|
||||
public function get_name() {
|
||||
return 'cannaiq_specials_grid';
|
||||
}
|
||||
|
||||
public function get_title() {
|
||||
return __('CannaIQ Specials/Deals', 'cannaiq-menus');
|
||||
}
|
||||
|
||||
public function get_icon() {
|
||||
return 'eicon-price-table';
|
||||
}
|
||||
|
||||
public function get_categories() {
|
||||
return ['general'];
|
||||
}
|
||||
|
||||
protected function register_controls() {
|
||||
|
||||
// Content Section
|
||||
$this->start_controls_section(
|
||||
'content_section',
|
||||
[
|
||||
'label' => __('Content', 'cannaiq-menus'),
|
||||
'tab' => \Elementor\Controls_Manager::TAB_CONTENT,
|
||||
]
|
||||
);
|
||||
|
||||
$this->add_control(
|
||||
'store_id',
|
||||
[
|
||||
'label' => __('Store ID', 'cannaiq-menus'),
|
||||
'type' => \Elementor\Controls_Manager::NUMBER,
|
||||
'default' => get_option('cannaiq_default_store_id', 1),
|
||||
'min' => 1,
|
||||
]
|
||||
);
|
||||
|
||||
$this->add_control(
|
||||
'limit',
|
||||
[
|
||||
'label' => __('Number of Products', 'cannaiq-menus'),
|
||||
'type' => \Elementor\Controls_Manager::NUMBER,
|
||||
'default' => 8,
|
||||
'min' => 1,
|
||||
'max' => 50,
|
||||
]
|
||||
);
|
||||
|
||||
$this->add_control(
|
||||
'columns',
|
||||
[
|
||||
'label' => __('Columns', 'cannaiq-menus'),
|
||||
'type' => \Elementor\Controls_Manager::SELECT,
|
||||
'default' => '4',
|
||||
'options' => [
|
||||
'2' => __('2 Columns', 'cannaiq-menus'),
|
||||
'3' => __('3 Columns', 'cannaiq-menus'),
|
||||
'4' => __('4 Columns', 'cannaiq-menus'),
|
||||
],
|
||||
]
|
||||
);
|
||||
|
||||
$this->add_control(
|
||||
'category',
|
||||
[
|
||||
'label' => __('Category', 'cannaiq-menus'),
|
||||
'type' => \Elementor\Controls_Manager::SELECT,
|
||||
'default' => '',
|
||||
'options' => CannaIQ_Menus_Plugin::instance()->get_category_options(),
|
||||
'description' => __('Filter specials by category', 'cannaiq-menus'),
|
||||
]
|
||||
);
|
||||
|
||||
$this->end_controls_section();
|
||||
|
||||
// Display Options Section
|
||||
$this->start_controls_section(
|
||||
'display_section',
|
||||
[
|
||||
'label' => __('Display Options', 'cannaiq-menus'),
|
||||
'tab' => \Elementor\Controls_Manager::TAB_CONTENT,
|
||||
]
|
||||
);
|
||||
|
||||
$this->add_control(
|
||||
'show_image',
|
||||
[
|
||||
'label' => __('Show Image', 'cannaiq-menus'),
|
||||
'type' => \Elementor\Controls_Manager::SWITCHER,
|
||||
'label_on' => __('Yes', 'cannaiq-menus'),
|
||||
'label_off' => __('No', 'cannaiq-menus'),
|
||||
'return_value' => 'yes',
|
||||
'default' => 'yes',
|
||||
]
|
||||
);
|
||||
|
||||
$this->add_control(
|
||||
'show_discount_badge',
|
||||
[
|
||||
'label' => __('Show Discount Badge', 'cannaiq-menus'),
|
||||
'type' => \Elementor\Controls_Manager::SWITCHER,
|
||||
'label_on' => __('Yes', 'cannaiq-menus'),
|
||||
'label_off' => __('No', 'cannaiq-menus'),
|
||||
'return_value' => 'yes',
|
||||
'default' => 'yes',
|
||||
]
|
||||
);
|
||||
|
||||
$this->add_control(
|
||||
'show_original_price',
|
||||
[
|
||||
'label' => __('Show Original Price', 'cannaiq-menus'),
|
||||
'type' => \Elementor\Controls_Manager::SWITCHER,
|
||||
'label_on' => __('Yes', 'cannaiq-menus'),
|
||||
'label_off' => __('No', 'cannaiq-menus'),
|
||||
'return_value' => 'yes',
|
||||
'default' => 'yes',
|
||||
]
|
||||
);
|
||||
|
||||
$this->add_control(
|
||||
'show_thc',
|
||||
[
|
||||
'label' => __('Show THC', 'cannaiq-menus'),
|
||||
'type' => \Elementor\Controls_Manager::SWITCHER,
|
||||
'label_on' => __('Yes', 'cannaiq-menus'),
|
||||
'label_off' => __('No', 'cannaiq-menus'),
|
||||
'return_value' => 'yes',
|
||||
'default' => 'no',
|
||||
]
|
||||
);
|
||||
|
||||
$this->end_controls_section();
|
||||
|
||||
// Style Section
|
||||
$this->start_controls_section(
|
||||
'style_section',
|
||||
[
|
||||
'label' => __('Style', 'cannaiq-menus'),
|
||||
'tab' => \Elementor\Controls_Manager::TAB_STYLE,
|
||||
]
|
||||
);
|
||||
|
||||
$this->add_control(
|
||||
'card_background',
|
||||
[
|
||||
'label' => __('Card Background', 'cannaiq-menus'),
|
||||
'type' => \Elementor\Controls_Manager::COLOR,
|
||||
'default' => '#ffffff',
|
||||
'selectors' => [
|
||||
'{{WRAPPER}} .cannaiq-special-card' => 'background-color: {{VALUE}};',
|
||||
],
|
||||
]
|
||||
);
|
||||
|
||||
$this->add_control(
|
||||
'badge_background',
|
||||
[
|
||||
'label' => __('Badge Background', 'cannaiq-menus'),
|
||||
'type' => \Elementor\Controls_Manager::COLOR,
|
||||
'default' => '#ef4444',
|
||||
'selectors' => [
|
||||
'{{WRAPPER}} .cannaiq-discount-badge' => 'background-color: {{VALUE}};',
|
||||
],
|
||||
]
|
||||
);
|
||||
|
||||
$this->add_control(
|
||||
'sale_price_color',
|
||||
[
|
||||
'label' => __('Sale Price Color', 'cannaiq-menus'),
|
||||
'type' => \Elementor\Controls_Manager::COLOR,
|
||||
'default' => '#16a34a',
|
||||
'selectors' => [
|
||||
'{{WRAPPER}} .cannaiq-price-sale' => 'color: {{VALUE}};',
|
||||
],
|
||||
]
|
||||
);
|
||||
|
||||
$this->add_control(
|
||||
'card_border_radius',
|
||||
[
|
||||
'label' => __('Border Radius', 'cannaiq-menus'),
|
||||
'type' => \Elementor\Controls_Manager::SLIDER,
|
||||
'size_units' => ['px'],
|
||||
'range' => [
|
||||
'px' => [
|
||||
'min' => 0,
|
||||
'max' => 50,
|
||||
],
|
||||
],
|
||||
'default' => [
|
||||
'size' => 8,
|
||||
],
|
||||
'selectors' => [
|
||||
'{{WRAPPER}} .cannaiq-special-card' => 'border-radius: {{SIZE}}{{UNIT}};',
|
||||
],
|
||||
]
|
||||
);
|
||||
|
||||
$this->end_controls_section();
|
||||
}
|
||||
|
||||
protected function render() {
|
||||
$settings = $this->get_settings_for_display();
|
||||
|
||||
$args = [
|
||||
'store_id' => $settings['store_id'],
|
||||
'limit' => $settings['limit'],
|
||||
];
|
||||
|
||||
if (!empty($settings['category'])) {
|
||||
$args['type'] = $settings['category'];
|
||||
}
|
||||
|
||||
$plugin = CannaIQ_Menus_Plugin::instance();
|
||||
$products = $plugin->fetch_specials($args);
|
||||
|
||||
if (!$products) {
|
||||
echo '<p>' . __('No specials found.', 'cannaiq-menus') . '</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
$columns = $settings['columns'];
|
||||
?>
|
||||
<div class="cannaiq-specials-grid cannaiq-grid-cols-<?php echo esc_attr($columns); ?>">
|
||||
<?php foreach ($products as $product):
|
||||
$image_url = $product['image_url'] ?? $product['primary_image_url'] ?? '';
|
||||
$product_url = !empty($product['menu_url']) ? $product['menu_url'] : '#';
|
||||
$regular_price = $product['regular_price'] ?? 0;
|
||||
$sale_price = $product['sale_price'] ?? $regular_price;
|
||||
$discount = ($regular_price > 0 && $sale_price < $regular_price)
|
||||
? round((($regular_price - $sale_price) / $regular_price) * 100)
|
||||
: 0;
|
||||
?>
|
||||
<div class="cannaiq-special-card"
|
||||
<?php if ($product_url !== '#'): ?>onclick="window.open('<?php echo esc_url($product_url); ?>', '_blank')"<?php endif; ?>
|
||||
style="cursor: <?php echo ($product_url !== '#') ? 'pointer' : 'default'; ?>;">
|
||||
|
||||
<?php if ($settings['show_discount_badge'] === 'yes' && $discount > 0): ?>
|
||||
<div class="cannaiq-discount-badge">
|
||||
-<?php echo esc_html($discount); ?>%
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if ($settings['show_image'] === 'yes' && !empty($image_url)): ?>
|
||||
<div class="cannaiq-special-image">
|
||||
<img src="<?php echo esc_url($image_url); ?>"
|
||||
alt="<?php echo esc_attr($product['name']); ?>"
|
||||
loading="lazy" />
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<div class="cannaiq-special-content">
|
||||
<h3 class="cannaiq-special-title">
|
||||
<?php echo esc_html($product['name']); ?>
|
||||
</h3>
|
||||
|
||||
<?php if ($settings['show_thc'] === 'yes' && !empty($product['thc_percentage'])): ?>
|
||||
<span class="cannaiq-meta-item cannaiq-thc">
|
||||
THC: <?php echo esc_html($product['thc_percentage']); ?>%
|
||||
</span>
|
||||
<?php endif; ?>
|
||||
|
||||
<div class="cannaiq-special-price">
|
||||
<span class="cannaiq-price-sale">$<?php echo esc_html($sale_price); ?></span>
|
||||
<?php if ($settings['show_original_price'] === 'yes' && $regular_price > $sale_price): ?>
|
||||
<span class="cannaiq-price-regular cannaiq-strikethrough">$<?php echo esc_html($regular_price); ?></span>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
<?php
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user