- 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>
1959 lines
56 KiB
Markdown
1959 lines
56 KiB
Markdown
# 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<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:
|
|
|
|
```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. |
|