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 }