getcert-backend / src /lib /getcertV2API.ts
ggop213's picture
feat: Add GetCert YT competency certification engine and assessment flow
515e3fb
Raw History Blame Contribute Delete
14.5 kB
import type {
AssessmentPackV2,
AssessmentGenerationContextV2,
AssessmentResultV2,
AssessmentSubmissionV2,
CertificateDetailsV2,
CertificationTrack,
HealthStatusV2,
GeneratedCourseV2,
GeneratedProjectV2,
GitHubValidationReportV2,
IssuedCertificateV2,
PaymentRequestResponseV2,
ProjectEvaluationV2,
PublicProfileV2,
SmartQuestionV2,
FreeCourseItem,
FreeCourseCompletionPayload,
YtAnalyzeResponse,
YtAssessmentStartResponse,
YtAssessmentGradeResult,
} from '../types/getcertV2';
import { supabase } from './supabase';
import { DEFAULT_FREE_COURSES } from '../constants/defaultFreeCourses';
type ApiEnvelope<T> = {
success: boolean;
data?: T;
error?: string;
details?: unknown;
};
const API_BASE_URL = (import.meta.env.VITE_API_BASE_URL || '').trim().replace(/\/$/, '');
function parsePositiveTimeoutMs(value: unknown, fallbackMs: number): number {
const parsed = Number(value);
if (!Number.isFinite(parsed) || parsed <= 0) {
return fallbackMs;
}
return Math.round(parsed);
}
const API_REQUEST_TIMEOUT_MS = parsePositiveTimeoutMs(import.meta.env.VITE_API_REQUEST_TIMEOUT_MS, 120000);
function normalizePath(path: string): string {
return path.startsWith('/') ? path : `/${path}`;
}
function joinBaseAndPath(base: string, path: string): string {
const normalizedPath = normalizePath(path);
// Backward-compatible guard: if base already ends with /api and callers
// pass /api/... paths, avoid constructing /api/api/...
if (base.endsWith('/api') && normalizedPath.startsWith('/api/')) {
return `${base}${normalizedPath.slice(4)}`;
}
return `${base}${normalizedPath}`;
}
function buildUrl(path: string): string {
if (/^https?:\/\//i.test(path)) {
return path;
}
if (!API_BASE_URL) {
return normalizePath(path);
}
return joinBaseAndPath(API_BASE_URL, path);
}
function getNetworkErrorMessage(error: unknown): string {
const message = error instanceof Error ? error.message : 'Unknown network error';
return `Unable to reach API endpoint. ${message}. In local dev, run both frontend and API servers (npm run dev:full) or verify VITE_API_BASE_URL.`;
}
async function getAuthorizationHeader(): Promise<Record<string, string>> {
try {
const { data } = await supabase.auth.getSession();
const accessToken = data.session?.access_token?.trim();
if (!accessToken) {
return {};
}
return { Authorization: `Bearer ${accessToken}` };
} catch {
return {};
}
}
async function fetchWithTimeout(input: RequestInfo | URL, init: RequestInit = {}, timeoutMs = API_REQUEST_TIMEOUT_MS): Promise<Response> {
const controller = new AbortController();
const timeout = window.setTimeout(() => controller.abort(), timeoutMs);
try {
return await fetch(input, {
...init,
signal: controller.signal,
});
} finally {
window.clearTimeout(timeout);
}
}
// No withFallback wrappers β€” all errors propagate to the UI for retry
async function parseResponse<T>(response: Response): Promise<T> {
const raw = await response.text();
let payload: ApiEnvelope<T> | null = null;
if (raw) {
try {
payload = JSON.parse(raw) as ApiEnvelope<T>;
} catch {
payload = null;
}
}
if (!response.ok) {
if (payload?.error) {
throw new Error(payload.error);
}
if (!payload) {
throw new Error(
`API ${response.status} returned non-JSON at ${response.url}. Run npm run dev:full or confirm VITE_API_BASE_URL.`
);
}
throw new Error(`API request failed with status ${response.status}`);
}
if (!payload || !payload.success || payload.data === undefined) {
throw new Error('API returned an unexpected response shape');
}
return payload.data;
}
async function postJson<T>(path: string, body: unknown): Promise<T> {
let response: Response;
try {
const authorizationHeader = await getAuthorizationHeader();
response = await fetchWithTimeout(buildUrl(path), {
method: 'POST',
headers: { 'Content-Type': 'application/json', ...authorizationHeader },
body: JSON.stringify(body),
});
} catch (error) {
throw new Error(getNetworkErrorMessage(error));
}
return parseResponse<T>(response);
}
async function getJson<T>(path: string): Promise<T> {
let response: Response;
try {
const authorizationHeader = await getAuthorizationHeader();
response = await fetchWithTimeout(buildUrl(path), {
method: 'GET',
headers: { 'Content-Type': 'application/json', ...authorizationHeader },
});
} catch (error) {
throw new Error(getNetworkErrorMessage(error));
}
return parseResponse<T>(response);
}
let clientCachedFreeCourses: FreeCourseItem[] | null = null;
export const getcertV2API = {
generateCourse(skill: string): Promise<GeneratedCourseV2> {
return postJson<GeneratedCourseV2>('/api/v2/course', { skill });
},
generateAssessment(skill: string, moduleTitles: string[], context?: AssessmentGenerationContextV2): Promise<AssessmentPackV2> {
return postJson<AssessmentPackV2>('/api/v2/assessment', {
mode: 'generate',
skill,
moduleTitles,
courseSummary: context?.courseSummary,
moduleContext: context?.modules,
});
},
gradeAssessment(pack: AssessmentPackV2, submission: AssessmentSubmissionV2): Promise<AssessmentResultV2> {
return postJson<AssessmentResultV2>('/api/v2/assessment', {
mode: 'grade',
pack,
submission,
});
},
generateProject(skill: string): Promise<GeneratedProjectV2> {
return postJson<GeneratedProjectV2>('/api/v2/project', { skill });
},
validateGitHub(repoUrl: string): Promise<GitHubValidationReportV2> {
return postJson<GitHubValidationReportV2>('/api/v2/github-validate', { repoUrl });
},
generateProjectQuestions(
skill: string,
url: string,
retryCount = 0,
isDesignProject = false
): Promise<{
questions: SmartQuestionV2[];
metadata: GitHubValidationReportV2['metadata'];
validationSummary: {
overallPassed: boolean;
commitPassed: boolean;
readmePassed: boolean;
};
}> {
return postJson('/api/v2/project-questions', {
skill,
repoUrl: isDesignProject ? undefined : url,
portfolioUrl: isDesignProject ? url : undefined,
retryCount,
isDesignProject,
});
},
evaluateProject(payload: {
skill: string;
repoUrl?: string;
projectType: 'web' | 'python' | 'data' | 'design';
liveDemoUrl?: string;
portfolioUrl?: string;
notebookUrl?: string;
questions: SmartQuestionV2[];
answers: Array<{ questionId: string; answer: string }>;
}): Promise<ProjectEvaluationV2> {
return postJson<ProjectEvaluationV2>('/api/v2/project-evaluate', payload);
},
submitPaymentRequest(payload: {
userId: string;
txnId?: string;
skill: string;
projectTitle: string;
githubUrl?: string;
proofUrl: string;
finalScore: number;
scoreBreakdown?: ProjectEvaluationV2['scoreBreakdown'];
validationSummary?: ProjectEvaluationV2['validationSummary'];
projectType?: 'web' | 'python' | 'data' | 'design';
commitCount?: number;
liveDemoUrl?: string;
paymentRequestId?: string;
track?: CertificationTrack;
email?: string;
phone?: string;
promoCode?: string;
amountInr?: number;
}): Promise<PaymentRequestResponseV2> {
return postJson<PaymentRequestResponseV2>('/api/v2/payment-submit', payload);
},
getPaymentStatus(
userId: string,
paymentRequestId?: string,
txnId?: string,
skill?: string,
projectTitle?: string,
track?: string,
): Promise<PaymentRequestResponseV2> {
const query = new URLSearchParams({ userId });
if (paymentRequestId) {
query.set('paymentRequestId', paymentRequestId);
}
if (txnId) {
query.set('txnId', txnId);
}
if (skill) {
query.set('skill', skill);
}
if (projectTitle) {
query.set('projectTitle', projectTitle);
}
if (track) {
query.set('track', track);
}
return getJson<PaymentRequestResponseV2>(
`/api/v2/payment-status?${query.toString()}`
);
},
certify(payload: {
userId: string;
name: string;
email: string;
skill: string;
projectTitle: string;
githubUrl?: string;
proofUrl: string;
finalScore: number;
scoreBreakdown?: ProjectEvaluationV2['scoreBreakdown'];
validationSummary?: ProjectEvaluationV2['validationSummary'];
projectType?: 'web' | 'python' | 'data' | 'design';
commitCount?: number;
liveDemoUrl?: string;
paymentRequestId?: string;
txnId?: string;
track?: CertificationTrack;
}): Promise<IssuedCertificateV2> {
return postJson<IssuedCertificateV2>('/api/v2/certify', payload);
},
getCertificate(certificateId: string): Promise<CertificateDetailsV2> {
return getJson<CertificateDetailsV2>(`/api/v2/certificate/${encodeURIComponent(certificateId)}`);
},
getPublicProfile(userId: string): Promise<PublicProfileV2> {
return getJson<PublicProfileV2>(`/api/v2/profile/${encodeURIComponent(userId)}`);
},
getHealth(): Promise<HealthStatusV2> {
return getJson<HealthStatusV2>('/api/v2/health');
},
async getFreeCourses(): Promise<FreeCourseItem[]> {
if (clientCachedFreeCourses && clientCachedFreeCourses.length > 0) {
return clientCachedFreeCourses;
}
// 1. Direct Supabase query (Lightning fast: 30-50ms directly from Supabase, zero server cold-start)
try {
const { data, error } = await supabase
.from('free_courses')
.select('*')
.eq('is_active', true)
.order('display_order', { ascending: true });
if (!error && data && data.length > 0) {
const items = data.map((row: any) => {
const videoId = row.youtube_video_id || 'kqtD5dpn9C8';
const maxStudents = typeof row.max_students === 'number' && row.max_students > 0 ? row.max_students : null;
const certifiedCount = typeof row.certified_students_count === 'number' ? row.certified_students_count : 0;
return {
id: row.id,
courseId: row.course_id,
title: row.title,
skill: row.skill,
description: row.description || '',
category: row.category || 'Programming',
level: row.level || 'Beginner',
durationMinutes: row.duration_minutes || 30,
maxStudents,
certifiedStudentsCount: certifiedCount,
isSoldOut: Boolean(maxStudents !== null && certifiedCount >= maxStudents),
remainingSlots: maxStudents !== null ? Math.max(0, maxStudents - certifiedCount) : null,
youtubeUrl: row.youtube_url || `https://www.youtube.com/watch?v=${videoId}`,
youtubeVideoId: videoId,
channelTitle: row.channel_title || 'Programming with Mosh',
thumbnailUrl: row.thumbnail_url || `https://img.youtube.com/vi/${videoId}/hqdefault.jpg`,
embedUrl: `https://www.youtube-nocookie.com/embed/${videoId}?rel=0`,
isActive: Boolean(row.is_active ?? true),
displayOrder: row.display_order ?? 0,
keyTakeaways: Array.isArray(row.key_takeaways) ? row.key_takeaways : [],
quizQuestions: Array.isArray(row.quiz_questions) ? row.quiz_questions : [],
createdAt: row.created_at,
updatedAt: row.updated_at,
};
});
clientCachedFreeCourses = items;
return items;
}
} catch {
// Supabase query error or table not yet migrated
}
// 2. Fast API probe with 2000ms timeout (never hang for 120s on sleeping containers)
try {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 2000);
const url = buildUrl('/api/v2/free-courses');
const response = await fetch(url, { signal: controller.signal });
clearTimeout(timeout);
if (response.ok) {
const payload = await response.json();
if (payload?.success && Array.isArray(payload.data?.courses) && payload.data.courses.length > 0) {
clientCachedFreeCourses = payload.data.courses;
return payload.data.courses;
}
}
} catch {
// Backend cold starting or unreachable
}
// 3. Instant local fallback (0ms)
clientCachedFreeCourses = DEFAULT_FREE_COURSES;
return DEFAULT_FREE_COURSES;
},
async updateFreeCourseProgress(payload: {
courseId: string;
courseTitle: string;
progressPercentage?: number;
currentStep?: string;
status?: string;
score?: number;
}): Promise<{ success: boolean }> {
try {
return await postJson<{ success: boolean }>('/api/v2/free-courses/progress', payload);
} catch {
return { success: false };
}
},
completeFreeCourse(payload: FreeCourseCompletionPayload): Promise<IssuedCertificateV2> {
return postJson<IssuedCertificateV2>('/api/v2/free-courses/complete', payload);
},
// ─── GetCert YT ("Certify Your Learning from YouTube") ─────────────────────
analyzeYouTubeCourse(url: string): Promise<YtAnalyzeResponse> {
return postJson<YtAnalyzeResponse>('/api/v2/yt/analyze', { url });
},
startYtAssessment(snapshotId: string): Promise<YtAssessmentStartResponse> {
return postJson<YtAssessmentStartResponse>('/api/v2/yt/assessment/start', { snapshotId });
},
submitYtAssessment(payload: {
snapshotId: string;
attemptId: string;
answers: Record<string, number>;
}): Promise<YtAssessmentGradeResult> {
return postJson<YtAssessmentGradeResult>('/api/v2/yt/assessment/submit', payload);
},
checkoutYtCertification(payload: {
snapshotId: string;
score: number;
email?: string;
}): Promise<{
paywallEnabled: boolean;
amountInr: number;
checkoutUrl: string;
checkoutId: string;
paymentSessionId: string;
track: string;
message: string;
}> {
return postJson('/api/v2/yt/checkout', payload);
},
certifyYtCourse(payload: {
userId: string;
name: string;
email: string;
skill: string;
projectTitle: string;
finalScore: number;
paymentRequestId?: string;
txnId?: string;
track: 'ytcert';
extraMetadata?: Record<string, any>;
}): Promise<IssuedCertificateV2> {
return postJson<IssuedCertificateV2>('/api/v2/certify', payload);
},
};