Add brand history view to dutchie GraphQL migration
This commit is contained in:
322
backend/migrations/032_dutchie_graphql_schema.sql
Normal file
322
backend/migrations/032_dutchie_graphql_schema.sql
Normal file
@@ -0,0 +1,322 @@
|
||||
-- Migration 032: Dutchie GraphQL Schema Alignment
|
||||
-- Aligns product tables with actual Dutchie GraphQL FilteredProducts response
|
||||
-- This is the CANONICAL product schema - all views derive from this
|
||||
|
||||
-- =====================================================
|
||||
-- GRAPHQL FIELD → DB COLUMN MAPPING
|
||||
-- =====================================================
|
||||
-- GraphQL Field | DB Column | Notes
|
||||
-- ---------------------------|----------------------------|---------------------------
|
||||
-- _id / id | external_id | Dutchie's product ID
|
||||
-- Name | name | Product name
|
||||
-- cName | slug | URL slug
|
||||
-- enterpriseProductId | enterprise_product_id | Cross-dispensary ID
|
||||
-- DispensaryID | (via dispensary_id FK) | Store reference
|
||||
--
|
||||
-- BRAND:
|
||||
-- brand.id / brandId | brand_external_id | Dutchie brand ID
|
||||
-- brand.name / brandName | brand_name | Brand name
|
||||
-- brand.description | (stored in raw_data) | Brand description
|
||||
-- brand.imageUrl / brandLogo | brand_logo_url | Brand logo image
|
||||
--
|
||||
-- CATEGORY/TYPE:
|
||||
-- type | category | e.g., "Edible", "Flower"
|
||||
-- subcategory | subcategory | e.g., "gummies", "pre-rolls"
|
||||
-- strainType | strain_type | Indica/Sativa/Hybrid/N/A
|
||||
-- POSMetaData.canonicalCategory | canonical_category | e.g., "Edibles | Gummies and Candy"
|
||||
--
|
||||
-- PRICING (arrays - first element is primary):
|
||||
-- Prices[0] | price | Current price
|
||||
-- recPrices[0] | rec_price | Recreational price
|
||||
-- medicalPrices[0] | med_price | Medical price
|
||||
-- recSpecialPrices[0] | rec_special_price | Sale price (rec)
|
||||
-- medicalSpecialPrices[0] | med_special_price | Sale price (med)
|
||||
--
|
||||
-- SPECIALS:
|
||||
-- special | is_on_special | Boolean flag
|
||||
-- specialData | special_data | JSONB with full special info
|
||||
-- specialData.saleSpecials[0].specialName | special_name | Name of active special
|
||||
-- specialData.saleSpecials[0].discount | discount_percent | Discount percentage
|
||||
--
|
||||
-- INVENTORY (from POSMetaData.children):
|
||||
-- children[0].quantity | inventory_quantity | Total inventory
|
||||
-- children[0].quantityAvailable | inventory_available | Available online
|
||||
-- Status | status | "Active" or "Inactive"
|
||||
-- isBelowThreshold | is_below_threshold | Low stock flag
|
||||
--
|
||||
-- POTENCY:
|
||||
-- THCContent.range[0] | thc_percent | THC percentage
|
||||
-- CBDContent.range[0] | cbd_percent | CBD percentage
|
||||
-- THCContent.unit | (always PERCENTAGE) | Usually "PERCENTAGE"
|
||||
-- cannabinoidsV2 | cannabinoids | JSONB array of all cannabinoids
|
||||
--
|
||||
-- PACKAGING/WEIGHT:
|
||||
-- Options | options | TEXT[] of size options
|
||||
-- rawOptions | raw_options | TEXT[] original options
|
||||
-- weight | weight_mg | Weight in mg (numeric)
|
||||
-- measurements.netWeight.values[0] | net_weight_value | Net weight value
|
||||
-- measurements.netWeight.unit | net_weight_unit | e.g., "MILLIGRAMS"
|
||||
-- POSMetaData.canonicalSKU | sku | Product SKU
|
||||
--
|
||||
-- IMAGES:
|
||||
-- Image | image_url | Primary image URL
|
||||
-- images | additional_images | TEXT[] of additional images
|
||||
--
|
||||
-- FLAGS:
|
||||
-- featured | is_featured | Featured product
|
||||
-- medicalOnly | medical_only | Medical only flag
|
||||
-- recOnly | rec_only | Recreational only flag
|
||||
--
|
||||
-- TIMESTAMPS:
|
||||
-- createdAt | source_created_at | When created in Dutchie
|
||||
-- updatedAt | source_updated_at | Last update in Dutchie
|
||||
--
|
||||
-- RAW DATA:
|
||||
-- (full response) | raw_data | Complete GraphQL response
|
||||
|
||||
-- =====================================================
|
||||
-- ADD external_id TO products FIRST (needed for upsert)
|
||||
-- =====================================================
|
||||
ALTER TABLE products ADD COLUMN IF NOT EXISTS external_id VARCHAR(255);
|
||||
CREATE INDEX IF NOT EXISTS idx_products_external_id ON products(external_id) WHERE external_id IS NOT NULL;
|
||||
|
||||
-- =====================================================
|
||||
-- ADD NEW COLUMNS TO product_snapshots (if table exists)
|
||||
-- =====================================================
|
||||
|
||||
-- External IDs
|
||||
ALTER TABLE product_snapshots ADD COLUMN IF NOT EXISTS enterprise_product_id VARCHAR(255);
|
||||
ALTER TABLE product_snapshots ADD COLUMN IF NOT EXISTS sku VARCHAR(100);
|
||||
|
||||
-- Brand details
|
||||
ALTER TABLE product_snapshots ADD COLUMN IF NOT EXISTS brand_external_id VARCHAR(255);
|
||||
ALTER TABLE product_snapshots ADD COLUMN IF NOT EXISTS brand_logo_url TEXT;
|
||||
|
||||
-- Canonical category from POS
|
||||
ALTER TABLE product_snapshots ADD COLUMN IF NOT EXISTS canonical_category VARCHAR(255);
|
||||
|
||||
-- Pricing - multi-tier
|
||||
ALTER TABLE product_snapshots ADD COLUMN IF NOT EXISTS rec_price NUMERIC(10,2);
|
||||
ALTER TABLE product_snapshots ADD COLUMN IF NOT EXISTS med_price NUMERIC(10,2);
|
||||
ALTER TABLE product_snapshots ADD COLUMN IF NOT EXISTS rec_special_price NUMERIC(10,2);
|
||||
ALTER TABLE product_snapshots ADD COLUMN IF NOT EXISTS med_special_price NUMERIC(10,2);
|
||||
|
||||
-- Special details
|
||||
ALTER TABLE product_snapshots ADD COLUMN IF NOT EXISTS is_on_special BOOLEAN DEFAULT false;
|
||||
ALTER TABLE product_snapshots ADD COLUMN IF NOT EXISTS special_name TEXT;
|
||||
ALTER TABLE product_snapshots ADD COLUMN IF NOT EXISTS discount_percent NUMERIC(5,2);
|
||||
ALTER TABLE product_snapshots ADD COLUMN IF NOT EXISTS special_data JSONB;
|
||||
|
||||
-- Inventory from POSMetaData
|
||||
ALTER TABLE product_snapshots ADD COLUMN IF NOT EXISTS inventory_quantity INTEGER;
|
||||
ALTER TABLE product_snapshots ADD COLUMN IF NOT EXISTS inventory_available INTEGER;
|
||||
ALTER TABLE product_snapshots ADD COLUMN IF NOT EXISTS is_below_threshold BOOLEAN DEFAULT false;
|
||||
ALTER TABLE product_snapshots ADD COLUMN IF NOT EXISTS status VARCHAR(20);
|
||||
|
||||
-- Cannabinoids (full data, not just THC/CBD)
|
||||
ALTER TABLE product_snapshots ADD COLUMN IF NOT EXISTS cannabinoids JSONB;
|
||||
|
||||
-- Weight and packaging
|
||||
ALTER TABLE product_snapshots ADD COLUMN IF NOT EXISTS weight_mg INTEGER;
|
||||
ALTER TABLE product_snapshots ADD COLUMN IF NOT EXISTS net_weight_value NUMERIC(10,2);
|
||||
ALTER TABLE product_snapshots ADD COLUMN IF NOT EXISTS net_weight_unit VARCHAR(20);
|
||||
ALTER TABLE product_snapshots ADD COLUMN IF NOT EXISTS options TEXT[];
|
||||
ALTER TABLE product_snapshots ADD COLUMN IF NOT EXISTS raw_options TEXT[];
|
||||
|
||||
-- Additional images
|
||||
ALTER TABLE product_snapshots ADD COLUMN IF NOT EXISTS additional_images TEXT[];
|
||||
|
||||
-- Flags
|
||||
ALTER TABLE product_snapshots ADD COLUMN IF NOT EXISTS is_featured BOOLEAN DEFAULT false;
|
||||
ALTER TABLE product_snapshots ADD COLUMN IF NOT EXISTS medical_only BOOLEAN DEFAULT false;
|
||||
ALTER TABLE product_snapshots ADD COLUMN IF NOT EXISTS rec_only BOOLEAN DEFAULT false;
|
||||
|
||||
-- Source timestamps
|
||||
ALTER TABLE product_snapshots ADD COLUMN IF NOT EXISTS source_created_at TIMESTAMPTZ;
|
||||
ALTER TABLE product_snapshots ADD COLUMN IF NOT EXISTS source_updated_at TIMESTAMPTZ;
|
||||
|
||||
-- Rename existing column if needed (brand -> brand_name for clarity)
|
||||
DO $$
|
||||
BEGIN
|
||||
IF EXISTS (SELECT 1 FROM information_schema.columns
|
||||
WHERE table_name = 'product_snapshots' AND column_name = 'brand') THEN
|
||||
ALTER TABLE product_snapshots RENAME COLUMN brand TO brand_name;
|
||||
END IF;
|
||||
EXCEPTION WHEN OTHERS THEN
|
||||
-- Column already named brand_name or doesn't exist
|
||||
NULL;
|
||||
END $$;
|
||||
|
||||
-- =====================================================
|
||||
-- UPDATE products TABLE (canonical/current state)
|
||||
-- =====================================================
|
||||
|
||||
-- External IDs
|
||||
ALTER TABLE products ADD COLUMN IF NOT EXISTS enterprise_product_id VARCHAR(255);
|
||||
ALTER TABLE products ADD COLUMN IF NOT EXISTS sku VARCHAR(100);
|
||||
|
||||
-- Brand details
|
||||
ALTER TABLE products ADD COLUMN IF NOT EXISTS brand_external_id VARCHAR(255);
|
||||
ALTER TABLE products ADD COLUMN IF NOT EXISTS brand_logo_url TEXT;
|
||||
|
||||
-- Category details
|
||||
ALTER TABLE products ADD COLUMN IF NOT EXISTS subcategory VARCHAR(100);
|
||||
ALTER TABLE products ADD COLUMN IF NOT EXISTS canonical_category VARCHAR(255);
|
||||
|
||||
-- Pricing - multi-tier
|
||||
ALTER TABLE products ADD COLUMN IF NOT EXISTS rec_price NUMERIC(10,2);
|
||||
ALTER TABLE products ADD COLUMN IF NOT EXISTS med_price NUMERIC(10,2);
|
||||
ALTER TABLE products ADD COLUMN IF NOT EXISTS rec_special_price NUMERIC(10,2);
|
||||
ALTER TABLE products ADD COLUMN IF NOT EXISTS med_special_price NUMERIC(10,2);
|
||||
|
||||
-- Special details
|
||||
ALTER TABLE products ADD COLUMN IF NOT EXISTS is_on_special BOOLEAN DEFAULT false;
|
||||
ALTER TABLE products ADD COLUMN IF NOT EXISTS special_name TEXT;
|
||||
ALTER TABLE products ADD COLUMN IF NOT EXISTS discount_percent NUMERIC(5,2);
|
||||
ALTER TABLE products ADD COLUMN IF NOT EXISTS special_data JSONB;
|
||||
|
||||
-- Inventory
|
||||
ALTER TABLE products ADD COLUMN IF NOT EXISTS inventory_quantity INTEGER;
|
||||
ALTER TABLE products ADD COLUMN IF NOT EXISTS inventory_available INTEGER;
|
||||
ALTER TABLE products ADD COLUMN IF NOT EXISTS is_below_threshold BOOLEAN DEFAULT false;
|
||||
ALTER TABLE products ADD COLUMN IF NOT EXISTS status VARCHAR(20) DEFAULT 'Active';
|
||||
|
||||
-- Cannabinoids (full data)
|
||||
ALTER TABLE products ADD COLUMN IF NOT EXISTS cannabinoids JSONB;
|
||||
|
||||
-- Weight and packaging
|
||||
ALTER TABLE products ADD COLUMN IF NOT EXISTS weight_mg INTEGER;
|
||||
ALTER TABLE products ADD COLUMN IF NOT EXISTS net_weight_value NUMERIC(10,2);
|
||||
ALTER TABLE products ADD COLUMN IF NOT EXISTS net_weight_unit VARCHAR(20);
|
||||
ALTER TABLE products ADD COLUMN IF NOT EXISTS options TEXT[];
|
||||
ALTER TABLE products ADD COLUMN IF NOT EXISTS raw_options TEXT[];
|
||||
|
||||
-- Additional images
|
||||
ALTER TABLE products ADD COLUMN IF NOT EXISTS additional_images TEXT[];
|
||||
|
||||
-- Flags
|
||||
ALTER TABLE products ADD COLUMN IF NOT EXISTS is_featured BOOLEAN DEFAULT false;
|
||||
ALTER TABLE products ADD COLUMN IF NOT EXISTS medical_only BOOLEAN DEFAULT false;
|
||||
ALTER TABLE products ADD COLUMN IF NOT EXISTS rec_only BOOLEAN DEFAULT false;
|
||||
|
||||
-- Source timestamps
|
||||
ALTER TABLE products ADD COLUMN IF NOT EXISTS source_created_at TIMESTAMPTZ;
|
||||
ALTER TABLE products ADD COLUMN IF NOT EXISTS source_updated_at TIMESTAMPTZ;
|
||||
|
||||
-- Raw GraphQL data for debugging/reference
|
||||
ALTER TABLE products ADD COLUMN IF NOT EXISTS raw_data JSONB;
|
||||
|
||||
-- =====================================================
|
||||
-- INDEXES for new columns
|
||||
-- =====================================================
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_products_brand_external ON products(brand_external_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_products_enterprise ON products(enterprise_product_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_products_sku ON products(sku);
|
||||
CREATE INDEX IF NOT EXISTS idx_products_is_special ON products(is_on_special);
|
||||
CREATE INDEX IF NOT EXISTS idx_products_subcategory ON products(subcategory);
|
||||
CREATE INDEX IF NOT EXISTS idx_products_status ON products(status);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_snapshots_brand_external ON product_snapshots(brand_external_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_snapshots_enterprise ON product_snapshots(enterprise_product_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_snapshots_is_special ON product_snapshots(is_on_special);
|
||||
CREATE INDEX IF NOT EXISTS idx_snapshots_status ON product_snapshots(status);
|
||||
|
||||
-- =====================================================
|
||||
-- UPDATED VIEWS
|
||||
-- =====================================================
|
||||
|
||||
-- View: Current specials (products on sale)
|
||||
CREATE OR REPLACE VIEW current_specials AS
|
||||
SELECT
|
||||
p.id,
|
||||
p.store_id,
|
||||
p.name,
|
||||
p.brand,
|
||||
p.subcategory,
|
||||
p.strain_type,
|
||||
p.rec_price,
|
||||
p.rec_special_price,
|
||||
p.discount_percent,
|
||||
p.special_name,
|
||||
p.image_url,
|
||||
p.slug,
|
||||
p.thc_percentage,
|
||||
p.cbd_percentage,
|
||||
s.name AS store_name,
|
||||
s.slug AS store_slug
|
||||
FROM products p
|
||||
JOIN stores s ON s.id = p.store_id
|
||||
WHERE p.is_on_special = true
|
||||
AND p.status = 'Active'
|
||||
AND p.rec_special_price IS NOT NULL;
|
||||
|
||||
-- View: Current brands (derived from products)
|
||||
CREATE OR REPLACE VIEW derived_brands AS
|
||||
SELECT
|
||||
p.store_id,
|
||||
p.brand AS brand_name,
|
||||
p.brand_external_id,
|
||||
MAX(p.brand_logo_url) AS brand_logo_url,
|
||||
COUNT(*) AS product_count,
|
||||
COUNT(*) FILTER (WHERE p.status = 'Active') AS active_count,
|
||||
COUNT(*) FILTER (WHERE p.is_on_special) AS special_count,
|
||||
MIN(p.rec_price) AS min_price,
|
||||
MAX(p.rec_price) AS max_price,
|
||||
AVG(p.rec_price) AS avg_price,
|
||||
ARRAY_AGG(DISTINCT p.subcategory) FILTER (WHERE p.subcategory IS NOT NULL) AS categories,
|
||||
MAX(p.updated_at) AS last_updated
|
||||
FROM products p
|
||||
WHERE p.brand IS NOT NULL
|
||||
GROUP BY p.store_id, p.brand, p.brand_external_id;
|
||||
|
||||
-- View: Current categories (derived from products)
|
||||
CREATE OR REPLACE VIEW derived_categories AS
|
||||
SELECT
|
||||
p.store_id,
|
||||
p.subcategory AS category_name,
|
||||
COUNT(*) AS product_count,
|
||||
COUNT(*) FILTER (WHERE p.status = 'Active') AS active_count,
|
||||
COUNT(*) FILTER (WHERE p.is_on_special) AS special_count,
|
||||
MIN(p.rec_price) AS min_price,
|
||||
MAX(p.rec_price) AS max_price,
|
||||
ARRAY_AGG(DISTINCT p.brand) FILTER (WHERE p.brand IS NOT NULL) AS brands,
|
||||
MAX(p.updated_at) AS last_updated
|
||||
FROM products p
|
||||
WHERE p.subcategory IS NOT NULL
|
||||
GROUP BY p.store_id, p.subcategory;
|
||||
|
||||
-- =====================================================
|
||||
-- COMMENTS
|
||||
-- =====================================================
|
||||
|
||||
COMMENT ON COLUMN products.external_id IS 'Dutchie _id / id field - the product unique identifier in Dutchie';
|
||||
COMMENT ON COLUMN products.enterprise_product_id IS 'Dutchie enterpriseProductId - shared across dispensaries';
|
||||
COMMENT ON COLUMN products.brand_external_id IS 'Dutchie brand.id / brandId field';
|
||||
COMMENT ON COLUMN products.canonical_category IS 'Dutchie POSMetaData.canonicalCategory - pipe-separated category path';
|
||||
COMMENT ON COLUMN products.rec_special_price IS 'Dutchie recSpecialPrices[0] - discounted recreational price';
|
||||
COMMENT ON COLUMN products.special_data IS 'Full Dutchie specialData JSONB including all active specials';
|
||||
COMMENT ON COLUMN products.cannabinoids IS 'Full Dutchie cannabinoidsV2 array as JSONB';
|
||||
COMMENT ON COLUMN products.raw_data IS 'Complete Dutchie GraphQL response for this product';
|
||||
|
||||
-- Grant permissions
|
||||
GRANT SELECT ON current_specials TO scraper;
|
||||
GRANT SELECT ON derived_brands TO scraper;
|
||||
GRANT SELECT ON derived_categories TO scraper;
|
||||
-- ============================================================
|
||||
-- Brand history view (track brand count per dispensary over time)
|
||||
-- ============================================================
|
||||
DROP VIEW IF EXISTS public.v_brand_history CASCADE;
|
||||
CREATE OR REPLACE VIEW public.v_brand_history AS
|
||||
SELECT
|
||||
d.id AS dispensary_id,
|
||||
COALESCE(d.dba_name, d.name) AS dispensary_name,
|
||||
d.city,
|
||||
d.state,
|
||||
COUNT(DISTINCT p.brand_name) AS brand_count,
|
||||
MIN(p.created_at) AS first_seen_at,
|
||||
MAX(p.updated_at) AS last_seen_at
|
||||
FROM public.dutchie_products p
|
||||
JOIN public.dispensaries d ON d.id = p.dispensary_id
|
||||
WHERE p.brand_name IS NOT NULL
|
||||
GROUP BY d.id, d.dba_name, d.name, d.city, d.state;
|
||||
Reference in New Issue
Block a user