613
613parts an APC associate store
WORKER · TYPESCRIPT · v0.2.0
Cloudflare Worker · v2 endpoints

er_search and er_fulfillment.
production-ready typescript.

Two endpoints that power the v2 catalog-wide bot: er_search queries the catalog with a hybrid FlexSearch + custom-scoring engine and returns a Messenger Generic Template carousel; er_fulfillment evaluates zone + cart + tire-package flag and returns the right fulfillment lanes as quick replies.

Runtime
CF Worker
Lang
TypeScript 5
Search
FlexSearch
Storage
R2 + KV
Cold start
<30ms
P50 latency
~12ms
worker/ ├── wrangler.toml # Worker config ├── package.json # Dependencies ├── tsconfig.json # Strict TS └── src/ ├── index.ts # Router ├── types.ts # All TS types ├── handlers/ │ ├── search.ts # er_search │ └── fulfillment.ts # er_fulfillment └── lib/ ├── catalog.ts # R2 + KV cache ├── search-engine.ts # FlexSearch + scoring ├── zone.ts # Postal code check └── manychat.ts # Response helpers
Endpoints

two routes. clear contracts.

Both endpoints accept POST with JSON body, return ManyChat External Request format (version v2, content.messages, set_field_value), and always return 200 OK — even on error — to keep the conversation flowing.

POST /api/v1/er-search

Hybrid FlexSearch + custom-scoring against the nightly catalog index. Returns a Generic Template carousel of the top 3 in-stock matches with brand, price, branch with stock, and fitment confirmation. Sets cf_match_results, cf_top_match_sku, etc. for downstream flows.

Request body
JSON
{
  "subscriber_id": "1234567890",
  "cf_query": "oil filter for my civic",
  "cf_vehicle_id": "honda_civic_2018_15t",
  "cf_intent_subcategory": "filters"
}
Response
200 OK · ManyChat v2
{
  "version": "v2",
  "content": {
    "messages": [
      { "type": "text", "text": "found 3 in stock for you ↓" },
      {
        "attachment": {
          "type": "template",
          "payload": {
            "template_type": "generic",
            "elements": [
              {
                "title": "ACDelco PF64 Oil Filter",
                "subtitle": "$14.99 · in stock · belleville · fits your car ✓",
                "image_url": "https://ic.carid.com/acdelco/items/pf64_1.jpg",
                "buttons": [
                  { "type": "postback", "title": "Buy",     "payload": "BUY_ACD-PF64" },
                  { "type": "postback", "title": "Specs",   "payload": "SPECS_ACD-PF64" },
                  { "type": "postback", "title": "Compare", "payload": "COMPARE_ACD-PF64" }
                ]
              }
              /* +2 more cards */
            ]
          }
        }
      }
    ]
  },
  "set_field_value": {
    "cf_match_results": "ACD-PF64,FRAM-XG10358,KN-HP1010",
    "cf_match_count": 3,
    "cf_top_match_sku": "ACD-PF64",
    "cf_top_match_price": 14.99
  }
}
POST /api/v1/er-fulfillment

Evaluates zone (postal code FSA) + cart total + tire-package flag → returns the available fulfillment lanes. Bot uses returned cf_qr_labels + cf_qr_values to render quick replies dynamically. No hardcoded lane logic in ManyChat — all rules live in this Worker.

Request body
JSON
{
  "cf_postal": "K8N 4Z2",
  "cf_cart_total": 249,
  "cf_is_tire_package": false
}
Response
200 OK · ManyChat v2
{
  "version": "v2",
  "content": {
    "messages": [
      { "type": "text", "text": "👍 cart: $249.00." },
      { "type": "text", "text": "how do you want it?" }
    ]
  },
  "set_field_value": {
    "cf_in_zone": true,
    "cf_branch_origin": "belleville",
    "cf_fulfillment_lanes": "pickup_belleville,pickup_kingston,drop_paid",
    "cf_fulfillment_default": "pickup_belleville",
    "cf_delivery_fee": 15,
    "cf_cart_total_with_fee": 264,
    "cf_fulfillment_message": "pickup is free, or we'll drop it at your door for $15.",
    "cf_qr_labels": "Pickup belleville · free|Pickup kingston · free|Drop off · $15",
    "cf_qr_values": "pickup_belleville|pickup_kingston|drop_paid"
  }
}
Implementation · er_search

handlers/search.ts

The handler validates the query, loads the catalog from R2 (cached), runs the search engine, and shapes the result into a Messenger carousel. ~80 lines including the carousel-card builder.

src/handlers/search.ts
~110 lines · TypeScript
// 613parts Messenger Bot · er_search handler
// POST /api/v1/er-search

import { loadCatalog } from '../lib/catalog';
import { searchCatalog } from '../lib/search-engine';
import { ok, err } from '../lib/manychat';
import type { Env, ManyChatRequest, SearchResult } from '../types';

export async function handleSearch(req: ManyChatRequest, env: Env): Promise<Response> {
  // ----- Validate -----
  const query = req.cf_query?.trim();
  if (!query || query.length < 2) {
    return err('what part are you looking for? send a name, SKU, or symptom (e.g. "squeaky brakes").');
  }

  // ----- Load + search -----
  const parts = await loadCatalog(env);
  const results = searchCatalog(parts, {
    query,
    vehicleId: req.cf_vehicle_id,
    inStockOnly: true,
    limit: 3,
  });

  // ----- No matches -----
  if (results.length === 0) {
    return ok({
      version: 'v2',
      content: {
        messages: [{
          type: 'text',
          text: `couldn't find a match for "${query}" in stock. try a different name or SKU — or tap "Talk to a human" and one of our counter staff will help find it.`,
        }],
      },
      set_field_value: { cf_match_count: 0, cf_query_failed: query },
    });
  }

  // ----- Build the carousel -----
  const elements = results.map(r => buildCarouselCard(r));

  return ok({
    version: 'v2',
    content: {
      messages: [
        { type: 'text', text: `found ${results.length} in stock for you ↓` },
        {
          attachment: {
            type: 'template',
            payload: { template_type: 'generic', elements },
          },
        },
      ],
    },
    set_field_value: {
      cf_match_results: results.map(r => r.sku).join(','),
      cf_match_count: results.length,
      cf_top_match_sku: results[0].sku,
      cf_top_match_price: results[0].price,
    },
  });
}

// Build a Messenger Generic Template element (carousel card)
function buildCarouselCard(r: SearchResult) {
  const branchLabel =
    r.branch_with_stock === 'both'       ? 'in stock · both shops' :
    r.branch_with_stock === 'belleville' ? 'in stock · belleville'  :
                                           'in stock · kingston';

  const fitLabel = r.fitment_confirmed ? ' · fits your car ✓' : '';

  return {
    title: `${r.brand} ${r.name}`.slice(0, 80),
    subtitle: `$${r.price.toFixed(2)} · ${branchLabel}${fitLabel}`.slice(0, 80),
    image_url: r.image || `https://613parts.ca/img/sku/${r.sku}.jpg`,
    buttons: [
      { type: 'postback' as const, title: 'Buy',     payload: `BUY_${r.sku}` },
      { type: 'postback' as const, title: 'Specs',   payload: `SPECS_${r.sku}` },
      { type: 'postback' as const, title: 'Compare', payload: `COMPARE_${r.sku}` },
    ],
  };
}
Implementation · er_fulfillment

handlers/fulfillment.ts

Pure logic — no R2/KV reads needed. All decisions are made from request inputs (postal, cart total, tire-package flag) and constants. Returns the lane list + QR labels the bot renders dynamically.

src/handlers/fulfillment.ts
~90 lines · TypeScript
// 613parts Messenger Bot · er_fulfillment handler
// POST /api/v1/er-fulfillment
//
// Lane logic (v2):
//   In zone + (tire_package OR cart >= $300) → Lanes 01 + 02 + 03 (default 03 free drop)
//   In zone + cart < $300                    → Lanes 01 + 02      (default closer pickup)
//   Out of zone                              → Lane 01 only       (pickup either branch)

import { checkZone } from '../lib/zone';
import { ok, err } from '../lib/manychat';
import type { Env, ManyChatRequest, FulfillmentLane, QROption } from '../types';

const PAID_DROP_FEE = 15;
const FREE_DROP_THRESHOLD = 300;

export async function handleFulfillment(req: ManyChatRequest, _env: Env): Promise<Response> {
  // ----- Validate + coerce -----
  const postal = req.cf_postal?.trim();
  const cartTotal = Number(req.cf_cart_total) || 0;
  const isTirePackage = coerceBool(req.cf_is_tire_package);

  if (!postal) return err('postal code is required to show delivery options.');
  if (cartTotal <= 0) return err('cart total is required.');

  // ----- Zone + branch routing -----
  const zone = checkZone(postal);

  // ----- Lane selection -----
  const lanes: FulfillmentLane[] = [];
  let defaultLane: FulfillmentLane;
  let deliveryFee = 0;
  let message: string;

  if (zone.in_zone) {
    lanes.push('pickup_belleville', 'pickup_kingston');

    if (isTirePackage || cartTotal >= FREE_DROP_THRESHOLD) {
      // Lane 03 — free drop-off
      lanes.push('drop_free');
      defaultLane = 'drop_free';
      deliveryFee = 0;
      message = isTirePackage
        ? 'tire+rim package — free local drop-off included.'
        : `your order is over $${FREE_DROP_THRESHOLD} — free local drop-off included.`;
    } else {
      // Lane 02 — paid drop-off
      lanes.push('drop_paid');
      defaultLane = `pickup_${zone.branch}` as FulfillmentLane;
      deliveryFee = PAID_DROP_FEE;
      message = `pickup is free, or we'll drop it at your door for $${PAID_DROP_FEE}.`;
    }
  } else {
    // Out of zone — pickup only
    defaultLane = 'pickup_belleville';
    lanes.push('pickup_belleville', 'pickup_kingston');
    deliveryFee = 0;
    message = "we don't deliver to your area yet — but you can pick up at either of our shops.";
  }

  const qrOptions: QROption[] = lanes.map(lane => buildQROption(lane, deliveryFee));
  const cartTotalWithFee = cartTotal + deliveryFee;

  return ok({
    version: 'v2',
    content: {
      messages: [
        {
          type: 'text',
          text: deliveryFee > 0
            ? `👍 cart: $${cartTotal.toFixed(2)}.`
            : `👍 cart: $${cartTotal.toFixed(2)}. ${message}`,
        },
        { type: 'text', text: 'how do you want it?' },
      ],
    },
    set_field_value: {
      cf_in_zone: zone.in_zone,
      cf_branch_origin: zone.branch || '',
      cf_fulfillment_lanes: lanes.join(','),
      cf_fulfillment_default: defaultLane,
      cf_delivery_fee: deliveryFee,
      cf_cart_total_with_fee: cartTotalWithFee,
      cf_fulfillment_message: message,
      cf_qr_labels: qrOptions.map(q => q.label).join('|'),
      cf_qr_values: qrOptions.map(q => q.value).join('|'),
    },
  });
}

// ----- Helpers -----

function coerceBool(v: boolean | string | undefined): boolean {
  if (typeof v === 'boolean') return v;
  if (typeof v === 'string') return v.toLowerCase() === 'true' || v === '1';
  return false;
}

function buildQROption(lane: FulfillmentLane, fee: number): QROption {
  switch (lane) {
    case 'pickup_belleville':
      return { label: 'Pickup belleville · free', value: 'pickup_belleville' };
    case 'pickup_kingston':
      return { label: 'Pickup kingston · free', value: 'pickup_kingston' };
    case 'drop_paid':
      return { label: `Drop off · $${fee}`, value: 'drop_paid' };
    case 'drop_free':
      return { label: 'Drop off · free', value: 'drop_free' };
  }
}
Search Engine · The Heart

lib/search-engine.ts

Hybrid: FlexSearch retrieves candidates fast, then the canonical 613parts scoring formula re-ranks them. Identical scoring weights as the existing catalog architecture so behavior matches what shop staff already expect.

src/lib/search-engine.ts
~140 lines · TypeScript
// Scoring formula (matches the canonical 613parts catalog architecture):
//   exact SKU/MPN  +1500
//   token=SKU/MPN  +1000
//   substring      +600
//   brand match    +500
//   cat match      +300
//   sub match      +200
//   name token     +50
//   desc match     +10
//   in stock >5    +15
//   fits vehicle   +250

import FlexSearch from 'flexsearch';
import type { Part, SearchResult } from '../types';

let docIndex: any = null;
let indexedAt = 0;

function buildIndex(parts: Part[]): any {
  const index = new FlexSearch.Document({
    document: {
      id: 'sku',
      index: [
        { field: 'sku',   tokenize: 'forward' },
        { field: 'mpn',   tokenize: 'forward' },
        { field: 'name',  tokenize: 'forward' },
        { field: 'brand', tokenize: 'strict'  },
        { field: 'cat',   tokenize: 'strict'  },
        { field: 'sub',   tokenize: 'strict'  },
        { field: 'desc',  tokenize: 'forward' },
        { field: 'tags',  tokenize: 'strict'  },
      ],
    } as any,
  });
  for (const part of parts) index.add(part);
  docIndex = index;
  indexedAt = Date.now();
  return index;
}

export interface SearchOptions {
  query: string;
  vehicleId?: string;
  inStockOnly?: boolean;
  limit?: number;
}

export function searchCatalog(parts: Part[], opts: SearchOptions): SearchResult[] {
  const { query, vehicleId, inStockOnly = true, limit = 3 } = opts;
  if (!docIndex || indexedAt === 0) buildIndex(parts);

  const q = query.trim().toLowerCase();
  if (q.length < 2) return [];
  const tokens = q.split(/\s+/).filter(Boolean);

  // Step 1 — FlexSearch retrieves candidates fast (~5,000 SKUs in <1ms)
  const candidateSkus = new Set<string>();
  const fieldResults = docIndex.search(q, { limit: 50 });
  for (const fr of fieldResults) {
    for (const sku of fr.result) candidateSkus.add(String(sku));
  }

  // Step 2 — Substring fallback for SKUs/MPNs FlexSearch tokenizer missed
  for (const part of parts) {
    if (part.sku.toLowerCase().includes(q) || part.mpn.toLowerCase().includes(q)) {
      candidateSkus.add(part.sku);
    }
  }

  // Step 3 — Custom scoring on candidates
  const partMap = new Map(parts.map(p => [p.sku, p]));
  const scored: Array<{ part: Part; score: number }> = [];

  for (const sku of candidateSkus) {
    const part = partMap.get(sku);
    if (!part) continue;
    const totalStock = part.stock.belleville + part.stock.kingston;
    if (inStockOnly && totalStock === 0) continue;

    let score = 0;
    const skuLower = part.sku.toLowerCase();
    const mpnLower = part.mpn.toLowerCase();

    if (skuLower === q || mpnLower === q) score += 1500;

    for (const token of tokens) {
      if (skuLower === token || mpnLower === token) score += 1000;
      else if (skuLower.includes(token) || mpnLower.includes(token)) score += 600;

      if (part.brand.toLowerCase() === token) score += 500;
      if (part.cat.toLowerCase().includes(token)) score += 300;
      if (part.sub.toLowerCase().includes(token)) score += 200;
      if (part.name.toLowerCase().includes(token)) score += 50;
      if (part.desc.toLowerCase().includes(token)) score += 10;
    }

    if (totalStock > 5) score += 15;
    if (vehicleId && part.fits.includes(vehicleId)) score += 250;

    if (score > 0) scored.push({ part, score });
  }

  scored.sort((a, b) => b.score - a.score);

  return scored.slice(0, limit).map(({ part, score }) => ({
    sku: part.sku,
    mpn: part.mpn,
    name: part.name,
    brand: part.brand,
    price: part.price,
    price_label: part.price_label,
    image: part.image,
    branch_with_stock:
      part.stock.belleville > 0 && part.stock.kingston > 0 ? 'both' :
      part.stock.belleville > 0 ? 'belleville' : 'kingston',
    fitment_confirmed: vehicleId ? part.fits.includes(vehicleId) : false,
    score,
  }));
}
Supporting libraries

three small files. zero ceremony.

Catalog loader, postal zone check, and ManyChat response helpers. Each does one thing.

src/lib/catalog.ts
~50 lines · two-tier cache (memory + KV)
import type { Env, Part } from '../types';

const CATALOG_KEY = 'catalog-search-latest.json';
const CACHE_TTL_SECONDS = 60 * 60;        // 1 hour for KV
const IN_MEMORY_TTL_MS  = 30 * 60 * 1000; // 30 min for isolate

let inMemoryCache: { parts: Part[]; cachedAt: number } | null = null;

export async function loadCatalog(env: Env): Promise<Part[]> {
  const now = Date.now();

  // 1. In-memory cache (per-isolate)
  if (inMemoryCache && now - inMemoryCache.cachedAt < IN_MEMORY_TTL_MS) {
    return inMemoryCache.parts;
  }

  // 2. KV cache (shared across isolates)
  const cached = await env.SEARCH_CACHE.get<Part[]>('catalog', { type: 'json' });
  if (cached && Array.isArray(cached) && cached.length > 0) {
    inMemoryCache = { parts: cached, cachedAt: now };
    return cached;
  }

  // 3. Cold load from R2
  const obj = await env.CATALOG_BUCKET.get(CATALOG_KEY);
  if (!obj) throw new Error(`Catalog not found at ${CATALOG_KEY}.`);

  const parts = await obj.json<Part[]>();
  inMemoryCache = { parts, cachedAt: now };
  await env.SEARCH_CACHE.put('catalog', JSON.stringify(parts), {
    expirationTtl: CACHE_TTL_SECONDS,
  });

  return parts;
}
src/lib/zone.ts
~30 lines · pure function · only file to edit when zones change
const ZONE_FSAS = {
  belleville: ['K8N', 'K8P', 'K8R', 'K8V', 'K0K'],
  kingston:   ['K7K', 'K7L', 'K7M', 'K7N', 'K7P', 'K0H', 'K7R'],
} as const;

export interface ZoneCheckResult {
  in_zone: boolean;
  branch: 'belleville' | 'kingston' | null;
  fsa: string;
}

export function checkZone(postal: string): ZoneCheckResult {
  const fsa = postal.replace(/\s+/g, '').toUpperCase().slice(0, 3);

  if (ZONE_FSAS.belleville.includes(fsa as any)) {
    return { in_zone: true, branch: 'belleville', fsa };
  }
  if (ZONE_FSAS.kingston.includes(fsa as any)) {
    return { in_zone: true, branch: 'kingston', fsa };
  }
  return { in_zone: false, branch: null, fsa };
}
src/index.ts
~60 lines · the router
import { handleSearch } from './handlers/search';
import { handleFulfillment } from './handlers/fulfillment';
import { fallback } from './lib/manychat';
import type { Env, ManyChatRequest } from './types';

export default {
  async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
    const url = new URL(request.url);

    if (url.pathname === '/health') return new Response('ok', { status: 200 });

    if (request.method !== 'POST') {
      return new Response('Method Not Allowed', { status: 405, headers: { allow: 'POST' } });
    }

    let body: ManyChatRequest;
    try { body = (await request.json()) as ManyChatRequest; }
    catch { return new Response('Invalid JSON', { status: 400 }); }

    try {
      switch (url.pathname) {
        case '/api/v1/er-search':       return await handleSearch(body, env);
        case '/api/v1/er-fulfillment':  return await handleFulfillment(body, env);
        default:                         return new Response('Not Found', { status: 404 });
      }
    } catch (e) {
      console.error(`Worker error · path=${url.pathname}`, e);
      return fallback();
    }
  },
} satisfies ExportedHandler<Env>;
Deployment

five commands to live.

Cloudflare's free tier covers everything for a v1 launch (100k requests/day, 1GB R2 storage, 1k KV ops/day). Production tier kicks in at $5/month.

01 · INSTALL

Install & auth

Wrangler is Cloudflare's CLI. One npm install, one auth.

cd worker
npm install
npx wrangler login
02 · STORAGE

Create R2 bucket + KV namespace

Bucket holds the nightly catalog JSON; KV caches it.

npx wrangler r2 bucket create 613parts-catalog
npx wrangler kv namespace create SEARCH_CACHE
03 · SECRETS

Set Stripe key

Stored encrypted, only readable inside the Worker.

npx wrangler secret put STRIPE_SECRET
04 · LOCAL TEST

Run locally

Hot reload, full Worker runtime, real R2 + KV bindings.

npm run dev
# Then in another terminal:
curl -X POST http://localhost:8787/api/v1/er-search \
  -H 'content-type: application/json' \
  -d '{"cf_query":"oil filter civic"}'
05 · DEPLOY

Push to Cloudflare

Builds + deploys in ~10 seconds. Live globally on edge in 30 sec.

npm run deploy
# Returns: https://613parts-bot-worker.<account>.workers.dev
06 · ROUTE

Wire to ManyChat

In ManyChat: Settings → External Requests → Add. Method POST, URL = your Worker URL + path. Map ManyChat custom fields to JSON body.

# In ManyChat External Request config:
URL:    https://bot.613parts.ca/api/v1/er-search
Method: POST
Body:   { "cf_query": "{{cf_query}}", "cf_vehicle_id": "{{cf_vehicle_id}}" }
Pre-launch checklist

test these before pointing real ad traffic.

Twelve scenarios covering happy paths, edge cases, and failure modes. Each one is a curl command — run them all green and you're production-ready.

er_search returns 3 cards for "oil filter civic"

Curl with vehicle_id=honda_civic_2018_15t and confirm response has 3 elements with fitment_confirmed=true.

er_search returns no-match message for gibberish

Send cf_query="zxqwerty", confirm 200 OK with cf_match_count=0.

er_search exact SKU match scores highest

Send the exact SKU of a known part — confirm it returns first.

er_fulfillment in-zone + small order

K8N 4Z2 + $249 + tire_package=false → 3 lanes, default pickup_belleville, fee $15.

er_fulfillment in-zone + large order

K7L 4V1 + $419 + tire_package=false → 3 lanes (incl drop_free), default drop_free, fee $0.

er_fulfillment in-zone + tire package

K8P 1X9 + $1249 + tire_package=true → drop_free is default regardless of total.

er_fulfillment out-of-zone

M5V 2T6 + $249 (Toronto) → only 2 lanes, both pickup.

er_fulfillment normalizes postal codes

"k8n4z2", "K8N 4Z2", "K8N4Z2" all return identical results.

er_search handles missing query gracefully

Empty body → 200 OK with helpful prompt, NOT a 400.

Cold start under 30ms

First request after deploy → check tail logs, p99 should be <50ms.

Catalog cache invalidation works

Upload new catalog to R2 → DELETE the KV key → next request reloads.

Worker error returns graceful fallback

Force a throw (e.g. malformed catalog JSON) → bot still gets a 200 with "talk to human" message.