# Store Menu API Specification ## Overview This document specifies the **Store-Facing API** that powers the WordPress "Dutchie Plus replacement" plugin. This API is completely separate from the Brand Intelligence API. ### Architecture Separation ``` ┌─────────────────────────────────────────────────────────────────┐ │ CRAWLER + CANONICAL TABLES │ │ (Single Source of Truth) │ │ │ │ ┌──────────────┐ ┌──────────────┐ ┌──────────────────────┐ │ │ │ products │ │ stores │ │ store_products │ │ │ │ table │ │ table │ │ (current state) │ │ │ └──────────────┘ └──────────────┘ └──────────────────────┘ │ └─────────────────────────────┬───────────────────────────────────┘ │ ┌─────────────────────┴─────────────────────┐ ▼ ▼ ┌───────────────────────────────┐ ┌───────────────────────────────┐ │ BRAND INTELLIGENCE API │ │ STORE MENU API │ │ (Cannabrands App) │ │ (WordPress Plugin) │ ├───────────────────────────────┤ ├───────────────────────────────┤ │ Path: /api/brands/:key/... │ │ Path: /api/stores/:key/... │ │ Auth: JWT + brand_key claim │ │ Auth: X-Store-API-Key header │ │ Scope: Cross-store analytics │ │ Scope: Single store only │ │ │ │ │ │ READS FROM: │ │ READS FROM: │ │ • brand_daily_metrics │ │ • products │ │ • brand_promo_daily_metrics │ │ • categories │ │ • brand_store_events │ │ • stores │ │ • brand_store_presence │ │ • (NO analytics tables) │ │ • store_product_snapshots │ │ │ └───────────────────────────────┘ └───────────────────────────────┘ ``` ### Key Principles 1. **Read-only** - No writes to the database 2. **Store-scoped** - Each request is scoped to a single store 3. **Current state only** - No historical analytics 4. **Cache-friendly** - Deterministic responses for given inputs 5. **WordPress-optimized** - Designed for transient caching --- ## 1. Authentication ### 1.1 Store API Keys Each store gets one or more API keys stored in a dedicated table: ```sql CREATE TABLE store_api_keys ( id SERIAL PRIMARY KEY, store_id INTEGER NOT NULL REFERENCES stores(id) ON DELETE CASCADE, api_key VARCHAR(64) NOT NULL, name VARCHAR(100) NOT NULL DEFAULT 'WordPress Plugin', is_active BOOLEAN NOT NULL DEFAULT TRUE, rate_limit_hour INTEGER NOT NULL DEFAULT 1000, expires_at TIMESTAMPTZ, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), last_used_at TIMESTAMPTZ, CONSTRAINT uq_store_api_keys_key UNIQUE (api_key) ); CREATE INDEX idx_store_api_keys_key ON store_api_keys(api_key) WHERE is_active = TRUE; CREATE INDEX idx_store_api_keys_store ON store_api_keys(store_id); ``` ### 1.2 Request Authentication **Header:** `X-Store-API-Key: sk_live_abc123def456...` **Resolution Flow:** ``` Request: GET /api/stores/deeply-rooted/menu Header: X-Store-API-Key: sk_live_abc123def456 ↓ ┌───────────────────────────────────────────────────┐ │ 1. Lookup API key in store_api_keys │ │ WHERE api_key = 'sk_live_abc123def456' │ │ AND is_active = TRUE │ │ AND (expires_at IS NULL OR expires_at > NOW())│ └───────────────────────────────────────────────────┘ ↓ ┌───────────────────────────────────────────────────┐ │ 2. Verify store_key matches the API key's store │ │ API key's store_id → stores.slug must match │ │ the :store_key in the URL path │ └───────────────────────────────────────────────────┘ ↓ ┌───────────────────────────────────────────────────┐ │ 3. Rate limit check (1000 req/hour default) │ └───────────────────────────────────────────────────┘ ↓ Request proceeds ``` ### 1.3 Store Key Resolution The `store_key` path parameter can be: - Store slug (e.g., `deeply-rooted-sacramento`) - Store ID (e.g., `1`) ```typescript async function resolveStoreKey(db: Pool, storeKey: string): Promise { // Try as numeric ID first if (/^\d+$/.test(storeKey)) { const byId = await db.query( `SELECT id FROM stores WHERE id = $1`, [parseInt(storeKey)] ); if (byId.rows.length > 0) return byId.rows[0].id; } // Try as slug const bySlug = await db.query( `SELECT id FROM stores WHERE slug = $1`, [storeKey] ); if (bySlug.rows.length > 0) return bySlug.rows[0].id; return null; } ``` ### 1.4 Security Guarantees - API key only grants access to its associated store - No cross-store data leakage possible - Rate limiting prevents abuse - Keys can be rotated/revoked without code changes --- ## 2. Canonical Data Types ### 2.1 ProductForStore Object This is the canonical product representation used across all store endpoints: ```typescript interface ProductForStore { // Identifiers product_id: number; // products.id (canonical product) store_product_id: number | null; // store_products.id (if using intelligence layer) slug: string; // URL-safe identifier // Basic info name: string; // Product name (without brand prefix) full_name: string; // Full name including brand brand_name: string | null; // Brand name brand_id: number | null; // canonical_brands.id (if mapped) // Classification category: CategoryInfo; subcategory: CategoryInfo | null; strain_type: 'indica' | 'sativa' | 'hybrid' | 'cbd' | null; // Sizing weight: string | null; // Display weight (e.g., "1g", "3.5g", "1oz") weight_grams: number | null; // Numeric grams for sorting // Cannabinoids thc_percent: number | null; thc_range: string | null; // "25-30%" if range cbd_percent: number | null; // Availability in_stock: boolean; // Pricing (all in cents for precision) price_cents: number; // Current selling price regular_price_cents: number | null; // Pre-discount price (if on special) // Specials is_on_special: boolean; special: SpecialInfo | null; // Media image_url: string | null; // Primary image URL images: ImageSet; // Description description: string | null; // Metadata terpenes: string[] | null; effects: string[] | null; flavors: string[] | null; // Links dutchie_url: string | null; // Timestamps last_updated_at: string; // ISO 8601 } interface CategoryInfo { id: number; name: string; slug: string; } interface SpecialInfo { type: 'percent_off' | 'dollar_off' | 'bogo' | 'bundle' | 'set_price' | 'other'; text: string; // Raw special text for display badge_text: string; // Short badge (e.g., "20% OFF", "BOGO") value: number | null; // Discount value (percent or dollars) savings_cents: number | null; // Calculated savings in cents } interface ImageSet { thumbnail: string | null; medium: string | null; full: string | null; } ``` ### 2.2 Example ProductForStore JSON ```json { "product_id": 12345, "store_product_id": 67890, "slug": "thunder-bud-1g-pre-roll", "name": "1g Pre-Roll", "full_name": "Thunder Bud 1g Pre-Roll", "brand_name": "Thunder Bud", "brand_id": 22, "category": { "id": 3, "name": "Pre-Rolls", "slug": "pre-rolls" }, "subcategory": { "id": 15, "name": "Singles", "slug": "singles" }, "strain_type": "hybrid", "weight": "1g", "weight_grams": 1.0, "thc_percent": 27.5, "thc_range": null, "cbd_percent": 0.1, "in_stock": true, "price_cents": 1200, "regular_price_cents": 1500, "is_on_special": true, "special": { "type": "percent_off", "text": "20% off all Thunder Bud pre-rolls", "badge_text": "20% OFF", "value": 20, "savings_cents": 300 }, "image_url": "https://images.dutchie.com/abc123/medium.jpg", "images": { "thumbnail": "https://images.dutchie.com/abc123/thumb.jpg", "medium": "https://images.dutchie.com/abc123/medium.jpg", "full": "https://images.dutchie.com/abc123/full.jpg" }, "description": "Premium pre-roll featuring Alien Marker strain. Hand-rolled with care.", "terpenes": ["Limonene", "Myrcene"], "effects": ["Relaxed", "Happy"], "flavors": ["Citrus", "Earthy"], "dutchie_url": "https://dutchie.com/dispensary/deeply-rooted/product/thunder-bud-1g-pre-roll", "last_updated_at": "2025-02-28T15:03:00Z" } ``` ### 2.3 CategoryForStore Object ```typescript interface CategoryForStore { id: number; name: string; slug: string; icon: string | null; // Icon identifier product_count: number; // Total products in category in_stock_count: number; // Products currently in stock on_special_count: number; // Products on special children: CategoryForStore[]; // Subcategories } ``` --- ## 3. API Endpoints ### 3.1 GET /api/stores/:store_key/menu Returns the full product menu for a store. **Path Parameters:** | Param | Type | Description | |-------|------|-------------| | `store_key` | string | Store slug or ID | **Query Parameters:** | Param | Type | Required | Default | Description | |-------|------|----------|---------|-------------| | `category` | string | No | - | Filter by category slug (e.g., `flower`, `pre-rolls`) | | `brand` | string | No | - | Filter by brand name (partial match) | | `search` | string | No | - | Free text search on product name | | `in_stock` | boolean | No | `true` | Filter by stock status | | `sort` | string | No | `name_asc` | Sort order (see below) | | `page` | number | No | `1` | Page number (1-indexed) | | `per_page` | number | No | `50` | Products per page (max 200) | **Sort Options:** - `name_asc` - Name A-Z (default) - `name_desc` - Name Z-A - `price_asc` - Price low to high - `price_desc` - Price high to low - `thc_desc` - THC% high to low - `newest` - Most recently updated first **Response:** ```json { "store": { "id": 1, "name": "Deeply Rooted", "slug": "deeply-rooted-sacramento", "city": "Sacramento", "state": "CA", "logo_url": null, "dutchie_url": "https://dutchie.com/dispensary/deeply-rooted" }, "meta": { "total_products": 342, "total_pages": 7, "current_page": 1, "per_page": 50, "filters_applied": { "category": null, "brand": null, "search": null, "in_stock": true }, "sort": "name_asc", "generated_at": "2025-02-28T15:00:00Z", "cache_ttl_seconds": 300 }, "products": [ { "product_id": 12345, "store_product_id": 67890, "slug": "thunder-bud-1g-pre-roll", "name": "1g Pre-Roll", "full_name": "Thunder Bud 1g Pre-Roll", "brand_name": "Thunder Bud", "brand_id": 22, "category": { "id": 3, "name": "Pre-Rolls", "slug": "pre-rolls" }, "subcategory": null, "strain_type": "hybrid", "weight": "1g", "weight_grams": 1.0, "thc_percent": 27.5, "thc_range": null, "cbd_percent": 0.1, "in_stock": true, "price_cents": 1200, "regular_price_cents": 1500, "is_on_special": true, "special": { "type": "percent_off", "text": "20% off all Thunder Bud pre-rolls", "badge_text": "20% OFF", "value": 20, "savings_cents": 300 }, "image_url": "https://images.dutchie.com/abc123/medium.jpg", "images": { "thumbnail": "https://images.dutchie.com/abc123/thumb.jpg", "medium": "https://images.dutchie.com/abc123/medium.jpg", "full": "https://images.dutchie.com/abc123/full.jpg" }, "description": "Premium pre-roll featuring Alien Marker strain.", "terpenes": ["Limonene", "Myrcene"], "effects": ["Relaxed", "Happy"], "flavors": ["Citrus", "Earthy"], "dutchie_url": "https://dutchie.com/dispensary/deeply-rooted/product/thunder-bud-1g-pre-roll", "last_updated_at": "2025-02-28T15:03:00Z" } // ... more products ] } ``` **SQL Query:** ```sql -- Main menu query SELECT p.id AS product_id, p.slug, p.name, COALESCE(p.brand || ' ' || p.name, p.name) AS full_name, p.brand AS brand_name, cb.id AS brand_id, p.category_id, c.name AS category_name, c.slug AS category_slug, pc.id AS parent_category_id, pc.name AS parent_category_name, pc.slug AS parent_category_slug, p.strain_type, p.weight, -- Parse weight to grams for sorting CASE WHEN p.weight ~ '^\d+(\.\d+)?g$' THEN CAST(REGEXP_REPLACE(p.weight, 'g$', '') AS NUMERIC) WHEN p.weight ~ '^\d+(\.\d+)?oz$' THEN CAST(REGEXP_REPLACE(p.weight, 'oz$', '') AS NUMERIC) * 28.35 ELSE NULL END AS weight_grams, p.thc_percentage AS thc_percent, p.cbd_percentage AS cbd_percent, p.in_stock, -- Current price in cents ROUND(COALESCE(p.price, 0) * 100)::INTEGER AS price_cents, -- Regular price (if on special) CASE WHEN p.original_price IS NOT NULL AND p.original_price > p.price THEN ROUND(p.original_price * 100)::INTEGER ELSE NULL END AS regular_price_cents, -- Special detection (p.special_text IS NOT NULL AND p.special_text != '') AS is_on_special, p.special_text, -- Images p.image_url_full, p.image_url, p.thumbnail_path, p.medium_path, -- Description & metadata p.description, p.metadata, p.dutchie_url, p.last_seen_at AS last_updated_at FROM products p LEFT JOIN categories c ON c.id = p.category_id LEFT JOIN categories pc ON pc.id = c.parent_id LEFT JOIN canonical_brands cb ON LOWER(cb.name) = LOWER(p.brand) WHERE p.store_id = $1 AND ($2::TEXT IS NULL OR c.slug = $2 OR pc.slug = $2) -- category filter AND ($3::TEXT IS NULL OR p.brand ILIKE '%' || $3 || '%') -- brand filter AND ($4::TEXT IS NULL OR p.name ILIKE '%' || $4 || '%') -- search filter AND ($5::BOOLEAN IS NULL OR p.in_stock = $5) -- in_stock filter ORDER BY CASE WHEN $6 = 'name_asc' THEN p.name END ASC, CASE WHEN $6 = 'name_desc' THEN p.name END DESC, CASE WHEN $6 = 'price_asc' THEN p.price END ASC, CASE WHEN $6 = 'price_desc' THEN p.price END DESC, CASE WHEN $6 = 'thc_desc' THEN p.thc_percentage END DESC NULLS LAST, CASE WHEN $6 = 'newest' THEN p.last_seen_at END DESC, p.name ASC -- Secondary sort for stability LIMIT $7 OFFSET $8; -- Count query for pagination SELECT COUNT(*) AS total FROM products p LEFT JOIN categories c ON c.id = p.category_id LEFT JOIN categories pc ON pc.id = c.parent_id WHERE p.store_id = $1 AND ($2::TEXT IS NULL OR c.slug = $2 OR pc.slug = $2) AND ($3::TEXT IS NULL OR p.brand ILIKE '%' || $3 || '%') AND ($4::TEXT IS NULL OR p.name ILIKE '%' || $4 || '%') AND ($5::BOOLEAN IS NULL OR p.in_stock = $5); ``` --- ### 3.2 GET /api/stores/:store_key/specials Returns only products currently on special. **Query Parameters:** | Param | Type | Required | Default | Description | |-------|------|----------|---------|-------------| | `category` | string | No | - | Filter by category slug | | `brand` | string | No | - | Filter by brand name | | `special_type` | string | No | - | Filter: `percent_off`, `dollar_off`, `bogo`, `bundle` | | `sort` | string | No | `savings_desc` | Sort order | | `limit` | number | No | `50` | Max products (max 100) | **Sort Options:** - `savings_desc` - Highest savings first (default) - `savings_percent_desc` - Highest % discount first - `price_asc` - Price low to high - `name_asc` - Name A-Z **Response:** ```json { "store": { "id": 1, "name": "Deeply Rooted", "slug": "deeply-rooted-sacramento" }, "meta": { "total_specials": 34, "filters_applied": { "category": null, "brand": null, "special_type": null }, "limit": 50, "generated_at": "2025-02-28T15:00:00Z", "cache_ttl_seconds": 300 }, "specials": [ { "product_id": 12345, "store_product_id": 67890, "slug": "thunder-bud-1g-pre-roll", "name": "1g Pre-Roll", "full_name": "Thunder Bud 1g Pre-Roll", "brand_name": "Thunder Bud", "brand_id": 22, "category": { "id": 3, "name": "Pre-Rolls", "slug": "pre-rolls" }, "subcategory": null, "strain_type": "hybrid", "weight": "1g", "weight_grams": 1.0, "thc_percent": 27.5, "thc_range": null, "cbd_percent": 0.1, "in_stock": true, "price_cents": 1200, "regular_price_cents": 1500, "is_on_special": true, "special": { "type": "percent_off", "text": "20% off all Thunder Bud pre-rolls", "badge_text": "20% OFF", "value": 20, "savings_cents": 300 }, "image_url": "https://images.dutchie.com/abc123/medium.jpg", "images": { "thumbnail": "https://images.dutchie.com/abc123/thumb.jpg", "medium": "https://images.dutchie.com/abc123/medium.jpg", "full": "https://images.dutchie.com/abc123/full.jpg" }, "description": "Premium pre-roll featuring Alien Marker strain.", "terpenes": null, "effects": null, "flavors": null, "dutchie_url": "https://dutchie.com/dispensary/deeply-rooted/product/thunder-bud-1g-pre-roll", "last_updated_at": "2025-02-28T15:03:00Z" } // ... more specials ], "summary": { "by_type": { "percent_off": 18, "dollar_off": 5, "bogo": 8, "bundle": 2, "other": 1 }, "by_category": [ { "slug": "flower", "name": "Flower", "count": 12 }, { "slug": "concentrates", "name": "Concentrates", "count": 10 }, { "slug": "pre-rolls", "name": "Pre-Rolls", "count": 8 } ], "avg_savings_percent": 22.5, "total_savings_available_cents": 15600 } } ``` **SQL Query:** ```sql SELECT p.id AS product_id, p.slug, p.name, COALESCE(p.brand || ' ' || p.name, p.name) AS full_name, p.brand AS brand_name, cb.id AS brand_id, c.id AS category_id, c.name AS category_name, c.slug AS category_slug, p.strain_type, p.weight, p.thc_percentage AS thc_percent, p.cbd_percentage AS cbd_percent, p.in_stock, ROUND(COALESCE(p.price, 0) * 100)::INTEGER AS price_cents, ROUND(COALESCE(p.original_price, p.price) * 100)::INTEGER AS regular_price_cents, TRUE AS is_on_special, p.special_text, p.image_url_full, p.image_url, p.thumbnail_path, p.medium_path, p.description, p.metadata, p.dutchie_url, p.last_seen_at AS last_updated_at, -- Calculated savings ROUND((COALESCE(p.original_price, p.price) - p.price) * 100)::INTEGER AS savings_cents, CASE WHEN p.original_price > 0 THEN ROUND(((p.original_price - p.price) / p.original_price) * 100, 1) ELSE 0 END AS savings_percent FROM products p LEFT JOIN categories c ON c.id = p.category_id LEFT JOIN canonical_brands cb ON LOWER(cb.name) = LOWER(p.brand) WHERE p.store_id = $1 AND p.special_text IS NOT NULL AND p.special_text != '' AND p.in_stock = TRUE -- Only show in-stock specials AND ($2::TEXT IS NULL OR c.slug = $2) AND ($3::TEXT IS NULL OR p.brand ILIKE '%' || $3 || '%') ORDER BY CASE WHEN $4 = 'savings_desc' THEN (COALESCE(p.original_price, p.price) - p.price) END DESC, CASE WHEN $4 = 'savings_percent_desc' THEN CASE WHEN p.original_price > 0 THEN ((p.original_price - p.price) / p.original_price) ELSE 0 END END DESC, CASE WHEN $4 = 'price_asc' THEN p.price END ASC, CASE WHEN $4 = 'name_asc' THEN p.name END ASC, p.name ASC LIMIT $5; ``` --- ### 3.3 GET /api/stores/:store_key/products General paginated product listing with full filter support. Used for search results and custom layouts. **Query Parameters:** | Param | Type | Required | Default | Description | |-------|------|----------|---------|-------------| | `category` | string | No | - | Filter by category slug | | `brand` | string | No | - | Filter by brand name | | `search` | string | No | - | Free text search | | `in_stock` | boolean | No | - | Filter by stock status (null = all) | | `specials_only` | boolean | No | `false` | Only show products on special | | `strain_type` | string | No | - | Filter: `indica`, `sativa`, `hybrid`, `cbd` | | `min_thc` | number | No | - | Minimum THC% | | `max_price` | number | No | - | Maximum price in dollars | | `sort` | string | No | `name_asc` | Sort order | | `page` | number | No | `1` | Page number | | `per_page` | number | No | `24` | Products per page (max 100) | **Response:** ```json { "store": { "id": 1, "name": "Deeply Rooted", "slug": "deeply-rooted-sacramento" }, "meta": { "total_products": 45, "total_pages": 2, "current_page": 1, "per_page": 24, "filters_applied": { "category": "flower", "brand": null, "search": null, "in_stock": true, "specials_only": false, "strain_type": null, "min_thc": null, "max_price": null }, "sort": "thc_desc", "generated_at": "2025-02-28T15:00:00Z", "cache_ttl_seconds": 300 }, "products": [ // ... array of ProductForStore objects ], "facets": { "categories": [ { "slug": "indoor", "name": "Indoor", "count": 25 }, { "slug": "outdoor", "name": "Outdoor", "count": 15 }, { "slug": "greenhouse", "name": "Greenhouse", "count": 5 } ], "brands": [ { "name": "Raw Garden", "count": 12 }, { "name": "Connected", "count": 8 }, { "name": "Alien Labs", "count": 5 } ], "strain_types": [ { "type": "hybrid", "count": 20 }, { "type": "indica", "count": 15 }, { "type": "sativa", "count": 10 } ], "price_range": { "min_cents": 1500, "max_cents": 8500 }, "thc_range": { "min": 15.0, "max": 35.0 } } } ``` --- ### 3.4 GET /api/stores/:store_key/categories Returns the category tree with product counts. **Response:** ```json { "store": { "id": 1, "name": "Deeply Rooted", "slug": "deeply-rooted-sacramento" }, "meta": { "total_categories": 12, "generated_at": "2025-02-28T15:00:00Z", "cache_ttl_seconds": 600 }, "categories": [ { "id": 1, "name": "Flower", "slug": "flower", "icon": "flower", "product_count": 85, "in_stock_count": 72, "on_special_count": 8, "children": [ { "id": 10, "name": "Indoor", "slug": "indoor", "icon": null, "product_count": 45, "in_stock_count": 40, "on_special_count": 4, "children": [] }, { "id": 11, "name": "Outdoor", "slug": "outdoor", "icon": null, "product_count": 25, "in_stock_count": 20, "on_special_count": 2, "children": [] }, { "id": 12, "name": "Greenhouse", "slug": "greenhouse", "icon": null, "product_count": 15, "in_stock_count": 12, "on_special_count": 2, "children": [] } ] }, { "id": 2, "name": "Pre-Rolls", "slug": "pre-rolls", "icon": "joint", "product_count": 42, "in_stock_count": 38, "on_special_count": 5, "children": [ { "id": 20, "name": "Singles", "slug": "singles", "icon": null, "product_count": 28, "in_stock_count": 25, "on_special_count": 3, "children": [] }, { "id": 21, "name": "Packs", "slug": "packs", "icon": null, "product_count": 14, "in_stock_count": 13, "on_special_count": 2, "children": [] } ] }, { "id": 3, "name": "Vapes", "slug": "vapes", "icon": "vape", "product_count": 65, "in_stock_count": 58, "on_special_count": 7, "children": [] }, { "id": 4, "name": "Concentrates", "slug": "concentrates", "icon": "dab", "product_count": 48, "in_stock_count": 42, "on_special_count": 6, "children": [] }, { "id": 5, "name": "Edibles", "slug": "edibles", "icon": "cookie", "product_count": 56, "in_stock_count": 50, "on_special_count": 4, "children": [] }, { "id": 6, "name": "Tinctures", "slug": "tinctures", "icon": "dropper", "product_count": 18, "in_stock_count": 16, "on_special_count": 2, "children": [] }, { "id": 7, "name": "Topicals", "slug": "topicals", "icon": "lotion", "product_count": 12, "in_stock_count": 10, "on_special_count": 1, "children": [] }, { "id": 8, "name": "Accessories", "slug": "accessories", "icon": "gear", "product_count": 16, "in_stock_count": 14, "on_special_count": 1, "children": [] } ] } ``` **SQL Query:** ```sql -- Get categories with counts for a store WITH category_counts AS ( SELECT c.id, c.name, c.slug, c.parent_id, c.icon, COUNT(p.id) AS product_count, COUNT(p.id) FILTER (WHERE p.in_stock = TRUE) AS in_stock_count, COUNT(p.id) FILTER (WHERE p.special_text IS NOT NULL AND p.special_text != '') AS on_special_count FROM categories c LEFT JOIN products p ON p.category_id = c.id AND p.store_id = $1 WHERE c.store_id = $1 OR c.store_id IS NULL -- Global + store-specific categories GROUP BY c.id, c.name, c.slug, c.parent_id, c.icon HAVING COUNT(p.id) > 0 -- Only categories with products ) SELECT * FROM category_counts ORDER BY CASE WHEN parent_id IS NULL THEN 0 ELSE 1 END, -- Parents first product_count DESC, name ASC; ``` --- ### 3.5 GET /api/stores/:store_key/brands Returns all brands available at the store. **Response:** ```json { "store": { "id": 1, "name": "Deeply Rooted", "slug": "deeply-rooted-sacramento" }, "meta": { "total_brands": 48, "generated_at": "2025-02-28T15:00:00Z", "cache_ttl_seconds": 600 }, "brands": [ { "name": "Raw Garden", "slug": "raw-garden", "brand_id": 15, "product_count": 24, "in_stock_count": 21, "on_special_count": 3, "categories": ["Concentrates", "Vapes"], "price_range": { "min_cents": 2500, "max_cents": 6500 } }, { "name": "Stiiizy", "slug": "stiiizy", "brand_id": 8, "product_count": 18, "in_stock_count": 15, "on_special_count": 0, "categories": ["Vapes", "Flower", "Edibles"], "price_range": { "min_cents": 2000, "max_cents": 5500 } }, { "name": "Thunder Bud", "slug": "thunder-bud", "brand_id": 22, "product_count": 12, "in_stock_count": 10, "on_special_count": 4, "categories": ["Flower", "Pre-Rolls"], "price_range": { "min_cents": 1200, "max_cents": 4500 } } // ... more brands ] } ``` --- ### 3.6 GET /api/stores/:store_key/product/:product_slug Returns detailed information for a single product. **Response:** ```json { "store": { "id": 1, "name": "Deeply Rooted", "slug": "deeply-rooted-sacramento" }, "product": { "product_id": 12345, "store_product_id": 67890, "slug": "thunder-bud-1g-pre-roll", "name": "1g Pre-Roll", "full_name": "Thunder Bud 1g Pre-Roll", "brand_name": "Thunder Bud", "brand_id": 22, "category": { "id": 3, "name": "Pre-Rolls", "slug": "pre-rolls" }, "subcategory": { "id": 15, "name": "Singles", "slug": "singles" }, "strain_type": "hybrid", "strain_name": "Alien Marker", "weight": "1g", "weight_grams": 1.0, "thc_percent": 27.5, "thc_range": null, "cbd_percent": 0.1, "in_stock": true, "price_cents": 1200, "regular_price_cents": 1500, "is_on_special": true, "special": { "type": "percent_off", "text": "20% off all Thunder Bud pre-rolls", "badge_text": "20% OFF", "value": 20, "savings_cents": 300 }, "image_url": "https://images.dutchie.com/abc123/medium.jpg", "images": { "thumbnail": "https://images.dutchie.com/abc123/thumb.jpg", "medium": "https://images.dutchie.com/abc123/medium.jpg", "full": "https://images.dutchie.com/abc123/full.jpg" }, "description": "Premium pre-roll featuring Alien Marker strain. Hand-rolled with care using top-shelf flower. Perfect for a smooth, balanced experience.", "terpenes": [ { "name": "Limonene", "percentage": 1.2 }, { "name": "Myrcene", "percentage": 0.8 }, { "name": "Caryophyllene", "percentage": 0.5 } ], "effects": ["Relaxed", "Happy", "Euphoric", "Uplifted"], "flavors": ["Citrus", "Earthy", "Pine"], "lineage": "Unknown lineage", "dutchie_url": "https://dutchie.com/dispensary/deeply-rooted/product/thunder-bud-1g-pre-roll", "last_updated_at": "2025-02-28T15:03:00Z" }, "related_products": [ { "product_id": 12346, "slug": "thunder-bud-3-5g-flower", "name": "3.5g Flower", "full_name": "Thunder Bud 3.5g Flower", "brand_name": "Thunder Bud", "category": { "id": 1, "name": "Flower", "slug": "flower" }, "price_cents": 3500, "image_url": "https://images.dutchie.com/def456/medium.jpg", "in_stock": true, "is_on_special": true }, { "product_id": 12400, "slug": "alien-labs-pre-roll-1g", "name": "1g Pre-Roll", "full_name": "Alien Labs 1g Pre-Roll", "brand_name": "Alien Labs", "category": { "id": 3, "name": "Pre-Rolls", "slug": "pre-rolls" }, "price_cents": 1800, "image_url": "https://images.dutchie.com/ghi789/medium.jpg", "in_stock": true, "is_on_special": false } ] } ``` --- ## 4. Specials Detection (API Layer) > **IMPORTANT**: The crawler is FROZEN. All specials detection happens in the API layer, not in the crawler. > See [CRAWL_OPERATIONS.md](./CRAWL_OPERATIONS.md) for the frozen crawler policy. ### 4.1 Why API-Layer Detection? The crawler captures special information but doesn't always set the `is_special` flag correctly: | Data Source | Current State | Products | |-------------|---------------|----------| | `is_special = true` | Always false | 0 | | "Special Offer" in product name | Embedded in name text | 325 | | `sale_price < regular_price` | Price comparison | 4 | | `special_text` field | Empty | 0 | Since the crawler is frozen, we detect specials at query time using multiple signals. ### 4.2 Specials Detection Rules (Priority Order) The API determines `is_on_special = true` if ANY of these conditions are met: ```sql -- Computed is_on_special in API queries CASE -- Rule 1: "Special Offer" appears in the product name WHEN p.name ILIKE '%Special Offer%' THEN TRUE -- Rule 2: sale_price is less than regular_price WHEN p.sale_price IS NOT NULL AND p.regular_price IS NOT NULL AND p.sale_price::numeric < p.regular_price::numeric THEN TRUE -- Rule 3: price is less than original_price WHEN p.price IS NOT NULL AND p.original_price IS NOT NULL AND p.price::numeric < p.original_price::numeric THEN TRUE -- Rule 4: special_text field is populated (if crawler sets it) WHEN p.special_text IS NOT NULL AND p.special_text != '' THEN TRUE ELSE FALSE END AS is_on_special ``` ### 4.3 Clean Product Name Function Strip "Special Offer" from the display name to avoid redundancy: ```typescript function cleanProductName(rawName: string): string { return rawName .replace(/\s*Special Offer\s*$/i, '') // Remove trailing "Special Offer" .replace(/\s+$/, '') // Trim trailing whitespace .trim(); } // Example: // Input: "Canamo Cured Batter | CuracaoCanamo ConcentratesHybridTHC: 77.66%CBD: 0.15%Special Offer" // Output: "Canamo Cured Batter | CuracaoCanamo ConcentratesHybridTHC: 77.66%CBD: 0.15%" ``` ### 4.4 Compute Discount Percentage ```typescript function computeDiscountPercent( salePriceCents: number | null, regularPriceCents: number | null ): number | null { if (!salePriceCents || !regularPriceCents || regularPriceCents <= 0) { return null; } if (salePriceCents >= regularPriceCents) { return null; // No discount } return Math.round((1 - salePriceCents / regularPriceCents) * 100); } ``` ### 4.5 Updated Specials SQL Query Replace the specials query in Section 3.2 with this API-layer detection query: ```sql -- Get specials with API-layer detection WITH detected_specials AS ( SELECT p.*, -- Detect special using multiple rules CASE WHEN p.name ILIKE '%Special Offer%' THEN TRUE WHEN p.sale_price IS NOT NULL AND p.regular_price IS NOT NULL AND p.sale_price::numeric < p.regular_price::numeric THEN TRUE WHEN p.price IS NOT NULL AND p.original_price IS NOT NULL AND p.price::numeric < p.original_price::numeric THEN TRUE WHEN p.special_text IS NOT NULL AND p.special_text != '' THEN TRUE ELSE FALSE END AS is_on_special, -- Determine special type CASE WHEN p.name ILIKE '%Special Offer%' THEN 'special_offer' WHEN p.sale_price IS NOT NULL AND p.regular_price IS NOT NULL AND p.sale_price::numeric < p.regular_price::numeric THEN 'percent_off' WHEN p.price IS NOT NULL AND p.original_price IS NOT NULL AND p.price::numeric < p.original_price::numeric THEN 'percent_off' WHEN p.special_text IS NOT NULL AND p.special_text != '' THEN 'promo' ELSE NULL END AS computed_special_type, -- Compute discount percentage CASE WHEN p.sale_price IS NOT NULL AND p.regular_price IS NOT NULL AND p.regular_price::numeric > 0 THEN ROUND((1 - p.sale_price::numeric / p.regular_price::numeric) * 100) WHEN p.price IS NOT NULL AND p.original_price IS NOT NULL AND p.original_price::numeric > 0 THEN ROUND((1 - p.price::numeric / p.original_price::numeric) * 100) ELSE NULL END AS discount_percent, -- Clean product name (remove "Special Offer" suffix) REGEXP_REPLACE(p.name, '\s*Special Offer\s*$', '', 'i') AS clean_name FROM products p WHERE p.store_id = $1 AND p.in_stock = TRUE ) SELECT ds.id AS product_id, ds.slug, ds.clean_name AS name, COALESCE(ds.brand || ' ' || ds.clean_name, ds.clean_name) AS full_name, ds.brand AS brand_name, cb.id AS brand_id, c.id AS category_id, c.name AS category_name, c.slug AS category_slug, ds.strain_type, ds.weight, ds.thc_percentage AS thc_percent, ds.cbd_percentage AS cbd_percent, ds.in_stock, -- Price in cents ROUND(COALESCE(ds.sale_price, ds.price, 0) * 100)::INTEGER AS price_cents, -- Regular price in cents ROUND(COALESCE(ds.regular_price, ds.original_price, ds.price) * 100)::INTEGER AS regular_price_cents, ds.is_on_special, ds.computed_special_type, ds.discount_percent, ds.image_url_full, ds.image_url, ds.thumbnail_path, ds.medium_path, ds.description, ds.metadata, ds.dutchie_url, ds.last_seen_at AS last_updated_at, -- Calculated savings CASE WHEN ds.regular_price IS NOT NULL AND ds.sale_price IS NOT NULL THEN ROUND((ds.regular_price - ds.sale_price) * 100)::INTEGER WHEN ds.original_price IS NOT NULL AND ds.price IS NOT NULL THEN ROUND((ds.original_price - ds.price) * 100)::INTEGER ELSE NULL END AS savings_cents FROM detected_specials ds LEFT JOIN categories c ON c.id = ds.category_id LEFT JOIN canonical_brands cb ON LOWER(cb.name) = LOWER(ds.brand) WHERE ds.is_on_special = TRUE AND ($2::TEXT IS NULL OR c.slug = $2) -- category filter AND ($3::TEXT IS NULL OR ds.brand ILIKE '%' || $3 || '%') -- brand filter ORDER BY ds.discount_percent DESC NULLS LAST, ds.name ASC LIMIT $4 OFFSET $5; ``` ### 4.6 Special Badge Generation Generate the badge text based on detected special type: ```typescript function generateSpecialBadge( specialType: string | null, discountPercent: number | null, hasSpecialOfferInName: boolean ): string { if (discountPercent && discountPercent > 0) { return `${discountPercent}% OFF`; } if (hasSpecialOfferInName) { return 'SPECIAL'; } switch (specialType) { case 'percent_off': return discountPercent ? `${discountPercent}% OFF` : 'SALE'; case 'special_offer': return 'SPECIAL'; case 'promo': return 'PROMO'; default: return 'DEAL'; } } ``` --- ## 5. Special Text Parsing ### 5.1 Parse Function The special parsing logic determines `special.type`, `special.badge_text`, and `special.value`: ```typescript interface ParsedSpecial { type: 'percent_off' | 'dollar_off' | 'bogo' | 'bundle' | 'set_price' | 'other'; text: string; // Raw text badge_text: string; // Short display text value: number | null; // Numeric value savings_cents: number | null; // Calculated savings } function parseSpecialText( specialText: string | null, currentPriceCents: number, regularPriceCents: number | null ): ParsedSpecial | null { if (!specialText || specialText.trim() === '') { return null; } const text = specialText.trim(); const lower = text.toLowerCase(); // Calculate savings from price difference const savingsCents = regularPriceCents && regularPriceCents > currentPriceCents ? regularPriceCents - currentPriceCents : null; // 1. Percent off: "20% off", "25% OFF", "Save 30%" const pctMatch = lower.match(/(\d+(?:\.\d+)?)\s*%\s*(?:off|discount)?/i) || lower.match(/save\s*(\d+(?:\.\d+)?)\s*%/i); if (pctMatch) { const percent = parseFloat(pctMatch[1]); return { type: 'percent_off', text, badge_text: `${Math.round(percent)}% OFF`, value: percent, savings_cents: savingsCents }; } // 2. Dollar off: "$5 off", "$10 OFF" const dollarMatch = lower.match(/\$(\d+(?:\.\d+)?)\s*off/i); if (dollarMatch) { const dollars = parseFloat(dollarMatch[1]); return { type: 'dollar_off', text, badge_text: `$${Math.round(dollars)} OFF`, value: dollars, savings_cents: dollars * 100 }; } // 3. BOGO: "BOGO", "Buy One Get One", "B1G1", "Buy 2 Get 1" if (/\bbogo\b|buy\s*one\s*get\s*one|b1g1/i.test(lower)) { return { type: 'bogo', text, badge_text: 'BOGO', value: 50, // 50% effective discount savings_cents: savingsCents }; } const bogoMatch = lower.match(/buy\s*(\d+)\s*get\s*(\d+)/i); if (bogoMatch) { const buy = parseInt(bogoMatch[1]); const get = parseInt(bogoMatch[2]); return { type: 'bogo', text, badge_text: `B${buy}G${get}`, value: Math.round((get / (buy + get)) * 100), savings_cents: savingsCents }; } // 4. Bundle: "3 for $100", "2/$50" const bundleMatch = lower.match(/(\d+)\s*(?:for|\/)\s*\$(\d+(?:\.\d+)?)/i); if (bundleMatch) { const qty = parseInt(bundleMatch[1]); const price = parseFloat(bundleMatch[2]); return { type: 'bundle', text, badge_text: `${qty} FOR $${Math.round(price)}`, value: price, savings_cents: null // Can't calculate without knowing single item price }; } // 5. Set price: "Now $25", "Only $30" const setPriceMatch = lower.match(/(?:now|only|just|sale)\s*\$(\d+(?:\.\d+)?)/i); if (setPriceMatch) { const setPrice = parseFloat(setPriceMatch[1]); return { type: 'set_price', text, badge_text: `NOW $${Math.round(setPrice)}`, value: setPrice, savings_cents: savingsCents }; } // 6. Other - has text but couldn't parse return { type: 'other', text, badge_text: 'SPECIAL', value: null, savings_cents: savingsCents }; } ``` --- ## 5. Caching Strategy ### 5.1 Server-Side Cache Headers All store API responses include cache control headers: ```http HTTP/1.1 200 OK Content-Type: application/json Cache-Control: public, max-age=300, stale-while-revalidate=60 ETag: "abc123def456" X-Cache-TTL: 300 X-Generated-At: 2025-02-28T15:00:00Z ``` ### 5.2 Recommended TTLs | Endpoint | TTL | Rationale | |----------|-----|-----------| | `/menu` | 5 minutes | Prices and stock change frequently | | `/specials` | 5 minutes | Deals may be time-sensitive | | `/products` | 5 minutes | Same as menu | | `/categories` | 10 minutes | Category structure rarely changes | | `/brands` | 10 minutes | Brand list rarely changes | | `/product/:slug` | 5 minutes | Individual product details | ### 5.3 WordPress Transient Strategy Recommended caching approach for the WP plugin: ```php class Cannabrands_Cache { const PREFIX = 'cbm_'; // Default TTLs in seconds const TTL_MENU = 300; // 5 minutes const TTL_SPECIALS = 300; // 5 minutes const TTL_CATEGORIES = 600; // 10 minutes const TTL_BRANDS = 600; // 10 minutes /** * Build a deterministic cache key */ public static function build_key(string $endpoint, array $params = []): string { $store_key = get_option('cannabrands_store_key'); // Sort params for consistent key ksort($params); $param_hash = md5(serialize($params)); return self::PREFIX . $store_key . '_' . $endpoint . '_' . $param_hash; } /** * Get cached data or fetch from API */ public static function get_or_fetch( string $endpoint, array $params, callable $fetch_callback, int $ttl = self::TTL_MENU ) { $key = self::build_key($endpoint, $params); // Try cache first $cached = get_transient($key); if ($cached !== false) { return $cached; } // Fetch fresh data $data = $fetch_callback(); if (!is_wp_error($data)) { set_transient($key, $data, $ttl); } return $data; } /** * Clear all plugin caches */ public static function clear_all(): void { global $wpdb; $wpdb->query( "DELETE FROM {$wpdb->options} WHERE option_name LIKE '_transient_" . self::PREFIX . "%' OR option_name LIKE '_transient_timeout_" . self::PREFIX . "%'" ); } } ``` ### 5.4 Cache Key Examples ``` cbm_deeply-rooted_menu_d41d8cd98f00b204e9800998ecf8427e cbm_deeply-rooted_menu_7d793037a0760186574b0282f2f435e7 // With filters cbm_deeply-rooted_specials_d41d8cd98f00b204e9800998ecf8427e cbm_deeply-rooted_categories_d41d8cd98f00b204e9800998ecf8427e ``` --- ## 6. Elementor Widget → API Mapping ### 6.1 Menu Widget **Widget Controls:** | Control | Type | Default | Maps To | |---------|------|---------|---------| | Category | Select | All | `?category=` | | Brand | Text | - | `?brand=` | | Sort By | Select | Name A-Z | `?sort=` | | Products Per Page | Number | 50 | `?per_page=` | | In Stock Only | Toggle | Yes | `?in_stock=true` | | Show Images | Toggle | Yes | - (frontend only) | | Show Prices | Toggle | Yes | - (frontend only) | | Show THC | Toggle | Yes | - (frontend only) | | Show Special Badge | Toggle | Yes | - (frontend only) | | Columns | Number | 4 | - (frontend only) | **API Call:** ``` GET /api/stores/{store_key}/menu ?category={category} &brand={brand} &sort={sort} &per_page={per_page} &in_stock={in_stock} &page=1 ``` **Fields Used from Response:** ```typescript // From ProductForStore { product_id, // For data attributes slug, // For URL generation full_name, // Display name brand_name, // Brand label category, // Category badge strain_type, // Strain indicator weight, // Size label thc_percent, // THC display in_stock, // Stock badge price_cents, // Price display regular_price_cents, // Strikethrough price is_on_special, // Special styling special.badge_text, // Special badge image_url, // Product image dutchie_url // Link target } ``` ### 6.2 Specials Widget **Widget Controls:** | Control | Type | Default | Maps To | |---------|------|---------|---------| | Heading | Text | "Today's Specials" | - (frontend) | | Category | Select | All | `?category=` | | Brand | Text | - | `?brand=` | | Deal Type | Select | All | `?special_type=` | | Limit | Number | 8 | `?limit=` | | Show Original Price | Toggle | Yes | - (frontend) | | Show Savings | Toggle | Yes | - (frontend) | | Layout | Select | Grid | - (frontend) | **API Call:** ``` GET /api/stores/{store_key}/specials ?category={category} &brand={brand} &special_type={special_type} &limit={limit} ``` **Fields Used:** ```typescript { product_id, slug, full_name, brand_name, category, strain_type, weight, thc_percent, price_cents, // Current price regular_price_cents, // Strikethrough special.type, // For styling special.text, // Deal description special.badge_text, // Badge special.savings_cents, // Savings display image_url, dutchie_url } ``` ### 6.3 Carousel Widget **Widget Controls:** | Control | Type | Default | Maps To | |---------|------|---------|---------| | Heading | Text | "Featured Products" | - (frontend) | | Source | Select | All Products | Endpoint selection | | Category | Select | All | `?category=` | | Specials Only | Toggle | No | `?specials_only=true` | | Brand | Text | - | `?brand=` | | Limit | Number | 12 | `?limit=` | | Slides to Show | Number | 4 | - (frontend) | | Autoplay | Toggle | No | - (frontend) | | Autoplay Speed | Number | 3000 | - (frontend) | | Show Arrows | Toggle | Yes | - (frontend) | | Show Dots | Toggle | Yes | - (frontend) | **API Call:** ``` // If Specials Only = true: GET /api/stores/{store_key}/specials ?category={category} &brand={brand} &limit={limit} // Otherwise: GET /api/stores/{store_key}/products ?category={category} &brand={brand} &specials_only={specials_only} &in_stock=true &per_page={limit} ``` **Fields Used:** ```typescript { product_id, slug, full_name, brand_name, price_cents, regular_price_cents, is_on_special, special.badge_text, image_url, dutchie_url } ``` ### 6.4 Category Navigation Widget **Widget Controls:** | Control | Type | Default | Maps To | |---------|------|---------|---------| | Layout | Select | Grid | - (frontend) | | Show Product Count | Toggle | Yes | - (frontend) | | Show Icons | Toggle | Yes | - (frontend) | | Link Behavior | Select | Filter Menu | - (frontend) | **API Call:** ``` GET /api/stores/{store_key}/categories ``` **Fields Used:** ```typescript { id, name, slug, icon, product_count, in_stock_count, on_special_count, children[] } ``` --- ## 7. Shortcode → API Mapping ### 7.1 Menu Shortcode ``` [cannabrands_menu category="flower" brand="Raw Garden" sort="price_asc" limit="24" columns="4" show_images="yes" show_price="yes" show_thc="yes" in_stock="yes" ] ``` **Maps to:** ``` GET /api/stores/{store_key}/menu ?category=flower &brand=Raw+Garden &sort=price_asc &per_page=24 &in_stock=true ``` ### 7.2 Specials Shortcode ``` [cannabrands_specials category="pre-rolls" brand="Thunder Bud" type="percent_off" limit="8" layout="grid" show_savings="yes" ] ``` **Maps to:** ``` GET /api/stores/{store_key}/specials ?category=pre-rolls &brand=Thunder+Bud &special_type=percent_off &limit=8 ``` ### 7.3 Carousel Shortcode ``` [cannabrands_carousel category="vapes" specials_only="true" limit="10" visible="4" autoplay="yes" speed="3000" ] ``` **Maps to:** ``` GET /api/stores/{store_key}/specials ?category=vapes &limit=10 ``` ### 7.4 Categories Shortcode ``` [cannabrands_categories layout="grid" columns="4" show_count="yes" show_icons="yes" ] ``` **Maps to:** ``` GET /api/stores/{store_key}/categories ``` ### 7.5 Single Product Shortcode ``` [cannabrands_product slug="thunder-bud-1g-pre-roll" layout="card"] ``` **Maps to:** ``` GET /api/stores/{store_key}/product/thunder-bud-1g-pre-roll ``` --- ## 8. Error Responses ### 8.1 Standard Error Format ```json { "error": { "code": "STORE_NOT_FOUND", "message": "No store found with key: invalid-store", "status": 404 } } ``` ### 8.2 Error Codes | Code | HTTP Status | Description | |------|-------------|-------------| | `AUTH_REQUIRED` | 401 | Missing X-Store-API-Key header | | `INVALID_API_KEY` | 401 | API key not found or inactive | | `STORE_MISMATCH` | 403 | API key doesn't match requested store | | `RATE_LIMITED` | 429 | Rate limit exceeded | | `STORE_NOT_FOUND` | 404 | Store key not found | | `PRODUCT_NOT_FOUND` | 404 | Product slug not found | | `INVALID_PARAMS` | 400 | Invalid query parameters | | `INTERNAL_ERROR` | 500 | Server error | ### 8.3 Rate Limit Response ```json { "error": { "code": "RATE_LIMITED", "message": "Rate limit exceeded. Try again in 42 seconds.", "status": 429, "retry_after": 42 } } ``` **Headers:** ```http HTTP/1.1 429 Too Many Requests Retry-After: 42 X-RateLimit-Limit: 1000 X-RateLimit-Remaining: 0 X-RateLimit-Reset: 1709139600 ``` --- ## 9. Data Source Summary ### 9.1 Tables Used by Store API | Table | Used For | |-------|----------| | `stores` | Store info (name, slug, location) | | `products` | Product data (name, price, THC, images, etc.) | | `categories` | Category tree and counts | | `canonical_brands` | Brand ID lookups (optional) | | `store_api_keys` | Authentication | ### 9.2 Tables NOT Used by Store API | Table | Belongs To | |-------|------------| | `brand_daily_metrics` | Brand Intelligence only | | `brand_promo_daily_metrics` | Brand Intelligence only | | `brand_store_events` | Brand Intelligence only | | `brand_store_presence` | Brand Intelligence only | | `store_products` | Brand Intelligence only | | `store_product_snapshots` | Brand Intelligence only | | `crawl_runs` | Internal crawler only | ### 9.3 Data Flow Diagram ``` ┌─────────────────────────────────────────────────────────────┐ │ STORE API REQUEST │ │ GET /api/stores/deeply-rooted/menu │ └────────────────────────────┬────────────────────────────────┘ │ ▼ ┌─────────────────────────────────────────────────────────────┐ │ AUTH MIDDLEWARE │ │ • Validate X-Store-API-Key │ │ • Check store_api_keys table │ │ • Verify store_key matches │ │ • Check rate limits │ └────────────────────────────┬────────────────────────────────┘ │ ▼ ┌─────────────────────────────────────────────────────────────┐ │ QUERY BUILDER │ │ • Build SQL from query params │ │ • Apply filters (category, brand, search, in_stock) │ │ • Apply sorting │ │ • Apply pagination │ └────────────────────────────┬────────────────────────────────┘ │ ▼ ┌─────────────────────────────────────────────────────────────┐ │ DATABASE QUERY │ │ │ │ SELECT FROM: │ │ • products (main data) │ │ • categories (category info) │ │ • canonical_brands (brand_id lookup) │ │ │ │ NOT FROM: │ │ • brand_daily_metrics │ │ • brand_store_events │ │ • store_product_snapshots │ └────────────────────────────┬────────────────────────────────┘ │ ▼ ┌─────────────────────────────────────────────────────────────┐ │ RESPONSE BUILDER │ │ • Transform to ProductForStore format │ │ • Parse special text │ │ • Build image URLs │ │ • Add cache headers │ └────────────────────────────┬────────────────────────────────┘ │ ▼ ┌─────────────────────────────────────────────────────────────┐ │ JSON RESPONSE │ │ { store: {...}, meta: {...}, products: [...] } │ └─────────────────────────────────────────────────────────────┘ ``` --- ## Document Version | Version | Date | Author | Changes | |---------|------|--------|---------| | 1.0 | 2025-02-28 | Claude | Initial Store API specification | | 1.1 | 2025-11-30 | Claude | Added API-layer specials detection (Section 4). Crawler is frozen - all specials detection now happens at query time. |