ggop213 commited on
Commit
46ab86e
·
1 Parent(s): 69cc6bf

fix(cert): auto-issue certificate on payment confirmation & sync course to 100% certified

Browse files
api/index.ts CHANGED
@@ -56,6 +56,7 @@ import {
56
  assertApprovedPaymentForCertificate,
57
  markPaymentRequestCertificateIssued,
58
  reviewPaymentRequest,
 
59
  } from './v2/_services/paymentApprovalSystem.js';
60
  import { getOrder, createOrder, isOrderPaidOnCashfree, getOrderPayments } from './v2/_services/cashfreeService.js';
61
  import { issueCertificate } from './v2/_services/certificationSystem.js';
@@ -831,23 +832,20 @@ async function handlePaymentStatus(req: VercelRequest, res: VercelResponse): Pro
831
  }
832
  }
833
 
834
- let alreadyIssuedCertificate = false;
835
- if (request.status === 'paid' && !request.certificateIssued && request.id) {
836
- const supabase = getSupabaseClientForRequest(authToken);
837
- const { data } = await supabase
838
- .from('certificates')
839
- .select('id')
840
- .eq('metadata->>payment_request_id', request.id)
841
- .maybeSingle();
842
- alreadyIssuedCertificate = Boolean(data);
843
  }
844
 
845
  ok(res, {
846
  paywallEnabled: true,
847
  amountInr: getCertificateFeeInr(request.track as CertificationTrack),
848
  request,
849
- canIssueCertificate:
850
- request.status === 'paid' && !request.certificateIssued && !alreadyIssuedCertificate,
851
  });
852
  } catch (error) {
853
  fail(res, 500, 'Failed to fetch payment status', error instanceof Error ? error.message : error);
@@ -1262,8 +1260,9 @@ async function handleWebhookCashfree(req: VercelRequest, res: VercelResponse): P
1262
  String(body.data?.payment?.payment_status || '').toUpperCase() === 'USER_DROPPED';
1263
 
1264
  if (isSuccess && requestRow.status !== 'paid') {
1265
- logger.info(`[Cashfree Webhook] Marking payment request ${orderId} as PAID.`, ctx);
1266
  await updatePaymentRequestStatus(orderId, 'paid', null);
 
1267
  } else if (isFailed && requestRow.status === 'pending') {
1268
  logger.info(`[Cashfree Webhook] Marking payment request ${orderId} as REJECTED.`, ctx);
1269
  await updatePaymentRequestStatus(orderId, 'rejected', null);
 
56
  assertApprovedPaymentForCertificate,
57
  markPaymentRequestCertificateIssued,
58
  reviewPaymentRequest,
59
+ processApprovedPaymentAndIssueCertificate,
60
  } from './v2/_services/paymentApprovalSystem.js';
61
  import { getOrder, createOrder, isOrderPaidOnCashfree, getOrderPayments } from './v2/_services/cashfreeService.js';
62
  import { issueCertificate } from './v2/_services/certificationSystem.js';
 
832
  }
833
  }
834
 
835
+ let certificatePayload = null;
836
+ if (request.status === 'paid' && request.id) {
837
+ certificatePayload = await processApprovedPaymentAndIssueCertificate(request.id);
838
+ if (certificatePayload) {
839
+ request.certificateIssued = true;
840
+ }
 
 
 
841
  }
842
 
843
  ok(res, {
844
  paywallEnabled: true,
845
  amountInr: getCertificateFeeInr(request.track as CertificationTrack),
846
  request,
847
+ certificate: certificatePayload,
848
+ canIssueCertificate: false,
849
  });
850
  } catch (error) {
851
  fail(res, 500, 'Failed to fetch payment status', error instanceof Error ? error.message : error);
 
1260
  String(body.data?.payment?.payment_status || '').toUpperCase() === 'USER_DROPPED';
1261
 
1262
  if (isSuccess && requestRow.status !== 'paid') {
1263
+ logger.info(`[Cashfree Webhook] Marking payment request ${orderId} as PAID and auto-issuing certificate.`, ctx);
1264
  await updatePaymentRequestStatus(orderId, 'paid', null);
1265
+ await processApprovedPaymentAndIssueCertificate(orderId);
1266
  } else if (isFailed && requestRow.status === 'pending') {
1267
  logger.info(`[Cashfree Webhook] Marking payment request ${orderId} as REJECTED.`, ctx);
1268
  await updatePaymentRequestStatus(orderId, 'rejected', null);
api/v2/_services/certificationSystem.ts CHANGED
@@ -1,5 +1,5 @@
1
  import type { CertificatePayload, CertificationTrack, ScoreBreakdown, ValidationSummary } from '../_types.js';
2
- import { getSupabaseClientForRequest } from '../_shared/supabaseAdmin.js';
3
 
4
  function makeCertificateId(skill: string, track: CertificationTrack = 'verifiedcert'): string {
5
  const date = new Date();
@@ -32,7 +32,8 @@ export async function issueCertificate(input: {
32
  authToken?: string | null;
33
  track?: CertificationTrack;
34
  }): Promise<CertificatePayload> {
35
- const supabase = getSupabaseClientForRequest(input.authToken);
 
36
  const certTrack = input.track || 'verifiedcert';
37
 
38
  if (input.paymentRequestId) {
 
1
  import type { CertificatePayload, CertificationTrack, ScoreBreakdown, ValidationSummary } from '../_types.js';
2
+ import { getSupabaseAdmin, getSupabaseClientForRequest } from '../_shared/supabaseAdmin.js';
3
 
4
  function makeCertificateId(skill: string, track: CertificationTrack = 'verifiedcert'): string {
5
  const date = new Date();
 
32
  authToken?: string | null;
33
  track?: CertificationTrack;
34
  }): Promise<CertificatePayload> {
35
+ const serviceRoleKey = process.env.SUPABASE_SERVICE_ROLE_KEY;
36
+ const supabase = (!input.authToken && serviceRoleKey) ? getSupabaseAdmin() : getSupabaseClientForRequest(input.authToken);
37
  const certTrack = input.track || 'verifiedcert';
38
 
39
  if (input.paymentRequestId) {
api/v2/_services/paymentApprovalSystem.ts CHANGED
@@ -1,5 +1,7 @@
1
  import { getSupabaseAdmin, getSupabaseClientForRequest } from '../_shared/supabaseAdmin.js';
2
  import { isOrderPaidOnCashfree } from './cashfreeService.js';
 
 
3
  import { backendLogger as logger } from '../_shared/backendLogger.js';
4
  import type {
5
  ApprovedPaymentContext,
@@ -116,7 +118,8 @@ async function findCertificateByPaymentRequestId(
116
  }
117
 
118
  export async function submitPaymentRequest(input: PaymentRequestCreateInput & { id?: string }, authToken?: string | null): Promise<PaymentRequest> {
119
- const supabase = getSupabaseClientForRequest(authToken);
 
120
 
121
  const { data, error } = await supabase
122
  .from(PAYMENT_REQUESTS_TABLE)
@@ -407,5 +410,191 @@ export async function updatePaymentRequestStatus(
407
  throw new Error(`Failed to update payment request status: ${error?.message || 'unknown error'}${rlsHint}`);
408
  }
409
 
 
 
 
 
 
 
 
410
  return mapPaymentRequest(data);
411
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  import { getSupabaseAdmin, getSupabaseClientForRequest } from '../_shared/supabaseAdmin.js';
2
  import { isOrderPaidOnCashfree } from './cashfreeService.js';
3
+ import { issueCertificate } from './certificationSystem.js';
4
+ import { sendCertificateIssuedEmail } from './emailService.js';
5
  import { backendLogger as logger } from '../_shared/backendLogger.js';
6
  import type {
7
  ApprovedPaymentContext,
 
118
  }
119
 
120
  export async function submitPaymentRequest(input: PaymentRequestCreateInput & { id?: string }, authToken?: string | null): Promise<PaymentRequest> {
121
+ const serviceRoleKey = process.env.SUPABASE_SERVICE_ROLE_KEY;
122
+ const supabase = (!authToken && serviceRoleKey) ? getSupabaseAdmin() : getSupabaseClientForRequest(authToken);
123
 
124
  const { data, error } = await supabase
125
  .from(PAYMENT_REQUESTS_TABLE)
 
410
  throw new Error(`Failed to update payment request status: ${error?.message || 'unknown error'}${rlsHint}`);
411
  }
412
 
413
+ if (status === 'paid') {
414
+ // Automatically issue certificate and set my_courses progress to 100%
415
+ void processApprovedPaymentAndIssueCertificate(requestId).catch((err) => {
416
+ logger.error('[Auto-Issuance Trigger Failed in updatePaymentRequestStatus]', err);
417
+ });
418
+ }
419
+
420
  return mapPaymentRequest(data);
421
  }
422
+
423
+ // Concurrency mutex to prevent duplicate simultaneous issuances for the same payment request
424
+ const inFlightIssuancesByOrderId = new Map<string, Promise<CertificatePayload | null>>();
425
+
426
+ export async function syncCourseProgressToCertified(
427
+ userId: string,
428
+ courseName: string,
429
+ track: string,
430
+ issuedAt: string,
431
+ score: number = 100
432
+ ): Promise<void> {
433
+ const supabase = getSupabaseAdmin();
434
+ const nowIso = issuedAt || new Date().toISOString();
435
+
436
+ try {
437
+ // 1. Upsert into my_courses
438
+ const { error: myCourseErr } = await supabase
439
+ .from('my_courses')
440
+ .upsert({
441
+ user_id: userId,
442
+ course_name: courseName,
443
+ status: 'certified',
444
+ current_step: 'certified',
445
+ progress_percentage: 100,
446
+ assessment_score: score,
447
+ certification_track: track,
448
+ completed_at: nowIso,
449
+ last_activity: nowIso,
450
+ updated_at: nowIso,
451
+ }, { onConflict: 'user_id,course_name' });
452
+
453
+ if (myCourseErr) {
454
+ logger.warn('[Auto-Issuance] my_courses upsert error:', myCourseErr);
455
+ }
456
+
457
+ // 2. Update course_sessions
458
+ await supabase
459
+ .from('course_sessions')
460
+ .update({
461
+ current_step: 'certified',
462
+ progress_percentage: 100,
463
+ updated_at: nowIso,
464
+ })
465
+ .eq('user_id', userId)
466
+ .eq('course_name', courseName);
467
+ } catch (syncErr) {
468
+ logger.warn(`[Auto-Issuance] Could not sync course progress to certified for ${courseName}:`, syncErr);
469
+ }
470
+ }
471
+
472
+ export async function processApprovedPaymentAndIssueCertificate(
473
+ paymentRequestId: string
474
+ ): Promise<CertificatePayload | null> {
475
+ const existingPromise = inFlightIssuancesByOrderId.get(paymentRequestId);
476
+ if (existingPromise) {
477
+ return existingPromise;
478
+ }
479
+
480
+ const executionPromise = (async () => {
481
+ const supabase = getSupabaseAdmin();
482
+
483
+ // 1. Fetch the payment request
484
+ const { data: request, error: reqErr } = await supabase
485
+ .from(PAYMENT_REQUESTS_TABLE)
486
+ .select('*')
487
+ .eq('id', paymentRequestId)
488
+ .maybeSingle();
489
+
490
+ if (reqErr || !request) {
491
+ logger.warn(`[Auto-Issuance] Payment request not found for ID: ${paymentRequestId}`, reqErr);
492
+ return null;
493
+ }
494
+
495
+ if (request.status !== 'paid') {
496
+ logger.warn(`[Auto-Issuance] Payment request ${paymentRequestId} is not marked paid (status: ${request.status})`);
497
+ return null;
498
+ }
499
+
500
+ const track = (request.track || 'quickcert') as CertificationTrack;
501
+
502
+ // 2. Check if certificate is already issued
503
+ const existingCert = await findCertificateByPaymentRequestId(request.id, null);
504
+ if (existingCert) {
505
+ if (!request.certificate_issued) {
506
+ await markPaymentRequestCertificateIssued(request.id, null);
507
+ }
508
+ await syncCourseProgressToCertified(request.user_id, request.skill, track, existingCert.issuedAt);
509
+ return existingCert;
510
+ }
511
+
512
+ // 3. Resolve student name & email safely from public.users & Supabase auth
513
+ let studentName = '';
514
+ let studentEmail = '';
515
+ try {
516
+ const { data: dbUser } = await supabase
517
+ .from('users')
518
+ .select('full_name, username, email')
519
+ .eq('id', request.user_id)
520
+ .maybeSingle();
521
+
522
+ const { data: authUserData } = await supabase.auth.admin.getUserById(request.user_id);
523
+
524
+ studentEmail = dbUser?.email || authUserData?.user?.email || '';
525
+ studentName =
526
+ dbUser?.full_name ||
527
+ dbUser?.username ||
528
+ authUserData?.user?.user_metadata?.full_name ||
529
+ authUserData?.user?.user_metadata?.name ||
530
+ (studentEmail ? studentEmail.split('@')[0] : '') ||
531
+ 'GetCert Student';
532
+ } catch (userErr) {
533
+ logger.warn(`[Auto-Issuance] Failed to resolve user info for ${request.user_id}:`, userErr);
534
+ studentName = 'GetCert Student';
535
+ }
536
+
537
+ // 4. Resolve assessment score from course_sessions
538
+ let score = 90;
539
+ try {
540
+ const { data: sessionData } = await supabase
541
+ .from('course_sessions')
542
+ .select('flow_state')
543
+ .eq('user_id', request.user_id)
544
+ .eq('course_name', request.skill)
545
+ .order('updated_at', { ascending: false })
546
+ .limit(1)
547
+ .maybeSingle();
548
+
549
+ if (sessionData?.flow_state?.assessmentScore) {
550
+ score = sessionData.flow_state.assessmentScore;
551
+ }
552
+ } catch {
553
+ // Default to 90
554
+ }
555
+
556
+ // 5. Issue official certificate
557
+ const issuedCertificate = await issueCertificate({
558
+ userId: request.user_id,
559
+ name: studentName,
560
+ email: studentEmail,
561
+ skill: request.skill,
562
+ projectTitle: request.project_title || `${request.skill} Quick Certification`,
563
+ githubUrl: '',
564
+ proofUrl: '',
565
+ finalScore: score,
566
+ paymentRequestId: request.id,
567
+ paymentTxnId: request.txn_id,
568
+ track,
569
+ authToken: null,
570
+ });
571
+
572
+ // 6. Mark payment request as certificate issued
573
+ await markPaymentRequestCertificateIssued(request.id, null);
574
+
575
+ // 7. Update my_courses and course_sessions to 100% and certified
576
+ await syncCourseProgressToCertified(request.user_id, request.skill, track, issuedCertificate.issuedAt, score);
577
+
578
+ // 8. Send notification email asynchronously
579
+ if (studentEmail) {
580
+ sendCertificateIssuedEmail({
581
+ to: studentEmail,
582
+ name: studentName,
583
+ certificate: issuedCertificate,
584
+ }).catch((emailErr) => {
585
+ logger.warn('[Auto-Issuance] Email dispatch notice:', emailErr);
586
+ });
587
+ }
588
+
589
+ logger.info(`[Auto-Issuance] Successfully issued certificate ${issuedCertificate.certificateId} and set my_courses progress to 100% for user ${request.user_id}`);
590
+ return issuedCertificate;
591
+ })();
592
+
593
+ inFlightIssuancesByOrderId.set(paymentRequestId, executionPromise);
594
+
595
+ try {
596
+ return await executionPromise;
597
+ } finally {
598
+ inFlightIssuancesByOrderId.delete(paymentRequestId);
599
+ }
600
+ }
public/llms-full.txt CHANGED
@@ -1,6 +1,6 @@
1
  # GetCert — full site reference
2
 
3
- Generated 2026-10-11 from https://getcert.eu.cc
4
 
5
  ## Summary
6
 
 
1
  # GetCert — full site reference
2
 
3
+ Generated 2026-10-12 from https://getcert.eu.cc
4
 
5
  ## Summary
6
 
public/llms.txt CHANGED
@@ -2,7 +2,7 @@
2
 
3
  > GetCert is a free online learning platform. Learners take free online courses, pass an assessment, and receive a certificate with a unique certificate ID that anyone can verify publicly at https://getcert.eu.cc/verify — no account required.
4
 
5
- Last updated: 2026-10-11
6
 
7
  ## What GetCert is
8
 
 
2
 
3
  > GetCert is a free online learning platform. Learners take free online courses, pass an assessment, and receive a certificate with a unique certificate ID that anyone can verify publicly at https://getcert.eu.cc/verify — no account required.
4
 
5
+ Last updated: 2026-10-12
6
 
7
  ## What GetCert is
8
 
public/sitemap.xml CHANGED
@@ -2,67 +2,67 @@
2
  <urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
3
  <url>
4
  <loc>https://getcert.eu.cc/</loc>
5
- <lastmod>2026-10-11</lastmod>
6
  <changefreq>weekly</changefreq>
7
  <priority>1.0</priority>
8
  </url>
9
  <url>
10
  <loc>https://getcert.eu.cc/courses</loc>
11
- <lastmod>2026-10-11</lastmod>
12
  <changefreq>weekly</changefreq>
13
  <priority>0.9</priority>
14
  </url>
15
  <url>
16
  <loc>https://getcert.eu.cc/learn</loc>
17
- <lastmod>2026-10-11</lastmod>
18
  <changefreq>weekly</changefreq>
19
  <priority>0.8</priority>
20
  </url>
21
  <url>
22
  <loc>https://getcert.eu.cc/verify</loc>
23
- <lastmod>2026-10-11</lastmod>
24
  <changefreq>monthly</changefreq>
25
  <priority>0.7</priority>
26
  </url>
27
  <url>
28
  <loc>https://getcert.eu.cc/yt</loc>
29
- <lastmod>2026-10-11</lastmod>
30
  <changefreq>weekly</changefreq>
31
  <priority>0.7</priority>
32
  </url>
33
  <url>
34
  <loc>https://getcert.eu.cc/about</loc>
35
- <lastmod>2026-10-11</lastmod>
36
  <changefreq>monthly</changefreq>
37
  <priority>0.6</priority>
38
  </url>
39
  <url>
40
  <loc>https://getcert.eu.cc/contact</loc>
41
- <lastmod>2026-10-11</lastmod>
42
  <changefreq>yearly</changefreq>
43
  <priority>0.5</priority>
44
  </url>
45
  <url>
46
  <loc>https://getcert.eu.cc/honor-code</loc>
47
- <lastmod>2026-10-11</lastmod>
48
  <changefreq>yearly</changefreq>
49
  <priority>0.4</priority>
50
  </url>
51
  <url>
52
  <loc>https://getcert.eu.cc/terms</loc>
53
- <lastmod>2026-10-11</lastmod>
54
  <changefreq>yearly</changefreq>
55
  <priority>0.3</priority>
56
  </url>
57
  <url>
58
  <loc>https://getcert.eu.cc/privacy</loc>
59
- <lastmod>2026-10-11</lastmod>
60
  <changefreq>yearly</changefreq>
61
  <priority>0.3</priority>
62
  </url>
63
  <url>
64
  <loc>https://getcert.eu.cc/refund</loc>
65
- <lastmod>2026-10-11</lastmod>
66
  <changefreq>yearly</changefreq>
67
  <priority>0.3</priority>
68
  </url>
@@ -92,13 +92,13 @@
92
  </url>
93
  <url>
94
  <loc>https://getcert.eu.cc/free-course/javascript-crash-course-for-beginners-68e3</loc>
95
- <lastmod>2026-10-11</lastmod>
96
  <changefreq>weekly</changefreq>
97
  <priority>0.8</priority>
98
  </url>
99
  <url>
100
  <loc>https://getcert.eu.cc/free-course/python-basics-30m</loc>
101
- <lastmod>2026-10-11</lastmod>
102
  <changefreq>weekly</changefreq>
103
  <priority>0.8</priority>
104
  </url>
 
2
  <urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
3
  <url>
4
  <loc>https://getcert.eu.cc/</loc>
5
+ <lastmod>2026-10-12</lastmod>
6
  <changefreq>weekly</changefreq>
7
  <priority>1.0</priority>
8
  </url>
9
  <url>
10
  <loc>https://getcert.eu.cc/courses</loc>
11
+ <lastmod>2026-10-12</lastmod>
12
  <changefreq>weekly</changefreq>
13
  <priority>0.9</priority>
14
  </url>
15
  <url>
16
  <loc>https://getcert.eu.cc/learn</loc>
17
+ <lastmod>2026-10-12</lastmod>
18
  <changefreq>weekly</changefreq>
19
  <priority>0.8</priority>
20
  </url>
21
  <url>
22
  <loc>https://getcert.eu.cc/verify</loc>
23
+ <lastmod>2026-10-12</lastmod>
24
  <changefreq>monthly</changefreq>
25
  <priority>0.7</priority>
26
  </url>
27
  <url>
28
  <loc>https://getcert.eu.cc/yt</loc>
29
+ <lastmod>2026-10-12</lastmod>
30
  <changefreq>weekly</changefreq>
31
  <priority>0.7</priority>
32
  </url>
33
  <url>
34
  <loc>https://getcert.eu.cc/about</loc>
35
+ <lastmod>2026-10-12</lastmod>
36
  <changefreq>monthly</changefreq>
37
  <priority>0.6</priority>
38
  </url>
39
  <url>
40
  <loc>https://getcert.eu.cc/contact</loc>
41
+ <lastmod>2026-10-12</lastmod>
42
  <changefreq>yearly</changefreq>
43
  <priority>0.5</priority>
44
  </url>
45
  <url>
46
  <loc>https://getcert.eu.cc/honor-code</loc>
47
+ <lastmod>2026-10-12</lastmod>
48
  <changefreq>yearly</changefreq>
49
  <priority>0.4</priority>
50
  </url>
51
  <url>
52
  <loc>https://getcert.eu.cc/terms</loc>
53
+ <lastmod>2026-10-12</lastmod>
54
  <changefreq>yearly</changefreq>
55
  <priority>0.3</priority>
56
  </url>
57
  <url>
58
  <loc>https://getcert.eu.cc/privacy</loc>
59
+ <lastmod>2026-10-12</lastmod>
60
  <changefreq>yearly</changefreq>
61
  <priority>0.3</priority>
62
  </url>
63
  <url>
64
  <loc>https://getcert.eu.cc/refund</loc>
65
+ <lastmod>2026-10-12</lastmod>
66
  <changefreq>yearly</changefreq>
67
  <priority>0.3</priority>
68
  </url>
 
92
  </url>
93
  <url>
94
  <loc>https://getcert.eu.cc/free-course/javascript-crash-course-for-beginners-68e3</loc>
95
+ <lastmod>2026-10-12</lastmod>
96
  <changefreq>weekly</changefreq>
97
  <priority>0.8</priority>
98
  </url>
99
  <url>
100
  <loc>https://getcert.eu.cc/free-course/python-basics-30m</loc>
101
+ <lastmod>2026-10-12</lastmod>
102
  <changefreq>weekly</changefreq>
103
  <priority>0.8</priority>
104
  </url>
src/components/MyCourses.tsx CHANGED
@@ -260,7 +260,13 @@ export function MyCourses({ userId }: MyCoursesProps) {
260
  <div className="grid grid-cols-2 sm:grid-cols-4 gap-4 text-xs text-slate-600">
261
  <div>
262
  <span className="font-semibold text-slate-400 block mb-0.5">Current Stage</span>
263
- <span className="inline-block px-2.5 py-0.5 bg-blue-50 text-blue-800 font-semibold rounded-md border border-blue-200/60">{course.current_step}</span>
 
 
 
 
 
 
264
  </div>
265
  {course.assessment_score && (
266
  <div>
@@ -283,24 +289,40 @@ export function MyCourses({ userId }: MyCoursesProps) {
283
  <div className="w-full md:w-48 space-y-2">
284
  <div className="flex justify-between items-center text-xs font-semibold">
285
  <span className="text-slate-500">Curriculum Progress</span>
286
- <span className="text-[#132F50] font-bold">{course.progress_percentage}%</span>
 
 
287
  </div>
288
  <div className="w-full h-2.5 bg-slate-100 rounded-full overflow-hidden">
289
  <div
290
- className="h-full bg-[#132F50] rounded-full transition-all duration-300"
291
- style={{ width: `${Math.max(5, course.progress_percentage)}%` }}
 
 
 
 
292
  />
293
  </div>
294
  </div>
295
 
296
  {/* Course Actions */}
297
  <div className="flex items-center gap-2.5">
298
- <button
299
- onClick={() => handleContinueCourse(course)}
300
- className="px-4 py-2 bg-[#132F50] hover:bg-blue-900 text-white rounded-xl text-xs font-bold tracking-tight shadow-xs transition-colors whitespace-nowrap cursor-pointer"
301
- >
302
- Resume Study →
303
- </button>
 
 
 
 
 
 
 
 
 
 
304
 
305
  <button
306
  onClick={() => handleRestartCourse(course.course_name)}
 
260
  <div className="grid grid-cols-2 sm:grid-cols-4 gap-4 text-xs text-slate-600">
261
  <div>
262
  <span className="font-semibold text-slate-400 block mb-0.5">Current Stage</span>
263
+ <span className={`inline-block px-2.5 py-0.5 font-semibold rounded-md border ${
264
+ course.status === 'certified'
265
+ ? 'bg-emerald-50 text-emerald-800 border-emerald-200/60'
266
+ : 'bg-blue-50 text-blue-800 border-blue-200/60'
267
+ }`}>
268
+ {course.status === 'certified' ? 'Certified' : course.current_step}
269
+ </span>
270
  </div>
271
  {course.assessment_score && (
272
  <div>
 
289
  <div className="w-full md:w-48 space-y-2">
290
  <div className="flex justify-between items-center text-xs font-semibold">
291
  <span className="text-slate-500">Curriculum Progress</span>
292
+ <span className={course.status === 'certified' || course.progress_percentage === 100 ? "text-emerald-700 font-bold" : "text-[#132F50] font-bold"}>
293
+ {course.status === 'certified' ? 100 : course.progress_percentage}%
294
+ </span>
295
  </div>
296
  <div className="w-full h-2.5 bg-slate-100 rounded-full overflow-hidden">
297
  <div
298
+ className={`h-full rounded-full transition-all duration-300 ${
299
+ course.status === 'certified' || course.progress_percentage === 100
300
+ ? 'bg-emerald-600'
301
+ : 'bg-[#132F50]'
302
+ }`}
303
+ style={{ width: `${course.status === 'certified' ? 100 : Math.max(5, course.progress_percentage)}%` }}
304
  />
305
  </div>
306
  </div>
307
 
308
  {/* Course Actions */}
309
  <div className="flex items-center gap-2.5">
310
+ {course.status === 'certified' || course.progress_percentage === 100 ? (
311
+ <button
312
+ onClick={() => navigate('/my-certificates')}
313
+ className="px-4 py-2 bg-emerald-600 hover:bg-emerald-700 text-white rounded-xl text-xs font-bold tracking-tight shadow-xs transition-colors whitespace-nowrap cursor-pointer flex items-center gap-1.5"
314
+ >
315
+ <span>View Certificate</span>
316
+ <span>🎓</span>
317
+ </button>
318
+ ) : (
319
+ <button
320
+ onClick={() => handleContinueCourse(course)}
321
+ className="px-4 py-2 bg-[#132F50] hover:bg-blue-900 text-white rounded-xl text-xs font-bold tracking-tight shadow-xs transition-colors whitespace-nowrap cursor-pointer"
322
+ >
323
+ Resume Study →
324
+ </button>
325
+ )}
326
 
327
  <button
328
  onClick={() => handleRestartCourse(course.course_name)}
src/components/certification-flow/quick-cert/QuickCertPayment.tsx CHANGED
@@ -50,6 +50,9 @@ export function QuickCertPayment({
50
 
51
  const handleIssuedCertificate = useCallback((issued: IssuedCertificateV2) => {
52
  setCertificate(issued);
 
 
 
53
  onCertified(issued);
54
  }, [onCertified]);
55
 
@@ -113,6 +116,12 @@ export function QuickCertPayment({
113
  );
114
  setPaywallEnabled(response.paywallEnabled);
115
 
 
 
 
 
 
 
116
  if (!response.request) {
117
  return null;
118
  }
@@ -134,7 +143,7 @@ export function QuickCertPayment({
134
  } finally {
135
  if (!silent) setLoadingAction(null);
136
  }
137
- }, [courseName, handleIssueCertificate, userId]);
138
 
139
  const handleProceedToCheckout = async () => {
140
  setError(null);
 
50
 
51
  const handleIssuedCertificate = useCallback((issued: IssuedCertificateV2) => {
52
  setCertificate(issued);
53
+ try {
54
+ localStorage.removeItem('getcert_active_flow');
55
+ } catch {}
56
  onCertified(issued);
57
  }, [onCertified]);
58
 
 
116
  );
117
  setPaywallEnabled(response.paywallEnabled);
118
 
119
+ if (response.certificate) {
120
+ handleIssuedCertificate(response.certificate);
121
+ setPaymentNote('Certificate issued successfully.');
122
+ return response.request;
123
+ }
124
+
125
  if (!response.request) {
126
  return null;
127
  }
 
143
  } finally {
144
  if (!silent) setLoadingAction(null);
145
  }
146
+ }, [courseName, handleIssueCertificate, handleIssuedCertificate, userId]);
147
 
148
  const handleProceedToCheckout = async () => {
149
  setError(null);
src/lib/courseSessionPersistence.ts CHANGED
@@ -397,19 +397,20 @@ export async function updateMyCourse(
397
  * Get all courses for a user (for My Courses page)
398
  * Cross-references both my_courses and certificates table to guarantee zero lost progress.
399
  */
400
- export async function getMyCourses(userId: string): Promise<{ courses: MyCourse[]; error?: string }> {
401
  try {
402
  logger.log(` Loading My Courses for user ${userId}`);
 
403
 
404
  // Query 1: Courses from my_courses table
405
- const coursesQuery = supabase
406
  .from('my_courses')
407
  .select('*')
408
  .eq('user_id', userId)
409
  .order('last_activity', { ascending: false });
410
 
411
  // Query 2: Earned certificates from certificates table
412
- const certsQuery = supabase
413
  .from('certificates')
414
  .select('id, user_id, topic, project_title, issued_at, metadata')
415
  .eq('user_id', userId)
@@ -420,6 +421,9 @@ export async function getMyCourses(userId: string): Promise<{ courses: MyCourse[
420
  if (coursesRes.error) {
421
  logger.error(' Error loading My Courses:', coursesRes.error);
422
  }
 
 
 
423
 
424
  const dbCourses: MyCourse[] = coursesRes.data || [];
425
  const dbCerts = certsRes.data || [];
@@ -436,30 +440,59 @@ export async function getMyCourses(userId: string): Promise<{ courses: MyCourse[
436
  const normalizedName = courseName.toLowerCase().trim();
437
  const topicNormalized = (cert.topic || '').toLowerCase().trim();
438
 
439
- const alreadyExists = Array.from(existingCourseNames).some(
440
- (existing) =>
441
- existing === normalizedName ||
442
- (topicNormalized && (existing.includes(topicNormalized) || topicNormalized.includes(existing)))
443
- );
444
-
445
- if (!alreadyExists) {
446
- let track: 'quickcert' | 'verifiedcert' | 'free_completion' = 'quickcert';
447
- if (
448
- meta.certification_track === 'free_completion' ||
449
- meta.track === 'free_completion' ||
450
- meta.free_course ||
451
- meta.completion_type === 'free_starter_course' ||
452
- String(meta.certificate_id || cert.id).startsWith('FC')
453
- ) {
454
- track = 'free_completion';
455
- } else if (
456
- meta.certification_track === 'verifiedcert' ||
457
- meta.track === 'verifiedcert' ||
458
- String(meta.certificate_id || cert.id).startsWith('VC')
459
- ) {
460
- track = 'verifiedcert';
461
- }
462
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
463
  const synthetic: MyCourse = {
464
  id: cert.id,
465
  user_id: cert.user_id,
@@ -481,7 +514,7 @@ export async function getMyCourses(userId: string): Promise<{ courses: MyCourse[
481
  existingCourseNames.add(normalizedName);
482
 
483
  // Self-heal: backfill into my_courses table asynchronously
484
- supabase
485
  .from('my_courses')
486
  .upsert({
487
  user_id: cert.user_id,
@@ -493,9 +526,7 @@ export async function getMyCourses(userId: string): Promise<{ courses: MyCourse[
493
  certification_track: track,
494
  completed_at: cert.issued_at,
495
  last_activity: cert.issued_at,
496
- }, { onConflict: 'user_id,course_name' })
497
- .then(() => {})
498
- .catch(() => {});
499
  }
500
  }
501
 
 
397
  * Get all courses for a user (for My Courses page)
398
  * Cross-references both my_courses and certificates table to guarantee zero lost progress.
399
  */
400
+ export async function getMyCourses(userId: string, customClient?: any): Promise<{ courses: MyCourse[]; error?: string }> {
401
  try {
402
  logger.log(` Loading My Courses for user ${userId}`);
403
+ const client = customClient || supabase;
404
 
405
  // Query 1: Courses from my_courses table
406
+ const coursesQuery = client
407
  .from('my_courses')
408
  .select('*')
409
  .eq('user_id', userId)
410
  .order('last_activity', { ascending: false });
411
 
412
  // Query 2: Earned certificates from certificates table
413
+ const certsQuery = client
414
  .from('certificates')
415
  .select('id, user_id, topic, project_title, issued_at, metadata')
416
  .eq('user_id', userId)
 
421
  if (coursesRes.error) {
422
  logger.error(' Error loading My Courses:', coursesRes.error);
423
  }
424
+ if (certsRes.error) {
425
+ logger.error(' Error loading Certificates:', certsRes.error);
426
+ }
427
 
428
  const dbCourses: MyCourse[] = coursesRes.data || [];
429
  const dbCerts = certsRes.data || [];
 
440
  const normalizedName = courseName.toLowerCase().trim();
441
  const topicNormalized = (cert.topic || '').toLowerCase().trim();
442
 
443
+ // Check if an existing enrolled course in my_courses matches this certificate
444
+ const matchingCourse = dbCourses.find((c) => {
445
+ const cNorm = c.course_name.toLowerCase().trim();
446
+ return (
447
+ cNorm === normalizedName ||
448
+ (topicNormalized && (cNorm.includes(topicNormalized) || topicNormalized.includes(cNorm)))
449
+ );
450
+ });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
451
 
452
+ let track: 'quickcert' | 'verifiedcert' | 'free_completion' = 'quickcert';
453
+ if (
454
+ meta.certification_track === 'free_completion' ||
455
+ meta.track === 'free_completion' ||
456
+ meta.free_course ||
457
+ meta.completion_type === 'free_starter_course' ||
458
+ String(meta.certificate_id || cert.id).startsWith('FC')
459
+ ) {
460
+ track = 'free_completion';
461
+ } else if (
462
+ meta.certification_track === 'verifiedcert' ||
463
+ meta.track === 'verifiedcert' ||
464
+ String(meta.certificate_id || cert.id).startsWith('VC')
465
+ ) {
466
+ track = 'verifiedcert';
467
+ }
468
+
469
+ if (matchingCourse) {
470
+ // Upgrade existing course to 100% certified immediately
471
+ if (matchingCourse.status !== 'certified' || matchingCourse.progress_percentage !== 100) {
472
+ matchingCourse.status = 'certified';
473
+ matchingCourse.current_step = 'certified';
474
+ matchingCourse.progress_percentage = 100;
475
+ matchingCourse.completed_at = cert.issued_at || matchingCourse.completed_at || new Date().toISOString();
476
+ matchingCourse.last_activity = cert.issued_at || matchingCourse.last_activity || new Date().toISOString();
477
+ if (meta.final_score) {
478
+ matchingCourse.assessment_score = meta.final_score;
479
+ }
480
+
481
+ // Asynchronously self-heal database row
482
+ void client
483
+ .from('my_courses')
484
+ .update({
485
+ status: 'certified',
486
+ current_step: 'certified',
487
+ progress_percentage: 100,
488
+ completed_at: matchingCourse.completed_at,
489
+ last_activity: matchingCourse.last_activity,
490
+ updated_at: new Date().toISOString(),
491
+ })
492
+ .eq('id', matchingCourse.id);
493
+ }
494
+ } else {
495
+ // Synthesize course from certificate if no course row existed
496
  const synthetic: MyCourse = {
497
  id: cert.id,
498
  user_id: cert.user_id,
 
514
  existingCourseNames.add(normalizedName);
515
 
516
  // Self-heal: backfill into my_courses table asynchronously
517
+ void client
518
  .from('my_courses')
519
  .upsert({
520
  user_id: cert.user_id,
 
526
  certification_track: track,
527
  completed_at: cert.issued_at,
528
  last_activity: cert.issued_at,
529
+ }, { onConflict: 'user_id,course_name' });
 
 
530
  }
531
  }
532
 
src/types/getcertV2.ts CHANGED
@@ -283,6 +283,7 @@ export interface PaymentRequestResponseV2 {
283
  canIssueCertificate?: boolean;
284
  checkoutUrl?: string;
285
  checkoutId?: string;
 
286
  }
287
 
288
  export interface PublicProfileV2 {
 
283
  canIssueCertificate?: boolean;
284
  checkoutUrl?: string;
285
  checkoutId?: string;
286
+ certificate?: IssuedCertificateV2 | null;
287
  }
288
 
289
  export interface PublicProfileV2 {
src/utils/secureEnvironment.ts CHANGED
@@ -29,11 +29,11 @@ class SecureEnvironment {
29
  }
30
 
31
  try {
32
- const env = import.meta.env;
33
 
34
  // Check for required environment variables
35
- const supabaseUrl = env.VITE_SUPABASE_URL;
36
- const supabaseAnonKey = env.VITE_SUPABASE_ANON_KEY;
37
 
38
  if (!supabaseUrl) {
39
  throw new Error('VITE_SUPABASE_URL is required');
 
29
  }
30
 
31
  try {
32
+ const env = (typeof import.meta !== 'undefined' && import.meta.env) ? import.meta.env : (process.env as any);
33
 
34
  // Check for required environment variables
35
+ const supabaseUrl = env.VITE_SUPABASE_URL || env.SUPABASE_URL;
36
+ const supabaseAnonKey = env.VITE_SUPABASE_ANON_KEY || env.SUPABASE_ANON_KEY;
37
 
38
  if (!supabaseUrl) {
39
  throw new Error('VITE_SUPABASE_URL is required');
src/utils/secureLogger.ts CHANGED
@@ -5,7 +5,7 @@
5
  * In production, all logging is disabled to prevent credential leaks.
6
  */
7
 
8
- const isDevelopment = import.meta.env.MODE === 'development';
9
 
10
  // List of sensitive patterns to redact
11
  const SENSITIVE_PATTERNS = [
 
5
  * In production, all logging is disabled to prevent credential leaks.
6
  */
7
 
8
+ const isDevelopment = (typeof import.meta !== 'undefined' && import.meta.env?.MODE === 'development') || process.env.NODE_ENV === 'development';
9
 
10
  // List of sensitive patterns to redact
11
  const SENSITIVE_PATTERNS = [