/ workers / auditandfix-api / src / index.js
index.js
  1  /**
  2   * Audit&Fix API Worker
  3   *
  4   * Cloudflare Worker that bridges auditandfix.com (PHP) with 333Method server.
  5   * Stores purchases in KV queue and serves pricing data.
  6   *
  7   * KV Namespaces:
  8   *   PURCHASES   - Queued purchase data (polled by 333Method every 5 min)
  9   *   PRICING     - Country pricing JSON (updated weekly by 333Method cron)
 10   *   SCANS       - Free scan results (polled by 333Method every 5 min for SQLite archival)
 11   *   VIDEO_DEMOS - Self-serve video demo requests (polled by 2Step pipeline)
 12   *
 13   * Scan flow:
 14   *   POST /scan            → fetch HTML → score → store in SCANS KV → return public result
 15   *   POST /scan/:id/email  → attach email to existing scan
 16   *   GET  /scan/:id        → retrieve public scan result
 17   *   GET  /scans/pending   → authenticated — poll for unprocessed scans (NixOS daemon)
 18   *   DELETE /scans/:id     → authenticated — mark scan processed (NixOS daemon)
 19   *
 20   * Video-demo flow:
 21   *   POST /video-demo           → create pending demo request, return demo_id
 22   *   GET  /video-demo/:id       → public status check (pending/verified/processing/ready)
 23   *   POST /video-demo/:id/email → attach email, mark verified
 24   *   GET  /video-demos/pending  → authenticated — 2Step pipeline polls verified demos
 25   *   DELETE /video-demos/:key   → authenticated — pipeline marks complete with video_url
 26   */
 27  
 28  import { scoreWebsite } from './scorer.js';
 29  
 30  const CORS_HEADERS = {
 31    'Access-Control-Allow-Origin': '*',
 32    'Access-Control-Allow-Methods': 'GET, POST, DELETE, OPTIONS',
 33    'Access-Control-Allow-Headers': 'Content-Type, X-Auth-Secret',
 34  };
 35  
 36  function jsonResponse(data, status = 200) {
 37    return new Response(JSON.stringify(data), {
 38      status,
 39      headers: { 'Content-Type': 'application/json', ...CORS_HEADERS },
 40    });
 41  }
 42  
 43  function errorResponse(message, status = 400) {
 44    return jsonResponse({ error: message }, status);
 45  }
 46  
 47  function requireAuth(request, env) {
 48    const secret = request.headers.get('X-Auth-Secret');
 49    if (!secret || secret !== env.AUTH_SECRET) {
 50      return errorResponse('Unauthorized', 401);
 51    }
 52    return null;
 53  }
 54  
 55  export default {
 56    async fetch(request, env) {
 57      if (request.method === 'OPTIONS') {
 58        return new Response(null, { status: 204, headers: CORS_HEADERS });
 59      }
 60  
 61      const url = new URL(request.url);
 62      const path = url.pathname;
 63  
 64      // Health check (public)
 65      if (path === '/health' && request.method === 'GET') {
 66        return jsonResponse({
 67          status: 'ok',
 68          service: 'auditandfix-api',
 69          timestamp: new Date().toISOString(),
 70        });
 71      }
 72  
 73      // Pricing endpoints
 74      if (path === '/pricing') {
 75        if (request.method === 'GET') {
 76          return handleGetPricing(env);
 77        }
 78        if (request.method === 'POST') {
 79          const authError = requireAuth(request, env);
 80          if (authError) return authError;
 81          return handlePostPricing(request, env);
 82        }
 83      }
 84  
 85      // Purchase endpoints
 86      if (path === '/purchases') {
 87        if (request.method === 'POST') {
 88          const authError = requireAuth(request, env);
 89          if (authError) return authError;
 90          return handlePostPurchase(request, env);
 91        }
 92        if (request.method === 'GET') {
 93          const authError = requireAuth(request, env);
 94          if (authError) return authError;
 95          return handleGetPurchases(env);
 96        }
 97      }
 98  
 99      // Delete individual purchase
100      const deleteMatch = path.match(/^\/purchases\/(.+)$/);
101      if (deleteMatch && request.method === 'DELETE') {
102        const authError = requireAuth(request, env);
103        if (authError) return authError;
104        return handleDeletePurchase(deleteMatch[1], env);
105      }
106  
107      // ── Free Scan endpoints ─────────────────────────────────────────────────
108  
109      // POST /scan  { url, utm_source?, utm_medium?, utm_campaign?, ref? }
110      if (path === '/scan' && request.method === 'POST') {
111        return handlePostScan(request, env);
112      }
113  
114      // POST /scan/:id/email  { email }
115      const emailMatch = path.match(/^\/scan\/([a-f0-9-]+)\/email$/);
116      if (emailMatch && request.method === 'POST') {
117        return handlePostScanEmail(emailMatch[1], request, env);
118      }
119  
120      // GET /scan/:id
121      const scanGetMatch = path.match(/^\/scan\/([a-f0-9-]+)$/);
122      if (scanGetMatch && request.method === 'GET') {
123        return handleGetScan(scanGetMatch[1], env);
124      }
125  
126      // GET /scans/pending — authenticated, for NixOS poll daemon
127      if (path === '/scans/pending' && request.method === 'GET') {
128        const authError = requireAuth(request, env);
129        if (authError) return authError;
130        return handleGetPendingScans(env);
131      }
132  
133      // DELETE /scans/:id — authenticated, mark scan as processed
134      const scanDeleteMatch = path.match(/^\/scans\/(.+)$/);
135      if (scanDeleteMatch && request.method === 'DELETE') {
136        const authError = requireAuth(request, env);
137        if (authError) return authError;
138        return handleDeleteScan(scanDeleteMatch[1], env);
139      }
140  
141      // ── Video Demo endpoints ────────────────────────────────────────────────
142  
143      // POST /video-demo  { business_name, place_id, niche, city, country_code, utm_* }
144      if (path === '/video-demo' && request.method === 'POST') {
145        return handlePostVideoDemo(request, env);
146      }
147  
148      // POST /video-demo/:id/email  { email }
149      // (must match before GET /video-demo/:id)
150      const videoDemoEmailMatch = path.match(/^\/video-demo\/([a-f0-9-]+)\/email$/);
151      if (videoDemoEmailMatch && request.method === 'POST') {
152        return handlePostVideoDemoEmail(videoDemoEmailMatch[1], request, env);
153      }
154  
155      // GET /video-demo/:id
156      const videoDemoGetMatch = path.match(/^\/video-demo\/([a-f0-9-]+)$/);
157      if (videoDemoGetMatch && request.method === 'GET') {
158        return handleGetVideoDemo(videoDemoGetMatch[1], env);
159      }
160  
161      // GET /video-demos/pending — authenticated, 2Step pipeline polls this
162      if (path === '/video-demos/pending' && request.method === 'GET') {
163        const authError = requireAuth(request, env);
164        if (authError) return authError;
165        return handleGetPendingVideoDemos(env);
166      }
167  
168      // DELETE /video-demos/:key — authenticated, pipeline marks demo complete
169      const videoDemoDeleteMatch = path.match(/^\/video-demos\/(.+)$/);
170      if (videoDemoDeleteMatch && request.method === 'DELETE') {
171        const authError = requireAuth(request, env);
172        if (authError) return authError;
173        return handleDeleteVideoDemo(videoDemoDeleteMatch[1], request, env);
174      }
175  
176      return errorResponse('Not Found', 404);
177    },
178  };
179  
180  /**
181   * GET /pricing - Public endpoint serving country pricing JSON
182   */
183  async function handleGetPricing(env) {
184    const data = await env.PRICING.get('pricing_data', { type: 'json' });
185    if (!data) {
186      return jsonResponse({});
187    }
188    return jsonResponse(data);
189  }
190  
191  /**
192   * POST /pricing - 333Method pushes updated pricing (authenticated)
193   */
194  async function handlePostPricing(request, env) {
195    try {
196      const data = await request.json();
197      await env.PRICING.put('pricing_data', JSON.stringify(data));
198      return jsonResponse({ success: true, countries: Object.keys(data).length });
199    } catch (e) {
200      return errorResponse(`Invalid JSON: ${e.message}`);
201    }
202  }
203  
204  /**
205   * POST /purchases - PHP site submits a new purchase (authenticated)
206   */
207  async function handlePostPurchase(request, env) {
208    try {
209      const purchase = await request.json();
210  
211      // Validate required fields
212      const required = [
213        'email',
214        'landing_page_url',
215        'paypal_order_id',
216        'amount',
217        'currency',
218        'amount_usd',
219      ];
220      for (const field of required) {
221        if (!purchase[field]) {
222          return errorResponse(`Missing required field: ${field}`);
223        }
224      }
225  
226      // Validate email format
227      if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(purchase.email)) {
228        return errorResponse('Invalid email format');
229      }
230  
231      // Validate URL format
232      try {
233        new URL(purchase.landing_page_url);
234      } catch {
235        return errorResponse('Invalid landing_page_url format');
236      }
237  
238      // Add timestamp
239      purchase.created_at = new Date().toISOString();
240  
241      // Store in KV with unique key
242      const key = `purchase_${Date.now()}_${purchase.paypal_order_id}`;
243      await env.PURCHASES.put(key, JSON.stringify(purchase));
244  
245      return jsonResponse({ success: true, id: key }, 201);
246    } catch (e) {
247      return errorResponse(`Invalid request: ${e.message}`);
248    }
249  }
250  
251  /**
252   * GET /purchases - 333Method polls for unprocessed purchases (authenticated)
253   */
254  async function handleGetPurchases(env) {
255    const list = await env.PURCHASES.list({ prefix: 'purchase_' });
256    const purchases = [];
257  
258    for (const key of list.keys) {
259      const data = await env.PURCHASES.get(key.name, { type: 'json' });
260      if (data) {
261        purchases.push({ id: key.name, ...data });
262      }
263    }
264  
265    return jsonResponse({ purchases, count: purchases.length });
266  }
267  
268  /**
269   * DELETE /purchases/:id - 333Method marks purchase as processed (authenticated)
270   */
271  async function handleDeletePurchase(id, env) {
272    const existing = await env.PURCHASES.get(id);
273    if (!existing) {
274      return errorResponse('Purchase not found', 404);
275    }
276  
277    await env.PURCHASES.delete(id);
278    return jsonResponse({ success: true, deleted: id });
279  }
280  
281  // ─── Free Scan Handlers ───────────────────────────────────────────────────────
282  
283  const FETCH_TIMEOUT_MS = 15000;
284  const RATE_LIMIT_TTL = 3600; // 1 hour KV TTL for rate limit entries
285  const MAX_SCANS_PER_HOUR = 10;
286  const CACHE_TTL_SECONDS = 86400; // 24h cache
287  const SCAN_KV_TTL = 86400 * 7; // 7 days before KV auto-expiry
288  
289  function normaliseScanUrl(raw) {
290    let url = (raw || '').trim();
291    if (!url.startsWith('http://') && !url.startsWith('https://')) {
292      url = `https://${url}`;
293    }
294    try {
295      const parsed = new URL(url);
296      if (parsed.hostname === 'auditandfix.com' || parsed.hostname === 'www.auditandfix.com') {
297        return null;
298      }
299      return parsed.href;
300    } catch {
301      return null;
302    }
303  }
304  
305  function extractDomain(url) {
306    try {
307      return new URL(url).hostname.replace(/^www\./, '');
308    } catch {
309      return url;
310    }
311  }
312  
313  function generateUUID() {
314    // Crypto is available in CF Workers
315    return crypto.randomUUID();
316  }
317  
318  /**
319   * Build the sanitised public response from a full scan record.
320   * Full factor scores are stored in KV but NOT returned to the client.
321   */
322  function buildPublicResponse(record) {
323    const fs = record.factor_scores;
324  
325    // Traffic-light summary (factor name → 'good'|'fair'|'needs_work')
326    const factor_summary = fs
327      ? Object.fromEntries(
328          Object.entries(fs).map(([f, d]) => {
329            const s = d?.score ?? 0;
330            return [f, s >= 7 ? 'good' : s >= 4 ? 'fair' : 'needs_work'];
331          })
332        )
333      : {};
334  
335    // Weakest factor (free peek)
336    let free_peek = null;
337    if (fs) {
338      let weakestScore = Infinity;
339      let weakest = null;
340      for (const [factor, data] of Object.entries(fs)) {
341        if ((data?.score ?? 10) < weakestScore) {
342          weakestScore = data.score;
343          weakest = { factor, ...data };
344        }
345      }
346      if (weakest) {
347        free_peek = {
348          factor: weakest.factor,
349          score: weakest.score,
350          reasoning: weakest.reasoning || null,
351          // evidence intentionally omitted
352        };
353      }
354    }
355  
356    // Count factors that aren't "good" (score < 7) — these are the opportunities
357    const issues_count = fs ? Object.values(fs).filter(d => (d?.score ?? 10) < 7).length : 0;
358  
359    return {
360      scan_id: record.scan_id,
361      domain: record.domain,
362      score: Math.round(record.score),
363      grade: record.grade,
364      factor_summary,
365      free_peek,
366      issues_count,
367      industry: record.industry,
368      country_code: record.country_code,
369      is_js_heavy: !!record.is_js_heavy,
370      cached: record.cached || false,
371    };
372  }
373  
374  /**
375   * POST /scan  { url, utm_source?, utm_medium?, utm_campaign?, ref? }
376   * Fetch HTML, score it, cache result in KV, return sanitised public response.
377   */
378  async function handlePostScan(request, env) {
379    const clientIp =
380      request.headers.get('CF-Connecting-IP') ||
381      request.headers.get('X-Forwarded-For')?.split(',')[0]?.trim() ||
382      'unknown';
383  
384    // Rate limit: 10 scans / IP / hour using KV
385    if (env.SCANS) {
386      const rlKey = `rl_${clientIp}`;
387      const existing = await env.SCANS.get(rlKey, { type: 'json' });
388      const count = existing?.count || 0;
389      if (count >= MAX_SCANS_PER_HOUR) {
390        return errorResponse('Too many scans — try again in an hour.', 429);
391      }
392      await env.SCANS.put(rlKey, JSON.stringify({ count: count + 1 }), {
393        expirationTtl: RATE_LIMIT_TTL,
394      });
395    }
396  
397    let body;
398    try {
399      body = await request.json();
400    } catch {
401      return errorResponse('Invalid JSON body');
402    }
403  
404    const rawUrl = body?.url;
405    if (!rawUrl || typeof rawUrl !== 'string') {
406      return errorResponse('url required');
407    }
408  
409    const url = normaliseScanUrl(rawUrl);
410    if (!url) {
411      return errorResponse('Invalid or unsupported URL');
412    }
413  
414    const domain = extractDomain(url);
415    const { utm_source, utm_medium, utm_campaign, ref } = body;
416  
417    // Check 24h cache
418    if (env.SCANS) {
419      const cacheKey = `cache_${domain}`;
420      const cached = await env.SCANS.get(cacheKey, { type: 'json' });
421      if (cached) {
422        return jsonResponse({ ...buildPublicResponse(cached), cached: true });
423      }
424    }
425  
426    // Fetch HTML
427    let html, finalUrl;
428    try {
429      const controller = new AbortController();
430      const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
431      const res = await fetch(url, {
432        signal: controller.signal,
433        headers: {
434          'User-Agent':
435            'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36',
436          Accept: 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
437          'Accept-Language': 'en-US,en;q=0.5',
438        },
439        redirect: 'follow',
440      });
441      clearTimeout(timer);
442  
443      if (!res.ok) {
444        return jsonResponse({ error: `Could not fetch that URL: HTTP ${res.status}` }, 422);
445      }
446      const contentType = res.headers.get('content-type') || '';
447      if (!contentType.includes('html')) {
448        return jsonResponse({ error: 'Could not fetch that URL: Not an HTML page' }, 422);
449      }
450      html = await res.text();
451      finalUrl = res.url;
452    } catch (err) {
453      if (err.name === 'AbortError') {
454        return jsonResponse({ error: 'Could not fetch that URL: Request timed out' }, 422);
455      }
456      return jsonResponse({ error: 'Scoring failed — please try again' }, 500);
457    }
458  
459    // Score
460    const result = scoreWebsite(html, finalUrl || url);
461    const scanId = generateUUID();
462  
463    const record = {
464      scan_id: scanId,
465      url: finalUrl || url,
466      domain,
467      ip_address: clientIp,
468      score: result.conversion_score ?? 0,
469      grade: result.letter_grade ?? 'F',
470      factor_scores: result.factor_scores,
471      industry: result.industry_classification || null,
472      country_code: result.country_code || null,
473      is_js_heavy: result.is_js_heavy ? 1 : 0,
474      utm_source: utm_source || null,
475      utm_medium: utm_medium || null,
476      utm_campaign: utm_campaign || null,
477      ref: ref || null,
478      email: null,
479      created_at: new Date().toISOString(),
480      processed: false,
481    };
482  
483    if (env.SCANS) {
484      // Store full record under scan_id (for NixOS archival)
485      await env.SCANS.put(`scan_${scanId}`, JSON.stringify(record), { expirationTtl: SCAN_KV_TTL });
486      // Store cache entry under domain (24h)
487      await env.SCANS.put(`cache_${domain}`, JSON.stringify(record), {
488        expirationTtl: CACHE_TTL_SECONDS,
489      });
490    }
491  
492    return jsonResponse(buildPublicResponse(record), 200);
493  }
494  
495  /**
496   * POST /scan/:id/email  { email }
497   * Attach an email address to an existing scan (email gate capture).
498   */
499  async function handlePostScanEmail(scanId, request, env) {
500    if (!env.SCANS) return jsonResponse({ success: true }); // graceful degradation
501  
502    let body;
503    try {
504      body = await request.json();
505    } catch {
506      return errorResponse('Invalid JSON body');
507    }
508  
509    const email = (body?.email || '').trim().toLowerCase().slice(0, 254);
510    if (!email || !email.includes('@')) {
511      return errorResponse('Valid email required');
512    }
513  
514    const record = await env.SCANS.get(`scan_${scanId}`, { type: 'json' });
515    if (!record) {
516      return errorResponse('Scan not found', 404);
517    }
518  
519    if (!record.email) {
520      record.email = email;
521      record.email_captured_at = new Date().toISOString();
522      // marketing_optin: explicit boolean from the checkbox (default false if absent)
523      record.marketing_optin = body?.marketing_optin === true || body?.marketing_optin === 1;
524      record.optin_timestamp = record.marketing_optin
525        ? body?.optin_timestamp || record.email_captured_at
526        : null;
527      await env.SCANS.put(`scan_${scanId}`, JSON.stringify(record), { expirationTtl: SCAN_KV_TTL });
528    }
529  
530    return jsonResponse({ success: true });
531  }
532  
533  /**
534   * GET /scan/:id
535   * Return the public scan result for a given scan_id.
536   */
537  async function handleGetScan(scanId, env) {
538    if (!env.SCANS) return errorResponse('Scan not found', 404);
539  
540    const record = await env.SCANS.get(`scan_${scanId}`, { type: 'json' });
541    if (!record) return errorResponse('Scan not found', 404);
542  
543    return jsonResponse(buildPublicResponse(record));
544  }
545  
546  /**
547   * GET /scans/pending
548   * Authenticated. Returns unprocessed scans for the NixOS poll daemon.
549   * The daemon archives these into the SQLite free_scans table.
550   */
551  async function handleGetPendingScans(env) {
552    if (!env.SCANS) return jsonResponse({ scans: [], count: 0 });
553  
554    const list = await env.SCANS.list({ prefix: 'scan_' });
555    const scans = [];
556  
557    for (const key of list.keys) {
558      const data = await env.SCANS.get(key.name, { type: 'json' });
559      if (data && !data.processed) {
560        scans.push({ kv_key: key.name, ...data });
561      }
562    }
563  
564    return jsonResponse({ scans, count: scans.length });
565  }
566  
567  /**
568   * DELETE /scans/:kv_key
569   * Authenticated. NixOS daemon marks a scan as processed after archiving to SQLite.
570   * We set processed=true rather than delete so the scan remains accessible via GET /scan/:id.
571   */
572  async function handleDeleteScan(kvKey, env) {
573    if (!env.SCANS) return jsonResponse({ success: true });
574  
575    const record = await env.SCANS.get(kvKey, { type: 'json' });
576    if (!record) return errorResponse('Scan not found', 404);
577  
578    record.processed = true;
579    await env.SCANS.put(kvKey, JSON.stringify(record), { expirationTtl: SCAN_KV_TTL });
580    return jsonResponse({ success: true, archived: kvKey });
581  }
582  
583  // ─── Video Demo Handlers ──────────────────────────────────────────────────────
584  
585  const DEMO_KV_TTL = 86400 * 30; // 30 days before KV auto-expiry
586  const DEMO_RATE_LIMIT_TTL = 3600; // 1 hour
587  const MAX_DEMOS_PER_HOUR = 3;
588  
589  const NICHE_CONFIG = {
590    pest_control:           { has_clips: true,  label: 'Pest Control' },
591    plumber:                { has_clips: true,  label: 'Plumber' },
592    house_cleaning:         { has_clips: true,  label: 'House Cleaning' },
593    dentist:                { has_clips: false, label: 'Dentist' },
594    electrician:            { has_clips: false, label: 'Electrician' },
595    roofing:                { has_clips: false, label: 'Roofing' },
596    hvac:                   { has_clips: false, label: 'HVAC' },
597    real_estate:            { has_clips: false, label: 'Real Estate' },
598    chiropractor:           { has_clips: false, label: 'Chiropractor' },
599    personal_injury_lawyer: { has_clips: false, label: 'Personal Injury Lawyer' },
600    pool_installer:         { has_clips: false, label: 'Pool Installer' },
601    dog_trainer:            { has_clips: false, label: 'Dog Trainer' },
602    med_spa:                { has_clips: false, label: 'Med Spa' },
603    other:                  { has_clips: false, label: 'Other' },
604  };
605  
606  const DISPOSABLE_EMAIL_DOMAINS = new Set([
607    'mailinator.com',
608    'guerrillamail.com',
609    'tempmail.com',
610    'throwaway.email',
611    'yopmail.com',
612    'sharklasers.com',
613    'guerrillamailblock.com',
614    'grr.la',
615    'dispostable.com',
616    'mailnesia.com',
617    'maildrop.cc',
618    'fakeinbox.com',
619    'trashmail.com',
620    'temp-mail.org',
621    'getnada.com',
622    'tmpmail.net',
623    'mohmal.com',
624    'burnermail.io',
625    'mailcatch.com',
626    'mintemail.com',
627    'tempr.email',
628    'harakirimail.com',
629  ]);
630  
631  /**
632   * POST /video-demo
633   * Create a pending video demo request. Rate limited to 3/hr/IP.
634   * Deduplicates by place_id — if a demo already exists for this business, return it.
635   */
636  async function handlePostVideoDemo(request, env) {
637    if (!env.VIDEO_DEMOS) return errorResponse('Service unavailable', 503);
638  
639    const clientIp =
640      request.headers.get('CF-Connecting-IP') ||
641      request.headers.get('X-Forwarded-For')?.split(',')[0]?.trim() ||
642      'unknown';
643  
644    // Rate limit: 3 demos / IP / hour
645    const rlKey = `rl_demo_${clientIp}`;
646    const existing = await env.VIDEO_DEMOS.get(rlKey, { type: 'json' });
647    const count = existing?.count || 0;
648    if (count >= MAX_DEMOS_PER_HOUR) {
649      return errorResponse('Too many requests — try again in an hour.', 429);
650    }
651  
652    let body;
653    try {
654      body = await request.json();
655    } catch {
656      return errorResponse('Invalid JSON body');
657    }
658  
659    // Validate required fields
660    const businessName = (body?.business_name || '').trim().slice(0, 200);
661    const placeId = (body?.place_id || '').trim().slice(0, 200);
662    const niche = (body?.niche || '').trim().toLowerCase();
663    const city = (body?.city || '').trim().slice(0, 100);
664    const countryCode = (body?.country_code || '').trim().toUpperCase().slice(0, 2);
665  
666    if (!businessName) return errorResponse('business_name required');
667    if (!placeId) return errorResponse('place_id required');
668    if (!niche || !NICHE_CONFIG[niche]) {
669      return errorResponse(`Invalid niche. Valid: ${Object.keys(NICHE_CONFIG).join(', ')}`);
670    }
671    if (!city) return errorResponse('city required');
672    if (!countryCode || countryCode.length !== 2) return errorResponse('country_code required (2-letter ISO)');
673  
674    // Deduplicate by place_id — return existing demo if one exists
675    const placeKey = `place_${placeId}`;
676    const existingDemoId = await env.VIDEO_DEMOS.get(placeKey);
677    if (existingDemoId) {
678      const existingRecord = await env.VIDEO_DEMOS.get(`demo_${existingDemoId}`, { type: 'json' });
679      if (existingRecord) {
680        return jsonResponse({
681          demo_id: existingDemoId,
682          status: existingRecord.status,
683          already_exists: true,
684        });
685      }
686    }
687  
688    // Increment rate limit counter
689    await env.VIDEO_DEMOS.put(rlKey, JSON.stringify({ count: count + 1 }), {
690      expirationTtl: DEMO_RATE_LIMIT_TTL,
691    });
692  
693    const nicheConfig = NICHE_CONFIG[niche];
694    const demoId = crypto.randomUUID();
695  
696    const record = {
697      demo_id: demoId,
698      business_name: businessName,
699      place_id: placeId,
700      niche,
701      city,
702      country_code: countryCode,
703      email: null,
704      email_verified_at: null,
705      status: 'pending',
706      video_url: null,
707      manual_fulfillment: !nicheConfig.has_clips,
708      has_clips: nicheConfig.has_clips,
709      created_at: new Date().toISOString(),
710      ip_address: clientIp,
711      utm_source: body?.utm_source || null,
712      utm_medium: body?.utm_medium || null,
713      utm_campaign: body?.utm_campaign || null,
714    };
715  
716    // Store demo record + place_id dedup index
717    await env.VIDEO_DEMOS.put(`demo_${demoId}`, JSON.stringify(record), {
718      expirationTtl: DEMO_KV_TTL,
719    });
720    await env.VIDEO_DEMOS.put(placeKey, demoId, { expirationTtl: DEMO_KV_TTL });
721  
722    return jsonResponse({ demo_id: demoId, status: 'pending' }, 201);
723  }
724  
725  /**
726   * POST /video-demo/:id/email  { email }
727   * Attach email to a demo record, mark status as 'verified'.
728   * Rejects disposable email domains.
729   */
730  async function handlePostVideoDemoEmail(demoId, request, env) {
731    if (!env.VIDEO_DEMOS) return errorResponse('Service unavailable', 503);
732  
733    let body;
734    try {
735      body = await request.json();
736    } catch {
737      return errorResponse('Invalid JSON body');
738    }
739  
740    const email = (body?.email || '').trim().toLowerCase().slice(0, 254);
741    if (!email || !email.includes('@')) {
742      return errorResponse('Valid email required');
743    }
744  
745    // Check disposable email domains
746    const emailDomain = email.split('@')[1];
747    if (DISPOSABLE_EMAIL_DOMAINS.has(emailDomain)) {
748      return errorResponse('Please use a business email address');
749    }
750  
751    const record = await env.VIDEO_DEMOS.get(`demo_${demoId}`, { type: 'json' });
752    if (!record) return errorResponse('Demo not found', 404);
753  
754    // Only update if email not already set (idempotent)
755    if (!record.email) {
756      record.email = email;
757      record.email_verified_at = new Date().toISOString();
758      record.status = 'verified';
759      await env.VIDEO_DEMOS.put(`demo_${demoId}`, JSON.stringify(record), {
760        expirationTtl: DEMO_KV_TTL,
761      });
762    }
763  
764    return jsonResponse({ success: true, status: record.status });
765  }
766  
767  /**
768   * GET /video-demo/:id
769   * Public status check. Returns status + video_url when ready.
770   * Never returns email (PII).
771   */
772  async function handleGetVideoDemo(demoId, env) {
773    if (!env.VIDEO_DEMOS) return errorResponse('Demo not found', 404);
774  
775    const record = await env.VIDEO_DEMOS.get(`demo_${demoId}`, { type: 'json' });
776    if (!record) return errorResponse('Demo not found', 404);
777  
778    return jsonResponse({
779      demo_id: record.demo_id,
780      business_name: record.business_name,
781      niche: record.niche,
782      city: record.city,
783      status: record.status,
784      video_url: record.status === 'ready' ? record.video_url : null,
785      has_clips: record.has_clips,
786      created_at: record.created_at,
787    });
788  }
789  
790  /**
791   * GET /video-demos/pending
792   * Authenticated. 2Step pipeline polls this to find verified demos awaiting video creation.
793   * Only returns demos with status === 'verified'.
794   */
795  async function handleGetPendingVideoDemos(env) {
796    if (!env.VIDEO_DEMOS) return jsonResponse({ demos: [], count: 0 });
797  
798    const list = await env.VIDEO_DEMOS.list({ prefix: 'demo_' });
799    const demos = [];
800  
801    for (const key of list.keys) {
802      const data = await env.VIDEO_DEMOS.get(key.name, { type: 'json' });
803      if (data && data.status === 'verified') {
804        demos.push({ kv_key: key.name, ...data });
805      }
806    }
807  
808    return jsonResponse({ demos, count: demos.length });
809  }
810  
811  /**
812   * DELETE /video-demos/:kv_key
813   * Authenticated. 2Step pipeline marks a demo as complete with video_url.
814   * Updates the existing KV record — does NOT delete the key.
815   * Expects body: { video_url, status: 'ready' }
816   */
817  async function handleDeleteVideoDemo(kvKey, request, env) {
818    if (!env.VIDEO_DEMOS) return errorResponse('Service unavailable', 503);
819  
820    const record = await env.VIDEO_DEMOS.get(kvKey, { type: 'json' });
821    if (!record) return errorResponse('Demo not found', 404);
822  
823    let body;
824    try {
825      body = await request.json();
826    } catch {
827      return errorResponse('Invalid JSON body');
828    }
829  
830    const videoUrl = (body?.video_url || '').trim();
831    const status = (body?.status || '').trim();
832  
833    if (!videoUrl) return errorResponse('video_url required');
834    if (status !== 'ready' && status !== 'processing') {
835      return errorResponse('status must be "ready" or "processing"');
836    }
837  
838    record.video_url = videoUrl;
839    record.status = status;
840    record.completed_at = new Date().toISOString();
841  
842    await env.VIDEO_DEMOS.put(kvKey, JSON.stringify(record), { expirationTtl: DEMO_KV_TTL });
843    return jsonResponse({ success: true, demo_id: record.demo_id, status: record.status });
844  }