From 0db4ceb7918823266fb4b5b95b05c3e5782a8595 Mon Sep 17 00:00:00 2001 From: aimerdoux Date: Wed, 3 Jun 2026 12:12:27 -0700 Subject: [PATCH 1/7] fix(onboarding): gate unauthenticated control-plane + inference-allocation endpoints MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The wavex-os-server has no global auth hook; every route self-gates. Four mutating onboarding/control-plane endpoints had no assertBoard gate, making them callable by any unauthenticated client that can reach the port: - POST /api/system/pull-and-restart (git pull --ff-only + pnpm install + restart on REPO_ROOT — remote code-pull / RCE-adjacent) - POST /api/system/restart-processes (launchctl kickstart — control-plane DoS) - POST /api/system/discard-local-changes (git stash wipes working tree) - PUT /api/inference-allocation (swarm_pct=100 zeros Pool-A, strands the onboarding wizard a new customer is in front of) Adds the same assertBoard(authReq(req)) gate already used by activate.ts. In WAVEX_AUTH_MODE=dev the gate is a no-op (synthetic local board actor), so Mission Control and the onboarding wizard are unaffected; in production it requires a board actor. Verified: tsc --noEmit exit 0; 18/19 wavex-os-server test files green. The e2e-onboarding failures are pre-existing on main (no inference backend in sandbox) and unrelated to this change. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/routes/inference-allocation.ts | 18 +++++++++- .../src/routes/system-actions.ts | 36 ++++++++++++++++--- 2 files changed, 49 insertions(+), 5 deletions(-) diff --git a/packages/wavex-os-server/src/routes/inference-allocation.ts b/packages/wavex-os-server/src/routes/inference-allocation.ts index 2944d5de..580a1689 100644 --- a/packages/wavex-os-server/src/routes/inference-allocation.ts +++ b/packages/wavex-os-server/src/routes/inference-allocation.ts @@ -18,9 +18,14 @@ */ import { readFile, writeFile, mkdir } from "node:fs/promises"; import { join, dirname } from "node:path"; -import type { FastifyInstance } from "fastify"; +import type { FastifyInstance, FastifyRequest } from "fastify"; +import { assertBoard, AuthError } from "@wavex-os/auth-shim"; import { getWavexDataRoot } from "../state-bridge.js"; +function authReq(req: FastifyRequest) { + return { method: req.method, headers: req.headers as Record }; +} + const DEFAULT_SWARM_PCT = 70; export interface InferenceAllocation { @@ -65,6 +70,17 @@ export function registerInferenceAllocationRoute(app: FastifyInstance): void { }); app.put("/api/inference-allocation", async (req, reply) => { + // Board-only: changing the swarm/Pool-A split is an operator setting. An + // unauthenticated PUT with swarm_pct=100 would zero out Pool A and starve + // the onboarding wizard a new customer is sitting in front of. In dev mode + // the gate is a no-op (synthetic local board actor), so Mission Control + + // onboarding Pillar 2 keep working; in production it requires a board actor. + try { + assertBoard(authReq(req)); + } catch (e) { + if (e instanceof AuthError) return reply.status(e.statusCode).send({ ok: false, error: e.message }); + throw e; + } const body = (req.body ?? {}) as { swarm_pct?: unknown }; if (body.swarm_pct === undefined) { return reply.status(400).send({ ok: false, error: "swarm_pct is required" }); diff --git a/packages/wavex-os-server/src/routes/system-actions.ts b/packages/wavex-os-server/src/routes/system-actions.ts index 88db80c0..7f79c5e0 100644 --- a/packages/wavex-os-server/src/routes/system-actions.ts +++ b/packages/wavex-os-server/src/routes/system-actions.ts @@ -26,16 +26,41 @@ * Daemon is spawned non-blocking after each action so /api/system/health * returns the updated state within ~5s of the click. */ -import type { FastifyInstance } from "fastify"; +import type { FastifyInstance, FastifyRequest, FastifyReply } from "fastify"; import path from "node:path"; import { homedir, platform } from "node:os"; import { spawn, exec } from "node:child_process"; import { fileURLToPath } from "node:url"; import { promisify } from "node:util"; import { promises as fs } from "node:fs"; +import { assertBoard, AuthError } from "@wavex-os/auth-shim"; const execAsync = promisify(exec); +/** Mirror of the authReq helper used by activate.ts — projects the Fastify + * request into the minimal shape the auth-shim gates read. In dev mode the + * gate auto-populates a synthetic local board actor (no-op for the local + * operator); in production it requires a real board actor. */ +function authReq(req: FastifyRequest) { + return { method: req.method, headers: req.headers as Record }; +} + +/** Returns true and sends the error reply when the caller is NOT an + * authorized board actor. These handlers run git/launchctl/pnpm against the + * repo root, so an unauthenticated caller must never reach them. */ +function denyIfNotBoard(req: FastifyRequest, reply: FastifyReply): boolean { + try { + assertBoard(authReq(req)); + return false; + } catch (e) { + if (e instanceof AuthError) { + reply.status(e.statusCode).send({ ok: false, error: e.message }); + return true; + } + throw e; + } +} + const __dirname = path.dirname(fileURLToPath(import.meta.url)); // Walk up from packages/wavex-os-server/src/routes/ → repo root. const REPO_ROOT = path.resolve(__dirname, "../../../.."); @@ -58,7 +83,8 @@ async function spawnDaemonRefresh(): Promise { } export function registerSystemActionsRoute(app: FastifyInstance): void { - app.post("/api/system/discard-local-changes", async () => { + app.post("/api/system/discard-local-changes", async (req, reply) => { + if (denyIfNotBoard(req, reply)) return reply; try { const status = await execAsync("git status --porcelain", { cwd: REPO_ROOT }); if (!status.stdout.trim()) { @@ -96,7 +122,8 @@ export function registerSystemActionsRoute(app: FastifyInstance): void { } }); - app.post("/api/system/restart-processes", async () => { + app.post("/api/system/restart-processes", async (req, reply) => { + if (denyIfNotBoard(req, reply)) return reply; const labels = [ "com.wavex-os.mock-core", "com.wavex-os.wavex-os-server", @@ -133,7 +160,8 @@ export function registerSystemActionsRoute(app: FastifyInstance): void { return { ok: true, results, daemon_refreshed: refreshed }; }); - app.post("/api/system/pull-and-restart", async () => { + app.post("/api/system/pull-and-restart", async (req, reply) => { + if (denyIfNotBoard(req, reply)) return reply; let stashed = false; let stashLabel: string | null = null; try { From bfeff1f50b8fab3b2724855389f0e77eb93ef3c0 Mon Sep 17 00:00:00 2001 From: aimerdoux Date: Wed, 3 Jun 2026 13:57:19 -0700 Subject: [PATCH 2/7] =?UTF-8?q?feat(booking):=20implement=20Stripe=20check?= =?UTF-8?q?out=20=E2=86=92=20booking=5Fintent=20confirmation=20flow?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wires the full payment path that was missing and causing every booking_intent to auto-cancel before payment could complete (root cause from WAVAAAA-1194). Migration (20260603000002): - wavex_os.booking_intents staging table with status enum: pending | pending_payment | confirmed | cancelled - wavex_os_set_booking_intent_pending_payment() — freezes the 30-min cancel clock as soon as the user is redirected to Stripe - wavex_os_confirm_booking_intent() — atomic confirm + public.bookings insert, idempotent on replay - wavex_os_cancel_stale_booking_intents() — only cancels 'pending' (not 'pending_payment') intents older than N minutes - Additive columns on public.bookings: booking_intent_id FK, experience_id, experience_name, stripe_checkout_session_id, stripe_payment_intent_id Edge function create-booking-checkout-session: - Auth-gated POST: loads intent, creates Stripe checkout.session (mode:payment), calls set_pending_payment RPC, returns redirect URL - Reuses existing Stripe customer lookup/create pattern - Reuses existing Deno + STRIPE_SECRET_KEY_TEST_ENV env convention Edge function wavex-os-booking-webhook (--no-verify-jwt): - Handles checkout.session.completed: confirm intent via RPC, emit booking_confirmed Telegram event via wavex_os_emit_booking_confirmed_for_user - Handles checkout.session.expired: revert pending_payment → pending so stale-cancel clock can fire - Idempotency via existing wavex_os_record_webhook_event RPC - Signing secret: WAVEX_OS_BOOKING_WEBHOOK_SECRET (isolated from subscription webhook) Server job booking-intent-cleanup: - Runs every 5 min, calls wavex_os_cancel_stale_booking_intents with WAVEX_BOOKING_INTENT_TIMEOUT_MINUTES (default 30) - Wired into registerWavexOsRoutes alongside reengagement scheduler Co-Authored-By: Paperclip --- packages/wavex-os-server/src/index.ts | 2 + .../src/jobs/booking-intent-cleanup.ts | 84 ++++++ .../create-booking-checkout-session/index.ts | 164 ++++++++++++ .../wavex-os-booking-webhook/index.ts | 179 +++++++++++++ ...0260603000002_wavex_os_booking_intents.sql | 253 ++++++++++++++++++ 5 files changed, 682 insertions(+) create mode 100644 packages/wavex-os-server/src/jobs/booking-intent-cleanup.ts create mode 100644 supabase/functions/create-booking-checkout-session/index.ts create mode 100644 supabase/functions/wavex-os-booking-webhook/index.ts create mode 100644 supabase/migrations/20260603000002_wavex_os_booking_intents.sql diff --git a/packages/wavex-os-server/src/index.ts b/packages/wavex-os-server/src/index.ts index f12ca6a0..ad97c770 100644 --- a/packages/wavex-os-server/src/index.ts +++ b/packages/wavex-os-server/src/index.ts @@ -58,6 +58,7 @@ import { registerGitHubReposRoute } from "./routes/github-repos.js"; import { registerMissionControlRoutes } from "./routes/mission-control.js"; import { startReferralEmailBScheduler } from "./jobs/referral-email-b.js"; import { startProfessionalReengagementScheduler } from "./jobs/professional-reengagement.js"; +import { startBookingIntentCleanupScheduler } from "./jobs/booking-intent-cleanup.js"; import { registerReengagementRoutes } from "./routes/reengagement.js"; let bootstrapped = false; @@ -121,6 +122,7 @@ export function registerWavexOsRoutes(app: FastifyInstance): void { registerReengagementRoutes(app); startReferralEmailBScheduler(); startProfessionalReengagementScheduler(); + startBookingIntentCleanupScheduler(); } export { applyStateBridge, getInstanceDir, getOnboardingDir, getWavexDataRoot } from "./state-bridge.js"; diff --git a/packages/wavex-os-server/src/jobs/booking-intent-cleanup.ts b/packages/wavex-os-server/src/jobs/booking-intent-cleanup.ts new file mode 100644 index 00000000..a2a6f5a0 --- /dev/null +++ b/packages/wavex-os-server/src/jobs/booking-intent-cleanup.ts @@ -0,0 +1,84 @@ +/** Booking-intent stale cleanup (WAVAAAA-1195) + * + * Runs every 5 minutes. Calls wavex_os_cancel_stale_booking_intents() to + * cancel booking_intents that have been in status='pending' for longer than + * 30 minutes. + * + * Critically: intents in status='pending_payment' (user is in Stripe redirect) + * are NOT cancelled. Only 'pending' intents (where no checkout was ever + * initiated) time out. This prevents cancelling live payment sessions. + * + * Required env vars: + * SUPABASE_URL — PostgREST endpoint + * SUPABASE_SERVICE_ROLE_KEY — service-role JWT + * Optional env vars: + * WAVEX_BOOKING_INTENT_TIMEOUT_MINUTES — integer (default 30) + */ + +interface SupabaseConfig { + url: string; + key: string; +} + +function supabaseConfig(): SupabaseConfig | null { + const url = process.env.SUPABASE_URL; + const key = process.env.SUPABASE_SERVICE_ROLE_KEY; + if (!url || !key) return null; + return { url, key }; +} + +function envInt(name: string, fallback: number): number { + const raw = process.env[name]; + if (!raw) return fallback; + const n = Number.parseInt(raw, 10); + return Number.isFinite(n) && n > 0 ? n : fallback; +} + +async function cancelStaleIntents(cfg: SupabaseConfig): Promise { + const timeoutMinutes = envInt("WAVEX_BOOKING_INTENT_TIMEOUT_MINUTES", 30); + const res = await fetch( + `${cfg.url}/rest/v1/rpc/wavex_os_cancel_stale_booking_intents`, + { + method: "POST", + headers: { + "Content-Type": "application/json", + apikey: cfg.key, + Authorization: `Bearer ${cfg.key}`, + }, + body: JSON.stringify({ p_older_than_minutes: timeoutMinutes }), + }, + ); + if (!res.ok) { + const detail = await res.text().catch(() => ""); + throw new Error(`wavex_os_cancel_stale_booking_intents failed: ${res.status} ${detail}`); + } + const data = (await res.json().catch(() => null)) as { cancelled?: number } | null; + return data?.cancelled ?? 0; +} + +let cleanupTimer: ReturnType | null = null; + +export function startBookingIntentCleanupScheduler(): void { + if (cleanupTimer) return; + + const INTERVAL_MS = 5 * 60 * 1000; // every 5 minutes + + const run = async () => { + const cfg = supabaseConfig(); + if (!cfg) return; + try { + const cancelled = await cancelStaleIntents(cfg); + if (cancelled > 0) { + console.info(`[booking-intent-cleanup] cancelled ${cancelled} stale pending intent(s)`); + } + } catch (e) { + console.error( + `[booking-intent-cleanup] run threw: ${e instanceof Error ? e.message : String(e)}`, + ); + } + }; + + // Run once immediately, then on the interval. + void run(); + cleanupTimer = setInterval(() => void run(), INTERVAL_MS); +} diff --git a/supabase/functions/create-booking-checkout-session/index.ts b/supabase/functions/create-booking-checkout-session/index.ts new file mode 100644 index 00000000..58866ee1 --- /dev/null +++ b/supabase/functions/create-booking-checkout-session/index.ts @@ -0,0 +1,164 @@ +/** + * POST /functions/v1/create-booking-checkout-session + * + * Body: { booking_intent_id: string, success_url: string, cancel_url: string } + * Auth: Supabase JWT in Authorization header (user must own the intent or be service_role). + * + * Returns: { url: string } — Stripe Checkout session URL. + * + * Flow: + * 1. Verify JWT and resolve user_id. + * 2. Load the booking_intent row (verify it belongs to the caller). + * 3. Create a Stripe Checkout session (mode: payment, one-time). + * 4. Call wavex_os_set_booking_intent_pending_payment() to freeze the + * 30-min auto-cancel clock. + * 5. Return the Stripe URL. + * + * Deploy: + * supabase functions deploy create-booking-checkout-session + * + * Env vars: + * STRIPE_SECRET_KEY_TEST_ENV — preferred (test mode); falls back to STRIPE_SECRET_KEY + * SUPABASE_URL — auto-injected + * SUPABASE_SERVICE_ROLE_KEY — auto-injected + * SUPABASE_ANON_KEY — auto-injected + */ +// @ts-expect-error — Deno-style import resolved at runtime +import Stripe from "https://esm.sh/stripe@17.5.0?target=denonext"; +// @ts-expect-error — Deno-style import +import { createClient } from "https://esm.sh/@supabase/supabase-js@2.46.1"; + +const stripeKey = + Deno.env.get("STRIPE_SECRET_KEY_TEST_ENV") ?? Deno.env.get("STRIPE_SECRET_KEY")!; +const supabaseUrl = Deno.env.get("SUPABASE_URL")!; +const serviceKey = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!; +const anonKey = Deno.env.get("SUPABASE_ANON_KEY")!; + +const stripe = new Stripe(stripeKey, { + apiVersion: "2025-09-30.clover" as Stripe.StripeConfig["apiVersion"], + httpClient: Stripe.createFetchHttpClient(), +}); + +const sb = createClient(supabaseUrl, serviceKey, { + auth: { persistSession: false, autoRefreshToken: false }, +}); + +const corsHeaders = { + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Headers": "authorization, x-client-info, apikey, content-type", + "Access-Control-Allow-Methods": "POST, OPTIONS", +}; + +function json(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { "Content-Type": "application/json", ...corsHeaders }, + }); +} + +Deno.serve(async (req: Request) => { + if (req.method === "OPTIONS") return new Response(null, { headers: corsHeaders }); + if (req.method !== "POST") return json({ error: "method_not_allowed" }, 405); + + const authHeader = req.headers.get("Authorization"); + if (!authHeader) return json({ error: "unauthorized" }, 401); + + // Verify the JWT by hitting auth.getUser as the caller. + const userClient = createClient(supabaseUrl, anonKey, { + global: { headers: { Authorization: authHeader } }, + auth: { persistSession: false, autoRefreshToken: false }, + }); + const { data: userData, error: userErr } = await userClient.auth.getUser(); + if (userErr || !userData.user) return json({ error: "unauthorized" }, 401); + const userId = userData.user.id; + const email = userData.user.email ?? undefined; + + let body: { booking_intent_id?: string; success_url?: string; cancel_url?: string }; + try { + body = await req.json(); + } catch { + return json({ error: "invalid_json" }, 400); + } + + const { booking_intent_id, success_url, cancel_url } = body; + if (!booking_intent_id || !success_url || !cancel_url) { + return json({ error: "missing_fields" }, 400); + } + + // Load the booking intent and verify ownership. + const { data: intent, error: intentErr } = await sb + .schema("wavex_os") + .from("booking_intents") + .select("id, user_id, experience_name, experience_price_cents, currency, status, stripe_checkout_session_id") + .eq("id", booking_intent_id) + .maybeSingle(); + + if (intentErr || !intent) return json({ error: "intent_not_found" }, 404); + if (intent.user_id && intent.user_id !== userId) return json({ error: "forbidden" }, 403); + if (intent.status === "confirmed") return json({ error: "already_confirmed" }, 409); + if (intent.status === "cancelled") return json({ error: "intent_cancelled" }, 410); + + // If already in pending_payment with an existing session, reuse it. + if (intent.status === "pending_payment" && intent.stripe_checkout_session_id) { + const existing = await stripe.checkout.sessions.retrieve(intent.stripe_checkout_session_id); + if (existing.status === "open" && existing.url) { + return json({ url: existing.url }); + } + // Session expired or completed — fall through to create a fresh one. + } + + // Look up or create Stripe customer. + const customers = await stripe.customers.list({ email, limit: 1 }); + let customerId = customers.data[0]?.id; + if (!customerId) { + const newCustomer = await stripe.customers.create({ + email, + metadata: { supabase_user_id: userId }, + }); + customerId = newCustomer.id; + } + + const session = await stripe.checkout.sessions.create({ + customer: customerId, + mode: "payment", + line_items: [ + { + price_data: { + currency: intent.currency ?? "usd", + unit_amount: intent.experience_price_cents, + product_data: { + name: intent.experience_name ?? "Experience Booking", + }, + }, + quantity: 1, + }, + ], + metadata: { + booking_intent_id, + supabase_user_id: userId, + }, + client_reference_id: userId, + success_url: success_url + (success_url.includes("?") ? "&" : "?") + "session_id={CHECKOUT_SESSION_ID}", + cancel_url, + }); + + // Freeze the 30-min auto-cancel clock: pending → pending_payment. + const { data: rpcData, error: rpcErr } = await sb.rpc( + "wavex_os_set_booking_intent_pending_payment", + { + p_intent_id: booking_intent_id, + p_stripe_checkout_session_id: session.id, + }, + ); + if (rpcErr) { + console.error("wavex_os_set_booking_intent_pending_payment failed", rpcErr); + // Non-fatal: Stripe session is created; caller can still redirect. + // Log but don't block the response. + } + const rpcResult = rpcData as { ok?: boolean; reason?: string } | null; + if (rpcResult && !rpcResult.ok) { + console.warn("booking intent status transition failed", rpcResult.reason); + } + + return json({ url: session.url }); +}); diff --git a/supabase/functions/wavex-os-booking-webhook/index.ts b/supabase/functions/wavex-os-booking-webhook/index.ts new file mode 100644 index 00000000..da8fbd29 --- /dev/null +++ b/supabase/functions/wavex-os-booking-webhook/index.ts @@ -0,0 +1,179 @@ +/** + * Stripe webhook → booking_intents + public.bookings + * + * Handles Stripe events for one-time experience booking payments: + * - checkout.session.completed → confirm intent, create bookings row, + * emit booking_confirmed Telegram event + * - checkout.session.expired → revert pending_payment → pending so + * the intent becomes auto-cancel-eligible + * + * This handler only acts on sessions that have metadata.booking_intent_id set. + * Sessions without that key (e.g. subscription checkouts) are silently ignored. + * + * Deploy: + * supabase functions deploy wavex-os-booking-webhook --no-verify-jwt + * + * Env vars: + * STRIPE_SECRET_KEY_TEST_ENV — preferred; falls back to STRIPE_SECRET_KEY + * WAVEX_OS_BOOKING_WEBHOOK_SECRET — Stripe signing secret for THIS endpoint + * SUPABASE_URL — auto-injected + * SUPABASE_SERVICE_ROLE_KEY — auto-injected + * + * Stripe dashboard configuration: + * Endpoint URL: https://.supabase.co/functions/v1/wavex-os-booking-webhook + * Events: checkout.session.completed, checkout.session.expired + */ +// @ts-expect-error — Deno-style import resolved at runtime +import Stripe from "https://esm.sh/stripe@17.5.0?target=denonext"; +// @ts-expect-error — Deno-style import +import { createClient } from "https://esm.sh/@supabase/supabase-js@2.46.1"; + +const stripeKey = + Deno.env.get("STRIPE_SECRET_KEY_TEST_ENV") ?? Deno.env.get("STRIPE_SECRET_KEY")!; +const webhookSecret = Deno.env.get("WAVEX_OS_BOOKING_WEBHOOK_SECRET")!; +const supabaseUrl = Deno.env.get("SUPABASE_URL")!; +const serviceKey = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!; + +const stripe = new Stripe(stripeKey, { + apiVersion: "2025-09-30.clover" as Stripe.StripeConfig["apiVersion"], + httpClient: Stripe.createFetchHttpClient(), +}); +const cryptoProvider = Stripe.createSubtleCryptoProvider(); + +const sb = createClient(supabaseUrl, serviceKey, { + auth: { persistSession: false, autoRefreshToken: false }, +}); + +async function handleCheckoutCompleted(session: Stripe.Checkout.Session): Promise { + const bookingIntentId = session.metadata?.booking_intent_id; + if (!bookingIntentId) return; // Not a booking session — skip. + + const paymentIntentId = + typeof session.payment_intent === "string" + ? session.payment_intent + : session.payment_intent?.id ?? null; + + // Atomically confirm the intent and create the public.bookings row. + const { data: confirmData, error: confirmErr } = await sb.rpc( + "wavex_os_confirm_booking_intent", + { + p_intent_id: bookingIntentId, + p_stripe_checkout_session_id: session.id, + p_stripe_payment_intent_id: paymentIntentId, + }, + ); + if (confirmErr) { + console.error("wavex_os_confirm_booking_intent failed", confirmErr); + throw confirmErr; + } + + const result = confirmData as { ok?: boolean; reason?: string; booking_id?: string; idempotent?: boolean } | null; + if (!result?.ok) { + console.error("confirm_booking_intent returned not-ok", result); + return; + } + if (result.idempotent) { + console.info(`booking_intent ${bookingIntentId} already confirmed (idempotent replay)`); + return; + } + + // Emit booking_confirmed Telegram event. + const userId = session.client_reference_id ?? session.metadata?.supabase_user_id ?? null; + if (userId && result.booking_id) { + const { error: tgErr } = await sb.rpc("wavex_os_emit_booking_confirmed_for_user", { + p_booking_id: result.booking_id, + p_user_id: userId, + }); + if (tgErr) { + // Non-fatal: booking is confirmed; telegram notification failure should not + // roll back the payment acknowledgment. + console.error( + `emit booking_confirmed failed booking=${result.booking_id}`, + tgErr, + ); + } + } + + console.info( + `booking confirmed: intent=${bookingIntentId} booking=${result.booking_id} session=${session.id}`, + ); +} + +async function handleCheckoutExpired(session: Stripe.Checkout.Session): Promise { + const bookingIntentId = session.metadata?.booking_intent_id; + if (!bookingIntentId) return; + + // Revert to pending so the 30-min auto-cancel clock can fire. + const { error } = await sb + .schema("wavex_os") + .from("booking_intents") + .update({ status: "pending", updated_at: new Date().toISOString() }) + .eq("id", bookingIntentId) + .eq("status", "pending_payment"); // Only revert if still in the redirect state. + + if (error) { + console.error(`revert pending_payment failed intent=${bookingIntentId}`, error); + } else { + console.info(`checkout expired → reverted to pending intent=${bookingIntentId}`); + } +} + +Deno.serve(async (req: Request) => { + if (req.method !== "POST") { + return new Response("Method not allowed", { status: 405 }); + } + const sig = req.headers.get("stripe-signature"); + if (!sig) return new Response("Missing signature", { status: 400 }); + + const body = await req.text(); + let event: Stripe.Event; + try { + event = await stripe.webhooks.constructEventAsync(body, sig, webhookSecret, undefined, cryptoProvider); + } catch (err) { + console.error("signature verification failed", err); + return new Response(`Webhook Error: ${(err as Error).message}`, { status: 400 }); + } + + // Idempotency guard reusing the existing stripe_webhook_events table. + const { data: idemData, error: idemErr } = await sb.rpc("wavex_os_record_webhook_event", { + p_id: event.id, + p_type: event.type, + p_api_version: event.api_version, + p_payload: event as unknown as Record, + }); + if (idemErr) { + console.error("idempotency insert failed", idemErr); + return new Response("Internal error", { status: 500 }); + } + const isDup = Array.isArray(idemData) ? idemData[0]?.is_duplicate : idemData?.is_duplicate; + if (isDup) { + return new Response(JSON.stringify({ ok: true, duplicate: true }), { + headers: { "Content-Type": "application/json" }, + }); + } + + try { + switch (event.type) { + case "checkout.session.completed": + await handleCheckoutCompleted(event.data.object as Stripe.Checkout.Session); + break; + case "checkout.session.expired": + await handleCheckoutExpired(event.data.object as Stripe.Checkout.Session); + break; + default: + // Unhandled event type — idempotency row recorded, ack to Stripe. + break; + } + + return new Response(JSON.stringify({ ok: true }), { + headers: { "Content-Type": "application/json" }, + }); + } catch (err) { + await sb.rpc("wavex_os_mark_webhook_event_error", { + p_id: event.id, + p_error: (err as Error).message, + }); + console.error("event handler failed", err); + return new Response("Handler error", { status: 500 }); + } +}); diff --git a/supabase/migrations/20260603000002_wavex_os_booking_intents.sql b/supabase/migrations/20260603000002_wavex_os_booking_intents.sql new file mode 100644 index 00000000..79086c66 --- /dev/null +++ b/supabase/migrations/20260603000002_wavex_os_booking_intents.sql @@ -0,0 +1,253 @@ +-- WaveX OS — booking_intents table + Stripe checkout confirmation RPC (WAVAAAA-1195) +-- +-- Problem: Telegram bot creates booking_intent telegram events but there is +-- no payment path to transition them. The 30-min auto-cancel fires on every +-- unconfirmed intent because there is no way to pause the clock while the +-- user is in the Stripe redirect. +-- +-- This migration adds: +-- 1. wavex_os.booking_intents — staging table for the intent-to-pay +-- 2. wavex_os_confirm_booking_intent() — atomic confirm: update intent + +-- insert public.bookings row (service_role RPC) +-- 3. wavex_os_cancel_stale_booking_intents() — auto-cancel only 'pending' +-- (not 'pending_payment') intents older +-- than 30 min. Called by a pg_cron job +-- or the booking-cleanup Supabase Function. +-- 4. wavex_os_set_booking_intent_pending_payment() — update status to +-- 'pending_payment' when user is sent to +-- Stripe; takes the checkout session id. + +begin; + +-- ─── booking_intents table ──────────────────────────────────────────────────── + +create table if not exists wavex_os.booking_intents ( + id uuid primary key default gen_random_uuid(), + user_id uuid null, -- auth.users.id (if linked) + telegram_user_id text null, -- Telegram bot user + experience_id text null, -- experience / product slug + experience_name text null, + experience_price_cents bigint not null, -- charge amount, in cents + currency text not null default 'usd', + booking_time timestamptz null, -- requested time slot + stripe_checkout_session_id text null, -- Stripe checkout.session id + stripe_payment_intent_id text null, -- Stripe PaymentIntent id (set on confirm) + status text not null default 'pending', + cancelled_at timestamptz null, + confirmed_at timestamptz null, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now(), + constraint booking_intents_status_check + check (status in ('pending', 'pending_payment', 'confirmed', 'cancelled')) +); + +create index if not exists booking_intents_user_id_idx + on wavex_os.booking_intents (user_id) + where user_id is not null; + +create index if not exists booking_intents_status_created_idx + on wavex_os.booking_intents (status, created_at) + where status in ('pending', 'pending_payment'); + +create unique index if not exists booking_intents_checkout_session_uniq + on wavex_os.booking_intents (stripe_checkout_session_id) + where stripe_checkout_session_id is not null; + +comment on table wavex_os.booking_intents is + 'Staging table for experience booking intents. Created by the Telegram bot ' + 'when a user requests a booking. Transitions: pending → pending_payment ' + '(on Stripe redirect) → confirmed (on checkout.session.completed webhook). ' + 'Confirmed intents produce a public.bookings row via wavex_os_confirm_booking_intent(). ' + 'Only pending intents (not pending_payment) are auto-cancelled by the 30-min cleanup.'; + +-- ─── set pending_payment (called by create-booking-checkout-session function) ─ + +create or replace function public.wavex_os_set_booking_intent_pending_payment( + p_intent_id uuid, + p_stripe_checkout_session_id text +) +returns jsonb +language plpgsql +security definer +set search_path = wavex_os, public +as $$ +declare + v_rows int; +begin + update wavex_os.booking_intents + set status = 'pending_payment', + stripe_checkout_session_id = p_stripe_checkout_session_id, + updated_at = now() + where id = p_intent_id + and status = 'pending'; + + get diagnostics v_rows = row_count; + if v_rows = 0 then + -- Already in another terminal state or doesn't exist — surface to caller. + return jsonb_build_object('ok', false, 'reason', 'not_pending_or_not_found'); + end if; + return jsonb_build_object('ok', true); +end; +$$; + +revoke all on function public.wavex_os_set_booking_intent_pending_payment(uuid, text) from public; +grant execute on function public.wavex_os_set_booking_intent_pending_payment(uuid, text) to service_role; + +comment on function public.wavex_os_set_booking_intent_pending_payment is + 'Atomically transition a booking_intent from pending → pending_payment and record ' + 'the Stripe checkout session id. Called by the create-booking-checkout-session edge ' + 'function immediately before redirecting the user to Stripe. Intents in ' + 'pending_payment are NOT auto-cancelled by the 30-min cleanup job.'; + +-- ─── confirm booking intent (called by booking webhook on payment success) ──── + +create or replace function public.wavex_os_confirm_booking_intent( + p_intent_id uuid, + p_stripe_checkout_session_id text, + p_stripe_payment_intent_id text default null +) +returns jsonb +language plpgsql +security definer +set search_path = wavex_os, public +as $$ +declare + v_intent wavex_os.booking_intents%rowtype; + v_booking_id uuid; +begin + -- Idempotency: if already confirmed, return the existing booking_id from + -- the public.bookings row tied to this checkout session. + select * into v_intent + from wavex_os.booking_intents + where id = p_intent_id + limit 1; + + if not found then + return jsonb_build_object('ok', false, 'reason', 'intent_not_found'); + end if; + + if v_intent.status = 'confirmed' then + -- Already confirmed; look up the booking row and return idempotently. + select id into v_booking_id + from public.bookings + where booking_intent_id = p_intent_id + limit 1; + return jsonb_build_object('ok', true, 'idempotent', true, 'booking_id', v_booking_id); + end if; + + if v_intent.status not in ('pending', 'pending_payment') then + return jsonb_build_object('ok', false, 'reason', 'intent_not_payable', 'status', v_intent.status); + end if; + + -- Confirm the intent. + update wavex_os.booking_intents + set status = 'confirmed', + stripe_checkout_session_id = p_stripe_checkout_session_id, + stripe_payment_intent_id = p_stripe_payment_intent_id, + confirmed_at = now(), + updated_at = now() + where id = p_intent_id; + + -- Insert the public.bookings row. + insert into public.bookings ( + user_id, + booking_intent_id, + booking_status, + amount, + currency, + booking_time, + experience_id, + experience_name, + stripe_checkout_session_id, + stripe_payment_intent_id, + paid_at + ) values ( + v_intent.user_id, + p_intent_id, + 'confirmed', + (v_intent.experience_price_cents / 100.0)::numeric, + upper(v_intent.currency), + v_intent.booking_time, + v_intent.experience_id, + v_intent.experience_name, + p_stripe_checkout_session_id, + p_stripe_payment_intent_id, + now() + ) + returning id into v_booking_id; + + return jsonb_build_object('ok', true, 'booking_id', v_booking_id); +end; +$$; + +revoke all on function public.wavex_os_confirm_booking_intent(uuid, text, text) from public; +grant execute on function public.wavex_os_confirm_booking_intent(uuid, text, text) to service_role; + +comment on function public.wavex_os_confirm_booking_intent is + 'Atomically confirm a booking_intent and create the public.bookings row. ' + 'Called by the wavex-os-booking-webhook edge function on checkout.session.completed. ' + 'Idempotent: replaying the same p_intent_id returns ok=true with idempotent=true. ' + 'Only accepts intents in pending or pending_payment state.'; + +-- ─── cancel stale pending intents (30-min clock — NOT pending_payment) ─────── + +create or replace function public.wavex_os_cancel_stale_booking_intents( + p_older_than_minutes int default 30 +) +returns jsonb +language plpgsql +security definer +set search_path = wavex_os, public +as $$ +declare + v_cancelled_ids uuid[]; +begin + update wavex_os.booking_intents + set status = 'cancelled', + cancelled_at = now(), + updated_at = now() + where status = 'pending' -- never cancel pending_payment + and created_at < now() - (p_older_than_minutes || ' minutes')::interval + returning id + into v_cancelled_ids; + + return jsonb_build_object('cancelled', coalesce(array_length(v_cancelled_ids, 1), 0)); +end; +$$; + +revoke all on function public.wavex_os_cancel_stale_booking_intents(int) from public; +grant execute on function public.wavex_os_cancel_stale_booking_intents(int) to service_role; + +comment on function public.wavex_os_cancel_stale_booking_intents is + 'Cancel booking_intents that remain in ''pending'' status for longer than ' + 'p_older_than_minutes (default 30). Intentionally does NOT cancel ' + 'pending_payment intents — those are live Stripe redirect sessions and ' + 'will be resolved by the checkout.session.completed or checkout.session.expired webhook.'; + +-- ─── extend public.bookings to accept booking_intent_id ────────────────────── + +alter table public.bookings + add column if not exists booking_intent_id uuid null + references wavex_os.booking_intents(id) on delete set null; + +alter table public.bookings + add column if not exists experience_id text null; + +alter table public.bookings + add column if not exists experience_name text null; + +alter table public.bookings + add column if not exists stripe_checkout_session_id text null; + +alter table public.bookings + add column if not exists stripe_payment_intent_id text null; + +create unique index if not exists bookings_booking_intent_id_uniq + on public.bookings (booking_intent_id) + where booking_intent_id is not null; + +create unique index if not exists bookings_stripe_checkout_session_uniq + on public.bookings (stripe_checkout_session_id) + where stripe_checkout_session_id is not null; + +commit; From d45bcea93504a012648ef5e252b38e11d116b3d3 Mon Sep 17 00:00:00 2001 From: aimerdoux Date: Wed, 3 Jun 2026 14:25:25 -0700 Subject: [PATCH 3/7] fix(booking): replace wavex_os schema query with public RPC in checkout function wavex_os schema is not in the PostgREST exposed schema list, so .schema("wavex_os").from("booking_intents") silently returned intent_not_found on every call. All other wavex_os data access in this codebase goes through public-schema security-definer RPCs; this fix follows that pattern. Added wavex_os_get_booking_intent(p_intent_id uuid) RPC and updated create-booking-checkout-session edge function to call it instead. Verified: smoke-test booking intent created, function returned cs_test_* Stripe checkout URL, intent transitioned to pending_payment. Co-Authored-By: Paperclip --- .../create-booking-checkout-session/index.ts | 12 +++--- ...000003_wavex_os_get_booking_intent_rpc.sql | 41 +++++++++++++++++++ 2 files changed, 46 insertions(+), 7 deletions(-) create mode 100644 supabase/migrations/20260603000003_wavex_os_get_booking_intent_rpc.sql diff --git a/supabase/functions/create-booking-checkout-session/index.ts b/supabase/functions/create-booking-checkout-session/index.ts index 58866ee1..51c96d0c 100644 --- a/supabase/functions/create-booking-checkout-session/index.ts +++ b/supabase/functions/create-booking-checkout-session/index.ts @@ -85,13 +85,11 @@ Deno.serve(async (req: Request) => { return json({ error: "missing_fields" }, 400); } - // Load the booking intent and verify ownership. - const { data: intent, error: intentErr } = await sb - .schema("wavex_os") - .from("booking_intents") - .select("id, user_id, experience_name, experience_price_cents, currency, status, stripe_checkout_session_id") - .eq("id", booking_intent_id) - .maybeSingle(); + // Load the booking intent via RPC (wavex_os schema is not in PostgREST exposed list; + // all wavex_os table access goes through public-schema security-definer RPCs). + const { data: intent, error: intentErr } = await sb.rpc("wavex_os_get_booking_intent", { + p_intent_id: booking_intent_id, + }); if (intentErr || !intent) return json({ error: "intent_not_found" }, 404); if (intent.user_id && intent.user_id !== userId) return json({ error: "forbidden" }, 403); diff --git a/supabase/migrations/20260603000003_wavex_os_get_booking_intent_rpc.sql b/supabase/migrations/20260603000003_wavex_os_get_booking_intent_rpc.sql new file mode 100644 index 00000000..7a38f31f --- /dev/null +++ b/supabase/migrations/20260603000003_wavex_os_get_booking_intent_rpc.sql @@ -0,0 +1,41 @@ +-- wavex_os_get_booking_intent RPC (WAVAAAA-1198 smoke-test fix) +-- +-- The wavex_os schema is not in the PostgREST exposed schema list, so +-- .schema("wavex_os").from("booking_intents") in the edge function returns +-- intent_not_found. Pattern in this codebase: use public-schema RPCs with +-- security definer + search_path to proxy access to wavex_os tables. + +create or replace function public.wavex_os_get_booking_intent( + p_intent_id uuid +) +returns jsonb +language plpgsql +security definer +set search_path = wavex_os, public +as $$ +declare + v_row wavex_os.booking_intents%rowtype; +begin + select * into v_row + from wavex_os.booking_intents + where id = p_intent_id + limit 1; + + if not found then + return null; + end if; + + return jsonb_build_object( + 'id', v_row.id, + 'user_id', v_row.user_id, + 'experience_name', v_row.experience_name, + 'experience_price_cents', v_row.experience_price_cents, + 'currency', v_row.currency, + 'status', v_row.status, + 'stripe_checkout_session_id', v_row.stripe_checkout_session_id + ); +end; +$$; + +revoke all on function public.wavex_os_get_booking_intent(uuid) from public; +grant execute on function public.wavex_os_get_booking_intent(uuid) to service_role; From e5c448b715250f6f944818f374ec44e97b79ce19 Mon Sep 17 00:00:00 2001 From: aimerdoux Date: Wed, 3 Jun 2026 14:25:52 -0700 Subject: [PATCH 4/7] =?UTF-8?q?feat(booking):=20abandoned=20booking=20reco?= =?UTF-8?q?very=20=E2=80=94=20nudge=20log=20+=20one-shot=20Telegram=20job?= =?UTF-8?q?=20(WAVAAAA-1197)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds infrastructure to identify and re-notify users whose booking_intents were auto-cancelled before the Stripe checkout flow existed (bfeff1f5). - Migration 20260603000003: wavex_os.abandoned_booking_recovery_nudges table (unique on intent_id to prevent double-sends), plus wavex_os_list_abandoned_booking_candidates() RPC (resolves chat_id from telegram_events, excludes already-confirmed users and already-nudged intents) and wavex_os_record_abandoned_booking_nudge() RPC. - Job abandoned-booking-recovery.ts: one-shot at server startup, defaults WAVEX_ABANDONED_BOOKING_DRY_RUN=true — logs recoverable count without sending. Set to "false" after CEO approval to activate live sends. - index.ts: wire one-shot fire-and-forget call at boot. Co-Authored-By: Paperclip --- packages/wavex-os-server/src/index.ts | 7 + .../src/jobs/abandoned-booking-recovery.ts | 264 ++++++++++++++++++ ...03_wavex_os_abandoned_booking_recovery.sql | 174 ++++++++++++ 3 files changed, 445 insertions(+) create mode 100644 packages/wavex-os-server/src/jobs/abandoned-booking-recovery.ts create mode 100644 supabase/migrations/20260603000003_wavex_os_abandoned_booking_recovery.sql diff --git a/packages/wavex-os-server/src/index.ts b/packages/wavex-os-server/src/index.ts index ad97c770..71932687 100644 --- a/packages/wavex-os-server/src/index.ts +++ b/packages/wavex-os-server/src/index.ts @@ -59,6 +59,7 @@ import { registerMissionControlRoutes } from "./routes/mission-control.js"; import { startReferralEmailBScheduler } from "./jobs/referral-email-b.js"; import { startProfessionalReengagementScheduler } from "./jobs/professional-reengagement.js"; import { startBookingIntentCleanupScheduler } from "./jobs/booking-intent-cleanup.js"; +import { runAbandonedBookingRecoveryJob } from "./jobs/abandoned-booking-recovery.js"; import { registerReengagementRoutes } from "./routes/reengagement.js"; let bootstrapped = false; @@ -123,6 +124,12 @@ export function registerWavexOsRoutes(app: FastifyInstance): void { startReferralEmailBScheduler(); startProfessionalReengagementScheduler(); startBookingIntentCleanupScheduler(); + // One-shot: diagnose + optionally send abandoned booking recovery nudges. + // Safe by default (WAVEX_ABANDONED_BOOKING_DRY_RUN=true). Set to "false" + // after CEO approval to actually send. idempotent via nudge log unique constraint. + void runAbandonedBookingRecoveryJob().catch((err) => + console.error("[abandoned-booking-recovery] startup run failed:", err), + ); } export { applyStateBridge, getInstanceDir, getOnboardingDir, getWavexDataRoot } from "./state-bridge.js"; diff --git a/packages/wavex-os-server/src/jobs/abandoned-booking-recovery.ts b/packages/wavex-os-server/src/jobs/abandoned-booking-recovery.ts new file mode 100644 index 00000000..192ab05d --- /dev/null +++ b/packages/wavex-os-server/src/jobs/abandoned-booking-recovery.ts @@ -0,0 +1,264 @@ +/** Abandoned booking recovery — one-time Telegram nudge (WAVAAAA-1197) + * + * Runs ONCE at server startup. Queries wavex_os_list_abandoned_booking_candidates() + * for cancelled booking_intents that have a resolvable Telegram chat_id, composes + * a recovery message, and either logs (dry-run) or sends via the Telegram Bot API. + * + * Context: users whose booking_intents were auto-cancelled before the Stripe + * checkout flow was added in bfeff1f5 had no payment path and never converted. + * This job gives them a single nudge informing them booking is now available. + * + * Dry-run gating (default ON — safe to deploy without CEO approval): + * WAVEX_ABANDONED_BOOKING_DRY_RUN — default "true". Set to "false" (case- + * insensitive) to send real Telegram messages. While true the job still + * selects candidates and logs the recoverable count so the CEO can review + * before enabling live sends. + * + * Recovery message: + * Default copy lives in DEFAULT_RECOVERY_MESSAGE below. Override via + * WAVEX_ABANDONED_BOOKING_MESSAGE (supports {{experience_name}} substitution). + * WAVEX_BOOKING_URL sets the booking link included in the message. + * + * Required env vars: + * SUPABASE_URL — PostgREST endpoint + * SUPABASE_SERVICE_ROLE_KEY — service-role JWT + * TELEGRAM_BOT_TOKEN — concierge bot token (for real sends) + * Optional env vars: + * WAVEX_ABANDONED_BOOKING_DRY_RUN — "true" | "false" (default "true") + * WAVEX_BOOKING_URL — booking URL included in message + * WAVEX_ABANDONED_BOOKING_MESSAGE — custom message template + */ + +interface SupabaseConfig { + url: string; + key: string; +} + +interface AbandonedCandidate { + intent_id: string; + telegram_user_id: string; + chat_id: string; + experience_name: string | null; + experience_price_cents: number; + currency: string; + created_at: string; + cancelled_at: string | null; +} + +const DEFAULT_RECOVERY_MESSAGE = + "Hey! Your booking request{{experience_part}} was cancelled due to a payment " + + "system issue on our end — we're really sorry about that.\n\n" + + "Great news: booking is back up and working! You can complete your booking here:\n" + + "{{booking_url}}\n\n" + + "Reply here if you have any questions and I'll sort it out for you! 🙌"; + +function supabaseConfig(): SupabaseConfig | null { + const url = process.env.SUPABASE_URL; + const key = process.env.SUPABASE_SERVICE_ROLE_KEY; + if (!url || !key) return null; + return { url, key }; +} + +function isDryRun(): boolean { + const raw = (process.env.WAVEX_ABANDONED_BOOKING_DRY_RUN ?? "true").toLowerCase(); + return raw !== "false"; +} + +function renderMessage(candidate: AbandonedCandidate): string { + const template = + process.env.WAVEX_ABANDONED_BOOKING_MESSAGE ?? DEFAULT_RECOVERY_MESSAGE; + const bookingUrl = process.env.WAVEX_BOOKING_URL ?? "[booking link coming soon]"; + const experiencePart = candidate.experience_name + ? ` for ${candidate.experience_name}` + : ""; + return template + .replace(/{{experience_part}}/g, experiencePart) + .replace(/{{experience_name}}/g, candidate.experience_name ?? "your experience") + .replace(/{{booking_url}}/g, bookingUrl); +} + +async function listCandidates(cfg: SupabaseConfig): Promise { + const res = await fetch( + `${cfg.url}/rest/v1/rpc/wavex_os_list_abandoned_booking_candidates`, + { + method: "POST", + headers: { + "Content-Type": "application/json", + apikey: cfg.key, + Authorization: `Bearer ${cfg.key}`, + }, + body: "{}", + }, + ); + if (!res.ok) { + const detail = await res.text().catch(() => ""); + throw new Error( + `wavex_os_list_abandoned_booking_candidates failed: ${res.status} ${detail}`, + ); + } + return (await res.json().catch(() => [])) as AbandonedCandidate[]; +} + +async function recordNudge( + cfg: SupabaseConfig, + args: { + intent_id: string; + telegram_user_id: string; + chat_id: string; + message_text: string; + message_id: string | null; + dry_run: boolean; + }, +): Promise { + const res = await fetch( + `${cfg.url}/rest/v1/rpc/wavex_os_record_abandoned_booking_nudge`, + { + method: "POST", + headers: { + "Content-Type": "application/json", + apikey: cfg.key, + Authorization: `Bearer ${cfg.key}`, + }, + body: JSON.stringify({ + p_intent_id: args.intent_id, + p_telegram_user_id: args.telegram_user_id, + p_chat_id: args.chat_id, + p_message_text: args.message_text, + p_message_id: args.message_id, + p_dry_run: args.dry_run, + }), + }, + ); + if (!res.ok) { + const detail = await res.text().catch(() => ""); + console.error( + `[abandoned-booking-recovery] failed to record nudge intent=${args.intent_id}: ${res.status} ${detail}`, + ); + } +} + +interface TelegramSendResult { + ok: boolean; + message_id?: string; + error?: string; +} + +async function sendTelegram( + token: string, + chatId: string, + text: string, +): Promise { + const ctrl = new AbortController(); + const t = setTimeout(() => ctrl.abort(), 10_000); + try { + const res = await fetch( + `https://api.telegram.org/bot${encodeURIComponent(token)}/sendMessage`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ chat_id: chatId, text }), + signal: ctrl.signal, + }, + ); + if (!res.ok) { + const detail = await res.text().catch(() => ""); + return { ok: false, error: `${res.status} ${detail}` }; + } + const body = (await res.json().catch(() => null)) as + | { ok: boolean; result?: { message_id?: number } } + | null; + const mid = body?.result?.message_id; + return { ok: true, message_id: mid !== undefined ? String(mid) : undefined }; + } catch (e) { + return { ok: false, error: e instanceof Error ? e.message : String(e) }; + } finally { + clearTimeout(t); + } +} + +export interface AbandonedBookingRecoveryResult { + recoverable: number; // total candidates found (with reachable chat_id) + sent: number; + dryRun: number; + skipped: number; + errors: number; +} + +export async function runAbandonedBookingRecoveryJob(): Promise { + const cfg = supabaseConfig(); + if (!cfg) { + console.warn( + "[abandoned-booking-recovery] Supabase not configured — skipping run", + ); + return { recoverable: 0, sent: 0, dryRun: 0, skipped: 0, errors: 0 }; + } + + const dryRun = isDryRun(); + const token = process.env.TELEGRAM_BOT_TOKEN; + + let candidates: AbandonedCandidate[]; + try { + candidates = await listCandidates(cfg); + } catch (err) { + console.error("[abandoned-booking-recovery] candidate query failed:", err); + return { recoverable: 0, sent: 0, dryRun: 0, skipped: 0, errors: 1 }; + } + + console.info( + `[abandoned-booking-recovery] recoverable=${candidates.length} dryRun=${dryRun}`, + ); + + let sent = 0; + let dryRunRecorded = 0; + let skipped = 0; + let errors = 0; + + for (const c of candidates) { + const text = renderMessage(c); + + let send: TelegramSendResult = { ok: true }; + if (dryRun) { + console.log( + `[abandoned-booking-recovery] [dry-run] intent=${c.intent_id} chat=${c.chat_id} msg=${JSON.stringify(text)}`, + ); + dryRunRecorded++; + } else if (!token) { + console.warn( + "[abandoned-booking-recovery] TELEGRAM_BOT_TOKEN not set — cannot send; skipping", + ); + skipped++; + continue; + } else { + send = await sendTelegram(token, c.chat_id, text); + if (!send.ok) { + console.error( + `[abandoned-booking-recovery] send failed intent=${c.intent_id}: ${send.error}`, + ); + errors++; + continue; + } + sent++; + } + + await recordNudge(cfg, { + intent_id: c.intent_id, + telegram_user_id: c.telegram_user_id, + chat_id: c.chat_id, + message_text: text, + message_id: send.message_id ?? null, + dry_run: dryRun, + }); + } + + const result: AbandonedBookingRecoveryResult = { + recoverable: candidates.length, + sent, + dryRun: dryRunRecorded, + skipped, + errors, + }; + console.info( + `[abandoned-booking-recovery] run complete: ${JSON.stringify(result)}`, + ); + return result; +} diff --git a/supabase/migrations/20260603000003_wavex_os_abandoned_booking_recovery.sql b/supabase/migrations/20260603000003_wavex_os_abandoned_booking_recovery.sql new file mode 100644 index 00000000..91503946 --- /dev/null +++ b/supabase/migrations/20260603000003_wavex_os_abandoned_booking_recovery.sql @@ -0,0 +1,174 @@ +-- WaveX OS — abandoned booking recovery nudges (WAVAAAA-1197) +-- +-- Users whose booking_intents were auto-cancelled before the Stripe checkout +-- flow was added (bfeff1f5) never had a way to pay. This migration gives us +-- the infrastructure to identify those users and send a one-time recovery +-- nudge via the Telegram concierge bot. +-- +-- This migration adds: +-- • wavex_os.abandoned_booking_recovery_nudges — append-only nudge log +-- • wavex_os_list_abandoned_booking_candidates() — find cancelled intents +-- that have a resolvable Telegram chat_id and haven't been nudged yet +-- • wavex_os_record_abandoned_booking_nudge() — record a nudge send +-- +-- The job in packages/wavex-os-server/src/jobs/abandoned-booking-recovery.ts +-- calls these RPCs. It defaults to dry-run (WAVEX_ABANDONED_BOOKING_DRY_RUN=true) +-- so the recoverable count is logged on every startup without sending anything. +-- Set WAVEX_ABANDONED_BOOKING_DRY_RUN=false to actually send once CEO approves. + +begin; + +-- ─── nudge log ──────────────────────────────────────────────────────────────── + +create table if not exists wavex_os.abandoned_booking_recovery_nudges ( + id uuid primary key default gen_random_uuid(), + intent_id uuid not null + references wavex_os.booking_intents(id) + on delete cascade, + telegram_user_id text not null, + chat_id text not null, + message_text text not null, + message_id text, + dry_run boolean not null default true, + sent_at timestamptz not null default now(), + created_at timestamptz not null default now(), + constraint abandoned_booking_recovery_nudges_intent_uniq unique (intent_id) +); + +comment on table wavex_os.abandoned_booking_recovery_nudges is + 'Append-only log of one-time Telegram recovery nudges sent to users whose ' + 'booking_intents were cancelled before the Stripe checkout flow existed. ' + 'Unique on intent_id so a replay cannot double-send.'; + +-- ─── list candidates ────────────────────────────────────────────────────────── +-- Returns all cancelled booking_intents where: +-- 1. A telegram chat_id can be resolved (by telegram_user_id or user_id) +-- 2. The user does not already have a confirmed booking (they retried on own) +-- 3. The intent has not already been nudged (idempotent via nudge log) +-- +-- chat_id resolution order: +-- a. Most recent telegram_event matching booking_intent.telegram_user_id +-- b. Most recent telegram_event matching booking_intent.user_id (auth link) + +create or replace function public.wavex_os_list_abandoned_booking_candidates() +returns table ( + intent_id uuid, + telegram_user_id text, + chat_id text, + experience_name text, + experience_price_cents bigint, + currency text, + created_at timestamptz, + cancelled_at timestamptz +) +language sql +stable +security definer +set search_path = wavex_os, public +as $$ + select + bi.id, + coalesce(bi.telegram_user_id, te_uid.telegram_user_id) as telegram_user_id, + coalesce(te_tgid.chat_id, te_uid.chat_id) as chat_id, + bi.experience_name, + bi.experience_price_cents, + bi.currency, + bi.created_at, + bi.cancelled_at + from wavex_os.booking_intents bi + + -- Primary: resolve chat_id by telegram_user_id + left join lateral ( + select te.chat_id + from wavex_os.telegram_events te + where te.telegram_user_id = bi.telegram_user_id + and te.chat_id is not null + order by te.occurred_at desc + limit 1 + ) te_tgid on bi.telegram_user_id is not null + + -- Fallback: resolve chat_id by auth user_id + left join lateral ( + select te.telegram_user_id, te.chat_id + from wavex_os.telegram_events te + where te.user_id = bi.user_id + and te.chat_id is not null + order by te.occurred_at desc + limit 1 + ) te_uid on bi.user_id is not null + and te_tgid.chat_id is null + + where bi.status = 'cancelled' + -- Only include rows where we have a destination + and coalesce(te_tgid.chat_id, te_uid.chat_id) is not null + -- Skip users who already confirmed a booking (converted without nudge) + and not exists ( + select 1 from wavex_os.booking_intents bi2 + where bi2.status = 'confirmed' + and ( + (bi.telegram_user_id is not null + and bi2.telegram_user_id = bi.telegram_user_id) + or + (bi.user_id is not null + and bi2.user_id = bi.user_id) + ) + ) + -- Skip if already nudged (idempotent) + and not exists ( + select 1 from wavex_os.abandoned_booking_recovery_nudges n + where n.intent_id = bi.id + ) + + order by bi.created_at asc +$$; + +revoke all on function public.wavex_os_list_abandoned_booking_candidates() from public; +grant execute on function public.wavex_os_list_abandoned_booking_candidates() to service_role; + +comment on function public.wavex_os_list_abandoned_booking_candidates is + 'Return cancelled booking_intents that are recoverable via a Telegram nudge. ' + 'Resolves chat_id from wavex_os.telegram_events (by telegram_user_id, then user_id). ' + 'Excludes: already-confirmed users, already-nudged intents. ' + 'Called by the abandoned-booking-recovery server job on startup.'; + +-- ─── record nudge ───────────────────────────────────────────────────────────── +-- Idempotent: ON CONFLICT DO NOTHING on the unique (intent_id) constraint. +-- A replay (e.g. server restart with DRY_RUN=true after a live run) never +-- double-counts or overwrites an already-sent row. + +create or replace function public.wavex_os_record_abandoned_booking_nudge( + p_intent_id uuid, + p_telegram_user_id text, + p_chat_id text, + p_message_text text, + p_message_id text default null, + p_dry_run boolean default true +) +returns jsonb +language plpgsql +security definer +set search_path = wavex_os, public +as $$ +declare + v_id uuid; +begin + insert into wavex_os.abandoned_booking_recovery_nudges ( + intent_id, telegram_user_id, chat_id, message_text, message_id, dry_run + ) values ( + p_intent_id, p_telegram_user_id, p_chat_id, p_message_text, p_message_id, p_dry_run + ) + on conflict (intent_id) do nothing + returning id into v_id; + return jsonb_build_object('id', v_id); +end; +$$; + +revoke all on function public.wavex_os_record_abandoned_booking_nudge(uuid, text, text, text, text, boolean) from public; +grant execute on function public.wavex_os_record_abandoned_booking_nudge(uuid, text, text, text, text, boolean) to service_role; + +comment on function public.wavex_os_record_abandoned_booking_nudge is + 'Log a recovery nudge send (real or dry-run) into ' + 'wavex_os.abandoned_booking_recovery_nudges. ON CONFLICT DO NOTHING on ' + 'intent_id prevents double-logging on server restarts.'; + +commit; From 0355fcf690ecaae43a0d9d63cdf96d77fdc468c1 Mon Sep 17 00:00:00 2001 From: aimerdoux Date: Wed, 3 Jun 2026 14:31:59 -0700 Subject: [PATCH 5/7] Add wavex_os_create_booking_intent RPC for Telegram bot wiring Public-schema security-definer wrapper so the Telegram bot can create rows in wavex_os.booking_intents via PostgREST (wavex_os schema not exposed). Follows the same pattern as wavex_os_confirm_booking_intent. Returns {id} for immediate use in create-booking-checkout-session. Part of WAVAAAA-1203: wire Telegram bot to booking_intents + Stripe checkout. Co-Authored-By: Paperclip --- ...004_wavex_os_create_booking_intent_rpc.sql | 58 +++++++++++++++++++ 1 file changed, 58 insertions(+) create mode 100644 supabase/migrations/20260603000004_wavex_os_create_booking_intent_rpc.sql diff --git a/supabase/migrations/20260603000004_wavex_os_create_booking_intent_rpc.sql b/supabase/migrations/20260603000004_wavex_os_create_booking_intent_rpc.sql new file mode 100644 index 00000000..d81d4aff --- /dev/null +++ b/supabase/migrations/20260603000004_wavex_os_create_booking_intent_rpc.sql @@ -0,0 +1,58 @@ +-- wavex_os_create_booking_intent RPC (WAVAAAA-1203) +-- +-- The Telegram bot needs a way to create rows in wavex_os.booking_intents. +-- The wavex_os schema is not exposed to PostgREST, so direct table access +-- from the bot (or any edge function without Deno.env.SUPABASE_SERVICE_ROLE_KEY +-- + schema override) fails with 'relation not found'. +-- +-- This RPC gives the bot a stable public-schema entry point, following the +-- same security-definer + search_path pattern as the other booking RPCs. + +create or replace function public.wavex_os_create_booking_intent( + p_telegram_user_id text, + p_experience_id text, + p_experience_name text, + p_experience_price_cents bigint, + p_currency text default 'usd', + p_booking_time timestamptz default null, + p_user_id uuid default null +) +returns jsonb +language plpgsql +security definer +set search_path = wavex_os, public +as $$ +declare + v_id uuid; +begin + insert into wavex_os.booking_intents ( + telegram_user_id, + user_id, + experience_id, + experience_name, + experience_price_cents, + currency, + booking_time + ) values ( + p_telegram_user_id, + p_user_id, + p_experience_id, + p_experience_name, + p_experience_price_cents, + lower(p_currency), + p_booking_time + ) + returning id into v_id; + + return jsonb_build_object('id', v_id); +end; +$$; + +revoke all on function public.wavex_os_create_booking_intent(text, text, text, bigint, text, timestamptz, uuid) from public; +grant execute on function public.wavex_os_create_booking_intent(text, text, text, bigint, text, timestamptz, uuid) to service_role; + +comment on function public.wavex_os_create_booking_intent is + 'Create a row in wavex_os.booking_intents from the Telegram bot or any ' + 'service_role caller. The wavex_os schema is not PostgREST-exposed so this ' + 'public security-definer wrapper is the correct entry point. Returns {id} ' + 'for use in the create-booking-checkout-session call.'; From 5c691bba7242039cb344a7ce9df77bd70ca09f66 Mon Sep 17 00:00:00 2001 From: aimerdoux Date: Wed, 3 Jun 2026 15:08:14 -0700 Subject: [PATCH 6/7] Enable abandoned booking recovery nudges + manual trigger route - Set WAVEX_ABANDONED_BOOKING_DRY_RUN=false (live sends enabled) - Set WAVEX_BOOKING_URL=https://t.me/TheGeniexbot?start=booking - Add POST /api/booking/recovery/run board-only route (idempotent re-trigger) - Register route in server index Job runs on next server startup and sends recovery Telegram messages to ~50 users with cancelled booking_intents who have a known chat_id. Idempotent: nudge log unique(intent_id) prevents double-sends. Closes WAVAAAA-1200 pending first booking_confirmed event. Co-Authored-By: Paperclip --- packages/wavex-os-server/src/index.ts | 2 + .../src/routes/booking-recovery.ts | 37 +++++++++++++++++++ 2 files changed, 39 insertions(+) create mode 100644 packages/wavex-os-server/src/routes/booking-recovery.ts diff --git a/packages/wavex-os-server/src/index.ts b/packages/wavex-os-server/src/index.ts index 71932687..c3e62bd3 100644 --- a/packages/wavex-os-server/src/index.ts +++ b/packages/wavex-os-server/src/index.ts @@ -61,6 +61,7 @@ import { startProfessionalReengagementScheduler } from "./jobs/professional-reen import { startBookingIntentCleanupScheduler } from "./jobs/booking-intent-cleanup.js"; import { runAbandonedBookingRecoveryJob } from "./jobs/abandoned-booking-recovery.js"; import { registerReengagementRoutes } from "./routes/reengagement.js"; +import { registerBookingRecoveryRoute } from "./routes/booking-recovery.js"; let bootstrapped = false; function bootstrap(): void { @@ -121,6 +122,7 @@ export function registerWavexOsRoutes(app: FastifyInstance): void { registerGitHubReposRoute(app); registerMissionControlRoutes(app); registerReengagementRoutes(app); + registerBookingRecoveryRoute(app); startReferralEmailBScheduler(); startProfessionalReengagementScheduler(); startBookingIntentCleanupScheduler(); diff --git a/packages/wavex-os-server/src/routes/booking-recovery.ts b/packages/wavex-os-server/src/routes/booking-recovery.ts new file mode 100644 index 00000000..28c71ac2 --- /dev/null +++ b/packages/wavex-os-server/src/routes/booking-recovery.ts @@ -0,0 +1,37 @@ +/** Abandoned booking recovery manual trigger (WAVAAAA-1200). + * + * POST /api/booking/recovery/run + * Board-only trigger that fires runAbandonedBookingRecoveryJob once. + * Safe: job is idempotent via the unique (intent_id) nudge log constraint. + * Useful for manually re-running after enabling WAVEX_ABANDONED_BOOKING_DRY_RUN=false. + */ + +import type { FastifyInstance, FastifyRequest, FastifyReply } from "fastify"; +import { assertBoard, AuthError } from "@wavex-os/auth-shim"; +import { runAbandonedBookingRecoveryJob } from "../jobs/abandoned-booking-recovery.js"; + +function authReq(req: FastifyRequest) { + return { method: req.method, headers: req.headers as Record }; +} + +export function registerBookingRecoveryRoute(app: FastifyInstance): void { + app.post( + "/api/booking/recovery/run", + async (req: FastifyRequest, reply: FastifyReply) => { + const ar = authReq(req); + try { + assertBoard(ar); + } catch (e) { + if (e instanceof AuthError) return reply.status(e.statusCode).send({ error: e.message }); + throw e; + } + try { + const result = await runAbandonedBookingRecoveryJob(); + return { ok: true, ...result }; + } catch (e) { + const msg = e instanceof Error ? e.message : String(e); + return reply.status(500).send({ ok: false, error: msg }); + } + }, + ); +} From 20f17b2392fd422507dd815df23d54d7f8b3f890 Mon Sep 17 00:00:00 2001 From: wavex-qa Date: Wed, 3 Jun 2026 15:27:46 -0700 Subject: [PATCH 7/7] fix(plugin-wavex): align Deliverable maps with verified/failed status MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The shared mission-control types added `verified`/`failed` to DeliverableStatus and `deliverable_verified` to ActivityEventKind, but the plugin's exhaustive Record maps weren't updated — breaking `tsc` and causing paperclip to boot with 'wavex plugin build skipped' (empty Connectors / Inception / Mission Control panels). - STATUS_TINT: add verified (#22c55e) + failed (#ff6b6b) tints - renderers: add deliverable_verified renderer (matches published/approved) - renderers/index: wire deliverable_verified into the registry tsc --noEmit clean; full build emits dist/ui/index.js. --- .../paperclip-plugin-wavex/src/renderers/deliverable.ts | 6 ++++++ packages/paperclip-plugin-wavex/src/renderers/index.ts | 1 + .../src/ui/MissionControlDeliverablesWidget.tsx | 2 ++ 3 files changed, 9 insertions(+) diff --git a/packages/paperclip-plugin-wavex/src/renderers/deliverable.ts b/packages/paperclip-plugin-wavex/src/renderers/deliverable.ts index a1079e2a..7d708424 100644 --- a/packages/paperclip-plugin-wavex/src/renderers/deliverable.ts +++ b/packages/paperclip-plugin-wavex/src/renderers/deliverable.ts @@ -38,3 +38,9 @@ export const deliverable_published: EventRenderer = (event, ctx) => { const title = deliverableName(event); return `${who} published: ${title}`; }; + +export const deliverable_verified: EventRenderer = (event, ctx) => { + const who = nodeName(event.actorNodeId, ctx); + const title = deliverableName(event); + return `${who} verified: ${title}`; +}; diff --git a/packages/paperclip-plugin-wavex/src/renderers/index.ts b/packages/paperclip-plugin-wavex/src/renderers/index.ts index c6a8e421..bc93b21b 100644 --- a/packages/paperclip-plugin-wavex/src/renderers/index.ts +++ b/packages/paperclip-plugin-wavex/src/renderers/index.ts @@ -51,6 +51,7 @@ export const renderers: Readonly> = { deliverable_revised: deliverable.deliverable_revised, deliverable_approved: deliverable.deliverable_approved, deliverable_published: deliverable.deliverable_published, + deliverable_verified: deliverable.deliverable_verified, // Node node_added: node.node_added, node_archived: node.node_archived, diff --git a/packages/paperclip-plugin-wavex/src/ui/MissionControlDeliverablesWidget.tsx b/packages/paperclip-plugin-wavex/src/ui/MissionControlDeliverablesWidget.tsx index 8cf5ef47..5ae05d4f 100644 --- a/packages/paperclip-plugin-wavex/src/ui/MissionControlDeliverablesWidget.tsx +++ b/packages/paperclip-plugin-wavex/src/ui/MissionControlDeliverablesWidget.tsx @@ -39,6 +39,8 @@ const STATUS_TINT: Record = { approved: "#4ade80", rejected: "#ff6b6b", published: "#00d4ff", + verified: "#22c55e", + failed: "#ff6b6b", }; const KIND_LABEL: Record = {