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
6 changes: 4 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -262,13 +262,14 @@ 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 exposes a server-mediated redirect route for authenticated in-app access

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 and lead business before redirecting
- 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 @@ -318,6 +319,7 @@ Prisma models included:
- `/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 redirect for lead owners
- `/api/stripe/webhook` - Stripe webhook

## Notes / MVP Constraints
Expand Down
57 changes: 57 additions & 0 deletions app/api/leads/[leadId]/recording/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import { auth } from '@clerk/nextjs/server';
import { NextResponse } from 'next/server';

import { db } from '@/lib/db';
import { 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 });
}

return NextResponse.redirect(lead.call!.recordingUrl!, { status: 303 });
}
13 changes: 12 additions & 1 deletion app/app/leads/[leadId]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,7 @@ export default async function LeadDetailPage({ params, searchParams }: { params:
<Card>
<CardHeader>
<CardTitle>Call Record</CardTitle>
<CardDescription>Twilio voice callback data for the originating call.</CardDescription>
<CardDescription>Twilio voice callback data and recording metadata for the originating call.</CardDescription>
</CardHeader>
<CardContent className="space-y-2 text-sm">
{lead.call ? (
Expand All @@ -102,6 +102,17 @@ export default async function LeadDetailPage({ params, searchParams }: { params:
<div className="grid grid-cols-2 gap-2"><span className="text-muted-foreground">Answered</span><span>{lead.call.answered ? 'Yes' : 'No'}</span></div>
<div className="grid grid-cols-2 gap-2"><span className="text-muted-foreground">Missed</span><span>{lead.call.missed ? 'Yes' : 'No'}</span></div>
<div className="grid grid-cols-2 gap-2"><span className="text-muted-foreground">Duration</span><span>{lead.call.callDurationSeconds ?? 0}s</span></div>
<div className="grid grid-cols-2 gap-2"><span className="text-muted-foreground">Recording status</span><span>{lead.call.recordingStatus || 'not_available'}</span></div>
<div className="grid grid-cols-2 gap-2"><span className="text-muted-foreground">Recording duration</span><span>{lead.call.recordingDurationSeconds ?? 0}s</span></div>
<div className="pt-1">
{lead.call.recordingUrl ? (
<form action={`/api/leads/${lead.id}/recording`} method="get">
<Button type="submit" variant="outline">Open recording</Button>
</form>
) : (
<p className="text-xs text-muted-foreground">Recording link unavailable until Twilio recording metadata is received.</p>
)}
</div>
</>
) : (
<p className="text-muted-foreground">No call record linked.</p>
Expand Down
32 changes: 32 additions & 0 deletions docs/PRODUCTION_READINESS_GAPS.md
Original file line number Diff line number Diff line change
Expand Up @@ -483,3 +483,35 @@ Dependencies: G4 (recommended)
- `npm run env:check` -> PASS
- Notes:
- No functional regressions observed in local validation gates.

- 2026-03-02 - G14 (DONE)
- Branch: `hardening/g14-recordings-ux`
- What changed:
- Added access-control helper for recording links in `lib/recording-access.ts`:
- only authenticated owner of the lead's business can open recordings
- blocks wrong-business and missing-recording cases
- Added server-mediated recording access route:
- `app/api/leads/[leadId]/recording/route.ts`
- validates auth + business ownership before redirecting to Twilio recording URL
- hides cross-business lead access behind `404`
- Updated lead detail UI (`app/app/leads/[leadId]/page.tsx`) to show:
- recording status
- recording duration
- gated `Open recording` action (uses server route, not raw URL)
- Added focused access-control tests in `tests/recording-access.test.ts`.
- Updated recording docs in `README.md` to reflect the in-app gated flow.
- Commands run + results:
- `npm test` -> PASS (38/38)
- `npm run lint` -> PASS
- `npm run build` -> PASS
- `npm run typecheck` -> PASS
- `npm run env:check` -> PASS
- Files touched:
- `lib/recording-access.ts`
- `app/api/leads/[leadId]/recording/route.ts`
- `app/app/leads/[leadId]/page.tsx`
- `tests/recording-access.test.ts`
- `README.md`
- `docs/PRODUCTION_READINESS_GAPS.md`
- Commit SHA:
- `4e8f4d8`
18 changes: 18 additions & 0 deletions lib/recording-access.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
export type RecordingAccessReason =
| 'ok'
| 'unauthenticated'
| 'wrong_business'
| 'recording_unavailable';

export function resolveRecordingAccessReason(input: {
requestUserId: string | null | undefined;
businessOwnerClerkId: string | null | undefined;
recordingUrl: string | null | undefined;
}): RecordingAccessReason {
if (!input.requestUserId) return 'unauthenticated';
if (!input.businessOwnerClerkId || input.businessOwnerClerkId !== input.requestUserId) {
return 'wrong_business';
}
if (!input.recordingUrl) return 'recording_unavailable';
return 'ok';
}
44 changes: 44 additions & 0 deletions tests/recording-access.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import assert from 'node:assert/strict';
import test from 'node:test';

import { resolveRecordingAccessReason } from '../lib/recording-access.ts';

test('recording access denies unauthenticated requests', () => {
const reason = resolveRecordingAccessReason({
requestUserId: null,
businessOwnerClerkId: 'user_123',
recordingUrl: 'https://api.twilio.com/recordings/abc',
});

assert.equal(reason, 'unauthenticated');
});

test('recording access denies users outside the lead business', () => {
const reason = resolveRecordingAccessReason({
requestUserId: 'user_123',
businessOwnerClerkId: 'user_456',
recordingUrl: 'https://api.twilio.com/recordings/abc',
});

assert.equal(reason, 'wrong_business');
});

test('recording access denies when no recording URL is present', () => {
const reason = resolveRecordingAccessReason({
requestUserId: 'user_123',
businessOwnerClerkId: 'user_123',
recordingUrl: null,
});

assert.equal(reason, 'recording_unavailable');
});

test('recording access allows authenticated owner with recording URL', () => {
const reason = resolveRecordingAccessReason({
requestUserId: 'user_123',
businessOwnerClerkId: 'user_123',
recordingUrl: 'https://api.twilio.com/recordings/abc',
});

assert.equal(reason, 'ok');
});