Spaces:
Running
Running
fix(cert): auto-issue certificate on payment confirmation & sync course to 100% certified
Browse files- api/index.ts +11 -12
- api/v2/_services/certificationSystem.ts +3 -2
- api/v2/_services/paymentApprovalSystem.ts +190 -1
- public/llms-full.txt +1 -1
- public/llms.txt +1 -1
- public/sitemap.xml +13 -13
- src/components/MyCourses.tsx +32 -10
- src/components/certification-flow/quick-cert/QuickCertPayment.tsx +10 -1
- src/lib/courseSessionPersistence.ts +61 -30
- src/types/getcertV2.ts +1 -0
- src/utils/secureEnvironment.ts +3 -3
- src/utils/secureLogger.ts +1 -1
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
|
| 835 |
-
if (request.status === 'paid' &&
|
| 836 |
-
|
| 837 |
-
|
| 838 |
-
.
|
| 839 |
-
|
| 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 |
-
|
| 850 |
-
|
| 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
|
|
|
|
| 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
|
|
|
|
| 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-
|
| 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-
|
| 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-
|
| 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 |
<changefreq>weekly</changefreq>
|
| 13 |
<priority>0.9</priority>
|
| 14 |
</url>
|
| 15 |
<url>
|
| 16 |
<loc>https://getcert.eu.cc/learn</loc>
|
| 17 |
-
<lastmod>2026-10-
|
| 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-
|
| 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-
|
| 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-
|
| 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-
|
| 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-
|
| 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-
|
| 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-
|
| 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-
|
| 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-
|
| 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-
|
| 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=
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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"
|
|
|
|
|
|
|
| 287 |
</div>
|
| 288 |
<div className="w-full h-2.5 bg-slate-100 rounded-full overflow-hidden">
|
| 289 |
<div
|
| 290 |
-
className=
|
| 291 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 292 |
/>
|
| 293 |
</div>
|
| 294 |
</div>
|
| 295 |
|
| 296 |
{/* Course Actions */}
|
| 297 |
<div className="flex items-center gap-2.5">
|
| 298 |
-
|
| 299 |
-
|
| 300 |
-
|
| 301 |
-
|
| 302 |
-
|
| 303 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 =
|
| 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 =
|
| 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 |
-
|
| 440 |
-
|
| 441 |
-
|
| 442 |
-
|
| 443 |
-
|
| 444 |
-
|
| 445 |
-
|
| 446 |
-
|
| 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 |
-
|
| 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 = [
|