Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
73 changes: 71 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -262,13 +264,23 @@ Current behavior:

- Forwarded calls are recorded via TwiML `<Dial record="record-from-answer-dual">`
- 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

Expand Down Expand Up @@ -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
Expand Down
75 changes: 75 additions & 0 deletions app/api/health/route.ts
Original file line number Diff line number Diff line change
@@ -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<T>(promise: Promise<T>, timeoutMs: number) {
return Promise.race<T>([
promise,
new Promise<T>((_, 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 }
)
);
}
116 changes: 116 additions & 0 deletions app/api/leads/[leadId]/recording/route.ts
Original file line number Diff line number Diff line change
@@ -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 });
}
48 changes: 41 additions & 7 deletions app/api/stripe/checkout/route.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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 {
Expand Down Expand Up @@ -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));
}
}
Loading