Skip to content

Commit 68652ba

Browse files
fix: resolve certificate pipeline DB and code issues
- Add tenant_id column to certificate_templates with unique constraint - Fix issue_certificate_if_eligible: add credential_json, correct PK refs - Fix handle_lesson_completion_xp trigger: remove nonexistent tenant_id ref - Fix lesson-navigation: remove tenant_id from lesson_completions insert - Fix issue route: use tenant_users for role check, profiles.username - Fix issue-certificate.ts: design_settings column name, add tenant_id - Update RLS policies to use tenant_users instead of user_roles Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 8e9cf60 commit 68652ba

5 files changed

Lines changed: 360 additions & 11 deletions

File tree

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

Lines changed: 43 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,9 +12,11 @@ import {
1212
IconCheck,
1313
IconLoader2,
1414
IconCircleCheck,
15+
IconCertificate,
1516
} from '@tabler/icons-react'
1617
import { useTranslations } from 'next-intl'
1718
import confetti from 'canvas-confetti'
19+
import { toast } from 'sonner'
1820

1921
interface LessonNavigationProps {
2022
lessonId: number
@@ -36,8 +38,10 @@ export function LessonNavigation({
3638
requireSequentialCompletion = false,
3739
}: LessonNavigationProps) {
3840
const t = useTranslations('components.lessonNavigation')
41+
const tGamification = useTranslations('gamification')
3942
const [loading, setLoading] = useState(false)
4043
const [completed, setCompleted] = useState(isCompleted)
44+
const [certificateCode, setCertificateCode] = useState<string | null>(null)
4145
const [, startTransition] = useTransition()
4246
const router = useRouter()
4347
const supabase = createClient()
@@ -69,11 +73,11 @@ export function LessonNavigation({
6973
await supabase.from('lesson_completions').insert({
7074
lesson_id: lessonId,
7175
user_id: user.id,
72-
tenant_id: tenantId,
7376
})
7477

7578
setCompleted(true)
7679
setLoading(false)
80+
toast.success(tGamification('xpAwarded.lesson_completion'))
7781

7882
// Celebrate completion with a subtle confetti burst from the button
7983
if (buttonRef.current) {
@@ -92,6 +96,26 @@ export function LessonNavigation({
9296
})
9397
}
9498

99+
// Check if a certificate was auto-issued after this lesson completion
100+
const { data: cert } = await supabase
101+
.from('certificates')
102+
.select('certificate_id, verification_code')
103+
.eq('user_id', user.id)
104+
.eq('course_id', courseId)
105+
.maybeSingle()
106+
107+
if (cert?.verification_code) {
108+
setCertificateCode(cert.verification_code)
109+
toast.success('Certificate Earned!', {
110+
description: 'You have completed all requirements and earned a certificate for this course.',
111+
duration: 8000,
112+
action: {
113+
label: 'View',
114+
onClick: () => router.push(`/verify/${cert.verification_code}`),
115+
},
116+
})
117+
}
118+
95119
if (nextLessonId) {
96120
startTransition(() => {
97121
router.push(`/dashboard/student/courses/${courseId}/lessons/${nextLessonId}`)
@@ -103,6 +127,23 @@ export function LessonNavigation({
103127
}
104128

105129
return (
130+
<>
131+
{certificateCode && (
132+
<div className="shrink-0 border-t border-emerald-200 dark:border-emerald-800 bg-gradient-to-r from-emerald-50 to-teal-50 dark:from-emerald-950/30 dark:to-teal-950/30 px-3 py-2 sm:px-4 sm:py-3">
133+
<div className="flex items-center justify-center gap-3 max-w-4xl mx-auto">
134+
<IconCertificate className="h-5 w-5 text-emerald-600 dark:text-emerald-400 shrink-0" />
135+
<span className="text-sm font-semibold text-emerald-800 dark:text-emerald-200">
136+
Certificate earned for this course!
137+
</span>
138+
<Link href={`/verify/${certificateCode}`}>
139+
<Button variant="outline" size="sm" className="border-emerald-300 dark:border-emerald-700 text-emerald-700 dark:text-emerald-300 hover:bg-emerald-100 dark:hover:bg-emerald-900/40 font-semibold gap-1.5">
140+
<IconCertificate className="h-3.5 w-3.5" />
141+
View
142+
</Button>
143+
</Link>
144+
</div>
145+
</div>
146+
)}
106147
<footer className="shrink-0 border-t bg-card/80 backdrop-blur-sm px-3 py-2 sm:px-4 sm:py-3 md:px-6">
107148
<div className="flex items-center justify-between gap-2 sm:gap-3 max-w-4xl mx-auto">
108149
{/* Previous */}
@@ -175,5 +216,6 @@ export function LessonNavigation({
175216
</div>
176217
</div>
177218
</footer>
219+
</>
178220
)
179221
}

app/api/certificates/issue/route.ts

Lines changed: 10 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -47,20 +47,22 @@ export async function POST(request: NextRequest) {
4747
const studentId = targetUserId || user.id
4848

4949
if (isTeacherIssue) {
50-
// Verify teacher/admin role
51-
const { data: roles } = await supabase
52-
.from('user_roles')
50+
// Verify teacher/admin role via tenant_users (authoritative)
51+
const { data: tenantUser } = await supabase
52+
.from('tenant_users')
5353
.select('role')
5454
.eq('user_id', user.id)
55+
.eq('tenant_id', tenantId)
56+
.eq('status', 'active')
57+
.maybeSingle()
5558

56-
const isTeacherOrAdmin = roles?.some(r => r.role === 'teacher' || r.role === 'admin')
59+
const isTeacherOrAdmin = tenantUser?.role === 'teacher' || tenantUser?.role === 'admin'
5760
if (!isTeacherOrAdmin) {
5861
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
5962
}
6063

6164
// Verify teacher owns this course (unless admin)
62-
const isAdmin = roles?.some(r => r.role === 'admin')
63-
if (!isAdmin && course.author_id !== user.id) {
65+
if (tenantUser?.role !== 'admin' && course.author_id !== user.id) {
6466
return NextResponse.json({ error: 'Not authorized for this course' }, { status: 403 })
6567
}
6668
}
@@ -203,11 +205,11 @@ async function simplifiedIssuance(
203205

204206
const { data: profile } = await supabase
205207
.from('profiles')
206-
.select('full_name, email')
208+
.select('full_name, username')
207209
.eq('id', userId)
208210
.single()
209211

210-
const studentName = profile?.full_name || profile?.email || 'Student'
212+
const studentName = profile?.full_name || profile?.username || 'Student'
211213

212214
// Get template (optional)
213215
const { data: template } = await supabase

lib/certificates/issue-certificate.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -169,6 +169,7 @@ export async function issueCertificate(
169169
completion_data: eligibility.completion,
170170
issued_at: issuedAt.toISOString(),
171171
expires_at: expiresAt?.toISOString(),
172+
tenant_id: template.course.tenant_id,
172173
})
173174
.select()
174175
.single();
@@ -195,7 +196,7 @@ export async function issueCertificate(
195196
signatureTitle: template.signature_title,
196197
signatureImage: template.signature_image_url,
197198
score: eligibility.completion.averageExamScore,
198-
designConfig: template.design_config,
199+
designConfig: template.design_settings,
199200
},
200201
certificate.certificate_id,
201202
supabase
@@ -210,7 +211,7 @@ export async function issueCertificate(
210211
issuedDate: issuedAt,
211212
issuerName,
212213
issuerLogo: template.logo_url,
213-
badgeColor: template.design_config?.primaryColor,
214+
badgeColor: template.design_settings?.primary_color,
214215
credential: signedCredential,
215216
},
216217
certificate.certificate_id,
@@ -242,6 +243,7 @@ export async function issueCertificate(
242243
// 15. Send notification to student
243244
await supabase.from('notifications').insert({
244245
user_id: userId,
246+
tenant_id: template.course.tenant_id,
245247
notification_type: 'certificate_issued',
246248
message: `Congratulations! You've earned a certificate for completing ${template.course.title}`,
247249
metadata: {
Lines changed: 190 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,190 @@
1+
-- =============================================================================
2+
-- Fix certificate_templates: add tenant_id, unique constraint, fix triggers
3+
-- =============================================================================
4+
5+
-- 1. Add tenant_id column to certificate_templates
6+
ALTER TABLE public.certificate_templates
7+
ADD COLUMN IF NOT EXISTS tenant_id UUID REFERENCES public.tenants(id);
8+
9+
-- 2. Backfill tenant_id from courses table
10+
UPDATE public.certificate_templates ct
11+
SET tenant_id = c.tenant_id
12+
FROM public.courses c
13+
WHERE ct.course_id = c.course_id
14+
AND ct.tenant_id IS NULL;
15+
16+
-- 3. Make tenant_id NOT NULL after backfill
17+
ALTER TABLE public.certificate_templates
18+
ALTER COLUMN tenant_id SET NOT NULL;
19+
20+
-- 4. Add UNIQUE constraint on (course_id, tenant_id) for upsert support
21+
ALTER TABLE public.certificate_templates
22+
ADD CONSTRAINT certificate_templates_course_tenant_unique
23+
UNIQUE (course_id, tenant_id);
24+
25+
-- 5. Add index for tenant_id lookups
26+
CREATE INDEX IF NOT EXISTS idx_certificate_templates_tenant
27+
ON public.certificate_templates(tenant_id);
28+
29+
-- =============================================================================
30+
-- Fix issue_certificate_if_eligible: wrong column name (id → template_id),
31+
-- missing tenant_id on certificate insert
32+
-- =============================================================================
33+
CREATE OR REPLACE FUNCTION public.issue_certificate_if_eligible(
34+
p_user_id UUID,
35+
p_course_id INTEGER
36+
)
37+
RETURNS JSONB
38+
LANGUAGE plpgsql
39+
SECURITY DEFINER
40+
AS $$
41+
DECLARE
42+
v_readiness JSONB;
43+
v_new_cert_id UUID;
44+
v_verification_code TEXT;
45+
v_template_id UUID;
46+
v_tenant_id UUID;
47+
v_enrollment_id INTEGER;
48+
BEGIN
49+
-- 1. Check eligibility using existing function
50+
v_readiness := public.check_and_issue_certificate(p_user_id, p_course_id);
51+
52+
-- If success is false, it might be already issued or not eligible
53+
IF NOT (v_readiness->>'success')::BOOLEAN THEN
54+
RETURN v_readiness;
55+
END IF;
56+
57+
-- 2. Get template for this course (use template_id, not id)
58+
SELECT template_id, tenant_id INTO v_template_id, v_tenant_id
59+
FROM public.certificate_templates
60+
WHERE course_id = p_course_id
61+
AND is_active = true
62+
LIMIT 1;
63+
64+
IF v_template_id IS NULL THEN
65+
-- Fallback: get tenant_id from courses if no template
66+
SELECT tenant_id INTO v_tenant_id FROM public.courses WHERE course_id = p_course_id;
67+
68+
RETURN jsonb_build_object('success', false, 'reason', 'No certificate template configured for this course');
69+
END IF;
70+
71+
-- 3. Get enrollment_id for proper FK
72+
SELECT enrollment_id INTO v_enrollment_id
73+
FROM public.enrollments
74+
WHERE user_id = p_user_id
75+
AND course_id = p_course_id
76+
AND status = 'active'
77+
LIMIT 1;
78+
79+
-- 4. Generate verification code
80+
v_verification_code := public.generate_verification_code();
81+
82+
-- 5. Insert certificate with tenant_id
83+
INSERT INTO public.certificates (
84+
user_id,
85+
course_id,
86+
template_id,
87+
enrollment_id,
88+
verification_code,
89+
tenant_id,
90+
issued_at,
91+
completion_data
92+
)
93+
VALUES (
94+
p_user_id,
95+
p_course_id,
96+
v_template_id,
97+
v_enrollment_id,
98+
v_verification_code,
99+
v_tenant_id,
100+
now(),
101+
v_readiness->'completion'
102+
)
103+
RETURNING certificate_id INTO v_new_cert_id;
104+
105+
RETURN jsonb_build_object(
106+
'success', true,
107+
'certificateId', v_new_cert_id,
108+
'verificationCode', v_verification_code,
109+
'message', 'Certificate issued automatically'
110+
);
111+
END;
112+
$$;
113+
114+
-- =============================================================================
115+
-- Update RLS policies for certificate_templates to use tenant_users + tenant_id
116+
-- =============================================================================
117+
118+
-- Drop old policies
119+
DROP POLICY IF EXISTS "Teachers can view templates for their courses" ON public.certificate_templates;
120+
DROP POLICY IF EXISTS "Teachers can create templates for their courses" ON public.certificate_templates;
121+
DROP POLICY IF EXISTS "Teachers can update their course templates" ON public.certificate_templates;
122+
123+
-- Students need to read templates (for certificate display)
124+
DROP POLICY IF EXISTS "Students can view active templates" ON public.certificate_templates;
125+
126+
-- New policies using tenant_users (per-tenant roles)
127+
CREATE POLICY "Teachers can view templates for their courses" ON public.certificate_templates
128+
FOR SELECT USING (
129+
EXISTS (
130+
SELECT 1 FROM public.tenant_users tu
131+
WHERE tu.user_id = auth.uid()
132+
AND tu.tenant_id = certificate_templates.tenant_id
133+
AND tu.role IN ('teacher', 'admin')
134+
AND tu.status = 'active'
135+
)
136+
OR EXISTS (
137+
SELECT 1 FROM public.courses c
138+
WHERE c.course_id = certificate_templates.course_id
139+
AND c.author_id = auth.uid()
140+
)
141+
);
142+
143+
CREATE POLICY "Students can view active templates" ON public.certificate_templates
144+
FOR SELECT USING (
145+
is_active = true
146+
AND EXISTS (
147+
SELECT 1 FROM public.enrollments e
148+
WHERE e.user_id = auth.uid()
149+
AND e.course_id = certificate_templates.course_id
150+
AND e.status = 'active'
151+
)
152+
);
153+
154+
CREATE POLICY "Teachers can create templates for their courses" ON public.certificate_templates
155+
FOR INSERT WITH CHECK (
156+
EXISTS (
157+
SELECT 1 FROM public.tenant_users tu
158+
WHERE tu.user_id = auth.uid()
159+
AND tu.tenant_id = certificate_templates.tenant_id
160+
AND tu.role IN ('teacher', 'admin')
161+
AND tu.status = 'active'
162+
)
163+
AND EXISTS (
164+
SELECT 1 FROM public.courses c
165+
WHERE c.course_id = certificate_templates.course_id
166+
AND (c.author_id = auth.uid() OR EXISTS (
167+
SELECT 1 FROM public.tenant_users tu2
168+
WHERE tu2.user_id = auth.uid()
169+
AND tu2.tenant_id = certificate_templates.tenant_id
170+
AND tu2.role = 'admin'
171+
AND tu2.status = 'active'
172+
))
173+
)
174+
);
175+
176+
CREATE POLICY "Teachers can update their course templates" ON public.certificate_templates
177+
FOR UPDATE USING (
178+
EXISTS (
179+
SELECT 1 FROM public.tenant_users tu
180+
WHERE tu.user_id = auth.uid()
181+
AND tu.tenant_id = certificate_templates.tenant_id
182+
AND tu.role IN ('teacher', 'admin')
183+
AND tu.status = 'active'
184+
)
185+
OR EXISTS (
186+
SELECT 1 FROM public.courses c
187+
WHERE c.course_id = certificate_templates.course_id
188+
AND c.author_id = auth.uid()
189+
)
190+
);

0 commit comments

Comments
 (0)