Skip to content

Commit 6b3c294

Browse files
feat(courses): free-lesson preview for prospective students (#426) (#485)
* feat(courses): free-lesson preview for prospective students (#426) Adds lessons.is_preview: teachers flag 1-2 lessons as free previews in the lesson editor; the public course page badges them and links to a new read-only public lesson view (/courses/[id]/lessons/[lessonId]) that works for logged-out visitors — no completion tracking, checkpoints, comments, or AI task. Also tightens the anon RLS policy on lessons, which previously exposed every lesson row (including content) to the anon key: anon can now read only published preview lessons of published courses in the current tenant. Public pages fetch curriculum metadata (never content) via the admin client with explicit tenant/published filters instead. Closes #426 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JdxSKg8JwB3XR4b1nqYCHU * fix(courses): harden public lesson preview (#426 follow-ups) - Column-level grants: anon can no longer read ai_task_description / ai_task_instructions (grading criteria) via direct REST — RLS grants row access, column privileges mask the teacher-only fields. Also revokes anon write privileges on lessons (defense in depth; RLS already blocked). - Sandboxed embeds on the public preview: teacher-authored embed_code now renders in an opaque-origin iframe (sandbox, no allow-same-origin), so scripts can't reach the site's cookies/DOM for logged-out visitors. Enrolled-student views keep the existing trusted rendering. - Perf: getPreviewLesson wrapped in React.cache (dedupes generateMetadata + page) and its course/lesson queries run in parallel. Verified live: anon REST column denial (42501), XSS probe via embed_code blocked by sandbox (SecurityError on document.cookie), preview page renders, non-preview/cross-tenant URLs still 404, npm run build passes. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GvMJCFEQjgnhhLrBFTEYPP * feat(teacher): upload lesson-content images to public bucket (#426 follow-up) The image block was paste-URL only, so teachers pasting signed lesson-resources URLs (private bucket, ~1h expiry) got content images that 404 for every reader once the token lapses — worse on public preview lessons where visitors have no session at all. - Image block: "Subir" button uploads via the existing uploadCourseImage action (teacher/admin-gated, 5MB, image mimes) to the public course-images bucket and fills in the permanent URL. - Image, audio, and file-download blocks: warn inline when the pasted URL is an expiring signed storage URL, pointing at the durable alternative. Verified in-browser: upload fills a public tenant-prefixed URL (anon fetch 200), preview renders, signed-URL paste shows the warning. Build passes. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GvMJCFEQjgnhhLrBFTEYPP --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent 3a3f56b commit 6b3c294

16 files changed

Lines changed: 408 additions & 31 deletions

File tree

Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
import { cache } from 'react'
2+
import { createAdminClient } from '@/lib/supabase/admin'
3+
import { getCurrentTenantId } from '@/lib/supabase/tenant'
4+
import { getTranslations } from 'next-intl/server'
5+
import { notFound } from 'next/navigation'
6+
import Link from 'next/link'
7+
import { ArrowLeft, Sparkles } from 'lucide-react'
8+
import { Button } from '@/components/ui/button'
9+
import { LessonContent } from '@/app/[locale]/dashboard/student/courses/[courseId]/lessons/[lessonId]/lesson-content'
10+
import { serializeLessonMdx } from '@/app/[locale]/dashboard/student/courses/[courseId]/lessons/[lessonId]/serialize-lesson'
11+
import type { Metadata } from 'next'
12+
import { buildPageMetadata } from '@/lib/seo'
13+
14+
export const dynamic = 'force-dynamic'
15+
16+
interface PageProps {
17+
params: Promise<{ id: string; lessonId: string; locale: string }>
18+
}
19+
20+
// Public free-preview lesson view (#426). Reachable by anyone — including
21+
// logged-out visitors (/courses/* is in the proxy public-route allowlist).
22+
// Uses the admin client with explicit guards mirroring the anon RLS policy:
23+
// published preview lesson, published course, current tenant. Read-only:
24+
// no completion tracking, no checkpoints, no comments, no AI task.
25+
// cache() dedupes the generateMetadata + page calls within a request.
26+
const getPreviewLesson = cache(async (courseId: number, lessonId: number, tenantId: string) => {
27+
if (!Number.isInteger(courseId) || !Number.isInteger(lessonId)) return null
28+
const admin = createAdminClient()
29+
30+
const [{ data: course }, { data: lesson }] = await Promise.all([
31+
admin
32+
.from('courses')
33+
.select('course_id, title')
34+
.eq('course_id', courseId)
35+
.eq('tenant_id', tenantId)
36+
.eq('status', 'published')
37+
.single(),
38+
admin
39+
.from('lessons')
40+
.select('id, title, sequence, description, content, video_url, embed_code')
41+
.eq('id', lessonId)
42+
.eq('course_id', courseId)
43+
.eq('tenant_id', tenantId)
44+
.eq('status', 'published')
45+
.eq('is_preview', true)
46+
.single(),
47+
])
48+
if (!course || !lesson) return null
49+
50+
return { course, lesson }
51+
})
52+
53+
export async function generateMetadata(props: PageProps): Promise<Metadata> {
54+
const { id, lessonId, locale } = await props.params
55+
const tenantId = await getCurrentTenantId()
56+
const data = await getPreviewLesson(Number(id), Number(lessonId), tenantId)
57+
if (!data) notFound()
58+
return buildPageMetadata({
59+
title: `${data.lesson.title}${data.course.title}`,
60+
description: data.lesson.description?.replace(/\s+/g, ' ').trim().slice(0, 160) || data.course.title,
61+
path: `/courses/${id}/lessons/${lessonId}`,
62+
locale,
63+
})
64+
}
65+
66+
export default async function PublicLessonPreviewPage(props: PageProps) {
67+
const { id, lessonId } = await props.params
68+
const [t, tenantId] = await Promise.all([
69+
getTranslations('coursePublicDetails'),
70+
getCurrentTenantId(),
71+
])
72+
73+
const data = await getPreviewLesson(Number(id), Number(lessonId), tenantId)
74+
if (!data) notFound()
75+
const { course, lesson } = data
76+
77+
return (
78+
// pt-16 clears the public layout's fixed navbar
79+
<div className="min-h-screen bg-background pt-16">
80+
{/* Preview notice banner */}
81+
<div className="border-b bg-primary/5">
82+
<div className="container mx-auto px-4 py-3 flex flex-wrap items-center justify-between gap-3">
83+
<div className="flex items-center gap-2 min-w-0 text-sm">
84+
<Sparkles className="w-4 h-4 text-primary flex-shrink-0" aria-hidden="true" />
85+
<span className="font-semibold text-primary flex-shrink-0">{t('preview.badge')}</span>
86+
<span className="text-muted-foreground truncate">{t('preview.notice')}</span>
87+
</div>
88+
<Link href={`/courses/${id}`} className="flex-shrink-0">
89+
<Button variant="outline" size="sm" className="gap-1.5">
90+
<ArrowLeft className="w-4 h-4" aria-hidden="true" />
91+
{t('preview.backToCourse')}
92+
</Button>
93+
</Link>
94+
</div>
95+
</div>
96+
97+
<div className="mx-auto max-w-4xl px-4 py-8 md:px-6 md:py-10 space-y-10">
98+
{/* Lesson header */}
99+
<header>
100+
<p className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground/60 mb-1">
101+
{course.title}
102+
</p>
103+
<h1 className="text-2xl md:text-3xl font-bold tracking-tight text-balance">
104+
{lesson.title}
105+
</h1>
106+
{lesson.description && (
107+
<p className="mt-2 text-muted-foreground leading-relaxed">{lesson.description}</p>
108+
)}
109+
</header>
110+
111+
<LessonContent
112+
mdx={await serializeLessonMdx(lesson.content)}
113+
videoUrl={lesson.video_url}
114+
embedCode={lesson.embed_code}
115+
embedMode="sandboxed"
116+
/>
117+
118+
{/* Enroll CTA */}
119+
<section className="rounded-2xl border-2 border-primary/10 bg-gradient-to-b from-primary/[0.06] to-transparent p-6 md:p-8 text-center space-y-4">
120+
<h2 className="text-xl font-bold">{t('preview.enrollTitle')}</h2>
121+
<Link href={`/courses/${id}`} className="inline-block">
122+
<Button size="lg" className="gap-2">
123+
{t('preview.enrollCta')}
124+
</Button>
125+
</Link>
126+
</section>
127+
</div>
128+
</div>
129+
)
130+
}

app/[locale]/(public)/courses/[id]/page.tsx

Lines changed: 38 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,7 @@ interface Lesson {
7979
title: string;
8080
sequence: number;
8181
description: string | null;
82+
is_preview: boolean;
8283
}
8384

8485
export default async function CourseDetailsPage(props: {
@@ -99,12 +100,6 @@ export default async function CourseDetailsPage(props: {
99100
.from("courses")
100101
.select(`
101102
*,
102-
lessons (
103-
id,
104-
title,
105-
sequence,
106-
description
107-
),
108103
category:course_categories (
109104
id,
110105
name
@@ -119,6 +114,17 @@ export default async function CourseDetailsPage(props: {
119114
notFound();
120115
}
121116

117+
// Curriculum via admin client: the anon role can only read preview lessons
118+
// (RLS), but the public page lists every published lesson's title. Metadata
119+
// only — never select content here.
120+
const lessonsPromise = createAdminClient()
121+
.from('lessons')
122+
.select('id, title, sequence, description, is_preview')
123+
.eq('course_id', courseId)
124+
.eq('tenant_id', tenantId)
125+
.eq('status', 'published')
126+
.order('sequence', { ascending: true });
127+
122128
const authorPromise = course.author_id
123129
? supabase
124130
.from("profiles")
@@ -161,18 +167,18 @@ export default async function CourseDetailsPage(props: {
161167
})()
162168
: Promise.resolve(false);
163169

164-
const [{ data: author }, hasAccess, { data: productCourses }, planCoversCourse, socialProof] = await Promise.all([
170+
const [{ data: author }, hasAccess, { data: productCourses }, planCoversCourse, socialProof, { data: lessonRows }] = await Promise.all([
165171
authorPromise,
166172
accessPromise,
167173
productCoursesPromise,
168174
planCoveragePromise,
169175
getCourseSocialProof(courseId, tenantId),
176+
lessonsPromise,
170177
]);
171178

172179
const { averageRating, reviewCount, studentCount, recentReviews } = socialProof;
173180

174-
// Sort lessons by sequence
175-
const lessons = course.lessons?.sort((a: Lesson, b: Lesson) => a.sequence - b.sequence) || [];
181+
const lessons: Lesson[] = lessonRows ?? [];
176182
const totalLessons = lessons.length;
177183
const estimatedHours = Math.floor(totalLessons * 10 / 60);
178184
const estimatedMinutes = (totalLessons * 10) % 60;
@@ -347,13 +353,30 @@ export default async function CourseDetailsPage(props: {
347353
</AccordionTrigger>
348354
<AccordionContent className="pb-4 space-y-1">
349355
{lessons.map((lesson: Lesson, index: number) => (
350-
<div key={lesson.id} className="flex items-center justify-between p-3 rounded-md hover:bg-zinc-800/50 transition-colors duration-150">
351-
<div className="flex items-center gap-3 min-w-0">
352-
<span className="text-xs text-zinc-400 font-mono w-6 text-right tabular-nums flex-shrink-0">{index + 1}</span>
353-
<BookOpen className="w-4 h-4 text-zinc-400 flex-shrink-0" aria-hidden="true" />
354-
<span className="text-sm text-zinc-200 truncate">{lesson.title}</span>
356+
lesson.is_preview ? (
357+
<Link
358+
key={lesson.id}
359+
href={`/courses/${params.id}/lessons/${lesson.id}`}
360+
className="flex items-center justify-between gap-3 p-3 rounded-md hover:bg-zinc-800/50 transition-colors duration-150 group"
361+
>
362+
<div className="flex items-center gap-3 min-w-0">
363+
<span className="text-xs text-zinc-400 font-mono w-6 text-right tabular-nums flex-shrink-0">{index + 1}</span>
364+
<PlayCircle className="w-4 h-4 text-cyan-400 flex-shrink-0" aria-hidden="true" />
365+
<span className="text-sm text-zinc-200 truncate group-hover:text-cyan-400 transition-colors duration-150">{lesson.title}</span>
366+
</div>
367+
<span className="flex-shrink-0 text-[10px] font-semibold uppercase tracking-wide text-cyan-400 border border-cyan-500/30 bg-cyan-500/10 rounded-full px-2 py-0.5">
368+
{t('sections.content.previewBadge')}
369+
</span>
370+
</Link>
371+
) : (
372+
<div key={lesson.id} className="flex items-center justify-between p-3 rounded-md hover:bg-zinc-800/50 transition-colors duration-150">
373+
<div className="flex items-center gap-3 min-w-0">
374+
<span className="text-xs text-zinc-400 font-mono w-6 text-right tabular-nums flex-shrink-0">{index + 1}</span>
375+
<BookOpen className="w-4 h-4 text-zinc-400 flex-shrink-0" aria-hidden="true" />
376+
<span className="text-sm text-zinc-200 truncate">{lesson.title}</span>
377+
</div>
355378
</div>
356-
</div>
379+
)
357380
))}
358381
</AccordionContent>
359382
</AccordionItem>

app/[locale]/(public)/courses/page.tsx

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { createClient } from "@/lib/supabase/server";
2+
import { createAdminClient } from "@/lib/supabase/admin";
23
import { getCurrentTenantId } from "@/lib/supabase/tenant";
34
import { CourseCard } from "@/components/public/course-card";
45
import { CourseSearchBar } from "@/components/shared/course-search-bar";
@@ -56,9 +57,6 @@ export default async function CoursesPage({
5657
id,
5758
full_name,
5859
avatar_url
59-
),
60-
lessons (
61-
id
6260
)
6361
`)
6462
.eq('tenant_id', tenantId)
@@ -81,6 +79,21 @@ export default async function CoursesPage({
8179
const courseIds = courses?.map(c => c.course_id) || [];
8280
let productMap: Record<number, { price: number; currency: string }> = {};
8381

82+
// Published-lesson counts via admin client: the anon role can only read
83+
// preview lessons (RLS), so a nested select would undercount for visitors.
84+
const lessonCountMap: Record<number, number> = {};
85+
if (courseIds.length > 0) {
86+
const { data: lessonRows } = await createAdminClient()
87+
.from('lessons')
88+
.select('id, course_id')
89+
.eq('tenant_id', tenantId)
90+
.eq('status', 'published')
91+
.in('course_id', courseIds);
92+
for (const row of lessonRows ?? []) {
93+
lessonCountMap[row.course_id] = (lessonCountMap[row.course_id] ?? 0) + 1;
94+
}
95+
}
96+
8497
if (courseIds.length > 0) {
8598
const { data: productCourses } = await supabase
8699
.from('product_courses')
@@ -111,7 +124,7 @@ export default async function CoursesPage({
111124
thumbnail_url: course.thumbnail_url,
112125
category: cat ? (Array.isArray(cat) ? cat[0] : cat) as { id: number; name: string } : null,
113126
author: auth ? (Array.isArray(auth) ? auth[0] : auth) as { id: string; full_name: string; avatar_url: string | null } : null,
114-
lessonCount: (course.lessons as any[])?.length || 0,
127+
lessonCount: lessonCountMap[course.course_id] ?? 0,
115128
price: productMap[course.course_id]?.price ?? null,
116129
currency: productMap[course.course_id]?.currency ?? null,
117130
};

app/[locale]/dashboard/student/courses/[courseId]/lessons/[lessonId]/lesson-content.tsx

Lines changed: 21 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -19,9 +19,16 @@ interface LessonContentProps {
1919
mdx: SerializeResult | null
2020
videoUrl: string | null
2121
embedCode: string | null
22+
/**
23+
* How to render teacher-authored embed_code HTML. 'trusted' injects it into
24+
* the page (enrolled-student views). 'sandboxed' isolates it in an
25+
* opaque-origin iframe (no allow-same-origin) — required on public pages
26+
* (#426 preview), where the audience is logged-out visitors.
27+
*/
28+
embedMode?: 'trusted' | 'sandboxed'
2229
}
2330

24-
export function LessonContent({ mdx, videoUrl, embedCode }: LessonContentProps) {
31+
export function LessonContent({ mdx, videoUrl, embedCode, embedMode = 'trusted' }: LessonContentProps) {
2532
const t = useTranslations('components.lessons')
2633
const tc = useTranslations('components.checkpoints')
2734
const checkpointsCtx = useCheckpoints()
@@ -102,10 +109,19 @@ export function LessonContent({ mdx, videoUrl, embedCode }: LessonContentProps)
102109

103110
{/* Custom embed code */}
104111
{embedCode && !videoEmbedUrl && !useCheckpointPlayer && (
105-
<div
106-
className="aspect-video w-full overflow-hidden rounded-xl shadow-lg"
107-
dangerouslySetInnerHTML={{ __html: embedCode }}
108-
/>
112+
embedMode === 'sandboxed' ? (
113+
<iframe
114+
srcDoc={`<style>html,body{margin:0;height:100%}</style>${embedCode}`}
115+
sandbox="allow-scripts allow-popups"
116+
title="Embedded content"
117+
className="aspect-video w-full overflow-hidden rounded-xl border-0 shadow-lg"
118+
/>
119+
) : (
120+
<div
121+
className="aspect-video w-full overflow-hidden rounded-xl shadow-lg"
122+
dangerouslySetInnerHTML={{ __html: embedCode }}
123+
/>
124+
)
109125
)}
110126

111127
{/* MDX content */}

app/[locale]/dashboard/teacher/courses/[courseId]/lessons/[lessonId]/page.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,7 @@ export default async function EditLessonPage({ params }: PageProps) {
9595
publish_at: lesson.publish_at || null,
9696
ai_task_description: lesson.ai_task_description || null,
9797
ai_task_instructions: lesson.ai_task_instructions || null,
98+
is_preview: lesson.is_preview ?? null,
9899
resources: resources || [],
99100
}}
100101
/>

app/actions/teacher/lessons.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ export interface LessonFormData {
1313
publish_at: string
1414
ai_task_description: string
1515
ai_task_instructions: string
16+
is_preview: boolean
1617
}
1718

1819
export async function createLesson(courseId: number, data: LessonFormData) {
@@ -35,6 +36,7 @@ export async function createLesson(courseId: number, data: LessonFormData) {
3536
sequence: data.sequence,
3637
status: data.publish ? ('published' as const) : ('draft' as const),
3738
publish_at: isScheduled ? data.publish_at : null,
39+
is_preview: data.is_preview ?? false,
3840
})
3941
.select('id')
4042
.single()
@@ -83,6 +85,7 @@ export async function updateLesson(
8385
sequence: data.sequence,
8486
status: data.publish ? ('published' as const) : ('draft' as const),
8587
publish_at: isScheduled ? data.publish_at : null,
88+
is_preview: data.is_preview ?? false,
8689
})
8790
.eq('id', lessonId)
8891
.eq('tenant_id', ctx.tenantId)

components/teacher/block-editor/editors/audio-block.tsx

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import type { AudioBlock } from '../types'
44
import { Input } from '@/components/ui/input'
55
import { IconVolume } from '@tabler/icons-react'
6+
import { ExpiringUrlWarning } from './expiring-url-warning'
67

78
interface AudioBlockEditorProps {
89
block: AudioBlock
@@ -21,6 +22,10 @@ export function AudioBlockEditor({ block, onChange }: AudioBlockEditorProps) {
2122
onChange={(e) => onChange({ src: e.target.value })}
2223
placeholder="Audio URL (e.g. https://example.com/audio.mp3)"
2324
/>
25+
<ExpiringUrlWarning
26+
url={block.src}
27+
hint="Usa un enlace permanente (hosting externo)."
28+
/>
2429
<Input
2530
value={block.title || ''}
2631
onChange={(e) => onChange({ title: e.target.value || undefined })}
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
'use client'
2+
3+
import { IconAlertTriangle } from '@tabler/icons-react'
4+
5+
/**
6+
* Supabase signed storage URLs (/storage/v1/object/sign/...) expire — pasted
7+
* into lesson content they break for every reader once the token lapses,
8+
* which surfaces as image/file 404s (issue #426 QA follow-up).
9+
*/
10+
export function isExpiringSignedUrl(url: string): boolean {
11+
return url.includes('/storage/v1/object/sign/')
12+
}
13+
14+
export function ExpiringUrlWarning({ url, hint }: { url: string; hint: string }) {
15+
if (!isExpiringSignedUrl(url)) return null
16+
return (
17+
<p className="flex items-start gap-1.5 text-xs text-amber-600 dark:text-amber-500">
18+
<IconAlertTriangle className="mt-0.5 h-3.5 w-3.5 flex-shrink-0" />
19+
<span>Esta URL firmada caduca (~1 hora) y dejará de funcionar para los estudiantes. {hint}</span>
20+
</p>
21+
)
22+
}

0 commit comments

Comments
 (0)