Skip to content

Commit 288f300

Browse files
committed
fix(academy): type safety, cert persistence, regex guard, mixed-lesson video, shorts support
- Derive AcademyCertificate from db $inferSelect to prevent schema drift - Add useCourseCertificate query hook; GET /api/academy/certificates now accepts courseId for authenticated lookup - Use useCourseCertificate in CourseProgress so certificate state survives page refresh - Guard new RegExp(valuePattern) in validation.ts with try/catch; log warn on invalid pattern - Add logger.warn for custom validation rules so content authors are alerted - Add YouTube Shorts URL support to LessonVideo (youtube.com/shorts/VIDEO_ID) - Fix mixed-lesson video gap: render videoUrl above quiz when mixed has quiz but no exercise - Add academy-scoped not-found.tsx with link back to /academy
1 parent 60f86a2 commit 288f300

File tree

8 files changed

+115
-31
lines changed

8 files changed

+115
-31
lines changed

apps/sim/app/academy/(catalog)/[courseSlug]/components/course-progress.tsx

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import Link from 'next/link'
66
import { getCompletedLessons } from '@/lib/academy/local-progress'
77
import type { Course } from '@/lib/academy/types'
88
import { useSession } from '@/lib/auth/auth-client'
9-
import { useIssueCertificate } from '@/hooks/queries/academy'
9+
import { useCourseCertificate, useIssueCertificate } from '@/hooks/queries/academy'
1010

1111
interface CourseProgressProps {
1212
course: Course
@@ -20,7 +20,10 @@ export function CourseProgress({ course, courseSlug }: CourseProgressProps) {
2020
setCompletedIds(getCompletedLessons())
2121
}, [])
2222
const { data: session } = useSession()
23-
const { mutate: issueCertificate, isPending, data: certificate, error } = useIssueCertificate()
23+
const { data: fetchedCert } = useCourseCertificate(session ? course.id : undefined)
24+
const { mutate: issueCertificate, isPending, data: issuedCert, error } = useIssueCertificate()
25+
// Prefer the server-fetched cert (survives page refresh) over the in-session mutation result.
26+
const certificate = fetchedCert ?? issuedCert
2427

2528
const allLessons = course.modules.flatMap((m) => m.lessons)
2629
const totalLessons = allLessons.length
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import Link from 'next/link'
2+
3+
export default function AcademyNotFound() {
4+
return (
5+
<main className='flex flex-1 flex-col items-center justify-center px-6 py-32 text-center'>
6+
<p className='mb-2 font-mono text-[#555] text-[13px] uppercase tracking-widest'>404</p>
7+
<h1 className='mb-3 font-[430] text-[#ECECEC] text-[28px] leading-[120%]'>Page not found</h1>
8+
<p className='mb-8 text-[#666] text-[15px]'>
9+
That course or lesson doesn't exist in the Academy.
10+
</p>
11+
<Link
12+
href='/academy'
13+
className='rounded-[5px] bg-[#ECECEC] px-5 py-2.5 font-[430] text-[#1C1C1C] text-[14px] transition-colors hover:bg-white'
14+
>
15+
Back to Academy
16+
</Link>
17+
</main>
18+
)
19+
}

apps/sim/app/academy/[courseSlug]/[lessonSlug]/page.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -165,7 +165,8 @@ export default function LessonPage({ params }: LessonPageProps) {
165165
)}
166166
{!hasExercise && hasQuiz && (
167167
<div className='flex-1 overflow-y-auto p-8'>
168-
<div className='mx-auto w-full max-w-xl'>
168+
<div className='mx-auto w-full max-w-xl space-y-8'>
169+
{hasVideo && <LessonVideo url={lesson.videoUrl!} title={lesson.title} />}
169170
<LessonQuiz
170171
lessonId={lesson.id}
171172
quizConfig={lesson.quizConfig!}

apps/sim/app/academy/components/lesson-video.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,9 @@ function resolveEmbedUrl(url: string): string | null {
3737
return `https://www.youtube.com/embed${parsed.pathname}`
3838
}
3939
if (parsed.hostname.includes('youtube.com')) {
40+
// Shorts: youtube.com/shorts/VIDEO_ID
41+
const shortsMatch = parsed.pathname.match(/^\/shorts\/([^/?]+)/)
42+
if (shortsMatch) return `https://www.youtube.com/embed/${shortsMatch[1]}`
4043
const v = parsed.searchParams.get('v')
4144
if (v) return `https://www.youtube.com/embed/${v}`
4245
}

apps/sim/app/api/academy/certificates/route.ts

Lines changed: 36 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -135,27 +135,53 @@ export async function POST(req: NextRequest) {
135135
/**
136136
* GET /api/academy/certificates?certificateNumber=SIM-2026-00042
137137
* Public endpoint for verifying a certificate by its number.
138+
*
139+
* GET /api/academy/certificates?courseId=...
140+
* Authenticated endpoint for looking up the current user's certificate for a course.
138141
*/
139142
export async function GET(req: NextRequest) {
140143
try {
141144
const { searchParams } = new URL(req.url)
142145
const certificateNumber = searchParams.get('certificateNumber')
146+
const courseId = searchParams.get('courseId')
143147

144-
if (!certificateNumber) {
145-
return NextResponse.json({ error: 'certificateNumber is required' }, { status: 400 })
148+
if (certificateNumber) {
149+
const [certificate] = await db
150+
.select()
151+
.from(academyCertificate)
152+
.where(eq(academyCertificate.certificateNumber, certificateNumber))
153+
.limit(1)
154+
155+
if (!certificate) {
156+
return NextResponse.json({ error: 'Certificate not found' }, { status: 404 })
157+
}
158+
return NextResponse.json({ certificate })
146159
}
147160

148-
const [certificate] = await db
149-
.select()
150-
.from(academyCertificate)
151-
.where(eq(academyCertificate.certificateNumber, certificateNumber))
152-
.limit(1)
161+
if (courseId) {
162+
const session = await getSession()
163+
if (!session?.user?.id) {
164+
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
165+
}
153166

154-
if (!certificate) {
155-
return NextResponse.json({ error: 'Certificate not found' }, { status: 404 })
167+
const [certificate] = await db
168+
.select()
169+
.from(academyCertificate)
170+
.where(
171+
and(
172+
eq(academyCertificate.userId, session.user.id),
173+
eq(academyCertificate.courseId, courseId)
174+
)
175+
)
176+
.limit(1)
177+
178+
return NextResponse.json({ certificate: certificate ?? null })
156179
}
157180

158-
return NextResponse.json({ certificate })
181+
return NextResponse.json(
182+
{ error: 'certificateNumber or courseId query parameter is required' },
183+
{ status: 400 }
184+
)
159185
} catch (error) {
160186
logger.error('Failed to verify certificate', { error })
161187
return NextResponse.json({ error: 'Failed to verify certificate' }, { status: 500 })

apps/sim/hooks/queries/academy.ts

Lines changed: 24 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,31 @@
1-
import { useMutation, useQueryClient } from '@tanstack/react-query'
1+
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
22
import type { AcademyCertificate } from '@/lib/academy/types'
33
import { fetchJson } from '@/hooks/selectors/helpers'
44

55
export const academyKeys = {
66
all: ['academy'] as const,
77
certificates: () => [...academyKeys.all, 'certificate'] as const,
8+
certificate: (courseId: string) => [...academyKeys.certificates(), courseId] as const,
9+
}
10+
11+
async function fetchCourseCertificate(
12+
courseId: string,
13+
signal: AbortSignal
14+
): Promise<AcademyCertificate | null> {
15+
const data = await fetchJson<{ certificate: AcademyCertificate | null }>(
16+
`/api/academy/certificates?courseId=${encodeURIComponent(courseId)}`,
17+
{ signal }
18+
)
19+
return data.certificate
20+
}
21+
22+
export function useCourseCertificate(courseId?: string) {
23+
return useQuery({
24+
queryKey: academyKeys.certificate(courseId ?? ''),
25+
queryFn: ({ signal }) => fetchCourseCertificate(courseId as string, signal),
26+
enabled: Boolean(courseId),
27+
staleTime: 60 * 1000,
28+
})
829
}
930

1031
export function useIssueCertificate() {
@@ -16,8 +37,8 @@ export function useIssueCertificate() {
1637
headers: { 'Content-Type': 'application/json' },
1738
body: JSON.stringify(variables),
1839
}).then((d) => d.certificate),
19-
onSettled: () => {
20-
queryClient.invalidateQueries({ queryKey: academyKeys.certificates() })
40+
onSettled: (_data, _error, variables) => {
41+
queryClient.invalidateQueries({ queryKey: academyKeys.certificate(variables.courseId) })
2142
},
2243
})
2344
}

apps/sim/lib/academy/types.ts

Lines changed: 6 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
* Sim Academy — shared type definitions.
33
* Course content is file-based (lib/academy/content/); only certificates are DB-backed.
44
*/
5+
import type { academyCertificate } from '@sim/db/schema'
56

67
export type LessonType = 'video' | 'exercise' | 'quiz' | 'mixed'
78

@@ -38,25 +39,18 @@ export interface Lesson {
3839

3940
export type AcademyCertStatus = 'active' | 'revoked' | 'expired'
4041

41-
export interface AcademyCertificate {
42-
id: string
43-
userId: string
44-
courseId: string
45-
status: AcademyCertStatus
46-
issuedAt: string
47-
expiresAt: string | null
48-
certificateNumber: string
49-
metadata: CertificateMetadata | null
50-
createdAt: string
51-
}
52-
5342
export interface CertificateMetadata {
5443
/** Recipient name at time of issuance */
5544
recipientName: string
5645
/** Course title at time of issuance */
5746
courseTitle: string
5847
}
5948

49+
/** Certificate record derived from the DB schema — metadata narrowed to its known shape. */
50+
export type AcademyCertificate = Omit<typeof academyCertificate.$inferSelect, 'metadata'> & {
51+
metadata: CertificateMetadata | null
52+
}
53+
6054
/**
6155
* Full configuration for an interactive canvas exercise.
6256
* Defined inline in each lesson file.

apps/sim/lib/academy/validation.ts

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { createLogger } from '@sim/logger'
12
import type {
23
ExerciseBlockState,
34
ExerciseEdgeState,
@@ -6,6 +7,8 @@ import type {
67
ValidationRuleResult,
78
} from '@/lib/academy/types'
89

10+
const logger = createLogger('AcademyValidation')
11+
912
/**
1013
* Validates a learner's exercise canvas state against a set of rules.
1114
* Runs identically on the client (real-time feedback) and server (progress recording).
@@ -47,8 +50,18 @@ function checkRule(
4750
const value = b.subBlocks?.[rule.subBlockId]
4851
if (rule.valueNotEmpty && (value === undefined || value === null || value === ''))
4952
return false
50-
if (rule.valuePattern && !new RegExp(rule.valuePattern).test(String(value ?? '')))
51-
return false
53+
if (rule.valuePattern) {
54+
let regex: RegExp
55+
try {
56+
regex = new RegExp(rule.valuePattern)
57+
} catch {
58+
logger.warn('Invalid valuePattern in block_configured rule', {
59+
pattern: rule.valuePattern,
60+
})
61+
return false
62+
}
63+
if (!regex.test(String(value ?? ''))) return false
64+
}
5265
return true
5366
})
5467
}
@@ -73,7 +86,11 @@ function checkRule(
7386
}
7487

7588
case 'custom': {
76-
// Custom validators run client-side via a registry; server always passes them
89+
// Custom validators run client-side via a registry; server always passes them.
90+
// Log a warning so content authors know if a custom validatorId is unrecognised.
91+
logger.warn('Custom validation rule encountered — no client registry implementation', {
92+
validatorId: rule.validatorId,
93+
})
7794
return true
7895
}
7996
}

0 commit comments

Comments
 (0)