You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
// ====================================// Auth Routes// ====================================constexpress=require('express');constrouter=express.Router();constauthController=require('../controllers/authController');const{ authenticate }=require('../middleware/auth');// POST /api/auth/registerrouter.post('/register',authController.register);// POST /api/auth/loginrouter.post('/login',authController.login);// POST /api/auth/login-phonerouter.post('/login-phone',authController.loginWithPhone);// POST /api/auth/verify-otprouter.post('/verify-otp',authController.verifyOTP);// GET /api/auth/merouter.get('/me',authenticate,authController.getMe);// POST /api/auth/logoutrouter.post('/logout',authenticate,authController.logout);module.exports=router;
server/src/controllers/authController.js
// ====================================// Auth Controller// ====================================const{ supabase, supabaseAdmin }=require('../config/database');constauthController={/** * Register a new user with email/password */register: async(req,res)=>{try{const{ email, password, full_name, phone, ward_id }=req.body;// Validate inputif(!email||!password||!full_name){returnres.status(400).json({success: false,message: 'Email, password, and full name are required.'});}// Register with Supabase Authconst{ data, error }=awaitsupabase.auth.signUp({
email,
password,options: {data: {
full_name,
phone
}}});if(error){returnres.status(400).json({success: false,message: error.message});}// Update profile with additional infoif(data.user){awaitsupabaseAdmin.from('profiles').update({
full_name,
phone,
ward_id,interests: req.body.interests||[],college: req.body.college||null,age: req.body.age||null}).eq('id',data.user.id);}res.status(201).json({success: true,message: 'Registration successful! Welcome to CivicStreak 🏙️',data: {user: data.user,session: data.session}});}catch(error){console.error('Register error:',error);res.status(500).json({success: false,message: 'Registration failed. Please try again.'});}},/** * Login with email/password */login: async(req,res)=>{try{const{ email, password }=req.body;const{ data, error }=awaitsupabase.auth.signInWithPassword({
email,
password
});if(error){returnres.status(401).json({success: false,message: 'Invalid credentials.'});}// Fetch full profileconst{data: profile}=awaitsupabaseAdmin.from('profiles').select('*, wards(ward_name, ward_number)').eq('id',data.user.id).single();res.json({success: true,message: 'Login successful!',data: {user: profile,session: data.session}});}catch(error){console.error('Login error:',error);res.status(500).json({success: false,message: 'Login failed.'});}},/** * Login with phone (send OTP) */loginWithPhone: async(req,res)=>{try{const{ phone }=req.body;const{ data, error }=awaitsupabase.auth.signInWithOtp({
phone
});if(error){returnres.status(400).json({success: false,message: error.message});}res.json({success: true,message: 'OTP sent to your phone!'});}catch(error){console.error('Phone login error:',error);res.status(500).json({success: false,message: 'Failed to send OTP.'});}},/** * Verify OTP */verifyOTP: async(req,res)=>{try{const{ phone, token }=req.body;const{ data, error }=awaitsupabase.auth.verifyOtp({
phone,
token,type: 'sms'});if(error){returnres.status(400).json({success: false,message: 'Invalid OTP.'});}res.json({success: true,message: 'Phone verified!',data: {user: data.user,session: data.session}});}catch(error){console.error('OTP verification error:',error);res.status(500).json({success: false,message: 'Verification failed.'});}},/** * Get current user profile */getMe: async(req,res)=>{try{const{data: profile, error }=awaitsupabaseAdmin.from('profiles').select(` *, wards(ward_name, ward_number, city), circle_members( circle_id, role, circles(name, adopted_issue_id) ) `).eq('id',req.userId).single();if(error||!profile){returnres.status(404).json({success: false,message: 'Profile not found.'});}// Get achievementsconst{data: achievements}=awaitsupabaseAdmin.from('user_achievements').select('*, achievements(*)').eq('user_id',req.userId);res.json({success: true,data: {
...profile,achievements: achievements||[]}});}catch(error){console.error('GetMe error:',error);res.status(500).json({success: false,message: 'Failed to fetch profile.'});}},/** * Logout */logout: async(req,res)=>{try{awaitsupabase.auth.signOut();res.json({success: true,message: 'Logged out successfully.'});}catch(error){res.status(500).json({success: false,message: 'Logout failed.'});}}};module.exports=authController;
server/src/routes/tasks.js
// ====================================// Task Routes// ====================================constexpress=require('express');constrouter=express.Router();consttaskController=require('../controllers/taskController');const{ authenticate }=require('../middleware/auth');const{ upload }=require('../config/cloudinary');// GET /api/tasks - Get available tasks for userrouter.get('/',authenticate,taskController.getAvailableTasks);// GET /api/tasks/my - Get user's assigned/completed tasksrouter.get('/my',authenticate,taskController.getMyTasks);// GET /api/tasks/today - Get today's recommended taskrouter.get('/today',authenticate,taskController.getTodaysTask);// GET /api/tasks/:id - Get single task detailsrouter.get('/:id',authenticate,taskController.getTaskById);// POST /api/tasks/:id/accept - Accept a taskrouter.post('/:id/accept',authenticate,taskController.acceptTask);// POST /api/tasks/:id/submit - Submit task completionrouter.post('/:id/submit',authenticate,upload.single('proof'),taskController.submitTask);// GET /api/tasks/categories/list - Get task categoriesrouter.get('/categories/list',authenticate,taskController.getCategories);module.exports=router;
server/src/controllers/taskController.js
// ====================================// Task Controller// ====================================const{ supabaseAdmin }=require('../config/database');const{ updateStreakAndXP }=require('../services/streakService');consttaskController={/** * Get available tasks for the authenticated user * Filters by ward, skill level, interests */getAvailableTasks: async(req,res)=>{try{const{ category, difficulty, interest, time }=req.query;// Get user profile for smart assignmentconst{data: profile}=awaitsupabaseAdmin.from('profiles').select('ward_id, skill_level, interests').eq('id',req.userId).single();letquery=supabaseAdmin.from('micro_tasks').select('*').eq('is_active',true).or(`ward_id.is.null,ward_id.eq.${profile?.ward_id}`);// Apply filtersif(category){query=query.eq('category',category);}if(difficulty){query=query.eq('difficulty',difficulty);}if(time){query=query.lte('estimated_minutes',parseInt(time));}// Order by relevancequery=query.order('created_at',{ascending: false});const{data: tasks, error }=awaitquery.limit(20);if(error)throwerror;// Get user's already assigned/completed tasks to excludeconst{data: userTasks}=awaitsupabaseAdmin.from('user_tasks').select('task_id').eq('user_id',req.userId).in('status',['assigned','in_progress','submitted','verified']);constcompletedTaskIds=newSet((userTasks||[]).map(ut=>ut.task_id));// Filter out already assigned tasksconstavailableTasks=tasks.filter(task=>!completedTaskIds.has(task.id));// Smart sorting: prioritize by user interestsif(profile?.interests?.length>0){availableTasks.sort((a,b)=>{constaMatch=a.interest_tags?.some(t=>profile.interests.includes(t)) ? 1 : 0;constbMatch=b.interest_tags?.some(t=>profile.interests.includes(t)) ? 1 : 0;returnbMatch-aMatch;});}res.json({success: true,data: availableTasks,count: availableTasks.length});}catch(error){console.error('Get tasks error:',error);res.status(500).json({success: false,message: 'Failed to fetch tasks.'});}},/** * Get user's own tasks (assigned, in-progress, completed) */getMyTasks: async(req,res)=>{try{const{ status }=req.query;letquery=supabaseAdmin.from('user_tasks').select(` *, micro_tasks(*) `).eq('user_id',req.userId).order('assigned_at',{ascending: false});if(status){query=query.eq('status',status);}const{data: tasks, error }=awaitquery;if(error)throwerror;res.json({success: true,data: tasks});}catch(error){console.error('Get my tasks error:',error);res.status(500).json({success: false,message: 'Failed to fetch your tasks.'});}},/** * Get today's recommended task (Civic Bite of the day) */getTodaysTask: async(req,res)=>{try{const{data: profile}=awaitsupabaseAdmin.from('profiles').select('ward_id, skill_level, interests').eq('id',req.userId).single();// Get a task the user hasn't done yetconst{data: completedIds}=awaitsupabaseAdmin.from('user_tasks').select('task_id').eq('user_id',req.userId);constexcludeIds=(completedIds||[]).map(t=>t.task_id);letquery=supabaseAdmin.from('micro_tasks').select('*').eq('is_active',true).lte('estimated_minutes',15).limit(1);if(excludeIds.length>0){query=query.not('id','in',`(${excludeIds.join(',')})`);}if(profile?.ward_id){query=query.or(`ward_id.is.null,ward_id.eq.${profile.ward_id}`);}const{data: tasks}=awaitquery;res.json({success: true,data: tasks?.[0]||null,message: tasks?.[0]
? "Here's your Civic Bite for today! 🎯"
: "You've completed all available tasks! Check back tomorrow 🌟"});}catch(error){console.error('Get today task error:',error);res.status(500).json({success: false,message: 'Failed to fetch today\'s task.'});}},/** * Get single task by ID */getTaskById: async(req,res)=>{try{const{ id }=req.params;const{data: task, error }=awaitsupabaseAdmin.from('micro_tasks').select('*').eq('id',id).single();if(error||!task){returnres.status(404).json({success: false,message: 'Task not found.'});}// Check if user has already accepted this taskconst{data: userTask}=awaitsupabaseAdmin.from('user_tasks').select('*').eq('user_id',req.userId).eq('task_id',id).order('assigned_at',{ascending: false}).limit(1).single();res.json({success: true,data: {
...task,user_status: userTask||null}});}catch(error){console.error('Get task error:',error);res.status(500).json({success: false,message: 'Failed to fetch task.'});}},/** * Accept/Start a task */acceptTask: async(req,res)=>{try{const{ id }=req.params;// Verify task exists and is activeconst{data: task}=awaitsupabaseAdmin.from('micro_tasks').select('*').eq('id',id).eq('is_active',true).single();if(!task){returnres.status(404).json({success: false,message: 'Task not found or no longer available.'});}// Check if already acceptedconst{data: existing}=awaitsupabaseAdmin.from('user_tasks').select('id, status').eq('user_id',req.userId).eq('task_id',id).in('status',['assigned','in_progress']).single();if(existing){returnres.status(400).json({success: false,message: 'You have already accepted this task.'});}// Create assignmentconst{data: assignment, error }=awaitsupabaseAdmin.from('user_tasks').insert({user_id: req.userId,task_id: id,status: 'in_progress',started_at: newDate().toISOString(),expires_at: newDate(Date.now()+7*24*60*60*1000).toISOString()}).select().single();if(error)throwerror;res.status(201).json({success: true,message: `Task accepted! You have 7 days to complete it. 💪`,data: assignment});}catch(error){console.error('Accept task error:',error);res.status(500).json({success: false,message: 'Failed to accept task.'});}},/** * Submit task completion with proof */submitTask: async(req,res)=>{try{const{ id }=req.params;// task_idconst{ proof_text, quiz_score }=req.body;constproof_url=req.file ? req.file.path : null;// Find the user's assignmentconst{data: userTask}=awaitsupabaseAdmin.from('user_tasks').select('*, micro_tasks(*)').eq('user_id',req.userId).eq('task_id',id).in('status',['assigned','in_progress']).order('assigned_at',{ascending: false}).limit(1).single();if(!userTask){returnres.status(404).json({success: false,message: 'No active assignment found for this task.'});}// Validate proof based on task requirementconsttask=userTask.micro_tasks;if(task.required_proof==='photo'&&!proof_url){returnres.status(400).json({success: false,message: 'This task requires a photo as proof.'});}if(task.required_proof==='text'&&!proof_text){returnres.status(400).json({success: false,message: 'This task requires a text submission.'});}// Calculate XP earnedconstxpEarned=task.xp_reward||20;// Update the assignmentconst{data: updated, error }=awaitsupabaseAdmin.from('user_tasks').update({status: 'submitted',
proof_url,
proof_text,
quiz_score,submitted_at: newDate().toISOString(),xp_earned: xpEarned}).eq('id',userTask.id).select().single();if(error)throwerror;// Update user stats (XP, streak, task count)awaitupdateStreakAndXP(req.userId,xpEarned);// Update task completion countawaitsupabaseAdmin.from('micro_tasks').update({times_completed: task.times_completed+1}).eq('id',id);// Check for new achievementsconstnewAchievements=awaitcheckAchievements(req.userId);res.json({success: true,message: `Task completed! +${xpEarned} XP earned 🎉`,data: {submission: updated,xp_earned: xpEarned,new_achievements: newAchievements}});}catch(error){console.error('Submit task error:',error);res.status(500).json({success: false,message: 'Failed to submit task.'});}},/** * Get task categories with counts */getCategories: async(req,res)=>{try{constcategories=[{key: 'DOCUMENT',label: '📸 Document',description: 'Photograph civic issues, infrastructure, progress'},{key: 'LEARN',label: '📝 Learn',description: 'Micro-lessons on governance, RTI, budgets'},{key: 'VOICE',label: '🌊 Voice',description: 'Sign petitions, submit feedback, attend hearings'},{key: 'CONNECT',label: '🤝 Connect',description: 'Talk to a neighbor, interview a local vendor'},{key: 'TRACK',label: '📊 Track',description: 'Follow up on a previously reported issue'},{key: 'MENTOR',label: '🎓 Mentor',description: 'Help a junior volunteer complete their first task'}];// Get counts for each categoryfor(letcatofcategories){const{ count }=awaitsupabaseAdmin.from('micro_tasks').select('id',{count: 'exact',head: true}).eq('category',cat.key).eq('is_active',true);cat.count=count||0;}res.json({success: true,data: categories});}catch(error){console.error('Get categories error:',error);res.status(500).json({success: false,message: 'Failed to fetch categories.'});}}};/** * Check and award achievements */asyncfunctioncheckAchievements(userId){constnewAchievements=[];try{// Get user profileconst{data: profile}=awaitsupabaseAdmin.from('profiles').select('*').eq('id',userId).single();// Get all achievementsconst{data: allAchievements}=awaitsupabaseAdmin.from('achievements').select('*');// Get user's existing achievementsconst{data: userAchievements}=awaitsupabaseAdmin.from('user_achievements').select('achievement_id').eq('user_id',userId);constearnedIds=newSet((userAchievements||[]).map(ua=>ua.achievement_id));for(constachievementofallAchievements||[]){if(earnedIds.has(achievement.id))continue;letearned=false;switch(achievement.requirement_type){case'tasks_completed':
earned=profile.tasks_completed>=achievement.requirement_value;break;case'streak_days':
earned=profile.current_streak>=achievement.requirement_value;break;case'xp_earned':
earned=profile.xp_points>=achievement.requirement_value;break;case'issues_reported':
earned=profile.issues_reported>=achievement.requirement_value;break;case'issues_resolved':
earned=profile.issues_resolved>=achievement.requirement_value;break;case'rtis_filed':
earned=profile.rtis_filed>=achievement.requirement_value;break;case'meetings_attended':
earned=profile.meetings_attended>=achievement.requirement_value;break;case'people_mentored':
earned=profile.people_mentored>=achievement.requirement_value;break;}if(earned){awaitsupabaseAdmin.from('user_achievements').insert({user_id: userId,achievement_id: achievement.id});// Award bonus XPif(achievement.xp_bonus>0){awaitsupabaseAdmin.from('profiles').update({xp_points: profile.xp_points+achievement.xp_bonus}).eq('id',userId);}newAchievements.push(achievement);}}}catch(error){console.error('Check achievements error:',error);}returnnewAchievements;}module.exports=taskController;
3.5 Services
server/src/services/streakService.js
// ====================================// Streak & XP Service// ====================================const{ supabaseAdmin }=require('../config/database');/** * Update user's streak and XP after task completion */constupdateStreakAndXP=async(userId,xpEarned)=>{try{// Get current profileconst{data: profile}=awaitsupabaseAdmin.from('profiles').select('*').eq('id',userId).single();if(!profile)thrownewError('Profile not found');consttoday=newDate().toISOString().split('T')[0];constlastActivity=profile.last_activity_date;letnewStreak=profile.current_streak;letstreakBonus=0;if(lastActivity===today){// Already active today — no streak change, just XP}elseif(lastActivity===getYesterday()){// Consecutive day — increment streaknewStreak+=1;streakBonus=5;// Daily streak bonus}else{// Streak broken — reset to 1newStreak=1;}constlongestStreak=Math.max(newStreak,profile.longest_streak);consttotalXP=xpEarned+streakBonus;// Determine level based on streakconstlevel=calculateLevel(newStreak);// Update profileawaitsupabaseAdmin.from('profiles').update({xp_points: profile.xp_points+totalXP,current_streak: newStreak,longest_streak: longestStreak,last_activity_date: today,tasks_completed: profile.tasks_completed+1,level: level,updated_at: newDate().toISOString()}).eq('id',userId);// Record in streak historyawaitsupabaseAdmin.from('streak_history').upsert({user_id: userId,activity_date: today,tasks_completed: 1,xp_earned: totalXP},{onConflict: 'user_id,activity_date'});return{
newStreak,
totalXP,
streakBonus,
level
};}catch(error){console.error('Update streak error:',error);throwerror;}};/** * Calculate user level based on streak days */functioncalculateLevel(streakDays){if(streakDays>=365)return'CivicStreak Fellow';if(streakDays>=180)return'Civic Champion';if(streakDays>=90)return'Ward Warrior';if(streakDays>=30)return'Active Citizen';if(streakDays>=7)return'Curious Citizen';return'Newcomer';}/** * Get yesterday's date in YYYY-MM-DD format */functiongetYesterday(){constd=newDate();d.setDate(d.getDate()-1);returnd.toISOString().split('T')[0];}/** * Reset streaks for users who missed a day (run via cron) */constresetBrokenStreaks=async()=>{try{constyesterday=getYesterday();const{data: staleUsers}=awaitsupabaseAdmin.from('profiles').select('id').lt('last_activity_date',yesterday).gt('current_streak',0);if(staleUsers&&staleUsers.length>0){for(constuserofstaleUsers){awaitsupabaseAdmin.from('profiles').update({current_streak: 0,updated_at: newDate().toISOString()}).eq('id',user.id);}console.log(`Reset streaks for ${staleUsers.length} users`);}}catch(error){console.error('Reset streaks error:',error);}};module.exports={ updateStreakAndXP, resetBrokenStreaks, calculateLevel };
server/src/services/aiService.js
// ====================================// AI Service — Google Gemini Integration// ====================================const{ GoogleGenerativeAI }=require('@google/generative-ai');constgenAI=newGoogleGenerativeAI(process.env.GEMINI_API_KEY);constaiService={/** * Generate personalized task suggestions based on user profile */suggestTasks: async(userProfile,wardInfo)=>{try{constmodel=genAI.getGenerativeModel({model: 'gemini-pro'});constprompt=`You are CivicStreak AI, a civic engagement assistant for Indian youth.User Profile:- Name: ${userProfile.full_name}- Ward: ${wardInfo?.ward_name||'Unknown'} (${wardInfo?.ward_number||''})- Interests: ${userProfile.interests?.join(', ')||'General'}- Skill Level: ${userProfile.skill_level}- Tasks Completed: ${userProfile.tasks_completed}- Current Streak: ${userProfile.current_streak} daysSuggest 3 personalized micro-tasks (5-15 minutes each) that this user can do TODAY in their ward. Each task should be specific, actionable, and location-aware.Format each task as JSON:{ "title": "Short engaging title with emoji", "description": "One line description", "category": "DOCUMENT|LEARN|VOICE|CONNECT|TRACK|MENTOR", "estimated_minutes": 5-15, "xp_reward": 10-50, "instructions": "Step by step what to do"}Return only a JSON array of 3 tasks. No other text. `;constresult=awaitmodel.generateContent(prompt);constresponse=awaitresult.response;consttext=response.text();// Parse JSON from responseconstjsonMatch=text.match(/\[[\s\S]*\]/);if(jsonMatch){returnJSON.parse(jsonMatch[0]);}return[];}catch(error){console.error('AI suggest tasks error:',error);return[];}},/** * Generate a summary for user's civic portfolio */generatePortfolioSummary: async(userStats)=>{try{constmodel=genAI.getGenerativeModel({model: 'gemini-pro'});constprompt=`Generate a brief, impressive 3-line civic portfolio summary for a student:- Tasks completed: ${userStats.tasks_completed}- Issues reported: ${userStats.issues_reported}- Issues resolved: ${userStats.issues_resolved}- RTIs filed: ${userStats.rtis_filed}- Meetings attended: ${userStats.meetings_attended}- People mentored: ${userStats.people_mentored}- Streak: ${userStats.current_streak} days- Level: ${userStats.level}Write it in first person, suitable for a college application or LinkedIn. Keep it under 100 words. `;constresult=awaitmodel.generateContent(prompt);constresponse=awaitresult.response;returnresponse.text().trim();}catch(error){console.error('AI portfolio summary error:',error);returnnull;}},/** * Analyze civic issue and suggest action plan */analyzeIssue: async(issueDescription,category)=>{try{constmodel=genAI.getGenerativeModel({model: 'gemini-pro'});constprompt=`As a civic governance expert in India, analyze this civic issue and suggest an action plan:Issue: ${issueDescription}Category: ${category}Provide:1. Which government department is responsible2. What RTI can be filed (one-liner)3. Who to escalate to if not resolved in 30 days4. A template complaint letter (2-3 lines)Keep response under 200 words. Be specific to Indian municipal governance (BMC/municipal corporation context). `;constresult=awaitmodel.generateContent(prompt);constresponse=awaitresult.response;returnresponse.text().trim();}catch(error){console.error('AI analyze issue error:',error);returnnull;}},/** * Generate quiz questions about a ward */generateWardQuiz: async(wardInfo)=>{try{constmodel=genAI.getGenerativeModel({model: 'gemini-pro'});constprompt=`Generate 5 multiple-choice quiz questions about civic governance for Indian youth, specifically for ward "${wardInfo.ward_name}" in ${wardInfo.city||'Mumbai'}.Topics: local governance structure, BMC functions, RTI basics, civic rights, ward committees.Format as JSON array:[ { "question": "Question text", "options": ["A", "B", "C", "D"], "correct": 0, "explanation": "Brief explanation" }]Return only the JSON array. `;constresult=awaitmodel.generateContent(prompt);constresponse=awaitresult.response;consttext=response.text();constjsonMatch=text.match(/\[[\s\S]*\]/);if(jsonMatch){returnJSON.parse(jsonMatch[0]);}return[];}catch(error){console.error('AI quiz generation error:',error);return[];}}};module.exports=aiService;
server/src/services/whatsappService.js
// ====================================// WhatsApp Bot Service// ====================================const{ sendWhatsAppMessage }=require('../config/twilio');const{ supabaseAdmin }=require('../config/database');constaiService=require('./aiService');constwhatsappService={/** * Handle incoming WhatsApp message */handleIncoming: async(from,body)=>{constphoneNumber=from.replace('whatsapp:','');constmessage=body.trim().toLowerCase();// Find user by phoneconst{data: user}=awaitsupabaseAdmin.from('profiles').select('*, wards(ward_name, ward_number)').eq('phone',phoneNumber).single();// New user — not registeredif(!user){returnwhatsappService.sendWelcomeMessage(phoneNumber);}// Route based on message contentswitch(message){case'hi':
case'hello':
case'hey':
case'start':
returnwhatsappService.sendDailyBrief(user);case'1':
case'task':
returnwhatsappService.sendTodaysTask(user);case'2':
case'portfolio':
returnwhatsappService.sendPortfolioSummary(user);case'3':
case'circle':
returnwhatsappService.sendCircleUpdate(user);case'4':
case'learn':
returnwhatsappService.sendLearningContent(user);case'5':
case'report':
returnwhatsappService.startIssueReport(user);case'streak':
returnwhatsappService.sendStreakInfo(user);case'help':
returnwhatsappService.sendHelpMessage(user);default:
// Check if it's a task submission (photo will come separately)if(message.startsWith('done')||message.startsWith('completed')){returnwhatsappService.handleTaskSubmission(user,body);}returnwhatsappService.sendDefaultResponse(user);}},/** * Welcome message for new users */sendWelcomeMessage: async(phoneNumber)=>{constmessage=`🏙️ *Welcome to CivicStreak!*CivicStreak turns democracy from a once-in-5-years vote into a daily 5-minute habit.To get started, please register on our web app:🌐 https://CivicStreak.vercel.app/registerOnce registered with this phone number, you'll get daily Civic Bites right here on WhatsApp!_Your City, Your Commitment_ 🇮🇳`;awaitsendWhatsAppMessage(phoneNumber,message);},/** * Daily brief — main menu */sendDailyBrief: async(user)=>{constward=user.wards;constmessage=`🙏 *Welcome to CivicStreak!*📍 Your ward: ${ward?.ward_name||'Not set'} (Ward ${ward?.ward_number||'—'})*Today's Civic Bite* 🍽️:📸 "There's a reported broken footpath near your ward. If you pass by today, take a quick photo and send it here. (5 min task)"🔥 Your streak: *${user.current_streak} days*⭐ Your XP: *${user.xp_points}*🏅 Level: *${user.level}**Reply with:*1️⃣ — Do today's task2️⃣ — See my portfolio3️⃣ — Circle check-in4️⃣ — Learn something new5️⃣ — Report a new issueType *help* for more options.`;awaitsendWhatsAppMessage(user.phone,message);},/** * Send today's task details */sendTodaysTask: async(user)=>{// Get a task for the userconst{data: tasks}=awaitsupabaseAdmin.from('micro_tasks').select('*').eq('is_active',true).or(`ward_id.is.null,ward_id.eq.${user.ward_id}`).limit(1);consttask=tasks?.[0];if(!task){awaitsendWhatsAppMessage(user.phone,"🌟 Amazing! You've completed all available tasks. Check back tomorrow for new Civic Bites!");return;}constmessage=`🎯 *Today's Task:**${task.title}*📝 ${task.description}⏱️ Time: ~${task.estimated_minutes} minutes⭐ Reward: ${task.xp_reward} XP📋 Category: ${task.category}*Instructions:*${task.instructions||'Follow the task description above.'}*To submit:*${task.required_proof==='photo' ? '📸 Send a photo as proof' : '✍️ Reply with "done" and your response'}_Reply *skip* to get a different task._`;awaitsendWhatsAppMessage(user.phone,message);// Auto-assign the taskawaitsupabaseAdmin.from('user_tasks').insert({user_id: user.id,task_id: task.id,status: 'in_progress',started_at: newDate().toISOString()});},/** * Send portfolio summary */sendPortfolioSummary: async(user)=>{constmessage=`📋 *Your Civic Portfolio*━━━━━━━━━━━━━━━━━━👤 *${user.full_name}*📍 ${user.wards?.ward_name||'Ward not set'}🔥 Streak: ${user.current_streak} days⭐ XP: ${user.xp_points}🏅 Level: ${user.level}📊 *Impact Stats:*• ${user.tasks_completed} tasks completed• ${user.issues_reported} issues documented• ${user.issues_resolved} issues resolved• ${user.rtis_filed} RTIs filed• ${user.meetings_attended} ward meetings attended• ${user.people_mentored} juniors mentored🌐 View full portfolio:https://CivicStreak.vercel.app/portfolio/${user.id}_Share this with colleges & employers!_ 🎓`;awaitsendWhatsAppMessage(user.phone,message);},/** * Send circle update */sendCircleUpdate: async(user)=>{const{data: membership}=awaitsupabaseAdmin.from('circle_members').select('*, circles(*, wards(ward_name))').eq('user_id',user.id).eq('is_active',true).single();if(!membership){awaitsendWhatsAppMessage(user.phone,`🤝 You haven't joined a Circle yet!\n\nCircles are teams of 5-8 youth from your ward who adopt a civic issue together.\n\n🌐 Join one at: https://CivicStreak.vercel.app/circles`);return;}constcircle=membership.circles;constmessage=`🤝 *Circle Update: ${circle.name}*📍 Ward: ${circle.wards?.ward_name}👥 Members: ${circle.member_count}🎯 Adopted Issue: ${circle.adopted_issue_id ? 'Active' : 'None yet'}⭐ Circle XP: ${circle.total_xp}Your Role: *${membership.role}*Next check-in: ${circle.next_checkin ? newDate(circle.next_checkin).toLocaleDateString() : 'Not scheduled'}🌐 Circle dashboard:https://CivicStreak.vercel.app/circles/${circle.id}`;awaitsendWhatsAppMessage(user.phone,message);},/** * Send learning content */sendLearningContent: async(user)=>{constlessons=[{title: "📜 RTI 101: Your Right to Know",content: `*What is RTI?*\nThe Right to Information Act (2005) allows any Indian citizen to request information from government bodies.\n\n*Key Facts:*\n• Fee: ₹10 per application\n• Response time: 30 days\n• You can ask about budgets, spending, decisions\n• File online at: rtionline.gov.in\n\n_Tomorrow: How to write an RTI application_`},{title: "🏛️ How Your Ward Works",content: `*Ward Committee Structure:*\n• Your ward is the smallest unit of city governance\n• Ward councilor is elected every 5 years\n• Ward committee meets monthly (open to public!)\n• Budget allocated per ward for roads, water, sanitation\n\n_Attend your next ward meeting to earn 150 XP!_`},{title: "📊 Reading the BMC Budget",content: `*Understanding Ward Budgets:*\n• Every ward gets a development fund\n• Budget boards must be displayed at ward offices\n• Key categories: Roads, Water, Sanitation, Electricity\n• You can RTI the spending details!\n\n_Task: Photograph your ward's budget board for 25 XP_`}];// Pick one based on dayconstdayIndex=newDate().getDate()%lessons.length;constlesson=lessons[dayIndex];constmessage=`📚 *Today's Civic Lesson:*\n\n${lesson.title}\n\n${lesson.content}`;awaitsendWhatsAppMessage(user.phone,message);},/** * Handle task submission via WhatsApp */handleTaskSubmission: async(user,messageBody)=>{// Find the user's most recent in-progress taskconst{data: activeTask}=awaitsupabaseAdmin.from('user_tasks').select('*, micro_tasks(*)').eq('user_id',user.id).eq('status','in_progress').order('started_at',{ascending: false}).limit(1).single();if(!activeTask){awaitsendWhatsAppMessage(user.phone,"❓ No active task found. Reply *1* to get today's task!");return;}// Mark as submittedconstxpEarned=activeTask.micro_tasks.xp_reward;awaitsupabaseAdmin.from('user_tasks').update({status: 'submitted',proof_text: messageBody,submitted_at: newDate().toISOString(),xp_earned: xpEarned}).eq('id',activeTask.id);// Update streak and XPconst{ updateStreakAndXP }=require('./streakService');conststreakInfo=awaitupdateStreakAndXP(user.id,xpEarned);constmessage=`✅ *Task Completed!*🎉 "${activeTask.micro_tasks.title}"⭐ +${xpEarned} XP earned🔥 Streak: ${streakInfo.newStreak} days${streakInfo.streakBonus>0 ? `🎁 Streak bonus: +${streakInfo.streakBonus} XP` : ''}🏅 Level: ${streakInfo.level}Keep going! Reply *1* for your next task._Every small action builds a better city._ 🏙️`;awaitsendWhatsAppMessage(user.phone,message);},/** * Start issue report flow */startIssueReport: async(user)=>{constmessage=`📢 *Report a Civic Issue*To report an issue, please visit our web app:🌐 https://CivicStreak.vercel.app/issues/newOr reply with a description of the issue and we'll log it for you:Example: "Broken streetlight on Link Road near Andheri station"📸 You can also send a photo of the issue.`;awaitsendWhatsAppMessage(user.phone,message);},/** * Send streak info */sendStreakInfo: async(user)=>{conststreakEmoji=user.current_streak>=7 ? '🔥' : '💪';constmilestones=[{days: 7,name: 'Curious Citizen',icon: '🌱'},{days: 30,name: 'Active Citizen',icon: '🌿'},{days: 90,name: 'Ward Warrior',icon: '⚔️'},{days: 180,name: 'Civic Champion',icon: '🏆'},{days: 365,name: 'CivicStreak Fellow',icon: '🎖️'}];constnextMilestone=milestones.find(m=>m.days>user.current_streak);constdaysToNext=nextMilestone
? nextMilestone.days-user.current_streak
: 0;constmessage=`${streakEmoji} *Your Streak Status*Current Streak: *${user.current_streak} days*Longest Streak: *${user.longest_streak} days*Level: *${user.level}*${nextMilestone ? `🎯 Next milestone: *${nextMilestone.name}* ${nextMilestone.icon}\n ${daysToNext} days to go!` : '🎖️ You\'ve reached the highest level! CivicStreak Fellow!'}*Milestones:*${milestones.map(m=>`${user.current_streak>=m.days ? '✅' : '⬜'} Day ${m.days}: ${m.name}${m.icon}`).join('\n')}_Complete one task daily to maintain your streak!_`;awaitsendWhatsAppMessage(user.phone,message);},/** * Help message */sendHelpMessage: async(user)=>{constmessage=`ℹ️ *CivicStreak Help**Commands:*• *hi/hello* — Daily brief & menu• *1 or task* — Get today's task• *2 or portfolio* — View your civic portfolio• *3 or circle* — Circle updates• *4 or learn* — Daily civic lesson• *5 or report* — Report a civic issue• *streak* — View streak status• *done [response]* — Submit task completion🌐 *Web Dashboard:*https://CivicStreak.vercel.app📧 *Support:*CivicStreak.help@gmail.com_CivicStreak — Your City, Your Commitment_ 🏙️`;awaitsendWhatsAppMessage(user.phone,message);},/** * Default response for unrecognized messages */sendDefaultResponse: async(user)=>{awaitsendWhatsAppMessage(user.phone,`🤔 I didn't understand that. Reply *help* to see available commands, or *hi* to start!`);}};module.exports=whatsappService;
3.6 Remaining Routes & Controllers
server/src/routes/issues.js
// ====================================// Issue Routes// ====================================constexpress=require('express');constrouter=express.Router();constissueController=require('../controllers/issueController');const{ authenticate, optionalAuth }=require('../middleware/auth');const{ upload }=require('../config/cloudinary');// GET /api/issues - Get all issues (filterable by ward)router.get('/',optionalAuth,issueController.getIssues);// GET /api/issues/:id - Get single issue with timelinerouter.get('/:id',optionalAuth,issueController.getIssueById);// POST /api/issues - Report a new issuerouter.post('/',authenticate,upload.array('photos',5),issueController.createIssue);// POST /api/issues/:id/update - Add update to issue timelinerouter.post('/:id/update',authenticate,upload.single('photo'),issueController.addUpdate);// POST /api/issues/:id/upvote - Upvote an issuerouter.post('/:id/upvote',authenticate,issueController.upvoteIssue);// GET /api/issues/:id/ai-analysis - Get AI analysis of issuerouter.get('/:id/ai-analysis',authenticate,issueController.getAIAnalysis);module.exports=router;
server/src/controllers/issueController.js
// ====================================// Issue Controller// ====================================const{ supabaseAdmin }=require('../config/database');constaiService=require('../services/aiService');constissueController={/** * Get all issues with filtering */getIssues: async(req,res)=>{try{const{ ward_id, status, category, sort }=req.query;letquery=supabaseAdmin.from('civic_issues').select(` *, profiles!reported_by(full_name, avatar_url), wards(ward_name, ward_number) `);if(ward_id)query=query.eq('ward_id',ward_id);if(status)query=query.eq('status',status);if(category)query=query.eq('category',category);// Sortingswitch(sort){case'oldest':
query=query.order('created_at',{ascending: true});break;case'upvotes':
query=query.order('upvotes',{ascending: false});break;default:
query=query.order('created_at',{ascending: false});}const{data: issues, error }=awaitquery.limit(50);if(error)throwerror;res.json({success: true,data: issues,count: issues.length});}catch(error){console.error('Get issues error:',error);res.status(500).json({success: false,message: 'Failed to fetch issues.'});}},/** * Get single issue with full timeline */getIssueById: async(req,res)=>{try{const{ id }=req.params;const{data: issue, error }=awaitsupabaseAdmin.from('civic_issues').select(` *, profiles!reported_by(full_name, avatar_url), wards(ward_name, ward_number) `).eq('id',id).single();if(error||!issue){returnres.status(404).json({success: false,message: 'Issue not found.'});}// Get timeline updatesconst{data: updates}=awaitsupabaseAdmin.from('issue_updates').select(` *, profiles!updated_by(full_name, avatar_url) `).eq('issue_id',id).order('created_at',{ascending: true});res.json({success: true,data: {
...issue,timeline: updates||[]}});}catch(error){console.error('Get issue error:',error);res.status(500).json({success: false,message: 'Failed to fetch issue.'});}},/** * Create a new civic issue */createIssue: async(req,res)=>{try{const{
title, description, category,
address, latitude, longitude, ward_id
}=req.body;// Get photo URLs from uploaded filesconstphoto_urls=req.files
? req.files.map(f=>f.path)
: [];// Determine ward from user profile if not providedletissueWardId=ward_id;if(!issueWardId){const{data: profile}=awaitsupabaseAdmin.from('profiles').select('ward_id').eq('id',req.userId).single();issueWardId=profile?.ward_id;}const{data: issue, error }=awaitsupabaseAdmin.from('civic_issues').insert({reported_by: req.userId,ward_id: issueWardId,
title,
description,
category,
address,
latitude,
longitude,
photo_urls,status: 'reported'}).select().single();if(error)throwerror;// Update user statsawaitsupabaseAdmin.from('profiles').update({issues_reported: supabaseAdmin.rpc('increment_field',{row_id: req.userId,field_name: 'issues_reported'})}).eq('id',req.userId);// Simpler: just increment directlyconst{data: profile}=awaitsupabaseAdmin.from('profiles').select('issues_reported').eq('id',req.userId).single();awaitsupabaseAdmin.from('profiles').update({issues_reported: (profile?.issues_reported||0)+1}).eq('id',req.userId);// Add initial timeline entryawaitsupabaseAdmin.from('issue_updates').insert({issue_id: issue.id,updated_by: req.userId,update_text: `Issue reported: ${title}`,new_status: 'reported'});res.status(201).json({success: true,message: 'Issue reported successfully! 📢',data: issue});}catch(error){console.error('Create issue error:',error);res.status(500).json({success: false,message: 'Failed to report issue.'});}},/** * Add update to issue timeline */addUpdate: async(req,res)=>{try{const{ id }=req.params;const{ update_text, new_status }=req.body;constphoto_url=req.file ? req.file.path : null;const{data: update, error }=awaitsupabaseAdmin.from('issue_updates').insert({issue_id: id,updated_by: req.userId,
update_text,
photo_url,
new_status
}).select().single();if(error)throwerror;// Update issue status if changedif(new_status){constupdateData={status: new_status,updated_at: newDate().toISOString()};if(new_status==='resolved'){updateData.resolution_date=newDate().toISOString();}awaitsupabaseAdmin.from('civic_issues').update(updateData).eq('id',id);}res.json({success: true,message: 'Update added!',data: update});}catch(error){console.error('Add update error:',error);res.status(500).json({success: false,message: 'Failed to add update.'});}},/** * Upvote an issue */upvoteIssue: async(req,res)=>{try{const{ id }=req.params;const{data: issue}=awaitsupabaseAdmin.from('civic_issues').select('upvotes').eq('id',id).single();awaitsupabaseAdmin.from('civic_issues').update({upvotes: (issue?.upvotes||0)+1}).eq('id',id);res.json({success: true,message: 'Issue upvoted!'});}catch(error){console.error('Upvote error:',error);res.status(500).json({success: false,message: 'Failed to upvote.'});}},/** * Get AI analysis of an issue */getAIAnalysis: async(req,res)=>{try{const{ id }=req.params;const{data: issue}=awaitsupabaseAdmin.from('civic_issues').select('title, description, category').eq('id',id).single();if(!issue){returnres.status(404).json({success: false,message: 'Issue not found.'});}constanalysis=awaitaiService.analyzeIssue(`${issue.title}: ${issue.description}`,issue.category);res.json({success: true,data: { analysis }});}catch(error){console.error('AI analysis error:',error);res.status(500).json({success: false,message: 'Failed to generate AI analysis.'});}}};module.exports=issueController;
server/src/routes/circles.js
// ====================================// Circle Routes// ====================================constexpress=require('express');constrouter=express.Router();constcircleController=require('../controllers/circleController');const{ authenticate }=require('../middleware/auth');router.get('/',authenticate,circleController.getCircles);router.get('/:id',authenticate,circleController.getCircleById);router.post('/',authenticate,circleController.createCircle);router.post('/:id/join',authenticate,circleController.joinCircle);router.post('/:id/adopt-issue',authenticate,circleController.adoptIssue);router.get('/:id/members',authenticate,circleController.getMembers);module.exports=router;
server/src/controllers/circleController.js
// ====================================// Circle Controller// ====================================const{ supabaseAdmin }=require('../config/database');constcircleController={/** * Get circles (filterable by ward) */getCircles: async(req,res)=>{try{const{ ward_id }=req.query;letquery=supabaseAdmin.from('circles').select(` *, wards(ward_name, ward_number), circle_members( user_id, role, profiles(full_name, avatar_url) ) `).eq('is_active',true).order('total_xp',{ascending: false});if(ward_id){query=query.eq('ward_id',ward_id);}const{data: circles, error }=awaitquery;if(error)throwerror;res.json({success: true,data: circles});}catch(error){console.error('Get circles error:',error);res.status(500).json({success: false,message: 'Failed to fetch circles.'});}},/** * Get single circle with members */getCircleById: async(req,res)=>{try{const{ id }=req.params;const{data: circle, error }=awaitsupabaseAdmin.from('circles').select(` *, wards(ward_name, ward_number), circle_members( user_id, role, joined_at, profiles(full_name, avatar_url, xp_points, current_streak, level) ), civic_issues!adopted_issue_id(title, status, description) `).eq('id',id).single();if(error||!circle){returnres.status(404).json({success: false,message: 'Circle not found.'});}res.json({success: true,data: circle});}catch(error){console.error('Get circle error:',error);res.status(500).json({success: false,message: 'Failed to fetch circle.'});}},/** * Create a new circle */createCircle: async(req,res)=>{try{const{ name, description, ward_id }=req.body;const{data: circle, error }=awaitsupabaseAdmin.from('circles').insert({
name,
description,
ward_id,created_by: req.userId,member_count: 1}).select().single();if(error)throwerror;// Add creator as captainawaitsupabaseAdmin.from('circle_members').insert({circle_id: circle.id,user_id: req.userId,role: 'Captain'});res.status(201).json({success: true,message: 'Circle created! Invite 4-7 more members from your ward. 🤝',data: circle});}catch(error){console.error('Create circle error:',error);res.status(500).json({success: false,message: 'Failed to create circle.'});}},/** * Join a circle */joinCircle: async(req,res)=>{try{const{ id }=req.params;// Check circle capacityconst{data: circle}=awaitsupabaseAdmin.from('circles').select('member_count, max_members').eq('id',id).single();if(!circle){returnres.status(404).json({success: false,message: 'Circle not found.'});}if(circle.member_count>=circle.max_members){returnres.status(400).json({success: false,message: 'This circle is full. Try another one!'});}// Check if already a memberconst{data: existing}=awaitsupabaseAdmin.from('circle_members').select('id').eq('circle_id',id).eq('user_id',req.userId).single();if(existing){returnres.status(400).json({success: false,message: 'You are already a member of this circle.'});}// Add memberawaitsupabaseAdmin.from('circle_members').insert({circle_id: id,user_id: req.userId,role: 'Member'});// Update member countawaitsupabaseAdmin.from('circles').update({member_count: circle.member_count+1}).eq('id',id);res.json({success: true,message: 'Welcome to the circle! 🤝'});}catch(error){console.error('Join circle error:',error);res.status(500).json({success: false,message: 'Failed to join circle.'});}},/** * Adopt a civic issue for the circle */adoptIssue: async(req,res)=>{try{const{ id }=req.params;const{ issue_id }=req.body;awaitsupabaseAdmin.from('circles').update({adopted_issue_id: issue_id}).eq('id',id);res.json({success: true,message: 'Issue adopted! Your circle will track this for 6 months. 🎯'});}catch(error){console.error('Adopt issue error:',error);res.status(500).json({success: false,message: 'Failed to adopt issue.'});}},/** * Get circle members */getMembers: async(req,res)=>{try{const{ id }=req.params;const{data: members, error }=awaitsupabaseAdmin.from('circle_members').select(` *, profiles(full_name, avatar_url, xp_points, current_streak, level) `).eq('circle_id',id).eq('is_active',true);if(error)throwerror;res.json({success: true,data: members});}catch(error){console.error('Get members error:',error);res.status(500).json({success: false,message: 'Failed to fetch members.'});}}};module.exports=circleController;
// ====================================// WhatsApp Webhook Routes// ====================================constexpress=require('express');constrouter=express.Router();constwhatsappService=require('../services/whatsappService');// POST /api/whatsapp/webhook — Twilio sends incoming messages hererouter.post('/webhook',async(req,res)=>{try{const{ From, Body, MediaUrl0 }=req.body;console.log(`📱 WhatsApp from ${From}: ${Body}`);// Handle incoming messageawaitwhatsappService.handleIncoming(From,Body,MediaUrl0);// Twilio expects a TwiML response (can be empty)res.set('Content-Type','text/xml');res.send('<Response></Response>');}catch(error){console.error('WhatsApp webhook error:',error);res.set('Content-Type','text/xml');res.send('<Response></Response>');}});// GET /api/whatsapp/webhook — Twilio verificationrouter.get('/webhook',(req,res)=>{res.status(200).send('CivicStreak WhatsApp Bot is active 🏙️');});module.exports=router;
server/src/routes/users.js
// ====================================// User Routes// ====================================constexpress=require('express');constrouter=express.Router();constuserController=require('../controllers/userController');const{ authenticate }=require('../middleware/auth');const{ upload }=require('../config/cloudinary');// GET /api/users/profilerouter.get('/profile',authenticate,userController.getProfile);// PUT /api/users/profilerouter.put('/profile',authenticate,userController.updateProfile);// PUT /api/users/avatarrouter.put('/avatar',authenticate,upload.single('avatar'),userController.updateAvatar);// POST /api/users/onboardingrouter.post('/onboarding',authenticate,userController.completeOnboarding);// GET /api/users/statsrouter.get('/stats',authenticate,userController.getStats);// GET /api/users/notificationsrouter.get('/notifications',authenticate,userController.getNotifications);// PUT /api/users/notifications/:id/readrouter.put('/notifications/:id/read',authenticate,userController.markNotificationRead);module.exports=router;
server/src/controllers/userController.js
// ====================================// User Controller// ====================================const{ supabaseAdmin }=require('../config/database');constuserController={/** * Get authenticated user's profile */getProfile: async(req,res)=>{try{const{data: profile, error }=awaitsupabaseAdmin.from('profiles').select(` *, wards(*) `).eq('id',req.userId).single();if(error||!profile){returnres.status(404).json({success: false,message: 'Profile not found.'});}res.json({success: true,data: profile});}catch(error){console.error('Get profile error:',error);res.status(500).json({success: false,message: 'Failed to fetch profile.'});}},/** * Update profile */updateProfile: async(req,res)=>{try{constallowedFields=['full_name','phone','ward_id','age','college','interests','preferred_language','whatsapp_opted_in'];constupdates={};for(constfieldofallowedFields){if(req.body[field]!==undefined){updates[field]=req.body[field];}}updates.updated_at=newDate().toISOString();const{data: profile, error }=awaitsupabaseAdmin.from('profiles').update(updates).eq('id',req.userId).select().single();if(error)throwerror;res.json({success: true,message: 'Profile updated!',data: profile});}catch(error){console.error('Update profile error:',error);res.status(500).json({success: false,message: 'Failed to update profile.'});}},/** * Update avatar */updateAvatar: async(req,res)=>{try{if(!req.file){returnres.status(400).json({success: false,message: 'No image provided.'});}constavatar_url=req.file.path;const{data: profile, error }=awaitsupabaseAdmin.from('profiles').update({ avatar_url,updated_at: newDate().toISOString()}).eq('id',req.userId).select().single();if(error)throwerror;res.json({success: true,message: 'Avatar updated!',data: {avatar_url: profile.avatar_url}});}catch(error){console.error('Update avatar error:',error);res.status(500).json({success: false,message: 'Failed to update avatar.'});}},/** * Complete onboarding flow */completeOnboarding: async(req,res)=>{try{const{ ward_id, interests, college, age, preferred_language }=req.body;const{data: profile, error }=awaitsupabaseAdmin.from('profiles').update({
ward_id,
interests,
college,
age,
preferred_language,onboarding_completed: true,updated_at: newDate().toISOString()}).eq('id',req.userId).select(`*, wards(ward_name, ward_number)`).single();if(error)throwerror;res.json({success: true,message: `Welcome to CivicStreak, ${profile.full_name}! 🎉 Your civic journey begins now.`,data: profile});}catch(error){console.error('Onboarding error:',error);res.status(500).json({success: false,message: 'Failed to complete onboarding.'});}},/** * Get user statistics */getStats: async(req,res)=>{try{const{data: profile}=awaitsupabaseAdmin.from('profiles').select(` xp_points, current_streak, longest_streak, level, tasks_completed, issues_reported, issues_resolved, rtis_filed, meetings_attended, people_mentored `).eq('id',req.userId).single();// Get task breakdown by categoryconst{data: taskBreakdown}=awaitsupabaseAdmin.from('user_tasks').select(` micro_tasks(category) `).eq('user_id',req.userId).in('status',['submitted','verified']);constcategoryCount={};(taskBreakdown||[]).forEach(t=>{constcat=t.micro_tasks?.category||'Unknown';categoryCount[cat]=(categoryCount[cat]||0)+1;});// Get streak history (last 30 days)constthirtyDaysAgo=newDate();thirtyDaysAgo.setDate(thirtyDaysAgo.getDate()-30);const{data: recentStreak}=awaitsupabaseAdmin.from('streak_history').select('activity_date, tasks_completed, xp_earned').eq('user_id',req.userId).gte('activity_date',thirtyDaysAgo.toISOString().split('T')[0]).order('activity_date',{ascending: true});// Get global rankconst{data: allUsers}=awaitsupabaseAdmin.from('profiles').select('id').gt('xp_points',profile?.xp_points||0);constglobalRank=(allUsers?.length||0)+1;res.json({success: true,data: {
...profile,category_breakdown: categoryCount,recent_activity: recentStreak||[],global_rank: globalRank}});}catch(error){console.error('Get stats error:',error);res.status(500).json({success: false,message: 'Failed to fetch stats.'});}},/** * Get user notifications */getNotifications: async(req,res)=>{try{const{data: notifications, error }=awaitsupabaseAdmin.from('notifications').select('*').eq('user_id',req.userId).order('created_at',{ascending: false}).limit(30);if(error)throwerror;constunreadCount=(notifications||[]).filter(n=>!n.is_read).length;res.json({success: true,data: {notifications: notifications||[],unread_count: unreadCount}});}catch(error){console.error('Get notifications error:',error);res.status(500).json({success: false,message: 'Failed to fetch notifications.'});}},/** * Mark notification as read */markNotificationRead: async(req,res)=>{try{const{ id }=req.params;awaitsupabaseAdmin.from('notifications').update({is_read: true}).eq('id',id).eq('user_id',req.userId);res.json({success: true,message: 'Marked as read.'});}catch(error){console.error('Mark read error:',error);res.status(500).json({success: false,message: 'Failed to mark notification.'});}}};module.exports=userController;