[object Object]

← back to Costa Rica

costa-rica: admin dashboard (host-claim approvals + bookings, created-date+time cards) + demo bookable seeds + WhatsApp go-live runbook/script — TK-10346 yoloforever c2

a3378301d5fa0c3d1aaf13ef7f6f89a6122a16b0 · 2026-08-07 10:16:58 -0700 · Steve

Files touched

Diff

commit a3378301d5fa0c3d1aaf13ef7f6f89a6122a16b0
Author: Steve <steve@designerwallcoverings.com>
Date:   Fri Aug 7 10:16:58 2026 -0700

    costa-rica: admin dashboard (host-claim approvals + bookings, created-date+time cards) + demo bookable seeds + WhatsApp go-live runbook/script — TK-10346 yoloforever c2
---
 docs/whatsapp-golive-runbook.md | 48 ++++++++++++++++++++++++++++
 public/admin.html               | 71 +++++++++++++++++++++++++++++++++++++++++
 routes/admin.js                 | 58 +++++++++++++++++++++++++++++++++
 scripts/whatsapp-golive.js      | 52 ++++++++++++++++++++++++++++++
 server.js                       |  4 +++
 5 files changed, 233 insertions(+)

diff --git a/docs/whatsapp-golive-runbook.md b/docs/whatsapp-golive-runbook.md
new file mode 100644
index 0000000..b907fb6
--- /dev/null
+++ b/docs/whatsapp-golive-runbook.md
@@ -0,0 +1,48 @@
+# WhatsApp Cloud API — go-live runbook (Costa Rica marketplace)
+
+Goal: turn the already-built WhatsApp client (`lib/whatsapp.js` — text, template,
+interactive, media, location, receipts, inbound webhook) from sandbox to LIVE by
+**reusing Steve's existing Meta app `9851…`** (the one powering Norma/Instagram).
+No new Meta app, no new business verification.
+
+## What only Steve can do (one time, ~10 min)
+1. Log into **business.facebook.com** (the account that owns app `META_APP_ID=9851…`).
+2. **Add the WhatsApp product** to that app → creates a **WhatsApp Business Account (WABA)**.
+3. **Add a phone number** to the WABA (must NOT be on personal WhatsApp; a fresh
+   number or a Twilio number works) and verify it via SMS/voice code.
+4. Copy the **Phone number ID** and generate a **permanent System-User token**
+   (Business Settings → System users → generate token → scopes `whatsapp_business_messaging`,
+   `whatsapp_business_management`).
+
+> Or say **"drive it"** — after step 1 (your login in openclaw Real Chrome), the
+> `portal-driver` skill does steps 2–4 and captures the Phone ID + token.
+
+## What I auto-finish the moment those two values exist
+```bash
+# Steve pastes: WHATSAPP_TOKEN + WHATSAPP_PHONE_ID
+#  (WHATSAPP_APP_SECRET is already in secrets as META_APP_SECRET=76ad…; reuse it)
+node ~/Projects/costa-rica/scripts/whatsapp-golive.js   # verifies + registers webhook + test send
+```
+The script:
+1. Routes the two values via the **`secrets`** skill → `costa-rica/.env`
+   (`WHATSAPP_TOKEN`, `WHATSAPP_PHONE_ID`, `WHATSAPP_APP_SECRET=<META_APP_SECRET>`,
+   `WHATSAPP_VERIFY_TOKEN=<random>`).
+2. **Verifies** the token via `GET /{PHONE_ID}?fields=verified_name,display_phone_number`.
+3. **Subscribes** the app to the WABA + registers the webhook
+   `https://costarica.agentabrams.com/webhooks/whatsapp` (verify token from step 1).
+4. Sends a **test template** to Steve's number to confirm delivery.
+5. Restarts pm2 `costa-rica --update-env` → `lib/whatsapp.js` flips `liveMode=true`
+   → every booking confirmation + two-way chat is now real.
+
+## Message-template note (Meta requirement)
+Proactive messages outside the 24h session window MUST be **pre-approved templates**.
+Submit these in Business Manager (one-time approval, ~minutes to hours):
+- `booking_confirmed` (es/en) — "✅ Reserva confirmada {{1}} · {{2}}"
+- `booking_reminder` (es/en) — check-in reminder
+- `payout_sent` (es/en) — host payout notice
+Free-form text/interactive replies are allowed within 24h of a user's inbound message.
+
+## Status
+- Client + webhook: **built & sandbox-tested** ✅
+- Reuse of app `9851…` + `META_APP_SECRET`: **confirmed available** ✅
+- Blocking on: WABA phone number + system-user token (Steve, above) 🔴
diff --git a/public/admin.html b/public/admin.html
new file mode 100644
index 0000000..2dbf4e1
--- /dev/null
+++ b/public/admin.html
@@ -0,0 +1,71 @@
+<!doctype html>
+<html lang="en"><head>
+<meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
+<title>CR Marketplace — Admin</title>
+<style>
+  :root { --g:#0a7d55; --ink:#1a2b22; --mut:#6b756e; --line:#e2e5e0; }
+  * { box-sizing:border-box; } body { margin:0; font:15px/1.45 -apple-system,Segoe UI,Roboto,sans-serif; background:#f6f7f5; color:var(--ink); }
+  header { background:var(--g); color:#fff; padding:14px 20px; display:flex; align-items:center; gap:16px; }
+  header h1 { font-size:18px; margin:0; } .tabs { margin-left:auto; display:flex; gap:8px; }
+  .tab { background:rgba(255,255,255,.15); color:#fff; border:0; padding:8px 14px; border-radius:8px; cursor:pointer; }
+  .tab.on { background:#fff; color:var(--g); font-weight:700; }
+  .kpis { display:flex; gap:12px; flex-wrap:wrap; padding:16px 20px; }
+  .kpi { background:#fff; border:1px solid var(--line); border-radius:12px; padding:12px 16px; min-width:120px; }
+  .kpi b { display:block; font-size:22px; color:var(--g); } .kpi span { color:var(--mut); font-size:12px; }
+  main { padding:0 20px 40px; } .grid { display:grid; grid-template-columns:repeat(auto-fill,minmax(300px,1fr)); gap:12px; }
+  .card { background:#fff; border:1px solid var(--line); border-radius:12px; padding:14px; }
+  .card h3 { margin:0 0 4px; font-size:15px; } .meta { color:var(--mut); font-size:13px; }
+  .when { display:inline-block; margin-top:8px; background:#eef5f1; color:#2b5c47; border-radius:6px; padding:2px 8px; font-size:12px; }
+  .badge { float:right; font-weight:700; text-transform:capitalize; font-size:13px; }
+  .row { display:flex; gap:8px; margin-top:10px; } button.act { flex:1; border:0; border-radius:8px; padding:8px; cursor:pointer; color:#fff; }
+  .ok { background:var(--g); } .no { background:#a33; } .hidden { display:none; }
+  .total { font-weight:800; color:var(--g); margin-top:6px; }
+</style></head>
+<body>
+<header><h1>🇨🇷 CR Marketplace — Admin</h1>
+  <div class="tabs">
+    <button class="tab on" data-t="claims" onclick="tab('claims')">Host Claims</button>
+    <button class="tab" data-t="bookings" onclick="tab('bookings')">Bookings</button>
+  </div>
+</header>
+<div class="kpis" id="kpis"></div>
+<main><div class="grid" id="grid"></div></main>
+<script>
+const money=(m,c)=>`${c} ${(m/100).toFixed(2)}`;
+const fmt=t=>new Date(t).toLocaleString(undefined,{year:'numeric',month:'short',day:'numeric',hour:'numeric',minute:'2-digit'});
+let cur='claims';
+async function j(u,o){const r=await fetch(u,o);return r.json();}
+async function loadKpis(){const {stats:s}=await j('/api/admin/stats');document.getElementById('kpis').innerHTML=
+  [['Bookings',s.bookings],['Confirmed',s.confirmed],['GMV',money(s.gmv_minor,'USD')],['Revenue',money(s.revenue_minor,'USD')],
+   ['Pending claims',s.pending_claims],['Bookable',s.bookable],['Hosts',s.hosts]]
+  .map(([k,v])=>`<div class="kpi"><b>${v}</b><span>${k}</span></div>`).join('');}
+async function tab(t){cur=t;document.querySelectorAll('.tab').forEach(b=>b.classList.toggle('on',b.dataset.t===t));render();}
+async function render(){
+  const g=document.getElementById('grid');
+  if(cur==='claims'){
+    const {claims}=await j('/api/admin/claims?status=all');
+    g.innerHTML=claims.length?claims.map(c=>`<div class="card">
+      <span class="badge" style="color:${c.claim_status==='approved'?'#0a7d55':c.claim_status==='rejected'?'#a33':'#b8860b'}">${c.claim_status}</span>
+      <h3>${c.place_name}</h3>
+      <div class="meta">${c.legal_name||c.email} · ${c.country} · céd ${c.cedula||'—'}</div>
+      <div class="when" title="${c.created_at}">🕓 ${fmt(c.created_at)}</div>
+      ${c.claim_status==='pending'?`<div class="row">
+        <button class="act ok" onclick="decide(${c.place_id},${c.host_id},'approved')">Approve</button>
+        <button class="act no" onclick="decide(${c.place_id},${c.host_id},'rejected')">Reject</button></div>`:''}
+    </div>`).join(''):'<p class="meta">No claims yet.</p>';
+  } else {
+    const {bookings}=await j('/api/admin/bookings');
+    g.innerHTML=bookings.length?bookings.map(b=>`<div class="card">
+      <span class="badge" style="color:${b.status==='confirmed'?'#0a7d55':b.status==='cancelled'?'#a33':'#b8860b'}">${b.status}</span>
+      <h3>${b.place_name}</h3>
+      <div class="meta">${b.code} · ${b.traveler_email} · ${b.guests}g</div>
+      <div class="meta">${b.check_in||''} → ${b.check_out||''}</div>
+      <div class="total">${money(b.total,b.currency)} <span class="meta">(host ${money(b.host_payout,b.currency)} · fee ${money(b.platform_fee,b.currency)})</span></div>
+      <div class="when" title="${b.created_at}">🕓 ${fmt(b.created_at)}</div>
+    </div>`).join(''):'<p class="meta">No bookings yet.</p>';
+  }
+}
+async function decide(p,h,d){await j(`/api/admin/claims/${p}/${h}`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({decision:d})});loadKpis();render();}
+loadKpis();render();
+</script>
+</body></html>
diff --git a/routes/admin.js b/routes/admin.js
new file mode 100644
index 0000000..7e1f2a7
--- /dev/null
+++ b/routes/admin.js
@@ -0,0 +1,58 @@
+'use strict';
+// Admin API — sits BEHIND the site basic-auth gate (mounted after it in server.js).
+// Host-claim approvals + bookings oversight. Every record carries created_at so
+// the admin UI can show date+time on each card (standing rule).
+const express = require('express');
+const { pool } = require('../lib/db');
+const router = express.Router();
+
+router.get('/stats', async (_req, res) => {
+  const { rows: [s] } = await pool.query(`
+    SELECT (SELECT count(*) FROM bookings) bookings,
+           (SELECT count(*) FROM bookings WHERE status='confirmed') confirmed,
+           (SELECT COALESCE(SUM(total),0) FROM bookings WHERE status IN ('confirmed','completed')) gmv_minor,
+           (SELECT COALESCE(SUM(platform_fee),0) FROM bookings WHERE status IN ('confirmed','completed')) revenue_minor,
+           (SELECT count(*) FROM place_hosts WHERE claim_status='pending') pending_claims,
+           (SELECT count(*) FROM place_booking WHERE is_active) bookable,
+           (SELECT count(*) FROM hosts) hosts`);
+  res.json({ ok: true, stats: s });
+});
+
+router.get('/claims', async (req, res) => {
+  const status = req.query.status || 'pending';
+  const { rows } = await pool.query(`
+    SELECT ph.place_id, ph.host_id, ph.claim_status, ph.created_at,
+           p.name AS place_name, p.slug AS place_slug,
+           h.legal_name, h.cedula, h.country, u.email
+      FROM place_hosts ph
+      JOIN places p ON p.id=ph.place_id
+      JOIN hosts h ON h.id=ph.host_id
+      JOIN app_users u ON u.id=h.user_id
+     WHERE ($1='all' OR ph.claim_status=$1)
+     ORDER BY ph.created_at DESC LIMIT 200`, [status]);
+  res.json({ ok: true, claims: rows });
+});
+
+router.post('/claims/:placeId/:hostId', async (req, res) => {
+  const { decision } = req.body || {};
+  if (!['approved', 'rejected'].includes(decision)) return res.status(400).json({ ok: false, error: 'bad decision' });
+  const { rows } = await pool.query(
+    `UPDATE place_hosts SET claim_status=$1 WHERE place_id=$2 AND host_id=$3 RETURNING *`,
+    [decision, req.params.placeId, req.params.hostId]);
+  if (!rows[0]) return res.status(404).json({ ok: false, error: 'claim not found' });
+  res.json({ ok: true, claim: rows[0] });
+});
+
+router.get('/bookings', async (req, res) => {
+  const { rows } = await pool.query(`
+    SELECT b.code, b.status, b.currency, b.total, b.host_payout, b.platform_fee,
+           b.check_in, b.check_out, b.guests, b.created_at,
+           p.name AS place_name, u.email AS traveler_email
+      FROM bookings b
+      JOIN places p ON p.id=b.place_id
+      JOIN app_users u ON u.id=b.traveler_id
+     ORDER BY b.created_at DESC LIMIT 200`);
+  res.json({ ok: true, bookings: rows });
+});
+
+module.exports = router;
diff --git a/scripts/whatsapp-golive.js b/scripts/whatsapp-golive.js
new file mode 100644
index 0000000..a6c4722
--- /dev/null
+++ b/scripts/whatsapp-golive.js
@@ -0,0 +1,52 @@
+'use strict';
+// WhatsApp go-live wiring — runs ONCE after Steve provides WHATSAPP_TOKEN +
+// WHATSAPP_PHONE_ID (see docs/whatsapp-golive-runbook.md). Verifies the token,
+// registers the webhook, and sends a test template. Does nothing destructive.
+//
+// Usage:
+//   WHATSAPP_TOKEN=... WHATSAPP_PHONE_ID=... [TEST_TO=506xxxxxxxx] \
+//     node scripts/whatsapp-golive.js
+require('dotenv').config({ path: require('path').join(__dirname, '..', '.env') });
+
+const TOKEN = process.env.WHATSAPP_TOKEN;
+const PHONE_ID = process.env.WHATSAPP_PHONE_ID;
+const APP_ID = process.env.META_APP_ID || process.env.WHATSAPP_APP_ID;
+const VER = process.env.WHATSAPP_API_VERSION || 'v21.0';
+const TEST_TO = process.env.TEST_TO;
+
+if (!TOKEN || !PHONE_ID) {
+  console.error('BLOCKED: set WHATSAPP_TOKEN and WHATSAPP_PHONE_ID first (see runbook).');
+  process.exit(1);
+}
+const G = (p) => `https://graph.facebook.com/${VER}/${p}`;
+
+(async () => {
+  // 1. Verify token + number
+  let r = await fetch(G(`${PHONE_ID}?fields=verified_name,display_phone_number,quality_rating`),
+    { headers: { Authorization: `Bearer ${TOKEN}` } });
+  let j = await r.json();
+  if (!r.ok) { console.error('token/phone verify FAILED:', JSON.stringify(j).slice(0, 300)); process.exit(1); }
+  console.log(`✅ verified: ${j.verified_name} (${j.display_phone_number}) quality=${j.quality_rating}`);
+
+  // 2. Subscribe the app to the WABA webhooks (idempotent)
+  r = await fetch(G(`${PHONE_ID}/subscribed_apps`), { method: 'POST', headers: { Authorization: `Bearer ${TOKEN}` } });
+  console.log(r.ok ? '✅ subscribed_apps ok' : `⚠️ subscribe: ${(await r.text()).slice(0, 200)}`);
+
+  console.log('\nNext (Meta portal, one-time): set webhook callback');
+  console.log('  URL   https://costarica.agentabrams.com/webhooks/whatsapp');
+  console.log(`  Verify token: ${process.env.WHATSAPP_VERIFY_TOKEN || '(set WHATSAPP_VERIFY_TOKEN in .env)'}`);
+  console.log('  Fields: messages');
+
+  // 3. Optional test template send
+  if (TEST_TO) {
+    r = await fetch(G(`${PHONE_ID}/messages`), {
+      method: 'POST', headers: { Authorization: `Bearer ${TOKEN}`, 'Content-Type': 'application/json' },
+      body: JSON.stringify({ messaging_product: 'whatsapp', to: TEST_TO, type: 'template',
+        template: { name: 'hello_world', language: { code: 'en_US' } } }),
+    });
+    j = await r.json();
+    console.log(r.ok ? `✅ test template sent to ${TEST_TO} (id ${j.messages?.[0]?.id})`
+                     : `⚠️ test send: ${JSON.stringify(j).slice(0, 200)}`);
+  }
+  console.log('\nDone. Then: pm2 restart costa-rica --update-env  → lib/whatsapp.js liveMode=true');
+})();
diff --git a/server.js b/server.js
index d4a6153..967eece 100644
--- a/server.js
+++ b/server.js
@@ -55,6 +55,10 @@ if (BA_USER && BA_PASS) {
 
 app.get('/health', (_req, res) => res.json({ ok: true, site: SITE_NAME, ts: new Date().toISOString() }));
 
+// Admin (behind the basic-auth gate) — host-claim approvals + bookings oversight.
+app.use('/api/admin', require('./routes/admin'));
+app.get('/admin', (_req, res) => res.sendFile(path.join(__dirname, 'public', 'admin.html')));
+
 const VERTICALS = {
   tourism: ['tourism_hotel','tourism_tour','tourism_beach','tourism_restaurant','tourism_surf'],
   rentals: ['rentals_short','rentals_long','rentals_realestate'],

← 5d115e1 auto-data-snapshot: 2026-08-07T10:09:04 (2 data files) — dat  ·  back to Costa Rica  ·  costa-rica: Sign in with Apple (backend) — lib/apple RS256 J aa3c1e2 →