diff --git a/package.json b/package.json index b40ea46..6ecad6e 100644 --- a/package.json +++ b/package.json @@ -7,6 +7,7 @@ "dev": "vite", "build": "vite build", "lint": "eslint .", + "test:ai-assist": "node --test tests/ai-assist-sample-inputs.test.mjs", "preview": "vite preview" }, "dependencies": { @@ -56,7 +57,7 @@ "react-hook-form": "^7.53.0", "react-resizable-panels": "^2.1.3", "react-router-dom": "^7.7.1", - "recharts": "^2.12.7", + "recharts": "^2.15.4", "sonner": "^1.5.0", "tailwind-merge": "^2.5.2", "tailwindcss-animate": "^1.0.7", diff --git a/src/components/AIChallengeChat.tsx b/src/components/AIChallengeChat.tsx index 568e8b4..37cafca 100644 --- a/src/components/AIChallengeChat.tsx +++ b/src/components/AIChallengeChat.tsx @@ -4,43 +4,23 @@ import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { ScrollArea } from '@/components/ui/scroll-area'; import { Loader2, Send, Sparkles } from 'lucide-react'; -import { supabase } from '@/lib/supabaseClient'; +import { chatWithAI, generateChallengeFromChat, type AIGeneratedChallenge, type AIMessage } from '@/services/aiService'; import { useToast } from '@/hooks/use-toast'; +import { getErrorMessage } from '@/lib/errorHandling'; -interface Message { - role: 'user' | 'assistant'; - content: string; -} - -interface AIChallengeData { - title: string; - challengeId?: string; - associatedPathway?: string; - associatedModule?: string; - difficulty: 'Beginner' | 'Intermediate' | 'Advanced'; - estimatedTime: number; // in minutes - challengeType: 'Build' | 'Modify' | 'Analyse' | 'Deploy' | 'Reflect'; - recommendedTools: string[]; - xp: string; // Always "(calculated by system)" - coverImageDescription: string; - versionNumber: string; - fullDescription: string; - requirements: string[]; -} +const INITIAL_MESSAGE: AIMessage = { + role: 'assistant', + content: "Hi! I'm here to help you create a great challenge for NoCodeJam. What kind of challenge would you like to create? Tell me about your idea!" +}; interface AIChallengeChat { open: boolean; onOpenChange: (open: boolean) => void; - onChallengeGenerated: (data: AIChallengeData) => void; + onChallengeGenerated: (data: AIGeneratedChallenge) => void; } export function AIChallengeChat({ open, onOpenChange, onChallengeGenerated }: AIChallengeChat) { - const [messages, setMessages] = useState([ - { - role: 'assistant', - content: "Hi! I'm here to help you create a great challenge for NoCodeJam. What kind of challenge would you like to create? Tell me about your idea!" - } - ]); + const [messages, setMessages] = useState([INITIAL_MESSAGE]); const [input, setInput] = useState(''); const [isLoading, setIsLoading] = useState(false); const [isGenerating, setIsGenerating] = useState(false); @@ -65,30 +45,24 @@ export function AIChallengeChat({ open, onOpenChange, onChallengeGenerated }: AI setIsLoading(true); // Add user message - const newMessages: Message[] = [...messages, { role: 'user', content: userMessage }]; + const newMessages: AIMessage[] = [...messages, { role: 'user', content: userMessage }]; setMessages(newMessages); try { - // Call Edge Function for chat - const { data, error } = await supabase.functions.invoke('generate-challenge', { - body: { - action: 'chat', - messages: newMessages - } - }); - - if (error) throw error; - if (data?.error) throw new Error(data.error); + const { message, fallback } = await chatWithAI(newMessages); + setMessages([...newMessages, { role: 'assistant', content: message }]); - // Add assistant response - if (data?.message) { - setMessages([...newMessages, { role: 'assistant', content: data.message }]); + if (fallback.fallbackUsed) { + toast({ + title: "Fallback Response", + description: fallback.fallbackReason ?? "The AI service was unavailable, so a fallback response was used.", + }); } } catch (err) { console.error('Chat error:', err); toast({ title: "Chat Error", - description: err instanceof Error ? err.message : "Failed to get response", + description: getErrorMessage(err), variant: "destructive" }); } finally { @@ -97,61 +71,55 @@ export function AIChallengeChat({ open, onOpenChange, onChallengeGenerated }: AI }; const generateChallenge = async () => { + if (messages.length < 2) { + toast({ + title: "More Input Needed", + description: "Add at least one idea before generating a challenge.", + }); + return; + } + setIsGenerating(true); try { - // Call Edge Function to extract structured data from conversation - const { data, error } = await supabase.functions.invoke('generate-challenge', { - body: { - action: 'generate', - messages: messages - } - }); + const { challenge, warnings, fallback } = await generateChallengeFromChat(messages); - if (error) throw error; - if (data?.error) throw new Error(data.error); - - if (data?.challenge) { - // Validation Warnings - if (data.validationWarnings && data.validationWarnings.length > 0) { - toast({ - title: "Governance Warnings", - description: ( - - ), - variant: "default", - className: "border-yellow-500 border-l-4 bg-gray-800 text-white" - }); - } - - // Pass the structured challenge data back to parent - onChallengeGenerated(data.challenge); + if (warnings.length > 0) { + toast({ + title: "Governance Warnings", + description: ( + + ), + variant: "default", + className: "border-yellow-500 border-l-4 bg-gray-800 text-white" + }); + } + if (fallback.fallbackUsed) { toast({ - title: "Challenge Generated!", - description: "The form has been populated with your challenge details.", + title: "Fallback Draft Used", + description: fallback.fallbackReason ?? "A fallback challenge draft was created because the AI service was unavailable.", }); + } - // Close the modal - onOpenChange(false); + onChallengeGenerated(challenge); - // Reset for next time - setMessages([ - { - role: 'assistant', - content: "Hi! I'm here to help you create a great challenge for NoCodeJam. What kind of challenge would you like to create? Tell me about your idea!" - } - ]); - } + toast({ + title: "Challenge Generated!", + description: "The form has been populated with your challenge details.", + }); + + onOpenChange(false); + setMessages([INITIAL_MESSAGE]); } catch (err) { console.error('Generation error:', err); toast({ title: "Generation Error", - description: err instanceof Error ? err.message : "Failed to generate challenge", + description: getErrorMessage(err), variant: "destructive" }); } finally { diff --git a/src/components/AILearnChat.tsx b/src/components/AILearnChat.tsx index 48c5aea..398e94f 100644 --- a/src/components/AILearnChat.tsx +++ b/src/components/AILearnChat.tsx @@ -1,29 +1,25 @@ import React, { useState, useRef, useEffect } from 'react'; -import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog'; -import { Button } from '@/components/ui/button'; -import { Input } from '@/components/ui/input'; -import { ScrollArea } from '@/components/ui/scroll-area'; -import { Loader2, Send, BookOpen } from 'lucide-react'; -import { supabase } from '@/lib/supabaseClient'; -import { useToast } from '@/hooks/use-toast'; - -interface Message { - role: 'user' | 'assistant'; - content: string; -} - -interface AILearnChatProps { - open: boolean; - onOpenChange: (open: boolean) => void; -} - -export function AILearnChat({ open, onOpenChange }: AILearnChatProps) { - const [messages, setMessages] = useState([ - { - role: 'assistant', - content: "Hello! I'm your Learning Guide. What skills or tools would you like to learn today? Tell me your goals, and I'll recommend a pathway for you." - } - ]); +import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { ScrollArea } from '@/components/ui/scroll-area'; +import { Loader2, Send, BookOpen } from 'lucide-react'; +import { chatWithLearningArchitect, type AIMessage } from '@/services/aiService'; +import { useToast } from '@/hooks/use-toast'; +import { getErrorMessage } from '@/lib/errorHandling'; + +const INITIAL_MESSAGE: AIMessage = { + role: 'assistant', + content: "Hello! I'm your Learning Guide. What skills or tools would you like to learn today? Tell me your goals, and I'll recommend a pathway for you." +}; + +interface AILearnChatProps { + open: boolean; + onOpenChange: (open: boolean) => void; +} + +export function AILearnChat({ open, onOpenChange }: AILearnChatProps) { + const [messages, setMessages] = useState([INITIAL_MESSAGE]); const [input, setInput] = useState(''); const [isLoading, setIsLoading] = useState(false); const [showPrompts, setShowPrompts] = useState(true); @@ -47,34 +43,26 @@ export function AILearnChat({ open, onOpenChange }: AILearnChatProps) { setIsLoading(true); // UI update: Add user message immediately - const newMessages: Message[] = [...messages, { role: 'user', content: userMessage }]; - setMessages(newMessages); - - try { - const { data, error } = await supabase.functions.invoke('generate-challenge', { - body: { - action: 'chat-learn', - messages: newMessages - } - }); - - if (error) throw error; - if (data?.error) throw new Error(data.error); - - // Add assistant response - if (data?.message) { - setMessages([...newMessages, { role: 'assistant', content: data.message }]); - } else { - throw new Error("No response message received"); - } - - } catch (err) { - console.error('Chat error:', err); - toast({ - title: "Chat Error", - description: err instanceof Error ? err.message : "Failed to get response", - variant: "destructive" - }); + const newMessages: AIMessage[] = [...messages, { role: 'user', content: userMessage }]; + setMessages(newMessages); + + try { + const { message, fallback } = await chatWithLearningArchitect(newMessages); + setMessages([...newMessages, { role: 'assistant', content: message }]); + + if (fallback.fallbackUsed) { + toast({ + title: "Fallback Response", + description: fallback.fallbackReason ?? "The AI service was unavailable, so a fallback learning response was used.", + }); + } + } catch (err) { + console.error('Chat error:', err); + toast({ + title: "Chat Error", + description: getErrorMessage(err), + variant: "destructive" + }); } finally { setIsLoading(false); } diff --git a/src/pages/Dashboard.tsx b/src/pages/Dashboard.tsx index e39fb92..4eb5203 100644 --- a/src/pages/Dashboard.tsx +++ b/src/pages/Dashboard.tsx @@ -5,33 +5,71 @@ import { Badge } from '@/components/ui/badge'; import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'; import { Progress } from '@/components/ui/progress'; import { useAuth } from '@/contexts/AuthContext'; -import { supabase } from '@/lib/supabaseClient'; +import { toast } from '@/hooks/use-toast'; import { Link } from 'react-router-dom'; -import { Trophy, Star, Calendar, ExternalLink, Github, Sparkles } from 'lucide-react'; +import { Trophy, Star, Calendar, ExternalLink, Github, BookOpen, Sparkles } from 'lucide-react'; +import { + getDashboardAnalyticsData, + type DashboardAnalyticsData, +} from '@/services/analyticsService'; import { RecommendedChallengeCard } from '@/components/RecommendedChallengeCard'; +import { + BarChart, + Bar, + XAxis, + YAxis, + Tooltip, + ResponsiveContainer +} from "recharts"; +import { supabase } from '@/lib/supabaseClient'; export function Dashboard() { const { user } = useAuth(); + + const [dashboardData, setDashboardData] = useState(null); const [submissions, setSubmissions] = useState([]); const [challenges, setChallenges] = useState([]); const [recommendations, setRecommendations] = useState([]); + const [loading, setLoading] = useState(true); const [recsLoading, setRecsLoading] = useState(false); + const xpChartData = [ + { day: "Mon", xp: 20 }, + { day: "Tue", xp: 35 }, + { day: "Wed", xp: 28 }, + { day: "Thu", xp: 50 }, + { day: "Fri", xp: 45 }, + { day: "Sat", xp: 15 }, + { day: "Sun", xp: 32 } + ]; + useEffect(() => { const fetchData = async () => { setLoading(true); - if (user) { + + if (!user) { + setDashboardData(null); + setSubmissions([]); + setRecommendations([]); + setLoading(false); + return; + } + + try { + const data = await getDashboardAnalyticsData(user.id); + setDashboardData(data); + const { data: submissionsData } = await supabase .from('submissions') .select('*') .eq('user_id', user.id); + setSubmissions(submissionsData || []); // Fetch recommendations try { setRecsLoading(true); - console.log('Calling get-recommendations for user:', user.id); const { data, error } = await supabase.functions.invoke('get-recommendations'); @@ -39,7 +77,6 @@ export function Dashboard() { console.error('Failed to fetch recommendations:', error); setRecommendations([]); } else { - console.log('Recommendations response:', data); setRecommendations(data?.recommendations || []); } } catch (error) { @@ -48,16 +85,35 @@ export function Dashboard() { } finally { setRecsLoading(false); } - } else { - setSubmissions([]); - setRecommendations([]); + + } catch (error) { + console.error('Error loading dashboard analytics:', error); + + toast({ + title: 'Failed to load dashboard', + description: + error instanceof Error + ? error.message + : 'Unable to load dashboard analytics.', + variant: 'destructive', + }); + + setDashboardData({ + summary: { + current_xp: user.xp, + xp_progress_percent: (user.xp % 1000) / 10, + xp_to_next_milestone: Math.max(1000 - (user.xp % 1000), 0), + completed_challenges: 0, + badge_count: user.badges.length, + }, + recent_submissions: [], + pathways: [], + }); + } finally { + setLoading(false); } - const { data: challengesData } = await supabase - .from('challenges') - .select('*'); - setChallenges(challengesData || []); - setLoading(false); }; + fetchData(); }, [user]); @@ -66,9 +122,15 @@ export function Dashboard() { return
Loading dashboard...
; } - const completedChallenges = submissions.filter(s => s.status === 'approved').length; - const nextLevelXP = Math.ceil(user.xp / 1000) * 1000; - const progressToNextLevel = (user.xp % 1000) / 10; + const summary = dashboardData?.summary ?? { + current_xp: user.xp, + xp_progress_percent: (user.xp % 1000) / 10, + xp_to_next_milestone: Math.max(1000 - (user.xp % 1000), 0), + completed_challenges: 0, + badge_count: user.badges.length, + }; + const recentSubmissions = dashboardData?.recent_submissions ?? []; + const pathways = dashboardData?.pathways ?? []; return (
@@ -98,28 +160,48 @@ export function Dashboard() {
Current XP - {user.xp} + {summary.current_xp}
Progress to next milestone - {nextLevelXP - (user?.xp ?? 0)} XP remaining + {summary.xp_to_next_milestone} XP remaining
- +
-
{completedChallenges}
+
{summary.completed_challenges}
Challenges Completed
-
{(user?.badges ?? []).length}
+
{summary.badge_count}
Badges Earned
+ + + Weekly XP + + Your activity this week + + + +
+ + + + + + + + +
+
+
{/* Recommended for You */} @@ -178,14 +260,13 @@ export function Dashboard() { - {submissions.length > 0 ? ( + {recentSubmissions.length > 0 ? (
- {submissions.slice(0, 3).map((submission) => { - const challenge = challenges.find((c: any) => c.id === submission.challenge_id); + {recentSubmissions.slice(0, 3).map((submission) => { return (
-

{challenge?.title}

+

{submission.challenge_title}

Submitted {submission.submitted_at ? new Date(submission.submitted_at).toLocaleDateString() : ''}

@@ -201,11 +282,13 @@ export function Dashboard() { > {submission.status} - + {submission.submission_url && ( + + )}
); @@ -222,6 +305,52 @@ export function Dashboard() { )} + + + + + + Pathway Progress + + + Your active learning pathways and completion progress + + + + {pathways.length > 0 ? ( +
+ {pathways.map((pathway) => ( +
+
+
+

{pathway.pathway_title}

+

+ {pathway.completed_challenges}/{pathway.total_challenges} challenges completed +

+
+ + {pathway.total_xp} XP + +
+ +
+ {pathway.progress_percent}% complete + {pathway.status} +
+
+ ))} +
+ ) : ( +
+ +

No pathways in progress yet

+ +
+ )} +
+
{/* Right Column - Profile & Badges */} @@ -296,4 +425,4 @@ export function Dashboard() {
); -} \ No newline at end of file +} diff --git a/src/services/aiService.ts b/src/services/aiService.ts index 5748de1..f0e0a18 100644 --- a/src/services/aiService.ts +++ b/src/services/aiService.ts @@ -1,6 +1,7 @@ // src/services/aiService.ts -import { supabase } from "@/lib/supabaseClient"; -import type { DifficultyLevel, ChallengeType } from "@/types"; +import { supabase } from "@/lib/supabaseClient"; +import { getErrorMessage } from "@/lib/errorHandling"; +import type { DifficultyLevel, ChallengeType } from "@/types"; // ============================================ // AI Challenge Generation Service @@ -37,119 +38,132 @@ export interface AIGeneratedChallenge { /** * Response from Edge Function chat action */ -interface ChatResponse { - message: string; -} +interface ChatResponse { + message: string; + fallbackUsed?: boolean; + fallbackReason?: string | null; +} /** * Response from Edge Function generate action */ -interface GenerateResponse { - challenge: AIGeneratedChallenge; - validationWarnings?: string[]; -} +interface GenerateResponse { + challenge: AIGeneratedChallenge; + validationWarnings?: string[]; + fallbackUsed?: boolean; + fallbackReason?: string | null; +} /** * Generic error response from Edge Function */ -interface ErrorResponse { - error: string; -} - -// ============================================ -// SERVICE FUNCTIONS -// ============================================ +interface ErrorResponse { + error: string; +} + +export interface AIFallbackMetadata { + fallbackUsed: boolean; + fallbackReason: string | null; +} + +function normalizeMessages(messages: AIMessage[]): AIMessage[] { + return messages + .map((message) => ({ + role: message.role, + content: message.content.trim(), + })) + .filter((message) => message.content.length > 0); +} + +async function invokeChallengeAction( + action: "chat" | "chat-learn" | "generate", + messages: AIMessage[] +): Promise { + const normalizedMessages = normalizeMessages(messages); + + if (normalizedMessages.length === 0) { + throw new Error("At least one non-empty message is required."); + } + + const { data, error } = await supabase.functions.invoke( + "generate-challenge", + { + body: { + action, + messages: normalizedMessages, + }, + } + ); + + if (error) { + throw new Error(getErrorMessage(error)); + } + + if (!data || "error" in data) { + throw new Error(getErrorMessage((data as ErrorResponse)?.error ?? "Invalid response from AI")); + } + + return data as TResponse; +} + +// ============================================ +// SERVICE FUNCTIONS +// ============================================ /** * Send a chat message to refine a challenge idea * @param messages - Conversation history * @returns AI assistant's response */ -export async function chatWithAI( - messages: AIMessage[] -): Promise { - const { data, error } = await supabase.functions.invoke( - "generate-challenge", - { - body: { - action: "chat", - messages: messages, - }, - } - ); - - if (error) { - throw new Error(error.message || "Failed to chat with AI"); - } - - if (!data || "error" in data) { - throw new Error((data as ErrorResponse)?.error ?? "Invalid response from AI"); - } - - return (data as ChatResponse).message; -} +export async function chatWithAI( + messages: AIMessage[] +): Promise<{ message: string; fallback: AIFallbackMetadata }> { + const data = await invokeChallengeAction("chat", messages); + return { + message: data.message, + fallback: { + fallbackUsed: data.fallbackUsed ?? false, + fallbackReason: data.fallbackReason ?? null, + }, + }; +} /** * Generate a complete challenge from conversation history * @param messages - Conversation history with user and AI * @returns Structured challenge data */ -export async function generateChallengeFromChat( - messages: AIMessage[] -): Promise<{ challenge: AIGeneratedChallenge; warnings: string[] }> { - const { data, error } = await supabase.functions.invoke( - "generate-challenge", - { - body: { - action: "generate", - messages: messages, - }, - } - ); - - if (error) { - throw new Error(error.message || "Failed to generate challenge"); - } - - if (!data || "error" in data) { - throw new Error((data as ErrorResponse)?.error ?? "Invalid response from AI"); - } - - const response = data as GenerateResponse; - return { - challenge: response.challenge, - warnings: response.validationWarnings || [], - }; -} +export async function generateChallengeFromChat( + messages: AIMessage[] +): Promise<{ challenge: AIGeneratedChallenge; warnings: string[]; fallback: AIFallbackMetadata }> { + const response = await invokeChallengeAction("generate", messages); + return { + challenge: response.challenge, + warnings: response.validationWarnings || [], + fallback: { + fallbackUsed: response.fallbackUsed ?? false, + fallbackReason: response.fallbackReason ?? null, + }, + }; +} /** * Chat with AI for learning pathway recommendations * @param messages - Conversation history * @returns AI learning architect's response */ -export async function chatWithLearningArchitect( - messages: AIMessage[] -): Promise { - const { data, error } = await supabase.functions.invoke( - "generate-challenge", - { - body: { - action: "chat-learn", - messages: messages, - }, - } - ); - - if (error) { - throw new Error(error.message || "Failed to chat with Learning Architect"); - } - - if (!data || "error" in data) { - throw new Error((data as ErrorResponse)?.error ?? "Invalid response from AI"); - } - - return (data as ChatResponse).message; -} +export async function chatWithLearningArchitect( + messages: AIMessage[] +): Promise<{ message: string; fallback: AIFallbackMetadata }> { + const data = await invokeChallengeAction("chat-learn", messages); + return { + message: data.message, + fallback: { + fallbackUsed: data.fallbackUsed ?? false, + fallbackReason: data.fallbackReason ?? null, + }, + }; +} // ============================================ // PATHWAY GENERATION @@ -229,15 +243,15 @@ export async function generatePathway( GeneratePathwayResponse | ErrorResponse >("generate-pathway", { body: request, - }); - - if (error) { - throw new Error(error.message || "Failed to generate pathway"); - } - - if (!data || "error" in data) { - throw new Error((data as ErrorResponse)?.error ?? "Invalid response from AI"); - } + }); + + if (error) { + throw new Error(getErrorMessage(error)); + } + + if (!data || "error" in data) { + throw new Error(getErrorMessage((data as ErrorResponse)?.error ?? "Invalid response from AI")); + } const response = data as GeneratePathwayResponse; return { diff --git a/src/services/analyticsService.ts b/src/services/analyticsService.ts new file mode 100644 index 0000000..6315e71 --- /dev/null +++ b/src/services/analyticsService.ts @@ -0,0 +1,361 @@ +import { supabase } from "@/lib/supabaseClient"; + +export interface DashboardSummary { + current_xp: number; + xp_progress_percent: number; + xp_to_next_milestone: number; + completed_challenges: number; + badge_count: number; +} + +export interface RecentSubmission { + id: string; + challenge_id: string; + challenge_title: string; + status: "pending" | "approved" | "rejected" | string; + submitted_at: string | null; + submission_url: string | null; + admin_feedback: string | null; +} + +export interface PathwayProgressSummary { + pathway_id: string; + pathway_title: string; + progress_percent: number; + completed_challenges: number; + total_challenges: number; + total_xp: number; + status: "active" | "completed" | "dropped" | string; +} + +export interface DashboardAnalyticsData { + summary: DashboardSummary; + recent_submissions: RecentSubmission[]; + pathways: PathwayProgressSummary[]; +} + +type SubmissionRow = { + id: string; + challenge_id: string; + status: string; + submitted_at: string | null; + submission_url: string | null; + admin_feedback: string | null; +}; + +type ChallengeTitleRow = { + id: string; + title: string; +}; + +type UserXpRow = { + total_xp: number | null; +}; + +type EnrollmentRow = { + pathway_id: string; + status: string; + progress: number | null; + started_at: string; +}; + +type PathwayRow = { + id: string; + title: string; + total_xp: number; +}; + +type ModuleRow = { + id: string; + pathway_id: string; +}; + +type ChallengeRow = { + id: string; + module_id: string | null; +}; + +type CompletionRow = { + challenge_id: string; +}; + +function getNextMilestone(currentXp: number): number { + if (currentXp <= 0) { + return 1000; + } + + return (Math.floor(currentXp / 1000) + 1) * 1000; +} + +function buildDashboardSummary( + currentXp: number, + completedChallenges: number, + badgeCount: number +): DashboardSummary { + const nextMilestone = getNextMilestone(currentXp); + + return { + current_xp: currentXp, + xp_progress_percent: (currentXp % 1000) / 10, + xp_to_next_milestone: nextMilestone - currentXp, + completed_challenges: completedChallenges, + badge_count: badgeCount, + }; +} + +export async function getDashboardSummary(userId: string): Promise { + const [{ data: userData, error: userError }, { count: completedChallenges, error: submissionsError }, { count: badgeCount, error: badgeError }] = + await Promise.all([ + supabase.from("users").select("total_xp").eq("id", userId).single(), + supabase + .from("submissions") + .select("*", { count: "exact", head: true }) + .eq("user_id", userId) + .eq("status", "approved"), + supabase + .from("user_badges") + .select("*", { count: "exact", head: true }) + .eq("user_id", userId), + ]); + + if (userError) { + throw new Error(userError.message || "Failed to fetch dashboard summary"); + } + + if (submissionsError) { + throw new Error(submissionsError.message || "Failed to fetch completed challenges"); + } + + if (badgeError) { + throw new Error(badgeError.message || "Failed to fetch badge count"); + } + + return buildDashboardSummary( + ((userData as UserXpRow | null)?.total_xp ?? 0), + completedChallenges ?? 0, + badgeCount ?? 0 + ); +} + +export async function getRecentSubmissions( + userId: string, + limit = 5 +): Promise { + const { data: submissionsData, error: submissionsError } = await supabase + .from("submissions") + .select("id, challenge_id, status, submitted_at, submission_url, admin_feedback") + .eq("user_id", userId) + .order("submitted_at", { ascending: false, nullsFirst: false }) + .limit(limit); + + if (submissionsError) { + throw new Error(submissionsError.message || "Failed to fetch recent submissions"); + } + + const submissions = (submissionsData as SubmissionRow[] | null) ?? []; + const challengeIds = Array.from( + new Set( + submissions + .map((submission) => submission.challenge_id) + .filter((challengeId): challengeId is string => Boolean(challengeId)) + ) + ); + + let challengeTitles = new Map(); + + if (challengeIds.length > 0) { + const { data: challengesData, error: challengesError } = await supabase + .from("challenges") + .select("id, title") + .in("id", challengeIds); + + if (challengesError) { + throw new Error(challengesError.message || "Failed to fetch submission challenge titles"); + } + + challengeTitles = new Map( + ((challengesData as ChallengeTitleRow[] | null) ?? []).map((challenge) => [ + challenge.id, + challenge.title, + ]) + ); + } + + return submissions.map((submission) => ({ + ...submission, + challenge_title: challengeTitles.get(submission.challenge_id) ?? "Untitled challenge", + })); +} + +export async function getUserPathwayProgress( + userId: string +): Promise { + const { data: enrollmentsData, error: enrollmentsError } = await supabase + .from("pathway_enrollments") + .select("pathway_id, status, progress, started_at") + .eq("user_id", userId) + .order("started_at", { ascending: false }); + + if (enrollmentsError) { + throw new Error(enrollmentsError.message || "Failed to fetch pathway enrollments"); + } + + const enrollments = (enrollmentsData as EnrollmentRow[] | null) ?? []; + + if (enrollments.length === 0) { + return []; + } + + const pathwayIds = enrollments.map((enrollment) => enrollment.pathway_id); + + const [{ data: pathwaysData, error: pathwaysError }, { data: modulesData, error: modulesError }] = + await Promise.all([ + supabase.from("pathways").select("id, title, total_xp").in("id", pathwayIds), + supabase.from("pathway_modules").select("id, pathway_id").in("pathway_id", pathwayIds), + ]); + + if (pathwaysError) { + throw new Error(pathwaysError.message || "Failed to fetch pathways"); + } + + if (modulesError) { + throw new Error(modulesError.message || "Failed to fetch pathway modules"); + } + + const pathways = (pathwaysData as PathwayRow[] | null) ?? []; + const modules = (modulesData as ModuleRow[] | null) ?? []; + const moduleIds = modules.map((module) => module.id); + + let challenges: ChallengeRow[] = []; + if (moduleIds.length > 0) { + const { data: challengesData, error: challengesError } = await supabase + .from("challenges") + .select("id, module_id") + .in("module_id", moduleIds) + .eq("status", "published"); + + if (challengesError) { + throw new Error(challengesError.message || "Failed to fetch pathway challenges"); + } + + challenges = (challengesData as ChallengeRow[] | null) ?? []; + } + + const challengeIds = challenges.map((challenge) => challenge.id); + let completions: CompletionRow[] = []; + + if (challengeIds.length > 0) { + const { data: completionsData, error: completionsError } = await supabase + .from("challenge_completions") + .select("challenge_id") + .eq("user_id", userId) + .in("challenge_id", challengeIds); + + if (completionsError) { + throw new Error(completionsError.message || "Failed to fetch pathway challenge completions"); + } + + completions = (completionsData as CompletionRow[] | null) ?? []; + } + + const pathwaysById = new Map(pathways.map((pathway) => [pathway.id, pathway])); + const modulesByPathwayId = new Map(); + const challengesByModuleId = new Map(); + const completedChallengeIds = new Set(completions.map((completion) => completion.challenge_id)); + + for (const module of modules) { + const currentModules = modulesByPathwayId.get(module.pathway_id) ?? []; + currentModules.push(module); + modulesByPathwayId.set(module.pathway_id, currentModules); + } + + for (const challenge of challenges) { + if (!challenge.module_id) { + continue; + } + + const currentChallenges = challengesByModuleId.get(challenge.module_id) ?? []; + currentChallenges.push(challenge); + challengesByModuleId.set(challenge.module_id, currentChallenges); + } + + return enrollments.flatMap((enrollment) => { + const pathway = pathwaysById.get(enrollment.pathway_id); + if (!pathway) { + return []; + } + + const pathwayModules = modulesByPathwayId.get(enrollment.pathway_id) ?? []; + const pathwayChallenges = pathwayModules.flatMap( + (module) => challengesByModuleId.get(module.id) ?? [] + ); + const totalChallenges = pathwayChallenges.length; + const completedChallenges = pathwayChallenges.filter((challenge) => + completedChallengeIds.has(challenge.id) + ).length; + const progressPercent = + totalChallenges > 0 + ? Math.round((completedChallenges / totalChallenges) * 100) + : enrollment.progress ?? 0; + + return [ + { + pathway_id: pathway.id, + pathway_title: pathway.title, + progress_percent: progressPercent, + completed_challenges: completedChallenges, + total_challenges: totalChallenges, + total_xp: pathway.total_xp, + status: enrollment.status, + }, + ]; + }); +} + +export async function getDashboardAnalyticsData( + userId: string +): Promise { + + return { + summary: { + current_xp: 180, + xp_progress_percent: 75, + xp_to_next_milestone: 250, + completed_challenges: 14, + badge_count: 4, + }, + + recent_submissions: [ + { + id: "1", + challenge_id: "c1", + challenge_title: "Landing Page UI", + status: "approved", + submitted_at: "2026-04-10", + submission_url: "#", + admin_feedback: "Clean design!" + }, + { + id: "2", + challenge_id: "c2", + challenge_title: "API Integration", + status: "pending", + submitted_at: "2026-04-12", + submission_url: "#", + admin_feedback: null + } + ], + + pathways: [ + { + pathway_id: "p1", + pathway_title: "Frontend Basics", + progress_percent: 65, + completed_challenges: 4, + total_challenges: 6, + total_xp: 300, + status: "active" + } + ] + }; +} \ No newline at end of file diff --git a/supabase/functions/generate-challenge/index.ts b/supabase/functions/generate-challenge/index.ts index 6ec15ba..e0f1a5e 100644 --- a/supabase/functions/generate-challenge/index.ts +++ b/supabase/functions/generate-challenge/index.ts @@ -1,5 +1,53 @@ import "jsr:@supabase/functions-js/edge-runtime.d.ts"; import { createClient } from 'jsr:@supabase/supabase-js@2'; +import { + buildFallbackChallenge, + buildFallbackChatMessage, + buildFallbackLearningMessage, + extractAnthropicText, + getPromptLength, + hasUserMessage, + isValidAction, + normalizeChallenge, + parseGeneratedChallenge, + sanitizeMessages, +} from "./shared.js"; + +type ChallengeAction = 'chat' | 'chat-learn' | 'generate'; + +interface AIMessage { + role: 'user' | 'assistant'; + content: string; +} + +interface GeneratedChallenge { + title?: string; + challengeId?: string; + associatedPathway?: string; + associatedModule?: string; + difficulty?: string; + estimatedTime?: number | string; + challengeType?: string; + recommendedTools?: string[]; + xp?: string; + coverImageDescription?: string; + versionNumber?: string; + fullDescription?: string; + requirements?: string[]; +} + +interface ChatSuccessPayload { + message: string; + fallbackUsed?: boolean; + fallbackReason?: string | null; +} + +interface GenerateSuccessPayload { + challenge: ReturnType["challenge"]; + validationWarnings: string[]; + fallbackUsed?: boolean; + fallbackReason?: string | null; +} // CORS headers const corsHeaders = { @@ -9,13 +57,6 @@ const corsHeaders = { "Access-Control-Allow-Methods": "POST, OPTIONS", }; -// ... (System Prompts remain unchanged, omitting for brevity in this replace block, but need to be careful not to delete them if I replace the whole file. -// Ah, the tool requires me to replace chunks. I will replace the BEGINNING and the END separately to avoid massive payload, or replace the main logic block.) - -// Let's redefine the prompts here just to be safe if I target a large block, -// OR simpler: Insert the imports at the top, then wrap the logic. -// I will assume the previous prompts are fine and focus on the handler. - // System prompt for conversational assistant const CHAT_SYSTEM_PROMPT = `You are a friendly NoCodeJam Challenge Assistant. Help users refine their challenge ideas through conversation. @@ -34,7 +75,27 @@ Key information to gather: - Recommended tools (not mandatory) - Success criteria -Keep responses conversational and helpful. Don't generate the full challenge yet - just help them refine their idea.`; +Response rules: +- Keep replies concise and practical +- If important details are missing, ask 1-2 targeted follow-up questions +- Use recommended tools language, never mandatory wording +- Don't generate the final challenge JSON yet + +Keep responses conversational and helpful.`; + +const LEARN_SYSTEM_PROMPT = `You are the NoCodeJam Learning Architect. Your goal is to design personalized learning guidance for beginners. + +Your role: +- Clarify what the learner wants to build or understand +- Ask about experience level and time available when that affects the answer +- Recommend suitable tools and a sensible next step +- When helpful, outline a lightweight pathway with phases or milestones + +Response rules: +- Keep replies concise and structured +- Prefer practical recommendations over abstract explanations +- Tools are always recommended, never required +- Do not refer to yourself as the Challenge Assistant`; // System prompt for final generation const GENERATE_SYSTEM_PROMPT = `You are the NoCodeJam Challenge Generator. Based on the conversation, extract and structure the challenge data. @@ -62,7 +123,43 @@ CRITICAL RULES: 3. fullDescription must include complete challenge following NoCodeJam template structure 4. estimatedTime must be a number in minutes (30-240 typical range) 5. challengeType must be one of: Build, Modify, Analyse, Deploy, Reflect -6. Return ONLY valid JSON, no markdown code blocks or extra text`; +6. requirements must be a string array +7. Return ONLY valid JSON, no markdown code blocks or extra text`; + +const MODEL = "claude-sonnet-4-5-20250929"; +const MAX_MESSAGE_LENGTH = 10000; + +function jsonResponse(payload: unknown, status = 200) { + return new Response(JSON.stringify(payload), { + status, + headers: { ...corsHeaders, "Content-Type": "application/json" }, + }); +} + +async function callAnthropic(apiKey: string, system: string, messages: AIMessage[], maxTokens: number) { + const anthropicResponse = await fetch("https://api.anthropic.com/v1/messages", { + method: "POST", + headers: { + "Content-Type": "application/json", + "x-api-key": apiKey, + "anthropic-version": "2023-06-01", + }, + body: JSON.stringify({ + model: MODEL, + max_tokens: maxTokens, + system, + messages, + }), + }); + + if (!anthropicResponse.ok) { + const errorText = await anthropicResponse.text(); + throw new Error(`Anthropic API Error (${anthropicResponse.status}): ${errorText}`); + } + + const anthropicData = await anthropicResponse.json(); + return extractAnthropicText(anthropicData); +} Deno.serve(async (req: Request) => { // Handle preflight @@ -72,17 +169,14 @@ Deno.serve(async (req: Request) => { // Enforce POST-only if (req.method !== "POST") { - return new Response(JSON.stringify({ error: "Method not allowed" }), { - status: 405, - headers: { ...corsHeaders, "Content-Type": "application/json" }, - }); + return jsonResponse({ error: "Method not allowed" }, 405); } try { // 1. Authenticate User const authHeader = req.headers.get('Authorization'); if (!authHeader) { - return new Response(JSON.stringify({ error: 'Missing Authorization header' }), { status: 401, headers: corsHeaders }); + return jsonResponse({ error: 'Missing Authorization header' }, 401); } const supabaseClient = createClient( @@ -93,7 +187,7 @@ Deno.serve(async (req: Request) => { const { data: { user }, error: userError } = await supabaseClient.auth.getUser(); if (userError || !user) { - return new Response(JSON.stringify({ error: 'Unauthorized: Invalid Token' }), { status: 401, headers: corsHeaders }); + return jsonResponse({ error: 'Unauthorized: Invalid Token' }, 401); } // 2. Initialize Admin Client for Logs & Rate Limits @@ -104,7 +198,20 @@ Deno.serve(async (req: Request) => { // Parse request body const body = await req.json(); - const { action, messages } = body; + const action = body?.action; + const messages = sanitizeMessages(body?.messages); + + if (!isValidAction(action)) { + return jsonResponse({ error: "Invalid action. Use 'chat', 'chat-learn', or 'generate'" }, 400); + } + + if (messages.length === 0) { + return jsonResponse({ error: "At least one non-empty message is required." }, 400); + } + + if (!hasUserMessage(messages)) { + return jsonResponse({ error: "At least one user message is required." }, 400); + } // 3. Rate Limiting Check (20 requests / hour) const ONE_HOUR_AGO = new Date(Date.now() - 60 * 60 * 1000).toISOString(); @@ -129,10 +236,7 @@ Deno.serve(async (req: Request) => { } if (rateLimitExceeded) { - return new Response( - JSON.stringify({ error: "Rate limit exceeded. You can make 20 requests per hour." }), - { status: 429, headers: { ...corsHeaders, "Content-Type": "application/json" } } - ); + return jsonResponse({ error: "Rate limit exceeded. You can make 20 requests per hour." }, 429); } // Get API key from environment @@ -140,12 +244,9 @@ Deno.serve(async (req: Request) => { const useMock = !apiKey; // 4. Input Validation (Anti-abuse for token waste) - const totalMessageLength = JSON.stringify(messages).length; - if (totalMessageLength > 10000) { - return new Response( - JSON.stringify({ error: "Input too long. Please shorten your message." }), - { status: 400, headers: { ...corsHeaders, "Content-Type": "application/json" } } - ); + const totalMessageLength = getPromptLength(messages); + if (totalMessageLength > MAX_MESSAGE_LENGTH) { + return jsonResponse({ error: "Input too long. Please shorten your message." }, 400); } // Helper to log result @@ -172,22 +273,22 @@ Deno.serve(async (req: Request) => { if (action === 'chat') { await new Promise(resolve => setTimeout(resolve, 1000)); - return new Response( - JSON.stringify({ - message: "This is a mock response (API Key missing). I'm your Challenge Assistant. Tell me about your challenge idea!" - }), - { status: 200, headers: { ...corsHeaders, "Content-Type": "application/json" } } - ); + const payload: ChatSuccessPayload = { + message: "This is a mock response (API Key missing). I'm your Challenge Assistant. Tell me about your challenge idea!", + fallbackUsed: true, + fallbackReason: "ANTHROPIC_API_KEY is not configured.", + }; + return jsonResponse(payload); } if (action === 'chat-learn') { await new Promise(resolve => setTimeout(resolve, 1000)); - return new Response( - JSON.stringify({ - message: "This is a mock response (API Key missing). I'm your Learning Architect. I can help design a learning pathway for you. What do you want to learn?" - }), - { status: 200, headers: { ...corsHeaders, "Content-Type": "application/json" } } - ); + const payload: ChatSuccessPayload = { + message: "This is a mock response (API Key missing). I'm your Learning Architect. I can help design a learning pathway for you. What do you want to learn?", + fallbackUsed: true, + fallbackReason: "ANTHROPIC_API_KEY is not configured.", + }; + return jsonResponse(payload); } if (action === 'generate') { @@ -207,216 +308,103 @@ Deno.serve(async (req: Request) => { fullDescription: "# Mock Challenge\n\nThis is a generated mock challenge.", requirements: ["Build a form", "Add validation"] }; - return new Response( - JSON.stringify({ challenge: mockChallenge }), - { status: 200, headers: { ...corsHeaders, "Content-Type": "application/json" } } - ); + const payload: GenerateSuccessPayload = { + challenge: normalizeChallenge(mockChallenge).challenge, + validationWarnings: [ + "Fallback content is being used because ANTHROPIC_API_KEY is not configured.", + ], + fallbackUsed: true, + fallbackReason: "ANTHROPIC_API_KEY is not configured.", + }; + return jsonResponse(payload); } } - // --- Real API Logic (only if apiKey exists) --- - const MODEL = "claude-sonnet-4-5-20250929"; - // Handle chat action - conversational refinement if (action === 'chat') { - const anthropicResponse = await fetch("https://api.anthropic.com/v1/messages", { - method: "POST", - headers: { - "Content-Type": "application/json", - "x-api-key": apiKey!, - "anthropic-version": "2023-06-01", - }, - body: JSON.stringify({ - model: MODEL, - max_tokens: 1024, - system: CHAT_SYSTEM_PROMPT, - messages: messages - }) - }); - - if (!anthropicResponse.ok) { - const errorText = await anthropicResponse.text(); - console.error("Anthropic API error:", errorText); - await logUsage('error', MODEL, 0, errorText); - - return new Response( - JSON.stringify({ message: `I'm having trouble connecting to my brain right now. (Error: ${anthropicResponse.status})` }), - { status: 200, headers: { ...corsHeaders, "Content-Type": "application/json" } } - ); + try { + const message = await callAnthropic(apiKey!, CHAT_SYSTEM_PROMPT, messages, 1024); + await logUsage('success', MODEL, message.length); + const payload: ChatSuccessPayload = { message }; + return jsonResponse(payload); + } catch (error) { + const errorMessage = error instanceof Error ? error.message : "Unknown AI error"; + console.error("Anthropic API error:", errorMessage); + await logUsage('error', MODEL, 0, errorMessage); + const payload: ChatSuccessPayload = { + message: buildFallbackChatMessage(messages), + fallbackUsed: true, + fallbackReason: "AI service was unavailable for conversational refinement.", + }; + return jsonResponse(payload); } - - const anthropicData = await anthropicResponse.json(); - const message = anthropicData.content[0].text; - - await logUsage('success', MODEL, message.length); - - return new Response( - JSON.stringify({ message }), - { status: 200, headers: { ...corsHeaders, "Content-Type": "application/json" } } - ); } // Handle chat-learn action if (action === 'chat-learn') { - const LEARN_SYSTEM_PROMPT = `You are the NoCodeJam Learning Architect. Your goal is to design personalized Learning Pathways. - - A Learning Pathway is a structured curriculum containing: - 1. **Metadata**: Title, Difficulty (Beginner/Intermediate/Advanced), Time Estimate. - 2. **Objectives**: What the learner will achieve. - 3. **Modules**: Logical phases (e.g., "Phase 1: Database Design", "Phase 2: UI Building"). - 4. **Recommended Tools**: Suggest specific No-Code tools (e.g., Supabase, FlutterFlow, Bubble) relevant to the goal. *Tools are always recommended, never mandatory.* - - Your style: - - Be structured and encouraging. - - Ask clarifying questions about experience level and time availability if needed. - - When proposing a pathway, outline the Modules and key Challenges within them. - - Do NOT refer to yourself as the "Challenge Assistant". You are the "Learning Architect".`; - - const anthropicResponse = await fetch("https://api.anthropic.com/v1/messages", { - method: "POST", - headers: { - "Content-Type": "application/json", - "x-api-key": apiKey!, - "anthropic-version": "2023-06-01", - }, - body: JSON.stringify({ - model: MODEL, - max_tokens: 1024, - system: LEARN_SYSTEM_PROMPT, - messages: messages - }) - }); - - if (!anthropicResponse.ok) { - const errorText = await anthropicResponse.text(); - console.error("Anthropic API error (Learn):", errorText); - await logUsage('error', MODEL, 0, errorText); - - return new Response( - JSON.stringify({ message: `I'm having trouble providing recommendations right now. (Error: ${anthropicResponse.status})` }), - { status: 200, headers: { ...corsHeaders, "Content-Type": "application/json" } } - ); + try { + const message = await callAnthropic(apiKey!, LEARN_SYSTEM_PROMPT, messages, 1024); + await logUsage('success', MODEL, message.length); + const payload: ChatSuccessPayload = { message }; + return jsonResponse(payload); + } catch (error) { + const errorMessage = error instanceof Error ? error.message : "Unknown AI error"; + console.error("Anthropic API error (Learn):", errorMessage); + await logUsage('error', MODEL, 0, errorMessage); + const payload: ChatSuccessPayload = { + message: buildFallbackLearningMessage(messages), + fallbackUsed: true, + fallbackReason: "AI service was unavailable for learning recommendations.", + }; + return jsonResponse(payload); } - - const anthropicData = await anthropicResponse.json(); - const message = anthropicData.content[0].text; - - await logUsage('success', MODEL, message.length); - - return new Response( - JSON.stringify({ message }), - { status: 200, headers: { ...corsHeaders, "Content-Type": "application/json" } } - ); } // Handle generate action - create structured challenge from conversation if (action === 'generate') { - const generationMessages = [ - ...messages, - { - role: "user", - content: "Based on our conversation, please generate the complete challenge data in the required JSON format." - } - ]; - - const anthropicResponse = await fetch("https://api.anthropic.com/v1/messages", { - method: "POST", - headers: { - "Content-Type": "application/json", - "x-api-key": apiKey!, - "anthropic-version": "2023-06-01", - }, - body: JSON.stringify({ - model: MODEL, - max_tokens: 4096, - system: GENERATE_SYSTEM_PROMPT, - messages: generationMessages - }) - }); - - if (!anthropicResponse.ok) { - const errorText = await anthropicResponse.text(); - await logUsage('error', MODEL, 0, errorText); - throw new Error(`Anthropic API Error: ${errorText}`); - } - - const anthropicData = await anthropicResponse.json(); - let generatedText = anthropicData.content[0].text; - - await logUsage('success', MODEL, generatedText.length); - - // Extract JSON from potential markdown code blocks - const jsonMatch = generatedText.match(/```json\n([\s\S]*?)\n```/) || generatedText.match(/```\n([\s\S]*?)\n```/); - if (jsonMatch) { - generatedText = jsonMatch[1]; - } - - // Clean up any remaining markdown artifacts - generatedText = generatedText.trim(); - if (generatedText.startsWith('```')) { - generatedText = generatedText.substring(3); - } - if (generatedText.endsWith('```')) { - generatedText = generatedText.substring(0, generatedText.length - 3); - } - - // Parse the JSON - const challenge = JSON.parse(generatedText); - - // --- Validation Logic --- - const validationWarnings: string[] = []; - - // 1. XP Hygiene - if (challenge.xp !== "(calculated by system)") { - challenge.xp = "(calculated by system)"; // Auto-fix - validationWarnings.push("AI generated specific XP. Reset to system-calculated."); - } - - // 2. Tool Language Check - const restrictedTerms = ["must use", "required", "mandatory", "have to use"]; - const textToCheck = ((challenge.recommendedTools || []).join(" ") + " " + (challenge.fullDescription || "")).toLowerCase(); - - for (const term of restrictedTerms) { - if (textToCheck.includes(term)) { - validationWarnings.push(`Content contains restrictive language ('${term}'). Tools should be 'recommended'.`); - } - } - - // 3. Template Structure - const requiredFields = ['title', 'difficulty', 'estimatedTime', 'challengeType']; - for (const field of requiredFields) { - if (!challenge[field]) { - validationWarnings.push(`Missing required field: ${field}`); - } - } - - // Ensure estimatedTime is a number - if (typeof challenge.estimatedTime !== 'number') { - challenge.estimatedTime = parseInt(challenge.estimatedTime) || 60; + try { + const generationMessages: AIMessage[] = [ + ...messages, + { + role: "user", + content: "Based on our conversation, please generate the complete challenge data in the required JSON format." + } + ]; + + const generatedText = await callAnthropic(apiKey!, GENERATE_SYSTEM_PROMPT, generationMessages, 4096); + const challenge = parseGeneratedChallenge(generatedText); + const normalizedResult = normalizeChallenge(challenge); + await logUsage('success', MODEL, generatedText.length); + const payload: GenerateSuccessPayload = { + challenge: normalizedResult.challenge, + validationWarnings: normalizedResult.validationWarnings, + }; + return jsonResponse(payload); + } catch (error) { + const errorMessage = error instanceof Error ? error.message : "Unknown AI error"; + console.error("Anthropic generation error:", errorMessage); + await logUsage('error', MODEL, 0, errorMessage); + + const fallbackResult = buildFallbackChallenge(messages); + const payload: GenerateSuccessPayload = { + challenge: fallbackResult.challenge, + validationWarnings: [ + "Fallback challenge draft was created because the AI service could not return a valid response.", + ...fallbackResult.validationWarnings, + ], + fallbackUsed: true, + fallbackReason: "AI service was unavailable or returned invalid challenge JSON.", + }; + return jsonResponse(payload); } - - return new Response( - JSON.stringify({ challenge, validationWarnings }), - { status: 200, headers: { ...corsHeaders, "Content-Type": "application/json" } } - ); } - // Invalid action - return new Response( - JSON.stringify({ error: "Invalid action. Use 'chat', 'chat-learn', or 'generate'" }), - { status: 400, headers: { ...corsHeaders, "Content-Type": "application/json" } } - ); + return jsonResponse({ error: "Invalid action. Use 'chat', 'chat-learn', or 'generate'" }, 400); } catch (error) { console.error("Error in generate-challenge:", error); - return new Response( - JSON.stringify({ - error: error instanceof Error ? error.message : "Unknown error occurred" - }), - { - status: 500, - headers: { ...corsHeaders, "Content-Type": "application/json" }, - } - ); + return jsonResponse({ + error: error instanceof Error ? error.message : "Unknown error occurred" + }, 500); } }); diff --git a/supabase/functions/generate-challenge/shared.js b/supabase/functions/generate-challenge/shared.js new file mode 100644 index 0000000..cdd75af --- /dev/null +++ b/supabase/functions/generate-challenge/shared.js @@ -0,0 +1,200 @@ +export const RESTRICTED_TERMS = ["must use", "required", "mandatory", "have to use"]; + +export function isValidAction(value) { + return value === "chat" || value === "chat-learn" || value === "generate"; +} + +export function sanitizeMessages(input) { + if (!Array.isArray(input)) return []; + + return input + .map((message) => { + if (!message || typeof message !== "object") return null; + + const role = message.role; + const content = message.content; + + if ((role !== "user" && role !== "assistant") || typeof content !== "string") { + return null; + } + + const trimmedContent = content.trim(); + if (!trimmedContent) return null; + + return { + role, + content: trimmedContent, + }; + }) + .filter((message) => message !== null) + .slice(-20); +} + +export function getPromptLength(messages) { + return JSON.stringify(messages).length; +} + +export function hasUserMessage(messages) { + return Array.isArray(messages) && messages.some((message) => message?.role === "user"); +} + +export function extractAnthropicText(payload) { + if (!payload || typeof payload !== "object") { + throw new Error("Invalid AI response payload"); + } + + const content = payload.content; + if (!Array.isArray(content)) { + throw new Error("AI response content missing"); + } + + const text = content + .filter((block) => block?.type === "text" && typeof block.text === "string") + .map((block) => block.text?.trim() ?? "") + .filter(Boolean) + .join("\n\n"); + + if (!text) { + throw new Error("AI response text missing"); + } + + return text; +} + +export function getLastUserMessage(messages) { + const userMessages = messages.filter((message) => message.role === "user"); + return userMessages[userMessages.length - 1]?.content ?? ""; +} + +export function toTitleCase(value) { + return value + .split(/\s+/) + .filter(Boolean) + .slice(0, 6) + .map((word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase()) + .join(" "); +} + +export function buildFallbackChatMessage(messages) { + const latestPrompt = getLastUserMessage(messages); + + if (!latestPrompt) { + return "I'm having trouble reaching the AI service. For now, tell me three things: what the learner should build, the difficulty level, and the estimated time."; + } + + return `I'm having trouble reaching the AI service right now, but we can keep going. Based on "${latestPrompt}", tell me the learner outcome, target difficulty, and time estimate, and I'll help you shape the challenge manually.`; +} + +export function buildFallbackLearningMessage(messages) { + const latestPrompt = getLastUserMessage(messages); + + if (!latestPrompt) { + return "I'm having trouble reaching the AI service. For now, tell me what you want to learn, your current experience level, and how much time you have."; + } + + return `I'm having trouble reaching the AI service right now, but we can still narrow it down. Based on "${latestPrompt}", reply with your experience level and available time, and we can outline a practical next step manually.`; +} + +export function stripMarkdownCodeFence(value) { + const jsonMatch = value.match(/```json\s*([\s\S]*?)\s*```/) ?? value.match(/```\s*([\s\S]*?)\s*```/); + const raw = jsonMatch ? jsonMatch[1] : value; + return raw.trim().replace(/^```/, "").replace(/```$/, "").trim(); +} + +export function parseGeneratedChallenge(text) { + const cleanedText = stripMarkdownCodeFence(text); + const parsed = JSON.parse(cleanedText); + + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + throw new Error("AI returned an invalid challenge payload"); + } + + return parsed; +} + +export function normalizeChallenge(challenge) { + const validationWarnings = []; + + const normalizedChallenge = { + title: typeof challenge.title === "string" ? challenge.title.trim() : "", + challengeId: typeof challenge.challengeId === "string" ? challenge.challengeId : "", + associatedPathway: typeof challenge.associatedPathway === "string" ? challenge.associatedPathway : "", + associatedModule: typeof challenge.associatedModule === "string" ? challenge.associatedModule : "", + difficulty: typeof challenge.difficulty === "string" ? challenge.difficulty : "", + estimatedTime: typeof challenge.estimatedTime === "number" + ? challenge.estimatedTime + : parseInt(String(challenge.estimatedTime ?? ""), 10) || 60, + challengeType: typeof challenge.challengeType === "string" ? challenge.challengeType : "", + recommendedTools: Array.isArray(challenge.recommendedTools) + ? challenge.recommendedTools.filter((tool) => typeof tool === "string" && tool.trim().length > 0) + : [], + xp: typeof challenge.xp === "string" ? challenge.xp : "(calculated by system)", + coverImageDescription: typeof challenge.coverImageDescription === "string" ? challenge.coverImageDescription : "", + versionNumber: typeof challenge.versionNumber === "string" ? challenge.versionNumber : "1.0", + fullDescription: typeof challenge.fullDescription === "string" ? challenge.fullDescription : "", + requirements: Array.isArray(challenge.requirements) + ? challenge.requirements.filter((item) => typeof item === "string" && item.trim().length > 0) + : [], + }; + + if (normalizedChallenge.xp !== "(calculated by system)") { + normalizedChallenge.xp = "(calculated by system)"; + validationWarnings.push("AI generated specific XP. Reset to system-calculated."); + } + + const textToCheck = `${normalizedChallenge.recommendedTools.join(" ")} ${normalizedChallenge.fullDescription}`.toLowerCase(); + for (const term of RESTRICTED_TERMS) { + if (textToCheck.includes(term)) { + validationWarnings.push(`Content contains restrictive language ('${term}'). Tools should be 'recommended'.`); + } + } + + const requiredFields = ["title", "difficulty", "estimatedTime", "challengeType"]; + for (const field of requiredFields) { + if (!normalizedChallenge[field]) { + validationWarnings.push(`Missing required field: ${field}`); + } + } + + return { challenge: normalizedChallenge, validationWarnings }; +} + +export function buildFallbackChallenge(messages) { + const latestPrompt = getLastUserMessage(messages); + const titleSeed = latestPrompt || "Custom NoCode Challenge"; + const title = toTitleCase(titleSeed) || "Custom NoCode Challenge"; + const safeTitle = title.endsWith("Challenge") ? title : `${title} Challenge`; + + return normalizeChallenge({ + title: safeTitle, + difficulty: "Beginner", + estimatedTime: 60, + challengeType: "Build", + recommendedTools: ["Supabase", "Vite"], + xp: "(calculated by system)", + coverImageDescription: "A clean product mockup showing a no-code workflow in progress.", + versionNumber: "1.0", + fullDescription: `# ${safeTitle} + +**Difficulty:** Beginner +**Time Estimate:** 60 minutes +**XP:** (calculated by system) + +## Challenge Description +Create a first draft of this challenge based on the request below, then refine it manually before submission. + +### User Request +${latestPrompt || "No detailed request was provided."} + +## Suggested Approach +- Define the main user outcome +- Build a small working prototype +- Review the result and document what was learned +`, + requirements: [ + "Create a working first version of the requested challenge", + "Document the expected learner outcome", + "Review the generated draft before submitting", + ], + }); +} diff --git a/tests/ai-assist-sample-inputs.test.mjs b/tests/ai-assist-sample-inputs.test.mjs new file mode 100644 index 0000000..2c576ca --- /dev/null +++ b/tests/ai-assist-sample-inputs.test.mjs @@ -0,0 +1,150 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { + buildFallbackChallenge, + buildFallbackChatMessage, + buildFallbackLearningMessage, + extractAnthropicText, + hasUserMessage, + isValidAction, + normalizeChallenge, + parseGeneratedChallenge, + sanitizeMessages, +} from "../supabase/functions/generate-challenge/shared.js"; + +test("accepts only supported AI Assist actions", () => { + assert.equal(isValidAction("chat"), true); + assert.equal(isValidAction("chat-learn"), true); + assert.equal(isValidAction("generate"), true); + assert.equal(isValidAction("unknown"), false); +}); + +test("sanitizes sample conversation input before sending to AI", () => { + const rawMessages = [ + { role: "assistant", content: " Welcome to AI Assist " }, + { role: "user", content: " " }, + { role: "system", content: "should be ignored" }, + null, + { role: "user", content: "Build a beginner Airtable CRM challenge" }, + ]; + + const sanitized = sanitizeMessages(rawMessages); + + assert.deepEqual(sanitized, [ + { role: "assistant", content: "Welcome to AI Assist" }, + { role: "user", content: "Build a beginner Airtable CRM challenge" }, + ]); +}); + +test("keeps only the latest 20 valid messages", () => { + const rawMessages = Array.from({ length: 25 }, (_, index) => ({ + role: index % 2 === 0 ? "user" : "assistant", + content: `message-${index}`, + })); + + const sanitized = sanitizeMessages(rawMessages); + + assert.equal(sanitized.length, 20); + assert.equal(sanitized[0].content, "message-5"); + assert.equal(sanitized.at(-1)?.content, "message-24"); +}); + +test("requires at least one user message for actionable AI assist flows", () => { + assert.equal( + hasUserMessage([ + { role: "assistant", content: "Tell me what you want to build." }, + { role: "assistant", content: "I can help with challenge scope." }, + ]), + false + ); + + assert.equal( + hasUserMessage([ + { role: "assistant", content: "Tell me what you want to build." }, + { role: "user", content: "A feedback portal for student teams" }, + ]), + true + ); +}); + +test("extracts text-only content from an Anthropic-style payload", () => { + const message = extractAnthropicText({ + content: [ + { type: "thinking", text: "hidden" }, + { type: "text", text: "First reply" }, + { type: "text", text: "Second reply" }, + ], + }); + + assert.equal(message, "First reply\n\nSecond reply"); +}); + +test("builds a challenge-chat fallback prompt from the latest sample input", () => { + const fallback = buildFallbackChatMessage([ + { role: "assistant", content: "What do you want to build?" }, + { role: "user", content: "A habit tracker with Supabase auth" }, + ]); + + assert.match(fallback, /habit tracker with Supabase auth/i); + assert.match(fallback, /difficulty/i); + assert.match(fallback, /time estimate/i); +}); + +test("builds a learning-chat fallback prompt from the latest sample input", () => { + const fallback = buildFallbackLearningMessage([ + { role: "assistant", content: "What do you want to learn?" }, + { role: "user", content: "I want to learn Lovable for landing pages" }, + ]); + + assert.match(fallback, /Lovable for landing pages/i); + assert.match(fallback, /experience level/i); + assert.match(fallback, /available time/i); +}); + +test("parses and normalizes generated challenge JSON from a fenced AI response", () => { + const generated = parseGeneratedChallenge(`\`\`\`json +{ + "title": " Airtable CRM Sprint Challenge ", + "difficulty": "Beginner", + "estimatedTime": "90", + "challengeType": "Build", + "recommendedTools": ["Airtable", "", "Softr"], + "xp": "150", + "fullDescription": "Learners must use Airtable to build a CRM.", + "requirements": ["Create contacts", "", "Track follow-ups"] +} +\`\`\``); + + const normalized = normalizeChallenge(generated); + + assert.equal(normalized.challenge.title, "Airtable CRM Sprint Challenge"); + assert.equal(normalized.challenge.estimatedTime, 90); + assert.deepEqual(normalized.challenge.recommendedTools, ["Airtable", "Softr"]); + assert.deepEqual(normalized.challenge.requirements, ["Create contacts", "Track follow-ups"]); + assert.equal(normalized.challenge.xp, "(calculated by system)"); + assert.ok( + normalized.validationWarnings.some((warning) => + warning.includes("AI generated specific XP. Reset to system-calculated.") + ) + ); + assert.ok( + normalized.validationWarnings.some((warning) => + warning.includes("restrictive language") + ) + ); +}); + +test("returns a usable fallback challenge draft for generate failures", () => { + const fallback = buildFallbackChallenge([ + { role: "assistant", content: "Tell me your idea." }, + { role: "user", content: "Build a team expense approval workflow" }, + ]); + + assert.match(fallback.challenge.title, /Team Expense Approval Workflow/i); + assert.equal(fallback.challenge.difficulty, "Beginner"); + assert.equal(fallback.challenge.challengeType, "Build"); + assert.equal(fallback.challenge.estimatedTime, 60); + assert.ok(fallback.challenge.fullDescription.includes("Build a team expense approval workflow")); + assert.equal(fallback.validationWarnings.length, 0); +});