diff --git a/README.md b/README.md index 94fb231..7ce1376 100644 --- a/README.md +++ b/README.md @@ -31,11 +31,13 @@ When a customer calls a business's Twilio number and the forwarded call is misse - Twilio SMS webhook (`/api/twilio/sms`) with lead qualification steps - Lead dashboard + filters + lead detail transcript + status updates - Stripe billing page + checkout + billing portal +- Public purchase entry route (`/buy`) for external marketing-site links - Stripe webhook sync for subscription status gating - SMS compliance commands (`STOP` / `START` / `HELP`) with DB-backed opt-out state - Call recording enabled on forwarded calls + recording metadata captured on callbacks - Twilio webhook protection: production-enforced `X-Twilio-Signature` validation, with shared-token fallback only in non-production - Webhook observability baseline: correlation IDs (`X-Correlation-Id`), centralized `app.error` reporting, optional alert webhook dispatch +- `/api/health` readiness endpoint for deploy smoke checks and uptime monitors - Production guardrail: `PORTFOLIO_DEMO_MODE` is blocked in production unless `ALLOW_PRODUCTION_DEMO_MODE=true` is explicitly set ## Local Setup @@ -262,13 +264,23 @@ Current behavior: - Forwarded calls are recorded via TwiML `` - The app stores recording metadata on `Call` (`recordingSid`, `recordingUrl`, `recordingStatus`, `recordingDurationSeconds`) when Twilio posts recording callbacks to `/api/twilio/status` -- The app does **not** proxy/download recording audio files; recordings remain hosted in Twilio unless you add a separate ingestion/storage pipeline +- Recording audio remains hosted in Twilio; CallbackCloser streams it through a server-side proxy for authenticated in-app access + +Recording URL safety + proxy behavior: + +- Stored recording URLs are validated before use: + - must use `https://` + - host must be allowlisted Twilio recording/API host: `api.twilio.com`, `api.us1.twilio.com`, `api.ie1.twilio.com`, or `api.au1.twilio.com` + - path must be a Twilio recording resource path (`.../Recordings/...`) +- Invalid/malformed recording URLs return `404` from `/api/leads/[leadId]/recording` +- Authorized requests are fetched from Twilio with server credentials and streamed back to the signed-in owner (no raw Twilio URL redirect) Where to access recordings: +- Lead detail page (`/app/leads/[leadId]`) shows recording status/duration and an authenticated `Open recording` action +- Recording links are mediated through `/api/leads/[leadId]/recording`, which checks the signed-in owner + business ownership and streams media via the server proxy - Twilio Console -> Monitor -> Calls (or Call Logs / Recordings, depending on account UI) - Database (`Call.recording*` fields) for metadata lookup / correlation -- The app does not currently surface recordings in the dashboard UI ## Billing Gating Behavior @@ -303,21 +315,78 @@ Prisma models included: 9. Optionally set `DEBUG_ENV_ENDPOINT_TOKEN`, then verify app URL resolution: - `https://YOUR_DOMAIN/api/debug/env?token=YOUR_DEBUG_ENV_ENDPOINT_TOKEN` +## External Buy Link + +Use this URL for the Buy CTA on `getrelayworks.com`: + +- `https://YOUR_DOMAIN/buy` + +Optional plan-specific links: + +- `https://YOUR_DOMAIN/buy?plan=starter` +- `https://YOUR_DOMAIN/buy?plan=pro` + +`/buy` handles auth/onboarding redirects and lands the user on `/app/billing`. + +## Production Launch Checklist + +Use this checklist before sending paid traffic from `getrelayworks.com` or allowing the release to auto-deploy to production. + +1. Confirm the production branch release content is complete. + - Merge and verify the launch branches that are not yet on `main`: + - `chore/p0-security-roadmap` + - `chore/product-ux-legal` + - `hardening/g14-recordings-ux` +2. Run the full verification suite from a clean checkout: + - `npm run env:check` + - `npm test` + - `npm run lint` + - `npm run typecheck` + - `npm run build` +3. Confirm Vercel production env vars match `docs/PRODUCTION_ENV.md`. +4. Apply production Prisma migrations: + - `npx prisma migrate deploy` + - optional smoke: `npm run db:smoke` +5. Confirm Stripe production setup: + - live products/prices exist + - live webhook targets `/api/stripe/webhook` + - billing portal is enabled +6. Confirm Twilio production setup: + - production number is assigned + - webhooks point to the production app URL + - `TWILIO_VALIDATE_SIGNATURE=true` + - answered, missed, STOP, START, and HELP flows are tested +7. Confirm Clerk production setup: + - production domain/origins are allowed + - sign-in and sign-up redirects work +8. Confirm monitoring and operations readiness: + - `/api/health` returns `200` + - alerting or error sink is live + - backup/restore drill evidence is current +9. Confirm customer-facing launch surface: + - `/terms`, `/privacy`, and `/refund` are public + - support inbox/contact path is monitored + - `getrelayworks.com` Buy CTA points to the approved production flow + ## Useful Routes - `/` - landing page +- `/buy` - external purchase entry (redirects through auth/onboarding to billing) - `/terms` - terms of service - `/privacy` - privacy policy - `/refund` - refund policy +- `/contact` - public support/contact page - `/sign-in` - Clerk sign-in - `/sign-up` - Clerk sign-up - `/app/onboarding` - create business record - `/app/leads` - dashboard - `/app/settings` - business settings + Twilio number provisioning - `/app/billing` - Stripe subscription page +- `/api/health` - readiness probe for deploy and uptime checks - `/api/twilio/voice` - Twilio voice webhook - `/api/twilio/status` - Twilio dial action callback - `/api/twilio/sms` - Twilio SMS webhook +- `/api/leads/[leadId]/recording` - authenticated recording media proxy for lead owners - `/api/stripe/webhook` - Stripe webhook ## Notes / MVP Constraints diff --git a/app/api/health/route.ts b/app/api/health/route.ts new file mode 100644 index 0000000..8537c1f --- /dev/null +++ b/app/api/health/route.ts @@ -0,0 +1,75 @@ +import { NextResponse } from 'next/server'; + +import { db } from '@/lib/db'; +import { getConfiguredAppBaseUrl } from '@/lib/env.server'; +import { getCorrelationIdFromRequest, withCorrelationIdHeader } from '@/lib/observability'; + +export const runtime = 'nodejs'; +export const dynamic = 'force-dynamic'; + +const DB_PROBE_TIMEOUT_MS = 2_000; + +function withTimeout(promise: Promise, timeoutMs: number) { + return Promise.race([ + promise, + new Promise((_, reject) => { + const timer = setTimeout(() => { + reject(new Error(`timeout_after_${timeoutMs}ms`)); + }, timeoutMs); + timer.unref?.(); + }), + ]); +} + +function hasValue(value: string | undefined) { + return Boolean(value?.trim()); +} + +function getEnvChecks() { + return { + appUrl: Boolean(getConfiguredAppBaseUrl()), + databaseUrl: hasValue(process.env.DATABASE_URL), + directDatabaseUrl: hasValue(process.env.DIRECT_DATABASE_URL), + clerk: hasValue(process.env.CLERK_SECRET_KEY) && hasValue(process.env.NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY), + stripe: hasValue(process.env.STRIPE_SECRET_KEY) && hasValue(process.env.STRIPE_WEBHOOK_SECRET), + twilio: hasValue(process.env.TWILIO_ACCOUNT_SID) && hasValue(process.env.TWILIO_AUTH_TOKEN), + }; +} + +async function getDatabaseCheck() { + try { + await withTimeout(db.$queryRaw`SELECT 1`, DB_PROBE_TIMEOUT_MS); + return { ok: true as const, detail: 'ok' }; + } catch (error) { + return { + ok: false as const, + detail: error instanceof Error ? error.message : 'db_probe_failed', + }; + } +} + +export async function GET(request: Request) { + const correlationId = getCorrelationIdFromRequest(request); + const withCorrelation = (response: NextResponse) => withCorrelationIdHeader(response, correlationId); + const envChecks = getEnvChecks(); + const dbCheck = await getDatabaseCheck(); + const envReady = Object.values(envChecks).every(Boolean); + const ready = envReady && dbCheck.ok; + + return withCorrelation( + NextResponse.json( + { + status: ready ? 'ok' : 'degraded', + timestamp: new Date().toISOString(), + checks: { + env: { + ready: envReady, + ...envChecks, + }, + database: dbCheck, + }, + }, + { status: ready ? 200 : 503 } + ) + ); +} diff --git a/app/api/leads/[leadId]/recording/route.ts b/app/api/leads/[leadId]/recording/route.ts new file mode 100644 index 0000000..6e4e000 --- /dev/null +++ b/app/api/leads/[leadId]/recording/route.ts @@ -0,0 +1,116 @@ +import { auth } from '@clerk/nextjs/server'; +import { NextResponse } from 'next/server'; + +import { db } from '@/lib/db'; +import { getTwilioRecordingMediaUrl, resolveRecordingAccessReason } from '@/lib/recording-access'; +import { absoluteUrl } from '@/lib/url'; + +export const runtime = 'nodejs'; +export const dynamic = 'force-dynamic'; + +export async function GET(_request: Request, { params }: { params: { leadId: string } }) { + const { userId } = await auth(); + if (!userId) { + return NextResponse.redirect(absoluteUrl('/sign-in'), { status: 303 }); + } + + const lead = await db.lead.findUnique({ + where: { id: params.leadId }, + select: { + id: true, + business: { + select: { + ownerClerkId: true, + }, + }, + call: { + select: { + recordingUrl: true, + }, + }, + }, + }); + + if (!lead) { + return NextResponse.json({ error: 'Lead not found' }, { status: 404 }); + } + + const accessReason = resolveRecordingAccessReason({ + requestUserId: userId, + businessOwnerClerkId: lead.business.ownerClerkId, + recordingUrl: lead.call?.recordingUrl ?? null, + }); + + if (accessReason === 'wrong_business') { + return NextResponse.json({ error: 'Lead not found' }, { status: 404 }); + } + + if (accessReason === 'recording_unavailable') { + return NextResponse.json({ error: 'Recording not available for this lead' }, { status: 404 }); + } + + if (accessReason !== 'ok') { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + const mediaUrl = getTwilioRecordingMediaUrl(lead.call!.recordingUrl!); + if (!mediaUrl) { + return NextResponse.json({ error: 'Lead not found' }, { status: 404 }); + } + + const accountSid = process.env.TWILIO_ACCOUNT_SID?.trim(); + const authToken = process.env.TWILIO_AUTH_TOKEN?.trim(); + if (!accountSid || !authToken) { + console.error('recording.proxy.misconfigured', { leadId: lead.id }); + return NextResponse.json({ error: 'Recording unavailable' }, { status: 503 }); + } + + let twilioResponse: Response; + try { + twilioResponse = await fetch(mediaUrl.toString(), { + headers: { + Authorization: `Basic ${Buffer.from(`${accountSid}:${authToken}`).toString('base64')}`, + Accept: 'audio/mpeg,audio/wav,*/*', + }, + cache: 'no-store', + }); + } catch (error) { + console.error('recording.proxy.fetch_error', { + leadId: lead.id, + host: mediaUrl.hostname, + error: error instanceof Error ? error.message : 'unknown_error', + }); + return NextResponse.json({ error: 'Recording unavailable' }, { status: 502 }); + } + + if (twilioResponse.status === 404) { + return NextResponse.json({ error: 'Recording not available for this lead' }, { status: 404 }); + } + + if (!twilioResponse.ok || !twilioResponse.body) { + console.error('recording.proxy.upstream_error', { + leadId: lead.id, + status: twilioResponse.status, + statusText: twilioResponse.statusText, + host: mediaUrl.hostname, + }); + return NextResponse.json({ error: 'Recording unavailable' }, { status: 502 }); + } + + const headers = new Headers(); + headers.set('Cache-Control', 'private, no-store'); + headers.set('Content-Type', twilioResponse.headers.get('content-type') ?? 'audio/mpeg'); + headers.set('X-Content-Type-Options', 'nosniff'); + + const contentLength = twilioResponse.headers.get('content-length'); + if (contentLength) { + headers.set('Content-Length', contentLength); + } + + const contentDisposition = twilioResponse.headers.get('content-disposition'); + if (contentDisposition) { + headers.set('Content-Disposition', contentDisposition); + } + + return new NextResponse(twilioResponse.body, { status: 200, headers }); +} diff --git a/app/api/stripe/checkout/route.ts b/app/api/stripe/checkout/route.ts index 1f69e6c..baa991b 100644 --- a/app/api/stripe/checkout/route.ts +++ b/app/api/stripe/checkout/route.ts @@ -1,7 +1,11 @@ import { auth } from '@clerk/nextjs/server'; import { NextResponse } from 'next/server'; +import { logAuditEvent } from '@/lib/audit-log'; import { db } from '@/lib/db'; +import { getConfiguredAppBaseUrl } from '@/lib/env.server'; +import { getCorrelationIdFromRequest, reportApplicationError, withCorrelationIdHeader } from '@/lib/observability'; +import { isAllowedRequestOrigin } from '@/lib/request-origin'; import { getStripe } from '@/lib/stripe'; import { absoluteUrl } from '@/lib/url'; import { checkoutSchema } from '@/lib/validators'; @@ -14,25 +18,31 @@ function errorRedirect(message: string) { } export async function POST(request: Request) { + const correlationId = getCorrelationIdFromRequest(request); + const withCorrelation = (response: NextResponse) => withCorrelationIdHeader(response, correlationId); + + if (process.env.NODE_ENV === 'production' && !isAllowedRequestOrigin(request, getConfiguredAppBaseUrl())) { + return withCorrelation(NextResponse.json({ error: 'Invalid request origin' }, { status: 403 })); + } const { userId } = await auth(); if (!userId) { - return NextResponse.redirect(absoluteUrl('/sign-in'), { status: 303 }); + return withCorrelation(NextResponse.redirect(absoluteUrl('/sign-in'), { status: 303 })); } const business = await db.business.findUnique({ where: { ownerClerkId: userId } }); if (!business) { - return NextResponse.redirect(absoluteUrl('/app/onboarding'), { status: 303 }); + return withCorrelation(NextResponse.redirect(absoluteUrl('/app/onboarding'), { status: 303 })); } const formData = await request.formData(); const parsed = checkoutSchema.safeParse(Object.fromEntries(formData)); if (!parsed.success) { - return errorRedirect('Invalid Stripe price selection'); + return withCorrelation(errorRedirect('Invalid Stripe price selection')); } const allowedPrices = [process.env.STRIPE_PRICE_STARTER, process.env.STRIPE_PRICE_PRO].filter(Boolean); if (!allowedPrices.includes(parsed.data.priceId)) { - return errorRedirect('Price ID is not allowed'); + return withCorrelation(errorRedirect('Price ID is not allowed')); } try { @@ -65,12 +75,36 @@ export async function POST(request: Request) { }); if (!session.url) { - return errorRedirect('Stripe did not return a checkout URL'); + return withCorrelation(errorRedirect('Stripe did not return a checkout URL')); } - return NextResponse.redirect(session.url, { status: 303 }); + logAuditEvent({ + event: 'billing.checkout_session_created', + actorType: 'user', + actorId: userId, + businessId: business.id, + targetType: 'stripe_checkout_session', + targetId: session.id, + correlationId, + metadata: { + priceId: parsed.data.priceId, + hasExistingStripeCustomer: Boolean(business.stripeCustomerId), + }, + }); + return withCorrelation(NextResponse.redirect(session.url, { status: 303 })); } catch (error) { + reportApplicationError({ + source: 'stripe.checkout', + event: 'route_error', + correlationId, + error, + metadata: { + userId, + businessId: business.id, + }, + alert: false, + }); const message = error instanceof Error ? error.message : 'Failed to create Stripe checkout session'; - return errorRedirect(message); + return withCorrelation(errorRedirect(message)); } } diff --git a/app/api/stripe/portal/route.ts b/app/api/stripe/portal/route.ts index f9a1ad5..044f14f 100644 --- a/app/api/stripe/portal/route.ts +++ b/app/api/stripe/portal/route.ts @@ -1,22 +1,34 @@ import { auth } from '@clerk/nextjs/server'; import { NextResponse } from 'next/server'; +import { logAuditEvent } from '@/lib/audit-log'; import { db } from '@/lib/db'; +import { getConfiguredAppBaseUrl } from '@/lib/env.server'; +import { getCorrelationIdFromRequest, reportApplicationError, withCorrelationIdHeader } from '@/lib/observability'; +import { isAllowedRequestOrigin } from '@/lib/request-origin'; import { getStripe } from '@/lib/stripe'; import { absoluteUrl } from '@/lib/url'; export const runtime = 'nodejs'; export const dynamic = 'force-dynamic'; -export async function POST() { +export async function POST(request: Request) { + const correlationId = getCorrelationIdFromRequest(request); + const withCorrelation = (response: NextResponse) => withCorrelationIdHeader(response, correlationId); + + if (process.env.NODE_ENV === 'production' && !isAllowedRequestOrigin(request, getConfiguredAppBaseUrl())) { + return withCorrelation(NextResponse.json({ error: 'Invalid request origin' }, { status: 403 })); + } const { userId } = await auth(); if (!userId) { - return NextResponse.redirect(absoluteUrl('/sign-in'), { status: 303 }); + return withCorrelation(NextResponse.redirect(absoluteUrl('/sign-in'), { status: 303 })); } const business = await db.business.findUnique({ where: { ownerClerkId: userId } }); if (!business?.stripeCustomerId) { - return NextResponse.redirect(absoluteUrl('/app/billing?error=No%20Stripe%20customer%20for%20this%20business'), { status: 303 }); + return withCorrelation( + NextResponse.redirect(absoluteUrl('/app/billing?error=No%20Stripe%20customer%20for%20this%20business'), { status: 303 }) + ); } try { @@ -26,9 +38,32 @@ export async function POST() { return_url: absoluteUrl('/app/billing'), }); - return NextResponse.redirect(session.url, { status: 303 }); + logAuditEvent({ + event: 'billing.portal_session_created', + actorType: 'user', + actorId: userId, + businessId: business.id, + targetType: 'stripe_portal_session', + targetId: business.stripeCustomerId, + correlationId, + metadata: { + returnUrl: absoluteUrl('/app/billing'), + }, + }); + return withCorrelation(NextResponse.redirect(session.url, { status: 303 })); } catch (error) { + reportApplicationError({ + source: 'stripe.portal', + event: 'route_error', + correlationId, + error, + metadata: { + userId, + businessId: business.id, + }, + alert: false, + }); const message = error instanceof Error ? error.message : 'Failed to open billing portal'; - return NextResponse.redirect(absoluteUrl(`/app/billing?error=${encodeURIComponent(message)}`), { status: 303 }); + return withCorrelation(NextResponse.redirect(absoluteUrl(`/app/billing?error=${encodeURIComponent(message)}`), { status: 303 })); } } diff --git a/app/api/twilio/provision-number/route.ts b/app/api/twilio/provision-number/route.ts new file mode 100644 index 0000000..c435a91 --- /dev/null +++ b/app/api/twilio/provision-number/route.ts @@ -0,0 +1,100 @@ +import { auth } from '@clerk/nextjs/server'; +import { NextResponse } from 'next/server'; + +import { db } from '@/lib/db'; +import { getCorrelationIdFromRequest, withCorrelationIdHeader } from '@/lib/observability'; +import { logTwilioError, logTwilioWarn } from '@/lib/twilio-logging'; +import { getTwilioProvisioningBlockReason, provisionPhoneNumber } from '@/lib/twilio-provision'; + +export const runtime = 'nodejs'; +export const dynamic = 'force-dynamic'; + +function buildBlockedResponse(blockReason: ReturnType) { + switch (blockReason) { + case 'already_has_number': + return NextResponse.json( + { error: 'This business already has a Twilio phone number.', code: blockReason }, + { status: 409 } + ); + case 'demo_mode': + return NextResponse.json( + { error: 'Twilio number provisioning is disabled while demo mode is enabled.', code: blockReason }, + { status: 409 } + ); + case 'missing_twilio_credentials': + return NextResponse.json( + { error: 'Twilio number provisioning is unavailable until Twilio credentials are configured.', code: blockReason }, + { status: 503 } + ); + default: + return NextResponse.json({ error: 'Provisioning is not available.' }, { status: 503 }); + } +} + +export async function POST(request: Request) { + const correlationId = getCorrelationIdFromRequest(request); + const withCorrelation = (response: NextResponse) => withCorrelationIdHeader(response, correlationId); + + const { userId } = await auth(); + if (!userId) { + return withCorrelation(NextResponse.json({ error: 'Unauthorized' }, { status: 401 })); + } + + const business = await db.business.findUnique({ + where: { ownerClerkId: userId }, + select: { + id: true, + name: true, + twilioSubaccountSid: true, + twilioPhoneNumber: true, + twilioPhoneNumberSid: true, + }, + }); + + if (!business) { + return withCorrelation(NextResponse.json({ error: 'Business not found' }, { status: 404 })); + } + + const blockReason = getTwilioProvisioningBlockReason(business); + if (blockReason) { + logTwilioWarn('provisioning', 'manual_provision_blocked', { + correlationId, + businessId: business.id, + ownerClerkId: userId, + decision: blockReason, + }); + + return withCorrelation(buildBlockedResponse(blockReason)); + } + + try { + const provisionedNumber = await provisionPhoneNumber({ + businessId: business.id, + businessName: business.name, + correlationId, + }); + + return withCorrelation( + NextResponse.json({ + ok: true, + phoneNumber: provisionedNumber.phoneNumber, + phoneNumberSid: provisionedNumber.phoneNumberSid, + twilioSubaccountSid: provisionedNumber.subaccountSid, + }) + ); + } catch (error) { + logTwilioError( + 'provisioning', + 'manual_provision_failed', + { + correlationId, + businessId: business.id, + ownerClerkId: userId, + decision: 'return_503', + }, + error + ); + + return withCorrelation(NextResponse.json({ error: 'Failed to provision Twilio number.' }, { status: 503 })); + } +} diff --git a/app/app/billing/page.tsx b/app/app/billing/page.tsx index 164c77c..afb207c 100644 --- a/app/app/billing/page.tsx +++ b/app/app/billing/page.tsx @@ -6,6 +6,7 @@ import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } import { requireBusiness } from '@/lib/auth'; import { db } from '@/lib/db'; import { getPortfolioDemoBlockedCount, isPortfolioDemoMode } from '@/lib/portfolio-demo'; +import { isSubscriptionActive } from '@/lib/subscription'; import { getConversationUsageForBusiness, resolveUsageTierFromSubscription } from '@/lib/usage'; import { describeAutomationBlockReason, @@ -18,12 +19,23 @@ function planPrice(priceId: string | undefined) { return priceId ? 'Configured via Stripe Price ID' : 'Missing env var'; } +function parseRequestedPlan(searchParams?: Record) { + const rawPlan = searchParams?.plan; + const normalized = typeof rawPlan === 'string' ? rawPlan.trim().toLowerCase() : ''; + if (normalized === 'starter' || normalized === 'pro') return normalized; + return null; +} + export default async function BillingPage({ searchParams }: { searchParams?: Record }) { const business = await requireBusiness(); const starterPriceId = process.env.STRIPE_PRICE_STARTER; const proPriceId = process.env.STRIPE_PRICE_PRO; const error = typeof searchParams?.error === 'string' ? searchParams.error : undefined; const checkout = typeof searchParams?.checkout === 'string' ? searchParams.checkout : undefined; + const requestedPlan = parseRequestedPlan(searchParams); + const subscriptionActive = isSubscriptionActive(business.subscriptionStatus); + const checkoutSucceeded = checkout === 'success'; + const checkoutCanceled = checkout === 'canceled'; const demoMode = isPortfolioDemoMode(); const [blockedCount, usage] = demoMode ? [getPortfolioDemoBlockedCount(), null] @@ -51,8 +63,29 @@ export default async function BillingPage({ searchParams }: { searchParams?: Rec {error ?
{error}
: null} - {checkout === 'success' ?
Stripe checkout completed. Webhook sync may take a few seconds.
: null} - {checkout === 'canceled' ?
Checkout canceled.
: null} + {requestedPlan ? ( +
+ Selected plan: {requestedPlan === 'starter' ? 'Starter' : 'Pro'}. Continue checkout below. +
+ ) : null} + {checkoutSucceeded && subscriptionActive ? ( +
+ Subscription is active. Next steps: connect your Twilio number in Business Settings, then monitor new leads in{' '} + Dashboard. +
+ ) : null} + {checkoutSucceeded && !subscriptionActive ? ( +
+

Stripe checkout completed. Subscription status is still syncing from webhook events.

+

If this does not update shortly, refresh this page and verify `STRIPE_WEBHOOK_SECRET` + webhook endpoint configuration.

+
+ + + +
+
+ ) : null} + {checkoutCanceled ?
Checkout canceled. You can restart anytime below.
: null} @@ -86,7 +119,7 @@ export default async function BillingPage({ searchParams }: { searchParams?: Rec
- + Starter Basic missed-call SMS follow-up and dashboard access. @@ -103,7 +136,7 @@ export default async function BillingPage({ searchParams }: { searchParams?: Rec - + Pro Higher volume and premium support workflows. diff --git a/app/app/error.tsx b/app/app/error.tsx new file mode 100644 index 0000000..b8ca797 --- /dev/null +++ b/app/app/error.tsx @@ -0,0 +1,35 @@ +'use client'; + +import { useEffect } from 'react'; + +import { Button } from '@/components/ui/button'; + +export default function AppError({ + error, + reset, +}: { + error: Error & { digest?: string }; + reset: () => void; +}) { + useEffect(() => { + console.error('app.route_error_boundary', { + message: error.message, + digest: error.digest ?? null, + }); + }, [error]); + + return ( +
+
+

Application Error

+

The dashboard hit an unexpected error.

+

+ Retry the request. If the problem repeats, capture the time and recent action for support. +

+
+ +
+ ); +} diff --git a/app/app/leads/[leadId]/page.tsx b/app/app/leads/[leadId]/page.tsx index bdf24be..99052dc 100644 --- a/app/app/leads/[leadId]/page.tsx +++ b/app/app/leads/[leadId]/page.tsx @@ -92,7 +92,7 @@ export default async function LeadDetailPage({ params, searchParams }: { params: Call Record - Twilio voice callback data for the originating call. + Twilio voice callback data and recording metadata for the originating call. {lead.call ? ( @@ -102,6 +102,17 @@ export default async function LeadDetailPage({ params, searchParams }: { params:
Answered{lead.call.answered ? 'Yes' : 'No'}
Missed{lead.call.missed ? 'Yes' : 'No'}
Duration{lead.call.callDurationSeconds ?? 0}s
+
Recording status{lead.call.recordingStatus || 'not_available'}
+
Recording duration{lead.call.recordingDurationSeconds ?? 0}s
+
+ {lead.call.recordingUrl ? ( +
+ +
+ ) : ( +

Recording link unavailable until Twilio recording metadata is received.

+ )} +
) : (

No call record linked.

diff --git a/app/app/onboarding/actions.ts b/app/app/onboarding/actions.ts index c5c46e7..af09398 100644 --- a/app/app/onboarding/actions.ts +++ b/app/app/onboarding/actions.ts @@ -5,20 +5,67 @@ import { redirect } from 'next/navigation'; import { revalidatePath } from 'next/cache'; import { upsertBusinessForOwner } from '@/lib/business'; +import { logTwilioError } from '@/lib/twilio-logging'; +import { getTwilioProvisioningBlockReason, provisionPhoneNumber } from '@/lib/twilio-provision'; import { onboardingSchema } from '@/lib/validators'; +const DEFAULT_POST_ONBOARDING_REDIRECT = '/app/leads'; + +function resolveSafePostOnboardingRedirectPath(value: FormDataEntryValue | null) { + if (typeof value !== 'string') return DEFAULT_POST_ONBOARDING_REDIRECT; + + const nextPath = value.trim(); + if (!nextPath || !nextPath.startsWith('/') || nextPath.startsWith('//')) { + return DEFAULT_POST_ONBOARDING_REDIRECT; + } + + if (nextPath === '/app') return DEFAULT_POST_ONBOARDING_REDIRECT; + if (!nextPath.startsWith('/app/')) return DEFAULT_POST_ONBOARDING_REDIRECT; + + return nextPath; +} + export async function saveOnboardingAction(formData: FormData) { const { userId } = await auth(); if (!userId) { redirect('/sign-in'); } + const postOnboardingRedirect = resolveSafePostOnboardingRedirectPath(formData.get('next')); + const parsed = onboardingSchema.safeParse(Object.fromEntries(formData)); if (!parsed.success) { redirect(`/app/onboarding?error=${encodeURIComponent(parsed.error.issues[0]?.message || 'Invalid form data')}`); } - await upsertBusinessForOwner(userId, parsed.data); + const business = await upsertBusinessForOwner(userId, parsed.data); + const provisioningBlockReason = getTwilioProvisioningBlockReason(business); + + if (!provisioningBlockReason) { + const correlationId = `onboarding_${business.id}`; + + try { + await provisionPhoneNumber({ + businessId: business.id, + businessName: business.name, + correlationId, + }); + } catch (error) { + logTwilioError( + 'provisioning', + 'onboarding_auto_provision_failed', + { + correlationId, + businessId: business.id, + ownerClerkId: userId, + decision: 'onboarding_completed_without_twilio_number', + }, + error + ); + } + } + revalidatePath('/app'); - redirect('/app/leads'); + revalidatePath('/app/settings'); + redirect(postOnboardingRedirect); } diff --git a/app/app/onboarding/page.tsx b/app/app/onboarding/page.tsx index 2ae97fa..fb873ee 100644 --- a/app/app/onboarding/page.tsx +++ b/app/app/onboarding/page.tsx @@ -8,12 +8,31 @@ import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import { db } from '@/lib/db'; -export default async function OnboardingPage({ searchParams }: { searchParams?: { error?: string } }) { +const DEFAULT_POST_ONBOARDING_REDIRECT = '/app/leads'; + +function resolveSafeNextPath(value: string | undefined) { + const nextPath = value?.trim(); + if (!nextPath || !nextPath.startsWith('/') || nextPath.startsWith('//')) { + return DEFAULT_POST_ONBOARDING_REDIRECT; + } + + if (nextPath === '/app') return DEFAULT_POST_ONBOARDING_REDIRECT; + if (!nextPath.startsWith('/app/')) return DEFAULT_POST_ONBOARDING_REDIRECT; + return nextPath; +} + +export default async function OnboardingPage({ + searchParams, +}: { + searchParams?: Record; +}) { const { userId } = await auth(); if (!userId) redirect('/sign-in'); const existing = await db.business.findUnique({ where: { ownerClerkId: userId } }); if (existing) redirect('/app/leads'); + const error = typeof searchParams?.error === 'string' ? searchParams.error : undefined; + const nextPath = resolveSafeNextPath(typeof searchParams?.next === 'string' ? searchParams.next : undefined); return (
@@ -27,12 +46,13 @@ export default async function OnboardingPage({ searchParams }: { searchParams?: Set the call forwarding and SMS qualification defaults. - {searchParams?.error ? ( + {error ? (
- {searchParams.error} + {error}
) : null}
+
diff --git a/app/app/settings/actions.ts b/app/app/settings/actions.ts index 5d03cd0..b597ed7 100644 --- a/app/app/settings/actions.ts +++ b/app/app/settings/actions.ts @@ -6,7 +6,10 @@ import { redirect } from 'next/navigation'; import { db } from '@/lib/db'; import { normalizePhoneNumber } from '@/lib/phone'; -import { getTwilioClient, getTwilioWebhookConfig, syncTwilioIncomingPhoneNumberWebhooks } from '@/lib/twilio'; +import { logTwilioError } from '@/lib/twilio-logging'; +import { provisionPhoneNumber } from '@/lib/twilio-provision'; +import { getTwilioBusinessClient } from '@/lib/twilio-client'; +import { syncTwilioIncomingPhoneNumberWebhooks } from '@/lib/twilio'; import { businessSettingsSchema, buyNumberSchema } from '@/lib/validators'; async function getBusinessForOwner() { @@ -28,8 +31,11 @@ function parseTwilioPhoneNumberSid(formData: FormData) { return sid; } -async function pickExistingTwilioIncomingNumber(phoneNumberSid?: string) { - const client = getTwilioClient(); +async function pickExistingTwilioIncomingNumber( + business: Awaited>, + phoneNumberSid?: string +) { + const client = getTwilioBusinessClient(business.twilioSubaccountSid); if (phoneNumberSid) { return client.incomingPhoneNumbers(phoneNumberSid).fetch(); } @@ -92,49 +98,27 @@ export async function buyTwilioNumberAction(formData: FormData) { } try { - const client = getTwilioClient(); - const webhookConfig = getTwilioWebhookConfig(); - const areaCode = parsed.data.areaCode?.trim() || undefined; - const areaCodeNumber = areaCode ? Number.parseInt(areaCode, 10) : undefined; - const candidates = await client.availablePhoneNumbers('US').local.list({ - limit: 1, - smsEnabled: true, - voiceEnabled: true, - ...(areaCodeNumber ? { areaCode: areaCodeNumber } : {}), - }); - - const candidate = candidates[0]; - if (!candidate?.phoneNumber) { - redirect('/app/settings?error=No%20US%20local%20numbers%20available'); - } - - const number = await client.incomingPhoneNumbers.create({ - phoneNumber: candidate.phoneNumber, - friendlyName: `${business.name} - CallbackCloser`, - voiceUrl: webhookConfig.voiceUrl, - voiceMethod: 'POST', - smsUrl: webhookConfig.smsUrl, - smsMethod: 'POST', - statusCallback: webhookConfig.statusUrl, - statusCallbackMethod: 'POST', - }); - - const syncedAt = new Date(); - await saveBusinessTwilioNumber(business.id, { - phoneNumber: number.phoneNumber, - phoneNumberSid: number.sid, - syncedAt, - }); - - console.info('Twilio webhook sync applied', { - phoneNumberSid: number.sid, - phoneNumber: number.phoneNumber, - appBaseUrl: webhookConfig.appBaseUrl, + const correlationId = `settings_buy_${business.id}`; + await provisionPhoneNumber({ + businessId: business.id, + businessName: business.name, + areaCode: parsed.data.areaCode, + correlationId, }); revalidatePath('/app/settings'); redirect('/app/settings?numberBought=1'); } catch (error) { + logTwilioError( + 'provisioning', + 'settings_manual_provision_failed', + { + correlationId: `settings_buy_${business.id}`, + businessId: business.id, + decision: 'redirect_with_error', + }, + error + ); const message = error instanceof Error ? error.message : 'Failed to buy number'; redirect(`/app/settings?error=${encodeURIComponent(message)}`); } @@ -144,9 +128,10 @@ export async function connectExistingTwilioNumberAction(formData: FormData) { const business = await getBusinessForOwner(); try { + const client = getTwilioBusinessClient(business.twilioSubaccountSid); const phoneNumberSid = parseTwilioPhoneNumberSid(formData); - const selectedNumber = await pickExistingTwilioIncomingNumber(phoneNumberSid); - const { number } = await syncTwilioIncomingPhoneNumberWebhooks(selectedNumber.sid); + const selectedNumber = await pickExistingTwilioIncomingNumber(business, phoneNumberSid); + const { number } = await syncTwilioIncomingPhoneNumberWebhooks(selectedNumber.sid, client); const syncedAt = new Date(); await saveBusinessTwilioNumber(business.id, { @@ -170,7 +155,8 @@ export async function resyncTwilioWebhooksAction() { } try { - const { number } = await syncTwilioIncomingPhoneNumberWebhooks(business.twilioPhoneNumberSid); + const client = getTwilioBusinessClient(business.twilioSubaccountSid); + const { number } = await syncTwilioIncomingPhoneNumberWebhooks(business.twilioPhoneNumberSid, client); const syncedAt = new Date(); await saveBusinessTwilioNumber(business.id, { diff --git a/app/app/settings/page.tsx b/app/app/settings/page.tsx index 426adfe..69e29f0 100644 --- a/app/app/settings/page.tsx +++ b/app/app/settings/page.tsx @@ -1,7 +1,8 @@ import { requireBusiness } from '@/lib/auth'; import { formatPhoneForDisplay } from '@/lib/phone'; import { getPortfolioDemoTwilioNumbers, getPortfolioDemoWebhookConfig, isPortfolioDemoMode } from '@/lib/portfolio-demo'; -import { getTwilioClient, getTwilioWebhookConfig } from '@/lib/twilio'; +import { getTwilioBusinessClient } from '@/lib/twilio-client'; +import { getTwilioWebhookConfig } from '@/lib/twilio'; import { saveBusinessSettingsAction, buyTwilioNumberAction, connectExistingTwilioNumberAction, resyncTwilioWebhooksAction } from '@/app/app/settings/actions'; import { CopyValueButton } from '@/components/copy-value-button'; import { Button } from '@/components/ui/button'; @@ -45,7 +46,7 @@ export default async function SettingsPage({ searchParams }: { searchParams?: Re existingTwilioNumbers = getPortfolioDemoTwilioNumbers(); } else { try { - const client = getTwilioClient(); + const client = getTwilioBusinessClient(business.twilioSubaccountSid); const numbers = await client.incomingPhoneNumbers.list({ limit: 50 }); existingTwilioNumbers = numbers.map((number) => ({ sid: number.sid, diff --git a/app/buy/page.tsx b/app/buy/page.tsx new file mode 100644 index 0000000..cd5f453 --- /dev/null +++ b/app/buy/page.tsx @@ -0,0 +1,41 @@ +import { auth } from '@clerk/nextjs/server'; +import { redirect } from 'next/navigation'; + +import { db } from '@/lib/db'; + +type PlanParam = 'starter' | 'pro'; + +function parsePlan(searchParams?: Record): PlanParam | null { + const raw = searchParams?.plan; + const value = typeof raw === 'string' ? raw.trim().toLowerCase() : ''; + if (value === 'starter' || value === 'pro') return value; + return null; +} + +function buildBuyPath(plan: PlanParam | null) { + if (!plan) return '/buy'; + return `/buy?plan=${encodeURIComponent(plan)}`; +} + +function buildBillingPath(plan: PlanParam | null) { + if (!plan) return '/app/billing'; + return `/app/billing?plan=${encodeURIComponent(plan)}`; +} + +export default async function BuyPage({ searchParams }: { searchParams?: Record }) { + const plan = parsePlan(searchParams); + const buyPath = buildBuyPath(plan); + const billingPath = buildBillingPath(plan); + const { userId } = await auth(); + + if (!userId) { + redirect(`/sign-up?redirect_url=${encodeURIComponent(buyPath)}`); + } + + const business = await db.business.findUnique({ where: { ownerClerkId: userId } }); + if (!business) { + redirect(`/app/onboarding?next=${encodeURIComponent(billingPath)}`); + } + + redirect(billingPath); +} diff --git a/app/contact/page.tsx b/app/contact/page.tsx new file mode 100644 index 0000000..ebed43d --- /dev/null +++ b/app/contact/page.tsx @@ -0,0 +1,44 @@ +import Link from 'next/link'; + +const EFFECTIVE_DATE = 'March 2, 2026'; +const SUPPORT_EMAIL = 'support@callbackcloser.com'; + +export default function ContactPage() { + return ( +
+
+
+

Contact

+

Effective date: {EFFECTIVE_DATE}

+
+ +
+

Support

+

+ Email {SUPPORT_EMAIL} and include your business name, account email, and a brief description of your request. +

+
+ +
+

Billing and Refund Questions

+

+ Include the charge date, last 4 digits of the card (if available), and any relevant Stripe receipt details. +

+
+ +
+

Privacy Requests

+

+ For data access, correction, or deletion requests, include your account email and business identifier so we can verify ownership. +

+
+ +
+ + Back to home + +
+
+
+ ); +} diff --git a/app/global-error.tsx b/app/global-error.tsx new file mode 100644 index 0000000..90023f5 --- /dev/null +++ b/app/global-error.tsx @@ -0,0 +1,41 @@ +'use client'; + +import { useEffect } from 'react'; + +export default function GlobalError({ + error, + reset, +}: { + error: Error & { digest?: string }; + reset: () => void; +}) { + useEffect(() => { + console.error('app.global_error_boundary', { + message: error.message, + digest: error.digest ?? null, + }); + }, [error]); + + return ( + + +
+
+

Unexpected Error

+

CallbackCloser could not finish this request.

+

+ Retry once. If the error persists, use the support contact listed on the public legal pages. +

+
+ +
+ + + ); +} diff --git a/app/page.tsx b/app/page.tsx index bd4a1d7..5fc5e6e 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -19,12 +19,12 @@ export default function LandingPage() { When a customer calls and nobody answers, CallbackCloser texts them instantly, captures the job details, and alerts the owner with a lead summary.

- - + + - +
@@ -52,6 +52,9 @@ export default function LandingPage() { Refund + + Contact +
diff --git a/app/privacy/page.tsx b/app/privacy/page.tsx index 8e20b25..1e0c42b 100644 --- a/app/privacy/page.tsx +++ b/app/privacy/page.tsx @@ -40,7 +40,11 @@ export default function PrivacyPage() {