Files
cannaiq/docs/STORE_API_SPECIFICATION.md
Kelly 9d8972aa86 Fix category-crawler-jobs store lookup query
- Fix column name from s.dutchie_plus_url to s.dutchie_url
- Add availability tracking and product freshness APIs
- Add crawl script for sequential dispensary processing

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-01 00:07:00 -07:00

56 KiB

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:

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)
async function resolveStoreKey(db: Pool, storeKey: string): Promise<number | null> {
  // 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:

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

{
  "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

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:

{
  "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:

-- 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:

{
  "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:

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:

{
  "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:

{
  "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:

-- 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:

{
  "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:

{
  "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 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:

-- 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:

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

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:

-- 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:

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:

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/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
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:

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:

// 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:

{
  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
}

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:

{
  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:

{
  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
[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

{
  "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

{
  "error": {
    "code": "RATE_LIMITED",
    "message": "Rate limit exceeded. Try again in 42 seconds.",
    "status": 429,
    "retry_after": 42
  }
}

Headers:

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.