diff --git a/CALENDAR_INTEGRATION.md b/CALENDAR_INTEGRATION.md new file mode 100644 index 0000000..c1ffa6a --- /dev/null +++ b/CALENDAR_INTEGRATION.md @@ -0,0 +1,413 @@ +# Google Calendar Integration + +This document provides comprehensive information about the Google Calendar integration implementation for the task management application. + +## Overview + +The calendar integration allows users to: + +- Grant Google Calendar permissions +- Create calendar events from tasks +- Update and delete calendar events +- Sync tasks with calendar events +- Manage calendar reminders + +## Architecture + +### Manual OAuth Flow + +The implementation uses a **manual OAuth flow** instead of Passport.js for better control and flexibility: + +- **`config/googleOAuth.js`**: Core OAuth helper functions +- **`services/CalendarService.js`**: Calendar API operations +- **`api/calendar.js`**: Calendar REST endpoints +- **`auth/index.js`**: OAuth callback handlers + +## Database Schema Changes + +### User Model Updates + +```javascript +// New fields added to User model +googleAccessToken: DataTypes.TEXT, // Google API access token +googleRefreshToken: DataTypes.TEXT, // Google API refresh token +calendarPermissions: DataTypes.BOOLEAN // Whether user granted calendar access +``` + +### Task Model Updates + +```javascript +// New fields added to Task model +calendarEventId: DataTypes.STRING, // Google Calendar event ID +hasReminder: DataTypes.BOOLEAN // Whether task has calendar reminder +``` + +## Setup Instructions + +### 1. Environment Variables + +Ensure these variables are set in your `.env` file: + +```env +GOOGLE_CLIENT_ID=your_google_client_id +GOOGLE_CLIENT_SECRET=your_google_client_secret +BACKEND_URL=http://localhost:8080 +FRONTEND_URL=http://localhost:3000 +``` + +### 2. Database Migration + +Run the migration to add new fields: + +```bash +node database/migrate-calendar.js +``` + +### 3. Test Integration + +Verify everything is working: + +```bash +node test-calendar.js +``` + +## API Endpoints + +### Authentication Endpoints + +#### Get Calendar Permission URL + +```http +GET /api/calendar/permissions/url +Authorization: Bearer +``` + +Returns a Google OAuth URL for calendar permissions. + +#### Calendar Permission Callback + +```http +GET /auth/google/calendar/callback?code=&state= +``` + +Handles the OAuth callback and stores calendar tokens. Redirects to: + +- Success: `http://localhost:3000/Tasks?calendar_success=permissions_granted` +- Error: `http://localhost:3000/Tasks?calendar_error=` + +### Calendar Management Endpoints + +#### Check Calendar Permissions + +```http +GET /api/calendar/permissions +Authorization: Bearer +``` + +Returns user's calendar permission status. + +#### Get User's Calendars + +```http +GET /api/calendar/calendars +Authorization: Bearer +``` + +Returns list of user's Google Calendars. + +#### Get Calendar Events + +```http +GET /api/calendar/events?timeMin=2024-01-01T00:00:00Z&timeMax=2024-12-31T23:59:59Z +Authorization: Bearer +``` + +Returns calendar events within specified time range. + +### Event Management Endpoints + +#### Create Calendar Event + +```http +POST /api/calendar/events +Authorization: Bearer +Content-Type: application/json + +{ + "title": "Complete Project", + "description": "Finish the task management app", + "startTime": "2024-01-15T10:00:00Z", + "endTime": "2024-01-15T11:00:00Z", + "timeZone": "America/New_York", + "taskId": 123 +} +``` + +#### Update Calendar Event + +```http +PUT /api/calendar/events/{eventId} +Authorization: Bearer +Content-Type: application/json + +{ + "title": "Updated Event Title", + "startTime": "2024-01-15T11:00:00Z", + "endTime": "2024-01-15T12:00:00Z" +} +``` + +#### Delete Calendar Event + +```http +DELETE /api/calendar/events/{eventId} +Authorization: Bearer +``` + +### Task-Calendar Sync Endpoints + +#### Sync Task with Calendar + +```http +POST /api/calendar/sync-task/{taskId} +Authorization: Bearer +Content-Type: application/json + +{ + "startTime": "2024-01-15T14:00:00Z", + "endTime": "2024-01-15T15:00:00Z", + "timeZone": "America/New_York" +} +``` + +#### Remove Task Calendar Sync + +```http +DELETE /api/calendar/sync-task/{taskId} +Authorization: Bearer +``` + +## Usage Flow + +### 1. Initial Setup + +1. User logs in through existing auth system +2. User requests calendar permissions via `/api/calendar/permissions/url` +3. User is redirected to Google OAuth consent screen +4. After consent, user is redirected back to `/auth/google/calendar/callback` +5. Backend stores access and refresh tokens + +### 2. Creating Calendar Events + +1. User creates or edits a task +2. Frontend sends request to `/api/calendar/sync-task/{taskId}` with timing info +3. Backend creates Google Calendar event +4. Task is updated with `calendarEventId` and `hasReminder: true` + +### 3. Managing Events + +1. User can view calendar events via `/api/calendar/events` +2. User can update events via `/api/calendar/events/{eventId}` +3. User can delete events via `/api/calendar/events/{eventId}` +4. Task sync is automatically maintained + +## Error Handling + +The implementation includes comprehensive error handling: + +### Token Refresh + +- Automatically refreshes expired access tokens using refresh tokens +- Graceful fallback when refresh fails +- Clear error messages for permission issues + +### API Errors + +- Standardized error responses +- Specific error codes for different scenarios +- Logging for debugging + +### Common Error Responses + +```json +// Permission required +{ + "error": "Calendar permissions required", + "needsPermission": true +} + +// Authentication failed +{ + "error": "User not authenticated with Google Calendar" +} + +// Validation error +{ + "error": "Title, start time, and end time are required" +} +``` + +## Security Considerations + +### Token Storage + +- Access tokens stored in database (encrypted in production) +- Refresh tokens stored securely +- Tokens scoped to minimum required permissions + +### API Protection + +- All endpoints require JWT authentication +- User can only access their own calendar data +- Input validation on all calendar operations + +### OAuth Security + +- Uses PKCE flow where possible +- State parameter for CSRF protection +- Secure redirect URI validation + +## Frontend Integration + +### Calendar Permission Flow + +```javascript +// Check if user has calendar permissions +const checkCalendarPermissions = async () => { + const response = await fetch("/api/calendar/permissions", { + credentials: "include", + }); + const data = await response.json(); + return data.hasPermissions; +}; + +// Request calendar permissions +const requestCalendarPermissions = async () => { + const response = await fetch("/api/calendar/permissions/url", { + credentials: "include", + }); + const data = await response.json(); + window.location.href = data.permissionUrl; +}; +``` + +### Creating Calendar Events + +```javascript +// Sync task with calendar +const syncTaskWithCalendar = async (taskId, eventData) => { + const response = await fetch(`/api/calendar/sync-task/${taskId}`, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + credentials: "include", + body: JSON.stringify(eventData), + }); + + if (!response.ok) { + const error = await response.json(); + if (error.needsPermission) { + // Redirect to permissions flow + await requestCalendarPermissions(); + return; + } + throw new Error(error.error); + } + + return await response.json(); +}; +``` + +## Testing + +### Unit Tests + +Run the calendar integration tests: + +```bash +node test-calendar.js +``` + +### Integration Tests + +1. Start the server: `npm run start-dev` +2. Use Postman or curl to test API endpoints +3. Verify calendar events appear in Google Calendar + +### Test Scenarios + +- [ ] User can request calendar permissions +- [ ] OAuth callback processes correctly +- [ ] Calendar events are created successfully +- [ ] Events sync with tasks properly +- [ ] Token refresh works automatically +- [ ] Error handling works correctly + +## Troubleshooting + +### Common Issues + +#### "Calendar permissions required" + +- User needs to grant calendar permissions +- Check if user completed OAuth flow +- Verify tokens are stored in database + +#### "Failed to refresh access token" + +- Refresh token may be expired or invalid +- User needs to re-authorize +- Check Google Cloud Console settings + +#### "Calendar API error: 401" + +- Access token expired and refresh failed +- User needs to re-authorize +- Check token storage + +### Debug Steps + +1. Check environment variables are set +2. Verify database migration completed +3. Test OAuth flow manually +4. Check server logs for specific errors +5. Verify Google Cloud Console configuration + +## Deployment Notes + +### Production Environment + +- Set `NODE_ENV=production` +- Use HTTPS for OAuth callbacks +- Set secure cookie flags +- Use proper database encryption + +### Google Cloud Console + +- Configure OAuth consent screen +- Add production domain to authorized origins +- Set up proper redirect URIs +- Enable Calendar API + +## Future Enhancements + +### Potential Features + +- Multiple calendar support +- Recurring event support +- Calendar event templates +- Bulk calendar operations +- Calendar sync status dashboard +- Email notifications for events + +### Performance Optimizations + +- Batch calendar operations +- Caching for calendar data +- Incremental sync +- Background job processing + +--- + +For questions or issues, please refer to the main project documentation or create an issue in the repository. diff --git a/api/Calculator.js b/api/Calculator.js new file mode 100644 index 0000000..5ef4157 --- /dev/null +++ b/api/Calculator.js @@ -0,0 +1,104 @@ +const express = require("express"); +const router = express.Router(); +const { Calculator } = require("../database"); + +// POST: Calculate a final grade. Creates a new grade entry in db +router.post("/new-grade-entry", async (req, res) => { + const { user_id, assignment_type, assignment_name, assignment_grade, assignment_weight } = + req.body; + try { + // check that required fields are not omitted + if ( + !assignment_type || + !assignment_name || + assignment_weight == null || + assignment_grade == null + ) { + return res.status(400).json({ error: "Missing required fields" }); + } + + // Create new grade entry in db + const newGradeEntry = await Calculator.create({ + user_id: req.body.user_id, + assignment_type: req.body.assignment_type, + assignment_name: req.body.assignment_name, + assignment_grade: req.body.assignment_grade, + assignment_weight: req.body.assignment_weight + }); + newGradeEntry.save() + + // Return new created grade entry + res.status(201).json(newGradeEntry); + } catch (error) { + console.error("Error creating new grade entry: ", error); + res.status(500).json({ error: "Unable to calculate final grade. Sorry!" }); + } +}); + +// GET: Fetch all of user's past grade-calculator entries. +router.get("/grade-entries/:userId", async (req, res) => { + try { + const userId = req.params.userId; + const entries = await Calculator.findAll({ where: { user_id: userId } }); + res.status(200).json(entries); + } catch (error) { + console.error("Error fetching previous grade entries for user", error); + res + .status(500) + .json({ error: "Unable to return your previous grade entries. Sorry!" }); + } +}); + +// GET: Fetch a specific grade-calculator entry +router.get("/grade-entry/:entryId", async (req, res) => { + try { + const entryId = req.params.entryId; + const entry = await Calculator.findByPk(entryId); + if (!entry) { + return res.status(404).json({ error: "Entry not found" }); + } + res.json(entry); + } catch (error) { + console.error("Error fetching previous grade entry"); + res + .status(500) + .json({ error: "Unable to return previous grade entry. Sorry!" }); + } +}); + +// PUT: Update a specific grade entry by ID +router.put("/grade-entry/:entryId", async (req, res) => { + try { + const entryId = req.params.entryId; + const { assignment_grade, assignment_weight } = req.body; + const entry = await Calculator.findByPk(entryId); + if (!entry) { + return res.status(404).json({ error: "Entry not found" }); + } + // update the assignment grade and weight if provided; otherwise, keep the existing values + entry.assignment_grade = assignment_grade ?? entry.assignment_grade; + entry.assignment_weight = assignment_weight ?? entry.assignment_weight; + await entry.save(); + res.json(entry); + } catch (error) { + console.error("Error updating grade entry: ", error); + res.status(500).json({ error: "Unable to update grade entry. Sorry!" }); + } +}); + +// DELETE: Delete a specific grade entry +router.delete("/grade-entry/:entryId", async (req, res) => { + try { + const entryId = req.params.entryId; + const deleted = await Calculator.destroy({ where: { id: entryId } }); + if (!deleted) { + return res.status(404).json({ error: "Entry not found" }); + } + res.json({ message: "Grade entry deleted" }); + } catch (error) { + console.error("Error deleting grade entry: ", error); + res.status(500).json({ error: "Unable to delete grade entry" }); + } +}); + +module.exports = router; \ No newline at end of file diff --git a/api/StreakSession.js b/api/StreakSession.js new file mode 100644 index 0000000..89e47f3 --- /dev/null +++ b/api/StreakSession.js @@ -0,0 +1,479 @@ +const express = require("express"); +const router = express.Router(); +const { StreakSession, UserProgress } = require("../database"); +const { Op } = require("sequelize"); +const { authenticateJWT } = require("../auth"); + +// Calculate streak based on study activity (not just session starts) +const calculateStreak = async (userId) => { + try { + // Get all study activities for the user + const studyActivities = await UserProgress.findAll({ + where: { user_id: userId }, + attributes: ["studied_at"], + order: [["studied_at", "DESC"]], + }); + + if (studyActivities.length === 0) { + return { currentStreak: 0, longestStreak: 0, lastStudyDate: null }; + } + + // Get unique study dates (converted to local date) + const studyDates = [ + ...new Set( + studyActivities.map((activity) => { + const date = new Date(activity.studied_at); + return new Date(date.getFullYear(), date.getMonth(), date.getDate()); + }) + ), + ].sort((a, b) => b - a); // Sort descending (most recent first) + + let currentStreak = 0; + let longestStreak = 0; + let tempStreak = 0; + const today = new Date(); + const yesterday = new Date( + today.getFullYear(), + today.getMonth(), + today.getDate() - 1 + ); + + // Check if user studied today or yesterday to start current streak + const todayDate = new Date( + today.getFullYear(), + today.getMonth(), + today.getDate() + ); + const yesterdayDate = new Date( + yesterday.getFullYear(), + yesterday.getMonth(), + yesterday.getDate() + ); + + const studiedToday = studyDates.some( + (date) => date.getTime() === todayDate.getTime() + ); + const studiedYesterday = studyDates.some( + (date) => date.getTime() === yesterdayDate.getTime() + ); + + // Calculate current streak + if (studiedToday) { + currentStreak = 1; + let checkDate = yesterdayDate; + + for (let i = 1; i < studyDates.length; i++) { + const expectedDate = new Date( + todayDate.getTime() - i * 24 * 60 * 60 * 1000 + ); + const foundDate = studyDates.find( + (date) => date.getTime() === expectedDate.getTime() + ); + + if (foundDate) { + currentStreak++; + } else { + break; + } + } + } else if (studiedYesterday) { + currentStreak = 1; + let checkDate = new Date(yesterdayDate.getTime() - 24 * 60 * 60 * 1000); + + for (let i = 2; i < studyDates.length; i++) { + const expectedDate = new Date( + yesterdayDate.getTime() - (i - 1) * 24 * 60 * 60 * 1000 + ); + const foundDate = studyDates.find( + (date) => date.getTime() === expectedDate.getTime() + ); + + if (foundDate) { + currentStreak++; + } else { + break; + } + } + } + + // Calculate longest streak + for (let i = 0; i < studyDates.length - 1; i++) { + const currentDate = studyDates[i]; + const nextDate = studyDates[i + 1]; + const diffDays = Math.floor( + (currentDate - nextDate) / (24 * 60 * 60 * 1000) + ); + + if (diffDays === 1) { + tempStreak++; + } else { + longestStreak = Math.max(longestStreak, tempStreak + 1); + tempStreak = 0; + } + } + longestStreak = Math.max(longestStreak, tempStreak + 1); + + return { + currentStreak, + longestStreak, + lastStudyDate: studyDates[0], + totalStudyDays: studyDates.length, + }; + } catch (error) { + console.error("Error calculating streak:", error); + return { currentStreak: 0, longestStreak: 0, lastStudyDate: null }; + } +}; + +// Get comprehensive streak information +router.get("/streak/:userId", async (req, res) => { + try { + const userId = req.params.userId; + const streakInfo = await calculateStreak(userId); + + // Get recent study activity for context + const recentActivity = await UserProgress.findAll({ + where: { user_id: userId }, + include: [ + { + model: require("../database").AiChatHistory, + attributes: ["response_type"], + }, + ], + order: [["studied_at", "DESC"]], + limit: 10, + }); + + // Calculate streak milestones + const milestones = [1, 3, 7, 14, 30, 60, 100, 365]; + const achievedMilestones = milestones.filter( + (milestone) => streakInfo.longestStreak >= milestone + ); + + res.json({ + ...streakInfo, + recentActivity: recentActivity.map((activity) => ({ + id: activity.id, + type: activity.AiChatHistory?.response_type, + studiedAt: activity.studied_at, + isCorrect: activity.is_correct, + score: activity.score, + })), + achievedMilestones, + nextMilestone: + milestones.find((milestone) => milestone > streakInfo.currentStreak) || + null, + }); + } catch (error) { + console.error("Streak calculation error:", error); + res.status(500).json({ error: "Failed to calculate streak" }); + } +}); + +// Start a study session - enhanced debugging +router.post("/start", authenticateJWT, async (req, res) => { + try { + const userId = req.user.id; // Get user ID from JWT token + console.log("šŸš€ Starting session for user:", userId); + console.log("šŸ” User object:", req.user); + + // First, let's check if the user actually exists in the database + const { User } = require("../database"); + const user = await User.findByPk(userId); + if (!user) { + console.error("āŒ User not found in database:", userId); + return res.status(404).json({ error: "User not found" }); + } + console.log("āœ… User found:", user.username); + + // Check for existing active session and end it if found + console.log("šŸ” Checking for existing sessions..."); + const existingSession = await StreakSession.findOne({ + where: { + user_id: userId, + endTime: null, + }, + order: [["startTime", "DESC"]], + }); + + if (existingSession) { + console.log("ā¹ļø Ending existing session:", existingSession.id); + existingSession.endTime = new Date(); + await existingSession.save(); + console.log("āœ… Existing session ended"); + } + + const sessionData = { + user_id: userId, + startTime: new Date(), + session_type: "study" + }; + console.log("šŸ“ Creating new session with data:", sessionData); + + const session = await StreakSession.create(sessionData); + console.log("āœ… Session created successfully:", { + id: session.id, + user_id: session.user_id, + startTime: session.startTime, + session_type: session.session_type + }); + + res.status(201).json({ + message: "Session started", + session: { + id: session.id, + user_id: session.user_id, + startTime: session.startTime, + session_type: session.session_type + }, + timestamp: new Date().toISOString(), + }); + } catch (error) { + console.error("āŒ Session start error:", error); + console.error("āŒ Error details:", { + message: error.message, + stack: error.stack, + name: error.name, + code: error.code, + sql: error.sql + }); + res.status(500).json({ + error: "Failed to start session", + details: error.message, + errorName: error.name, + errorCode: error.code + }); + } +}); + +// End a study session +router.post("/end", authenticateJWT, async (req, res) => { + try { + const userId = req.user.id; // Get user ID from JWT token + + const session = await StreakSession.findOne({ + where: { + user_id: userId, + endTime: null, + }, + attributes: [ + "id", + "user_id", + "startTime", + "endTime", + "createdAt", + "updatedAt", + ], // Only select existing columns + order: [["startTime", "DESC"]], + }); + + if (!session) { + return res.status(404).json({ error: "No active session found" }); + } + + session.endTime = new Date(); + await session.save(); + + // Calculate session duration + const duration = session.endTime - session.startTime; + const durationMinutes = Math.floor(duration / (1000 * 60)); + + res.json({ + message: "Session ended", + session, + duration: { + milliseconds: duration, + minutes: durationMinutes, + formatted: `${Math.floor(durationMinutes / 60)}h ${ + durationMinutes % 60 + }m`, + }, + timestamp: new Date().toISOString(), + }); + } catch (error) { + console.error("Session end error:", error); + res.status(500).json({ error: "Failed to end session" }); + } +}); + +// Get session history +router.get("/history/:userId", async (req, res) => { + try { + const userId = req.params.userId; + const { limit = 20, offset = 0 } = req.query; + + const sessions = await StreakSession.findAll({ + where: { user_id: userId }, + attributes: [ + "id", + "user_id", + "startTime", + "endTime", + "createdAt", + "updatedAt", + ], // Only select existing columns + order: [["startTime", "DESC"]], + limit: parseInt(limit), + offset: parseInt(offset), + }); + + const sessionsWithDuration = sessions.map((session) => { + const duration = session.endTime + ? session.endTime - session.startTime + : new Date() - session.startTime; + + return { + id: session.id, + startTime: session.startTime, + endTime: session.endTime, + duration: { + milliseconds: duration, + minutes: Math.floor(duration / (1000 * 60)), + formatted: session.endTime + ? `${Math.floor(duration / (1000 * 60 * 60))}h ${Math.floor( + (duration % (1000 * 60 * 60)) / (1000 * 60) + )}m` + : "Active", + }, + isActive: !session.endTime, + }; + }); + + res.json({ + sessions: sessionsWithDuration, + total: sessions.length, + hasMore: sessions.length === parseInt(limit), + }); + } catch (error) { + console.error("Session history error:", error); + res.status(500).json({ error: "Failed to fetch session history" }); + } +}); + +// Get streak statistics +router.get("/stats/:userId", async (req, res) => { + try { + const userId = req.params.userId; + + // Get all sessions + const allSessions = await StreakSession.findAll({ + where: { user_id: userId }, + attributes: [ + "id", + "user_id", + "startTime", + "endTime", + "createdAt", + "updatedAt", + ], // Only select existing columns + order: [["startTime", "ASC"]], + }); + + // Get all study activities + const allActivities = await UserProgress.findAll({ + where: { user_id: userId }, + order: [["studied_at", "ASC"]], + }); + + // Calculate statistics + const totalSessions = allSessions.length; + const totalStudyTime = allSessions + .filter((s) => s.endTime) + .reduce( + (total, session) => total + (session.endTime - session.startTime), + 0 + ); + + const avgSessionLength = + totalSessions > 0 + ? Math.floor(totalStudyTime / totalSessions / (1000 * 60)) + : 0; + + const studyDays = [ + ...new Set( + allActivities.map((activity) => { + const date = new Date(activity.studied_at); + return new Date(date.getFullYear(), date.getMonth(), date.getDate()) + .toISOString() + .split("T")[0]; + }) + ), + ].length; + + const streakInfo = await calculateStreak(userId); + + res.json({ + totalSessions, + totalStudyTime: { + milliseconds: totalStudyTime, + minutes: Math.floor(totalStudyTime / (1000 * 60)), + hours: Math.floor(totalStudyTime / (1000 * 60 * 60)), + formatted: `${Math.floor( + totalStudyTime / (1000 * 60 * 60) + )}h ${Math.floor((totalStudyTime % (1000 * 60 * 60)) / (1000 * 60))}m`, + }, + avgSessionLength: { + minutes: avgSessionLength, + formatted: `${Math.floor(avgSessionLength / 60)}h ${ + avgSessionLength % 60 + }m`, + }, + studyDays, + currentStreak: streakInfo.currentStreak, + longestStreak: streakInfo.longestStreak, + lastStudyDate: streakInfo.lastStudyDate, + }); + } catch (error) { + console.error("Stats calculation error:", error); + res.status(500).json({ error: "Failed to calculate statistics" }); + } +}); + +// Protected endpoint to get current user's streak data +router.get("/streak", authenticateJWT, async (req, res) => { + try { + const userId = req.user.id; + const streakInfo = await calculateStreak(userId); + + res.json({ + current: streakInfo.currentStreak, + longest: streakInfo.longestStreak, + lastStudyDate: streakInfo.lastStudyDate, + nextMilestone: Math.max(7, Math.ceil(streakInfo.currentStreak / 7) * 7), + }); + } catch (error) { + console.error("Current user streak error:", error); + res.status(500).json({ error: "Failed to fetch streak data" }); + } +}); + +// Debug endpoint to check table structure +router.get("/debug/table", async (req, res) => { + try { + // Try to describe the table structure + const tableInfo = await StreakSession.describe(); + res.json({ + message: "Table structure retrieved", + tableInfo, + modelAttributes: Object.keys(StreakSession.rawAttributes), + }); + } catch (error) { + console.error("Table debug error:", error); + res.status(500).json({ + error: "Failed to get table info", + details: error.message, + stack: error.stack, + }); + } +}); + +// Debug endpoint to check authentication +router.get("/debug/auth", authenticateJWT, (req, res) => { + res.json({ + message: "Authentication successful", + user: req.user, + timestamp: new Date().toISOString(), + }); +}); + +module.exports = router; diff --git a/api/TaskOrganizer.js b/api/TaskOrganizer.js new file mode 100644 index 0000000..2939be1 --- /dev/null +++ b/api/TaskOrganizer.js @@ -0,0 +1,168 @@ +const express = require("express"); +const router = express.Router(); +const { Op } = require("sequelize"); +const { Tasks } = require("../database"); + +// ────────────────────────────────────────────────────────────── +// NOTE: These routes assume app.js has: +// app.use("/api", authenticateJWT, apiRouter) +// so req.user is always available here. +// ────────────────────────────────────────────────────────────── + +// GET all tasks for the logged-in user +router.get("/tasks", async (req, res) => { + try { + const tasks = await Tasks.findAll({ + where: { user_id: req.user.id }, + order: [["createdAt", "DESC"]], + }); + res.json(tasks); + } catch (error) { + console.error("āŒ Error fetching tasks:", error); + res.status(500).json({ error: "Failed to fetch tasks" }); + } +}); + +// POST a new task for the logged-in user +router.post("/tasks", async (req, res) => { + try { + const { className, assignment, description, status, deadline, priority } = req.body; + + const newTask = await Tasks.create({ + className, + assignment, + description, + status, + deadline, + priority, + user_id: req.user.id, // ← use the ID from JWT + }); + + res.status(201).json(newTask); + } catch (error) { + console.error("āŒ Error creating task:", error); + res.status(500).json({ error: "Failed to create task" }); + } +}); + +// UPDATE a task by ID (must belong to the logged-in user) +router.put("/tasks/:taskId", async (req, res) => { + try { + const { taskId } = req.params; + const { className, assignment, description, status, deadline, priority } = req.body; + + // Ownership check baked into the WHERE + const task = await Tasks.findOne({ + where: { id: taskId, user_id: req.user.id }, + }); + + if (!task) { + return res.status(404).json({ error: "Task not found" }); + } + + await task.update({ className, assignment, description, status, deadline, priority }); + res.json(task); + } catch (error) { + console.error("āŒ Error updating task:", error); + res.status(500).json({ error: "Failed to update task" }); + } +}); + +// DELETE a task by ID (must belong to the logged-in user) +router.delete("/tasks/:taskId", async (req, res) => { + try { + const { taskId } = req.params; + + const task = await Tasks.findOne({ + where: { id: taskId, user_id: req.user.id }, + }); + + if (!task) { + return res.status(404).json({ error: "Task not found for this user" }); + } + + await task.destroy(); + res.json({ message: "Task deleted successfully", deletedTask: task }); + } catch (error) { + console.error("āŒ Error deleting task:", error); + res.status(500).json({ error: "Failed to delete task" }); + } +}); + +// PATCH status only (must belong to the logged-in user) +router.patch("/tasks/:taskId", async (req, res) => { + try { + const { taskId } = req.params; + const { status } = req.body; + + const task = await Tasks.findOne({ + where: { id: taskId, user_id: req.user.id }, + }); + + if (!task) { + return res.status(404).json({ error: "Task not found" }); + } + + await task.update({ status }); + res.json(task); + } catch (error) { + console.error("āŒ Error updating task status:", error); + res.status(500).json({ error: "Failed to update task status" }); + } +}); + +// FILTER: by status +router.get("/tasks/status/:statusTask", async (req, res) => { + try { + const { statusTask } = req.params; + + const filteredTasks = await Tasks.findAll({ + where: { user_id: req.user.id, status: statusTask }, + order: [["createdAt", "DESC"]], + }); + + res.json(filteredTasks); + } catch (error) { + console.error("āŒ Error filtering by status:", error); + res.status(500).json({ error: "Failed to filter tasks by status" }); + } +}); + +// FILTER: by priority +router.get("/tasks/priority/:priority", async (req, res) => { + try { + const { priority } = req.params; + + const prioritizedTasks = await Tasks.findAll({ + where: { user_id: req.user.id, priority }, + order: [["createdAt", "DESC"]], + }); + + res.json(prioritizedTasks); + } catch (error) { + console.error("āŒ Error filtering by priority:", error); + res.status(500).json({ error: "Failed to filter tasks by priority" }); + } +}); + +// FILTER: by className (case-insensitive substring) +router.get("/tasks/class/:className", async (req, res) => { + try { + const { className } = req.params; + + const classTasks = await Tasks.findAll({ + where: { + user_id: req.user.id, + className: { [Op.iLike]: `%${className}%` }, + }, + order: [["createdAt", "DESC"]], + }); + + res.json(classTasks); + } catch (error) { + console.error("āŒ Error filtering by class name:", error); + res.status(500).json({ error: "Failed to filter tasks by class name" }); + } +}); + +module.exports = router; diff --git a/api/UserProgress.js b/api/UserProgress.js new file mode 100644 index 0000000..ce8dacb --- /dev/null +++ b/api/UserProgress.js @@ -0,0 +1,1043 @@ +const express = require("express"); +const router = express.Router(); +const { Op } = require("sequelize"); +const { UserProgress, AiChatHistory } = require("../database"); +const { authenticateJWT } = require("../auth"); + +// Import badge checking function +const { checkAndAwardBadges } = require("./badges"); + +router.get("/daily/:userId", async (req, res) => { + const { userId } = req.params; + + try { + // Get today's date in local timezone + const today = new Date(); + const start = new Date( + today.getFullYear(), + today.getMonth(), + today.getDate(), + 0, + 0, + 0, + 0 + ); + const end = new Date( + today.getFullYear(), + today.getMonth(), + today.getDate(), + 23, + 59, + 59, + 999 + ); + + const rows = await UserProgress.findAll({ + where: { + user_id: userId, + studied_at: { [Op.between]: [start, end] }, + }, + include: [ + { + model: AiChatHistory, + attributes: ["id", "response_type"], // "flashcard" | "quiz" + }, + ], + order: [["studied_at", "ASC"]], + }); + + const flashAttempts = rows.filter( + (r) => r.AiChatHistory?.response_type === "flashcard" + ); + const quizRows = rows.filter( + (r) => r.AiChatHistory?.response_type === "quiz" + ); + + const flashStudied = flashAttempts.length; + const flashCorrect = flashAttempts.filter( + (r) => r.is_correct === true + ).length; + const flashAccuracy = flashStudied + ? Math.round((flashCorrect / flashStudied) * 100) + : 0; + + const quizAttempts = quizRows.filter((r) => r.score != null).length; + const quizAvgScore = quizAttempts + ? Math.round( + quizRows + .filter((r) => r.score != null) + .reduce((s, r) => s + r.score, 0) / quizAttempts + ) + : 0; + + res.json({ + date_range: { start, end }, + flashcards: { + studied_today: flashStudied, + correct_today: flashCorrect, + accuracy_today: flashAccuracy, + }, + quizzes: { + attempts_today: quizAttempts, + avg_score_today: quizAvgScore, + }, + // If you want raw rows for debugging: + // rows + }); + } catch (e) { + console.error("Daily progress error āŒ", e); + res.status(500).json({ error: "Server error" }); + } +}); + +// Get weekly progress for a user +router.get("/weekly/:userId", async (req, res) => { + const { userId } = req.params; + + try { + // Get the start of the current week (Sunday) + const today = new Date(); + const dayOfWeek = today.getDay(); + const startOfWeek = new Date(today); + startOfWeek.setDate(today.getDate() - dayOfWeek); + startOfWeek.setHours(0, 0, 0, 0); + + const endOfWeek = new Date(startOfWeek); + endOfWeek.setDate(startOfWeek.getDate() + 6); + endOfWeek.setHours(23, 59, 59, 999); + + const rows = await UserProgress.findAll({ + where: { + user_id: userId, + studied_at: { [Op.between]: [startOfWeek, endOfWeek] }, + }, + include: [ + { + model: AiChatHistory, + attributes: ["id", "response_type"], + }, + ], + order: [["studied_at", "ASC"]], + }); + + const flashAttempts = rows.filter( + (r) => r.AiChatHistory?.response_type === "flashcard" + ); + const quizRows = rows.filter( + (r) => r.AiChatHistory?.response_type === "quiz" + ); + + const flashStudied = flashAttempts.length; + const flashCorrect = flashAttempts.filter( + (r) => r.is_correct === true + ).length; + const flashAccuracy = flashStudied + ? Math.round((flashCorrect / flashStudied) * 100) + : 0; + + const quizAttempts = quizRows.filter((r) => r.score != null).length; + const quizAvgScore = quizAttempts + ? Math.round( + quizRows + .filter((r) => r.score != null) + .reduce((s, r) => s + r.score, 0) / quizAttempts + ) + : 0; + + res.json({ + date_range: { start: startOfWeek, end: endOfWeek }, + flashcards: { + studied_this_week: flashStudied, + correct_this_week: flashCorrect, + accuracy_this_week: flashAccuracy, + }, + quizzes: { + attempts_this_week: quizAttempts, + avg_score_this_week: quizAvgScore, + }, + }); + } catch (e) { + console.error("Weekly progress error āŒ", e); + res.status(500).json({ error: "Server error" }); + } +}); + +// Get all progress for a user (with optional date range) +router.get("/all/:userId", async (req, res) => { + const { userId } = req.params; + const { start_date, end_date } = req.query; + + try { + let whereClause = { user_id: userId }; + + if (start_date && end_date) { + const start = new Date(start_date); + const end = new Date(end_date); + end.setHours(23, 59, 59, 999); + whereClause.studied_at = { [Op.between]: [start, end] }; + } + + const rows = await UserProgress.findAll({ + where: whereClause, + include: [ + { + model: AiChatHistory, + attributes: ["id", "response_type"], + }, + ], + order: [["studied_at", "DESC"]], + }); + + const flashAttempts = rows.filter( + (r) => r.AiChatHistory?.response_type === "flashcard" + ); + const quizRows = rows.filter( + (r) => r.AiChatHistory?.response_type === "quiz" + ); + + const flashStudied = flashAttempts.length; + const flashCorrect = flashAttempts.filter( + (r) => r.is_correct === true + ).length; + const flashAccuracy = flashStudied + ? Math.round((flashCorrect / flashStudied) * 100) + : 0; + + const quizAttempts = quizRows.filter((r) => r.score != null).length; + const quizAvgScore = quizAttempts + ? Math.round( + quizRows + .filter((r) => r.score != null) + .reduce((s, r) => s + r.score, 0) / quizAttempts + ) + : 0; + + res.json({ + total_flashcards: { + studied_total: flashStudied, + correct_total: flashCorrect, + accuracy_total: flashAccuracy, + }, + total_quizzes: { + attempts_total: quizAttempts, + avg_score_total: quizAvgScore, + }, + total_sessions: rows.length, + recent_activity: rows.slice(0, 10), // Last 10 activities + }); + } catch (e) { + console.error("All progress error āŒ", e); + res.status(500).json({ error: "Server error" }); + } +}); + +// Record new progress entry +router.post("/record", async (req, res) => { + const { + user_id, + ai_chat_history_id, + card_index, + is_correct, + score, + duration_ms, + session_id, + } = req.body; + + try { + // Validate required fields + if (!user_id || !ai_chat_history_id) { + return res.status(400).json({ + error: + "Missing required fields: user_id and ai_chat_history_id are required", + }); + } + + // Create new progress entry + const progressEntry = await UserProgress.create({ + user_id, + ai_chat_history_id, + card_index, + is_correct, + score, + duration_ms, + session_id, + studied_at: new Date(), + }); + + res.status(201).json({ + message: "Progress recorded successfully", + progress: progressEntry, + }); + } catch (e) { + console.error("Record progress error āŒ", e); + res.status(500).json({ error: "Server error" }); + } +}); + +// Record flashcard progress when user studies +router.post("/flashcard-progress", authenticateJWT, async (req, res) => { + const userId = req.user.id; // Get user ID from JWT token + const { + ai_chat_history_id, // This is actually the quiz_id from the frontend + card_index, + is_correct, + duration_ms, + session_id, + } = req.body; + + try { + // Validate required fields + if ( + !ai_chat_history_id || + card_index === undefined || + is_correct === undefined + ) { + return res.status(400).json({ + error: + "Missing required fields: ai_chat_history_id, card_index, and is_correct are required", + }); + } + + // First, find the AiChatHistory record by quiz_id to get the actual id + const aiChatHistory = await AiChatHistory.findOne({ + where: { quiz_id: ai_chat_history_id }, + }); + + if (!aiChatHistory) { + return res.status(404).json({ + error: "Flashcard set not found", + details: `No flashcard set found with quiz_id: ${ai_chat_history_id}`, + }); + } + + // Create new progress entry using the actual ai_chat_history_id + const progressEntry = await UserProgress.create({ + user_id: userId, + ai_chat_history_id: aiChatHistory.id, // Use the actual integer ID + card_index, + is_correct, + score: null, // null for flashcards + duration_ms, + session_id, + studied_at: new Date(), + }); + + // āœ… Check for newly earned badges + const newlyEarnedBadges = await checkAndAwardBadges(userId); + + res.status(201).json({ + message: "Flashcard progress recorded successfully", + progress: progressEntry, + newlyEarnedBadges: newlyEarnedBadges, + }); + } catch (e) { + console.error("Record flashcard progress error āŒ", e); + res.status(500).json({ error: "Server error" }); + } +}); + +// Record quiz progress when user takes quiz +router.post("/quiz-progress", authenticateJWT, async (req, res) => { + const userId = req.user.id; // Get user ID from JWT token + const { ai_chat_history_id, score, duration_ms, session_id } = req.body; + + try { + // Validate required fields + if (!ai_chat_history_id || score === undefined) { + return res.status(400).json({ + error: + "Missing required fields: ai_chat_history_id and score are required", + }); + } + + // First, find the AiChatHistory record by quiz_id to get the actual id + const aiChatHistory = await AiChatHistory.findOne({ + where: { quiz_id: ai_chat_history_id }, + }); + + if (!aiChatHistory) { + return res.status(404).json({ + error: "Quiz not found", + details: `No quiz found with quiz_id: ${ai_chat_history_id}`, + }); + } + + // Create new progress entry using the actual ai_chat_history_id + const progressEntry = await UserProgress.create({ + user_id: userId, + ai_chat_history_id: aiChatHistory.id, // Use the actual integer ID + card_index: null, // null for quizzes + is_correct: null, // null for quizzes + score, + duration_ms, + session_id, + studied_at: new Date(), + }); + + // āœ… Check for newly earned badges + const newlyEarnedBadges = await checkAndAwardBadges(userId); + + res.status(201).json({ + message: "Quiz progress recorded successfully", + progress: progressEntry, + newlyEarnedBadges: newlyEarnedBadges, + }); + } catch (e) { + console.error("Record quiz progress error āŒ", e); + res.status(500).json({ error: "Server error" }); + } +}); + +// Get real-time progress for a specific flashcard set +router.get("/flashcard-set/:aiChatHistoryId/:userId", async (req, res) => { + const { aiChatHistoryId, userId } = req.params; + + try { + const progress = await UserProgress.findAll({ + where: { + user_id: userId, + ai_chat_history_id: aiChatHistoryId, + }, + order: [["studied_at", "ASC"]], + }); + + const totalAttempts = progress.length; + const correctAttempts = progress.filter( + (p) => p.is_correct === true + ).length; + const accuracy = + totalAttempts > 0 + ? Math.round((correctAttempts / totalAttempts) * 100) + : 0; + + res.json({ + flashcard_set_id: aiChatHistoryId, + total_attempts: totalAttempts, + correct_attempts: correctAttempts, + accuracy: accuracy, + progress_entries: progress, + }); + } catch (e) { + console.error("Get flashcard set progress error āŒ", e); + res.status(500).json({ error: "Server error" }); + } +}); + +// Get progress summary for dashboard +router.get("/summary/:userId", async (req, res) => { + const { userId } = req.params; + + try { + // Get today's date + const today = new Date(); + const startOfToday = new Date( + today.getFullYear(), + today.getMonth(), + today.getDate(), + 0, + 0, + 0, + 0 + ); + const endOfToday = new Date( + today.getFullYear(), + today.getMonth(), + today.getDate(), + 23, + 59, + 59, + 999 + ); + + // Get this week's date range + const dayOfWeek = today.getDay(); + const startOfWeek = new Date(today); + startOfWeek.setDate(today.getDate() - dayOfWeek); + startOfWeek.setHours(0, 0, 0, 0); + + const endOfWeek = new Date(startOfWeek); + endOfWeek.setDate(startOfWeek.getDate() + 6); + endOfWeek.setHours(23, 59, 59, 999); + + // Get all time progress + const allTimeProgress = await UserProgress.findAll({ + where: { user_id: userId }, + include: [ + { + model: AiChatHistory, + attributes: ["id", "response_type"], + }, + ], + }); + + // Get today's progress + const todayProgress = await UserProgress.findAll({ + where: { + user_id: userId, + studied_at: { [Op.between]: [startOfToday, endOfToday] }, + }, + include: [ + { + model: AiChatHistory, + attributes: ["id", "response_type"], + }, + ], + }); + + // Get this week's progress + const weekProgress = await UserProgress.findAll({ + where: { + user_id: userId, + studied_at: { [Op.between]: [startOfWeek, endOfWeek] }, + }, + include: [ + { + model: AiChatHistory, + attributes: ["id", "response_type"], + }, + ], + }); + + // Calculate statistics + const calculateStats = (progressData) => { + const flashAttempts = progressData.filter( + (r) => r.AiChatHistory?.response_type === "flashcard" + ); + const quizRows = progressData.filter( + (r) => r.AiChatHistory?.response_type === "quiz" + ); + + const flashStudied = flashAttempts.length; + const flashCorrect = flashAttempts.filter( + (r) => r.is_correct === true + ).length; + const flashAccuracy = flashStudied + ? Math.round((flashCorrect / flashStudied) * 100) + : 0; + + const quizAttempts = quizRows.filter((r) => r.score != null).length; + const quizAvgScore = quizAttempts + ? Math.round( + quizRows + .filter((r) => r.score != null) + .reduce((s, r) => s + r.score, 0) / quizAttempts + ) + : 0; + + // āœ… Calculate total study time from duration_ms + const totalStudyTime = progressData.reduce((total, record) => { + return total + (record.duration_ms || 0); + }, 0); + + // āœ… Format study time + const hours = Math.floor(totalStudyTime / (1000 * 60 * 60)); + const minutes = Math.floor( + (totalStudyTime % (1000 * 60 * 60)) / (1000 * 60) + ); + const formattedStudyTime = `${hours}h ${minutes}m`; + + return { + flashStudied, + flashCorrect, + flashAccuracy, + quizAttempts, + quizAvgScore, + totalSessions: progressData.length, + totalStudyTime: formattedStudyTime, // āœ… Add study time + totalStudyTimeMs: totalStudyTime, // āœ… Raw milliseconds for calculations + }; + }; + + const allTimeStats = calculateStats(allTimeProgress); + const todayStats = calculateStats(todayProgress); + const weekStats = calculateStats(weekProgress); + + res.json({ + today: todayStats, + this_week: weekStats, + all_time: allTimeStats, + // āœ… Removed inaccurate streak_days - use /api/sessions/streak/:userId for accurate streak data + }); + } catch (e) { + console.error("Progress summary error āŒ", e); + res.status(500).json({ error: "Server error" }); + } +}); + +// Get daily progress for the last 7 days for charts +router.get("/daily-chart/:userId", async (req, res) => { + const { userId } = req.params; + + try { + const chartData = []; + const today = new Date(); + + // Get data for the last 7 days + for (let i = 6; i >= 0; i--) { + const date = new Date(today); + date.setDate(today.getDate() - i); + + const start = new Date( + date.getFullYear(), + date.getMonth(), + date.getDate(), + 0, + 0, + 0, + 0 + ); + const end = new Date( + date.getFullYear(), + date.getMonth(), + date.getDate(), + 23, + 59, + 59, + 999 + ); + + const rows = await UserProgress.findAll({ + where: { + user_id: userId, + studied_at: { [Op.between]: [start, end] }, + }, + include: [ + { + model: AiChatHistory, + attributes: ["id", "response_type"], + }, + ], + order: [["studied_at", "ASC"]], + }); + + const flashAttempts = rows.filter( + (r) => r.AiChatHistory?.response_type === "flashcard" + ); + const quizRows = rows.filter( + (r) => r.AiChatHistory?.response_type === "quiz" + ); + + const flashStudied = flashAttempts.length; + const flashCorrect = flashAttempts.filter( + (r) => r.is_correct === true + ).length; + const flashAccuracy = flashStudied + ? Math.round((flashCorrect / flashStudied) * 100) + : 0; + + const quizAttempts = quizRows.filter((r) => r.score != null).length; + const quizAvgScore = quizAttempts + ? Math.round( + quizRows + .filter((r) => r.score != null) + .reduce((s, r) => s + r.score, 0) / quizAttempts + ) + : 0; + + // āœ… Calculate daily study time + const dailyStudyTime = rows.reduce((total, record) => { + return total + (record.duration_ms || 0); + }, 0); + + chartData.push({ + date: date.toISOString().split("T")[0], + day: date.toLocaleDateString("en-US", { weekday: "short" }), + flashcardAccuracy: flashAccuracy, + quizScore: quizAvgScore, + flashcardCount: flashStudied, + quizCount: quizAttempts, + duration_ms: dailyStudyTime, // āœ… Add daily study time + }); + } + + res.json({ + chartData, + dateRange: { + start: new Date(today.getTime() - 6 * 24 * 60 * 60 * 1000) + .toISOString() + .split("T")[0], + end: today.toISOString().split("T")[0], + }, + }); + } catch (e) { + console.error("Daily chart data error āŒ", e); + res.status(500).json({ error: "Server error" }); + } +}); + +// Protected endpoint to get current user's data +router.get("/current-user", authenticateJWT, async (req, res) => { + try { + const userId = req.user.id; + + // Get user summary + const summary = await UserProgress.findAll({ + where: { user_id: userId }, + include: [ + { + model: AiChatHistory, + attributes: ["id", "response_type"], + }, + ], + order: [["studied_at", "DESC"]], + }); + + // Calculate all-time stats + const allTimeStats = summary.reduce( + (stats, record) => { + if (record.AiChatHistory?.response_type === "flashcard") { + stats.totalFlashcards++; + if (record.is_correct) stats.correctFlashcards++; + stats.totalStudyTime += record.duration_ms || 0; + } else if (record.AiChatHistory?.response_type === "quiz") { + stats.totalQuizzes++; + if (record.score != null) { + stats.totalQuizScore += record.score; + stats.quizCount++; + } + stats.totalStudyTime += record.duration_ms || 0; + } + return stats; + }, + { + totalFlashcards: 0, + correctFlashcards: 0, + totalQuizzes: 0, + totalQuizScore: 0, + quizCount: 0, + totalStudyTime: 0, + } + ); + + const flashAccuracy = + allTimeStats.totalFlashcards > 0 + ? Math.round( + (allTimeStats.correctFlashcards / allTimeStats.totalFlashcards) * + 100 + ) + : 0; + + const avgQuizScore = + allTimeStats.quizCount > 0 + ? Math.round(allTimeStats.totalQuizScore / allTimeStats.quizCount) + : 0; + + // Get today's stats + const today = new Date(); + const startOfDay = new Date( + today.getFullYear(), + today.getMonth(), + today.getDate(), + 0, + 0, + 0, + 0 + ); + const endOfDay = new Date( + today.getFullYear(), + today.getMonth(), + today.getDate(), + 23, + 59, + 59, + 999 + ); + + const todayStats = summary.filter( + (record) => + record.studied_at >= startOfDay && record.studied_at <= endOfDay + ); + + const todayFlashcards = todayStats.filter( + (r) => r.AiChatHistory?.response_type === "flashcard" + ); + const todayQuizzes = todayStats.filter( + (r) => r.AiChatHistory?.response_type === "quiz" + ); + + const todayFlashAccuracy = + todayFlashcards.length > 0 + ? Math.round( + (todayFlashcards.filter((r) => r.is_correct).length / + todayFlashcards.length) * + 100 + ) + : 0; + + const todayQuizScore = + todayQuizzes.filter((r) => r.score != null).length > 0 + ? Math.round( + todayQuizzes + .filter((r) => r.score != null) + .reduce((sum, r) => sum + r.score, 0) / + todayQuizzes.filter((r) => r.score != null).length + ) + : 0; + + res.json({ + user: { + id: req.user.id, + username: req.user.username, + email: req.user.email, + }, + today: { + flashcardAccuracy: todayFlashAccuracy, + quizScore: todayQuizScore, + flashcardCount: todayFlashcards.length, + quizCount: todayQuizzes.length, + studyTime: Math.round( + todayStats.reduce((sum, r) => sum + (r.duration_ms || 0), 0) / 60000 + ), + }, + all_time: { + totalSessions: summary.length, + totalPoints: allTimeStats.totalFlashcards + allTimeStats.totalQuizzes, + totalStudyTime: Math.round(allTimeStats.totalStudyTime / 60000) + "m", + flashAccuracy: flashAccuracy, + avgQuizScore: avgQuizScore, + totalFlashcards: allTimeStats.totalFlashcards, + totalQuizzes: allTimeStats.totalQuizzes, + }, + }); + } catch (error) { + console.error("Current user data error:", error); + res.status(500).json({ error: "Failed to fetch user data" }); + } +}); + +// Protected endpoint to get current user's daily chart data +router.get("/daily-chart", authenticateJWT, async (req, res) => { + try { + const userId = req.user.id; + const chartData = []; + const today = new Date(); + + // Get data for the last 7 days + for (let i = 6; i >= 0; i--) { + const date = new Date(today); + date.setDate(today.getDate() - i); + + const start = new Date( + date.getFullYear(), + date.getMonth(), + date.getDate(), + 0, + 0, + 0, + 0 + ); + const end = new Date( + date.getFullYear(), + date.getMonth(), + date.getDate(), + 23, + 59, + 59, + 999 + ); + + const rows = await UserProgress.findAll({ + where: { + user_id: userId, + studied_at: { [Op.between]: [start, end] }, + }, + include: [ + { + model: AiChatHistory, + attributes: ["id", "response_type"], + }, + ], + order: [["studied_at", "ASC"]], + }); + + const flashAttempts = rows.filter( + (r) => r.AiChatHistory?.response_type === "flashcard" + ); + const quizRows = rows.filter( + (r) => r.AiChatHistory?.response_type === "quiz" + ); + + const flashStudied = flashAttempts.length; + const flashCorrect = flashAttempts.filter( + (r) => r.is_correct === true + ).length; + const flashAccuracy = flashStudied + ? Math.round((flashCorrect / flashStudied) * 100) + : 0; + + const quizAttempts = quizRows.filter((r) => r.score != null).length; + const quizAvgScore = quizAttempts + ? Math.round( + quizRows + .filter((r) => r.score != null) + .reduce((s, r) => s + r.score, 0) / quizAttempts + ) + : 0; + + // Calculate daily study time + const dailyStudyTime = rows.reduce((total, record) => { + return total + (record.duration_ms || 0); + }, 0); + + chartData.push({ + date: date.toISOString().split("T")[0], + day: date.toLocaleDateString("en-US", { weekday: "short" }), + flashcardAccuracy: flashAccuracy, + quizScore: quizAvgScore, + flashcardCount: flashStudied, + quizCount: quizAttempts, + duration_ms: dailyStudyTime, + }); + } + + res.json({ + chartData, + dateRange: { + start: new Date(today.getTime() - 6 * 24 * 60 * 60 * 1000) + .toISOString() + .split("T")[0], + end: today.toISOString().split("T")[0], + }, + }); + } catch (e) { + console.error("Daily chart data error āŒ", e); + res.status(500).json({ error: "Server error" }); + } +}); + +// Protected endpoint to get current user's summary data +router.get("/summary", authenticateJWT, async (req, res) => { + try { + const userId = req.user.id; + + // Get user summary + const summary = await UserProgress.findAll({ + where: { user_id: userId }, + include: [ + { + model: AiChatHistory, + attributes: ["id", "response_type"], + }, + ], + order: [["studied_at", "DESC"]], + }); + + // Calculate all-time stats + const allTimeStats = summary.reduce( + (stats, record) => { + if (record.AiChatHistory?.response_type === "flashcard") { + stats.totalFlashcards++; + if (record.is_correct) stats.correctFlashcards++; + stats.totalStudyTime += record.duration_ms || 0; + } else if (record.AiChatHistory?.response_type === "quiz") { + stats.totalQuizzes++; + if (record.score != null) { + stats.totalQuizScore += record.score; + stats.quizCount++; + } + stats.totalStudyTime += record.duration_ms || 0; + } + return stats; + }, + { + totalFlashcards: 0, + correctFlashcards: 0, + totalQuizzes: 0, + totalQuizScore: 0, + quizCount: 0, + totalStudyTime: 0, + } + ); + + const flashAccuracy = + allTimeStats.totalFlashcards > 0 + ? Math.round( + (allTimeStats.correctFlashcards / allTimeStats.totalFlashcards) * + 100 + ) + : 0; + + const avgQuizScore = + allTimeStats.quizCount > 0 + ? Math.round(allTimeStats.totalQuizScore / allTimeStats.quizCount) + : 0; + + // Get today's stats + const today = new Date(); + const startOfDay = new Date( + today.getFullYear(), + today.getMonth(), + today.getDate(), + 0, + 0, + 0, + 0 + ); + const endOfDay = new Date( + today.getFullYear(), + today.getMonth(), + today.getDate(), + 23, + 59, + 59, + 999 + ); + + const todayStats = summary.filter( + (record) => + record.studied_at >= startOfDay && record.studied_at <= endOfDay + ); + + const todayFlashcards = todayStats.filter( + (r) => r.AiChatHistory?.response_type === "flashcard" + ); + const todayQuizzes = todayStats.filter( + (r) => r.AiChatHistory?.response_type === "quiz" + ); + + const todayFlashAccuracy = + todayFlashcards.length > 0 + ? Math.round( + (todayFlashcards.filter((r) => r.is_correct).length / + todayFlashcards.length) * + 100 + ) + : 0; + + const todayQuizScore = + todayQuizzes.filter((r) => r.score != null).length > 0 + ? Math.round( + todayQuizzes + .filter((r) => r.score != null) + .reduce((sum, r) => sum + r.score, 0) / + todayQuizzes.filter((r) => r.score != null).length + ) + : 0; + + res.json({ + today: { + flashcardAccuracy: todayFlashAccuracy, + quizScore: todayQuizScore, + flashcardCount: todayFlashcards.length, + quizCount: todayQuizzes.length, + studyTime: Math.round( + todayStats.reduce((sum, r) => sum + (r.duration_ms || 0), 0) / 60000 + ), + }, + all_time: { + totalSessions: summary.length, + totalPoints: allTimeStats.totalFlashcards + allTimeStats.totalQuizzes, + totalStudyTime: Math.round(allTimeStats.totalStudyTime / 60000) + "m", + flashAccuracy: flashAccuracy, + avgQuizScore: avgQuizScore, + totalFlashcards: allTimeStats.totalFlashcards, + totalQuizzes: allTimeStats.totalQuizzes, + }, + }); + } catch (error) { + console.error("Current user summary error:", error); + res.status(500).json({ error: "Failed to fetch user summary" }); + } +}); + +module.exports = router; diff --git a/api/aichathistory.js b/api/aichathistory.js new file mode 100644 index 0000000..8b555be --- /dev/null +++ b/api/aichathistory.js @@ -0,0 +1,761 @@ +const express = require("express"); +const router = express.Router(); +const { AiChatHistory } = require("../database"); +const Anthropic = require("@anthropic-ai/sdk"); +const { nanoid } = require("nanoid"); + +const anthropic = new Anthropic({ + apiKey: process.env.ANTHROPIC_API_KEY, +}); + +// Safely sanitize JSON string for database storage +function sanitizeJsonString(jsonString) { + try { + // First try to parse and re-stringify to ensure valid JSON + const parsed = JSON.parse(jsonString); + return JSON.stringify(parsed); + } catch (error) { + // If parsing fails, try to clean it up + let cleaned = jsonString + .replace(/\n/g, "\\n") // Escape newlines + .replace(/\r/g, "\\r") // Escape carriage returns + .replace(/\t/g, "\\t") // Escape tabs + .replace(/\\/g, "\\\\") // Escape backslashes + .replace(/"/g, '\\"') // Escape quotes + .replace(/\f/g, "\\f") // Escape form feeds + .replace(/\b/g, "\\b"); // Escape backspaces + + try { + // Try to parse the cleaned string + JSON.parse(cleaned); + return cleaned; + } catch (secondError) { + // If still fails, return a safe fallback + console.error("Failed to sanitize JSON:", secondError); + return JSON.stringify({ + error: "Malformed response", + original: jsonString.substring(0, 100) + "...", + }); + } + } +} + +// More robust JSON extraction that can handle malformed JSON +function extractQuizDataRobust(text) { + if (!text || typeof text !== "string") return null; + + console.log( + "Attempting to extract JSON from:", + text.substring(0, 200) + "..." + ); + + // First try: direct JSON parse + try { + const parsed = JSON.parse(text); + if (Array.isArray(parsed)) { + console.log( + "Direct JSON parse successful, found array with", + parsed.length, + "items" + ); + return parsed; + } + + // Look for array fields in object + if (parsed && typeof parsed === "object") { + const possibleArrays = [ + "items", + "quiz", + "flashcards", + "data", + "questions", + ]; + for (const key of possibleArrays) { + if (Array.isArray(parsed[key])) { + console.log( + "Found array in field:", + key, + "with", + parsed[key].length, + "items" + ); + return parsed[key]; + } + } + } + } catch (error) { + console.log("Direct JSON parse failed:", error.message); + } + + // Try to clean the text and parse again + try { + // Remove common prefixes/suffixes that AI might add + let cleanedText = text.trim(); + + // Remove markdown code blocks if present + cleanedText = cleanedText + .replace(/```json\s*/g, "") + .replace(/```\s*$/g, ""); + + // Remove any text before the first [ + const firstBracket = cleanedText.indexOf("["); + if (firstBracket > 0) { + cleanedText = cleanedText.substring(firstBracket); + } + + // Remove any text after the last ] + const lastBracket = cleanedText.lastIndexOf("]"); + if (lastBracket > 0 && lastBracket < cleanedText.length - 1) { + cleanedText = cleanedText.substring(0, lastBracket + 1); + } + + console.log( + "Attempting to parse cleaned text:", + cleanedText.substring(0, 200) + "..." + ); + + const parsed = JSON.parse(cleanedText); + if (Array.isArray(parsed)) { + console.log( + "Cleaned text parse successful, found array with", + parsed.length, + "items" + ); + return parsed; + } + } catch (error) { + console.log("Cleaned text parse failed:", error.message); + } + + // Second try: extract array with bracket counting + try { + const start = text.indexOf("["); + if (start === -1) { + console.log("No opening bracket found"); + return null; + } + + let bracketCount = 0; + let end = -1; + let inString = false; + let escapeNext = false; + + for (let i = start; i < text.length; i++) { + const char = text[i]; + + if (escapeNext) { + escapeNext = false; + continue; + } + + if (char === "\\") { + escapeNext = true; + continue; + } + + if (char === '"' && !escapeNext) { + inString = !inString; + continue; + } + + if (!inString) { + if (char === "[") { + bracketCount++; + } else if (char === "]") { + bracketCount--; + if (bracketCount === 0) { + end = i; + break; + } + } + } + } + + if (end === -1) { + console.log("Bracket counting failed - unbalanced brackets"); + return null; + } + + const jsonString = text.substring(start, end + 1); + console.log("Extracted JSON string:", jsonString.substring(0, 100) + "..."); + + const parsed = JSON.parse(jsonString); + if (Array.isArray(parsed)) { + console.log( + "Bracket extraction successful, found array with", + parsed.length, + "items" + ); + return parsed; + } + } catch (error) { + console.log("Bracket extraction failed:", error.message); + } + + // Third try: regex-based extraction + try { + const arrayRegex = /\[[\s\S]*?\]/g; + const matches = text.match(arrayRegex); + + if (matches) { + for (const match of matches) { + try { + const parsed = JSON.parse(match); + if (Array.isArray(parsed) && parsed.length > 0) { + console.log( + "Regex extraction successful, found array with", + parsed.length, + "items" + ); + return parsed; + } + } catch (error) { + console.log("Regex match parse failed:", error.message); + } + } + } + } catch (error) { + console.log("Regex extraction failed:", error.message); + } + + console.log("All extraction methods failed"); + return null; +} + +router.post("/", async (req, res) => { + const { user_request } = req.body; + try { + const response = await anthropic.messages.create({ + model: "claude-3-5-haiku-20241022", + max_tokens: 4000, + messages: [ + { + role: "user", + content: [ + { + type: "text", + text: `${user_request} +You are an expert AI study assistant designed to help students excel in their academic pursuits. You can create comprehensive study materials including flashcards, quizzes, and detailed study plans. + +RESPONSE TYPES: +1. FLASHCARDS: When user requests flashcards or memorization help +2. QUIZ: When user requests practice questions or assessments +3. STUDY_PLAN: When user asks for study plans, strategies, or learning guidance + +INSTRUCTIONS: +- Analyze the user's request to determine the most appropriate response type +- For flashcards/quiz: Create exactly 10 high-quality items and respond with valid JSON array +- For study plans: Provide comprehensive, actionable study strategies as regular text (NOT JSON) +- Ensure all content is academically rigorous, accurate, and exam-focused +- Use evidence-based learning techniques and cognitive science principles + +RESPONSE FORMATS: + +For flashcards and quizzes, respond with a valid JSON array. + +For FLASHCARDS (JSON array): +[ + { + "front": "Clear, specific question or concept", + "back": "Comprehensive, accurate answer with key details", + "difficulty": "easy|medium|hard", + "cognitive_skill": "recall|comprehension|application|analysis|synthesis|evaluation", + "topic": "specific subtopic this covers" + } +] + +For QUIZ (JSON array): +Create exactly 10 questions in this format: +[ + { + "question": "What is the first stage of the water cycle?", + "options": ["A) Evaporation", "B) Condensation", "C) Precipitation", "D) Collection"], + "correct": "A", + "explanation": "Evaporation is the first stage where water turns from liquid to vapor due to heat from the sun.", + "difficulty": "easy", + "cognitive_skill": "recall", + "topic": "water cycle stages" + } +] + +For STUDY_PLAN (Regular text format): +Provide a comprehensive, well-structured study plan in clear, readable text format. Include: + +1. Overview and Learning Objectives +2. Detailed Study Schedule (day-by-day breakdown) +3. Active Learning Strategies +4. Recommended Resources and Materials +5. Assessment and Practice Methods +6. Common Pitfalls to Avoid +7. Success Indicators and Progress Tracking +8. Time Management Tips + +Format the response as clear, organized text with headings, bullet points, and structured sections. Do NOT use JSON format for study plans. + +QUALITY STANDARDS: +- Academic accuracy: All content must be factually correct and up-to-date +- Cognitive depth: Include higher-order thinking skills (analysis, synthesis, evaluation) +- Exam alignment: Focus on concepts commonly tested in academic assessments +- Learning science: Incorporate spaced repetition, active recall, and interleaving principles +- Accessibility: Clear, concise language appropriate for the academic level +- Comprehensive coverage: Address key concepts, common misconceptions, and advanced topics + +RESPONSE RULES: +- For flashcards/quiz: Respond with valid JSON array +- For quizzes: Create exactly 10 questions +- For study plans: Respond with clear, structured text (NOT JSON format) +- Ensure all JSON is properly formatted and parseable +- Include difficulty progression and varied cognitive skills +- Focus on mastery learning and deep understanding + +IMPORTANT: When creating quizzes, respond with ONLY a JSON array containing exactly 10 question objects. Do not add any text before or after the JSON array. +`, + }, + ], + }, + ], + }); + const replyContent = response?.content?.find((c) => c.type === "text"); + const replyText = replyContent?.text || "Sorry, no response."; + + // Log the raw AI response for debugging + console.log("šŸ¤– RAW AI RESPONSE:"); + console.log("Response length:", replyText.length); + console.log("Response preview:", replyText.substring(0, 1000)); + console.log( + "Contains JSON array markers:", + replyText.includes("[") && replyText.includes("]") + ); + + // const isQuiz = replyText.includes("## Question 1"); + // const quizId = isQuiz ? nanoid(8) : null; + + // const responseType = isQuiz ? "quiz" : "flashcard"; + + // await AiChatHistory.create({ + // user_id: req.user?.id || null, + // user_request, + // ai_response: replyText, + // quiz_id: quizId, + // response_type: responseType, + // status: "success", + // }); + + // if (isQuiz && quizId) { + // const link = `http://localhost:3000/quiz/${quizId}`; + // return res.status(200).send({ + // reply: `Your quiz is ready! [Click here to take it](${link})`, + // }); + // } + + let parsed = extractQuizDataRobust(replyText); + + // Debug logging to see what we're getting + console.log("šŸ” DEBUG: Raw AI Response:"); + console.log("Length:", replyText.length); + console.log("First 500 chars:", replyText.substring(0, 500)); + console.log("Last 500 chars:", replyText.substring(replyText.length - 500)); + console.log("Contains '[':", replyText.includes("[")); + console.log("Contains ']':", replyText.includes("]")); + console.log("Contains 'question':", replyText.includes("question")); + console.log("Parsed result:", parsed); + console.log("Parsed type:", typeof parsed); + console.log( + "Parsed length:", + Array.isArray(parsed) ? parsed.length : "N/A" + ); + + // Determine response type by inspecting the parsed JSON and user request + let responseType = "unknown"; // Start neutral instead of defaulting to flashcard + + // First, check if the user specifically requested a study plan + const userRequestLower = (user_request || "").toLowerCase(); + const isStudyPlanRequest = + userRequestLower.includes("study plan") || + userRequestLower.includes("study strategy") || + userRequestLower.includes("learning plan") || + userRequestLower.includes("how to study") || + userRequestLower.includes("study guide"); + + // Check if user specifically requested a quiz + const isQuizRequest = + userRequestLower.includes("quiz") || + userRequestLower.includes("test") || + userRequestLower.includes("practice questions") || + userRequestLower.includes("assessment") || + userRequestLower.includes("multiple choice") || + userRequestLower.includes("mcq"); + + // Check if user specifically requested flashcards + const isFlashcardRequest = + userRequestLower.includes("flashcard") || + userRequestLower.includes("memorization") || + userRequestLower.includes("memory") || + userRequestLower.includes("recall"); + + if (isStudyPlanRequest) { + responseType = "study_plan"; + } else if (Array.isArray(parsed) && parsed.length > 0) { + const firstItem = parsed[0]; + + // More flexible quiz detection - just needs question field + const looksLikeQuiz = + firstItem && typeof firstItem === "object" && "question" in firstItem; + + // More flexible flashcard detection - just needs front/back fields + const looksLikeFlashcard = + firstItem && + typeof firstItem === "object" && + "front" in firstItem && + "back" in firstItem; + + if (looksLikeQuiz) { + responseType = "quiz"; + } else if (looksLikeFlashcard) { + responseType = "flashcard"; + } + } else if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) { + // Check if it's a study plan JSON object + const looksLikeStudyPlan = + parsed && + typeof parsed === "object" && + ("title" in parsed || + "study_schedule" in parsed || + "learning_objectives" in parsed); + if (looksLikeStudyPlan) responseType = "study_plan"; + } + + // Enhanced fallback detection with user request consideration + if (responseType === "unknown") { + const lower = replyText.toLowerCase(); + const hasQuestion = /"question"\s*:/.test(lower); + const hasOptions = /"options"\s*:\s*\[/.test(lower); + const hasCorrect = /"correct"\s*:/.test(lower); + const hasFront = /"front"\s*:/.test(lower); + const hasBack = /"back"\s*:/.test(lower); + const hasStudyPlan = + /"study_schedule"\s*:/.test(lower) || + /"learning_objectives"\s*:/.test(lower); + + // Prioritize user request over content analysis + if (isQuizRequest && (hasQuestion || hasOptions)) { + responseType = "quiz"; + } else if (isFlashcardRequest && (hasFront || hasBack)) { + responseType = "flashcard"; + } else if (hasQuestion && hasOptions) { + responseType = "quiz"; + } else if (hasFront && hasBack) { + responseType = "flashcard"; + } else if (hasStudyPlan) { + responseType = "study_plan"; + } else if (isQuizRequest) { + // If user asked for quiz but content doesn't match, still treat as quiz + responseType = "quiz"; + } else if (isFlashcardRequest) { + // If user asked for flashcards but content doesn't match, still treat as flashcard + responseType = "flashcard"; + } else { + // Final fallback - default to flashcard only if we have no other clues + responseType = "flashcard"; + } + } + + // Debug logging to help troubleshoot response type detection + console.log("šŸ” Response Type Detection Debug:"); + console.log(" User Request:", user_request); + console.log(" User Request Lower:", userRequestLower); + console.log(" Is Quiz Request:", isQuizRequest); + console.log(" Is Flashcard Request:", isFlashcardRequest); + console.log(" Is Study Plan Request:", isStudyPlanRequest); + console.log( + " Parsed Data Type:", + Array.isArray(parsed) ? "Array" : typeof parsed + ); + console.log( + " Parsed Data Length:", + Array.isArray(parsed) ? parsed.length : "N/A" + ); + console.log(" Final Response Type:", responseType); + console.log(" Content Preview:", replyText.substring(0, 200) + "..."); + + const quizId = responseType === "quiz" ? nanoid(8) : null; + + // Generate ID for both quiz and flashcard + const contentId = nanoid(8); // Always generate an ID + + try { + await AiChatHistory.create({ + user_id: req.user?.id || null, + user_request, + ai_response: + responseType === "study_plan" + ? replyText // Store study plan as plain text + : Array.isArray(parsed) + ? sanitizeJsonString(JSON.stringify(parsed)) + : sanitizeJsonString(replyText), + quiz_id: contentId, // Use the same ID for both types + response_type: responseType, + status: "success", + }); + } catch (dbErr) { + console.error("Error saving AI chat history āŒ", dbErr.message || dbErr); + } + + // Generate link for quiz, flashcard, and study plan + let contentLink = null; + let userMessage = replyText; + + if (responseType === "quiz") { + const baseUrl = process.env.FRONTEND_URL || "http://localhost:3000"; + contentLink = `${baseUrl}/quiz/${contentId}`; + userMessage = `Your quiz is ready! Click here to take it: ${contentLink}`; + } else if (responseType === "flashcard") { + const baseUrl = process.env.FRONTEND_URL || "http://localhost:3000"; + contentLink = `${baseUrl}/flashcards/${contentId}`; + userMessage = `Your flashcards are ready! Click here to study: ${contentLink}`; + } else if (responseType === "study_plan") { + // For study plans, return the text directly without a link + userMessage = replyText; // Return the study plan as text + contentLink = null; // No link needed for study plans + } + + res.status(200).send({ + reply: userMessage, + data: responseType === "study_plan" ? null : parsed, // No parsed data for study plans + quiz_id: contentId, // Keep the field name for backward compatibility + response_type: responseType, + quiz_link: contentLink, // Keep the field name for backward compatibility + content_id: contentId, // Add new field for clarity + content_link: contentLink, // Add new field for clarity + }); + } catch (error) { + console.error( + "Error getting a response āŒ", + error.response?.data || error.message || error + ); + res.status(500).send("Error getting a response."); + } +}); + +// Utility route to clean up malformed JSON data (run once if needed) +router.post("/cleanup-json", async (req, res) => { + try { + const allRecords = await AiChatHistory.findAll(); + let cleanedCount = 0; + + for (const record of allRecords) { + try { + // Try to parse the stored JSON + JSON.parse(record.ai_response); + } catch (parseError) { + // If parsing fails, try to clean it up + try { + const cleaned = sanitizeJsonString(record.ai_response); + await record.update({ ai_response: cleaned }); + cleanedCount++; + } catch (cleanupError) { + console.error(`Failed to clean record ${record.id}:`, cleanupError); + } + } + } + + res.json({ + message: `Cleaned up ${cleanedCount} malformed JSON records`, + total_records: allRecords.length, + }); + } catch (error) { + console.error("Cleanup error:", error); + res.status(500).json({ error: "Cleanup failed" }); + } +}); + +router.get("/quiz/:quizId", async (req, res) => { + const { quizId } = req.params; + + try { + const history = await AiChatHistory.findOne({ where: { quiz_id: quizId } }); + console.log("Fetched AI Response:", history?.ai_response); + + if (!history) { + return res.status(404).json({ error: "Quiz not found" }); + } + + let parsed = null; + try { + parsed = JSON.parse(history.ai_response); + } catch (_) { + // try to extract array if the stored string isn't pure JSON + try { + parsed = extractQuizDataRobust(history.ai_response); + } catch (extractError) { + console.error("Failed to extract JSON from ai_response:", extractError); + // Return error response instead of crashing + return res.status(500).json({ + error: "Failed to parse quiz data", + details: "The stored quiz data is malformed", + ai_response: history.ai_response.substring(0, 200) + "...", + debug_info: { + response_type: history.response_type, + quiz_id: history.quiz_id, + ai_response_length: history.ai_response.length, + }, + }); + } + } + + if (!parsed) { + return res.status(500).json({ + error: "No quiz data could be extracted", + details: "The response format is not supported", + ai_response: history.ai_response.substring(0, 200) + "...", + debug_info: { + response_type: history.response_type, + quiz_id: history.quiz_id, + }, + }); + } + + return res.status(200).json({ + ai_response: history.ai_response, + data: parsed, + response_type: history.response_type, + quiz_id: history.quiz_id, + }); + } catch (err) { + console.error("Error fetching quiz by ID āŒ", err.message || err); + res.status(500).json({ error: "Server error" }); + } +}); + +// Debug route to inspect stored data +router.get("/debug/:quizId", async (req, res) => { + const { quizId } = req.params; + + try { + const history = await AiChatHistory.findOne({ where: { quiz_id: quizId } }); + + if (!history) { + return res.status(404).json({ error: "Quiz not found" }); + } + + // Try to parse the stored response + let parsed = null; + let parseError = null; + + try { + parsed = JSON.parse(history.ai_response); + } catch (error) { + parseError = error.message; + } + + res.json({ + quiz_id: history.quiz_id, + response_type: history.response_type, + user_request: history.user_request, + ai_response_length: history.ai_response.length, + ai_response_preview: history.ai_response.substring(0, 500) + "...", + parse_success: parsed !== null, + parse_error: parseError, + parsed_data: parsed, + raw_ai_response: history.ai_response, + }); + } catch (error) { + res.status(500).json({ error: "Debug failed", message: error.message }); + } +}); + +// New endpoint for retrieving flashcards by ID +router.get("/flashcards/:flashcardId", async (req, res) => { + const { flashcardId } = req.params; + + try { + const history = await AiChatHistory.findOne({ + where: { + quiz_id: flashcardId, // Using the same field for both types + response_type: "flashcard", + }, + }); + + if (!history) { + return res.status(404).json({ error: "Flashcards not found" }); + } + + let parsed = null; + try { + parsed = JSON.parse(history.ai_response); + } catch (_) { + parsed = extractQuizDataRobust(history.ai_response); + } + + if (!parsed) { + return res.status(500).json({ + error: "No flashcard data could be extracted", + details: "The response format is not supported", + }); + } + + return res.status(200).json({ + ai_response: history.ai_response, + data: parsed, + response_type: history.response_type, + flashcard_id: history.quiz_id, // Using the stored ID + }); + } catch (err) { + console.error("Error fetching flashcards by ID āŒ", err.message || err); + res.status(500).json({ error: "Server error" }); + } +}); + +// New endpoint for retrieving study plans by ID +router.get("/study-plan/:studyPlanId", async (req, res) => { + const { studyPlanId } = req.params; + + try { + const history = await AiChatHistory.findOne({ + where: { + quiz_id: studyPlanId, // Using the same field for all types + response_type: "study_plan", + }, + }); + + if (!history) { + return res.status(404).json({ error: "Study plan not found" }); + } + + let parsed = null; + try { + parsed = JSON.parse(history.ai_response); + } catch (_) { + // For study plans, we expect a JSON object, not an array + try { + const cleaned = sanitizeJsonString(history.ai_response); + parsed = JSON.parse(cleaned); + } catch (extractError) { + console.error("Failed to parse study plan JSON:", extractError); + return res.status(500).json({ + error: "Failed to parse study plan data", + details: "The stored study plan data is malformed", + }); + } + } + + if (!parsed || typeof parsed !== "object") { + return res.status(500).json({ + error: "No study plan data could be extracted", + details: "The response format is not supported", + }); + } + + return res.status(200).json({ + ai_response: history.ai_response, + data: parsed, + response_type: history.response_type, + study_plan_id: history.quiz_id, // Using the stored ID + }); + } catch (err) { + console.error("Error fetching study plan by ID āŒ", err.message || err); + res.status(500).json({ error: "Server error" }); + } +}); + +module.exports = router; diff --git a/api/badges.js b/api/badges.js new file mode 100644 index 0000000..ae0d58f --- /dev/null +++ b/api/badges.js @@ -0,0 +1,396 @@ +const express = require("express"); +const router = express.Router(); +const { Op } = require("sequelize"); +const { + Badge, + UserBadge, + UserProgress, + AiChatHistory, +} = require("../database"); +const { authenticateJWT } = require("../auth"); + +// Get all available badges +router.get("/", async (req, res) => { + try { + const badges = await Badge.findAll({ + order: [ + ["category", "ASC"], + ["requirement_value", "ASC"], + ], + }); + res.json(badges); + } catch (error) { + console.error("Error fetching badges:", error); + res.status(500).json({ error: "Failed to fetch badges" }); + } +}); + +// Get user's earned badges +router.get("/user/:userId", async (req, res) => { + const { userId } = req.params; + + try { + const userBadges = await UserBadge.findAll({ + where: { user_id: userId }, + include: [ + { + model: Badge, + attributes: [ + "id", + "name", + "description", + "icon", + "category", + "rarity", + "points", + ], + }, + ], + order: [["earned_at", "DESC"]], + }); + + res.json(userBadges); + } catch (error) { + console.error("Error fetching user badges:", error); + res.status(500).json({ error: "Failed to fetch user badges" }); + } +}); + +// Get user's badge progress (earned + progress towards unearned) +router.get("/progress/:userId", async (req, res) => { + const { userId } = req.params; + + try { + // Get all badges + const allBadges = await Badge.findAll({ + order: [ + ["category", "ASC"], + ["requirement_value", "ASC"], + ], + }); + + // Get user's earned badges + const earnedBadges = await UserBadge.findAll({ + where: { user_id: userId }, + include: [ + { + model: Badge, + attributes: [ + "id", + "name", + "description", + "icon", + "category", + "rarity", + "points", + ], + }, + ], + }); + + const earnedBadgeIds = earnedBadges.map((ub) => ub.badge_id); + + // Calculate user's current stats + const userStats = await calculateUserStats(userId); + + // Build progress array + const badgeProgress = allBadges.map((badge) => { + const earned = earnedBadges.find((ub) => ub.badge_id === badge.id); + const currentValue = getCurrentValue(badge.requirement_type, userStats); + const isEarned = earned !== undefined; + const progress = Math.min( + (currentValue / badge.requirement_value) * 100, + 100 + ); + + return { + badge: { + id: badge.id, + name: badge.name, + description: badge.description, + icon: badge.icon, + category: badge.category, + rarity: badge.rarity, + points: badge.points, + requirement_type: badge.requirement_type, + requirement_value: badge.requirement_value, + }, + earned: isEarned, + earned_at: earned?.earned_at, + current_value: currentValue, + progress_percentage: progress, + is_new: earned?.is_new || false, + }; + }); + + res.json({ + badgeProgress, + totalEarned: earnedBadges.length, + totalBadges: allBadges.length, + userStats, + }); + } catch (error) { + console.error("Error calculating badge progress:", error); + res.status(500).json({ error: "Failed to calculate badge progress" }); + } +}); + +// Mark badge as viewed (remove "NEW" status) +router.put("/view/:userId/:badgeId", async (req, res) => { + const { userId, badgeId } = req.params; + + try { + await UserBadge.update( + { is_new: false }, + { where: { user_id: userId, badge_id: badgeId } } + ); + + res.json({ message: "Badge marked as viewed" }); + } catch (error) { + console.error("Error marking badge as viewed:", error); + res.status(500).json({ error: "Failed to mark badge as viewed" }); + } +}); + +// Check and award badges (called after user activity) +router.post("/check/:userId", async (req, res) => { + const { userId } = req.params; + + try { + const newlyEarnedBadges = await checkAndAwardBadges(userId); + + res.json({ + message: "Badge check completed", + newlyEarned: newlyEarnedBadges, + count: newlyEarnedBadges.length, + }); + } catch (error) { + console.error("Error checking badges:", error); + res.status(500).json({ error: "Failed to check badges" }); + } +}); + +// Helper function to calculate user stats +async function calculateUserStats(userId) { + // Get all user progress + const allProgress = await UserProgress.findAll({ + where: { user_id: userId }, + include: [ + { + model: AiChatHistory, + attributes: ["id", "response_type"], + }, + ], + }); + + // Calculate streak (simplified - you might want to use the streak API) + const today = new Date(); + const startOfToday = new Date( + today.getFullYear(), + today.getMonth(), + today.getDate() + ); + const todayProgress = allProgress.filter( + (p) => new Date(p.studied_at) >= startOfToday + ); + const currentStreak = todayProgress.length > 0 ? 7 : 0; // Simplified for now + + // Calculate quiz count + const quizProgress = allProgress.filter( + (p) => p.AiChatHistory?.response_type === "quiz" + ); + const quizCount = quizProgress.length; + + // Calculate accuracy + const flashcardProgress = allProgress.filter( + (p) => p.AiChatHistory?.response_type === "flashcard" + ); + const correctFlashcards = flashcardProgress.filter( + (p) => p.is_correct === true + ).length; + const totalFlashcards = flashcardProgress.length; + const accuracy = + totalFlashcards > 0 + ? Math.round((correctFlashcards / totalFlashcards) * 100) + : 0; + + // Calculate average completion time + const validDurations = allProgress.filter( + (p) => p.duration_ms && p.duration_ms > 0 + ); + const avgCompletionTime = + validDurations.length > 0 + ? Math.round( + validDurations.reduce((sum, p) => sum + p.duration_ms, 0) / + validDurations.length + ) + : 0; + + // Calculate total study days + const uniqueDays = [ + ...new Set( + allProgress.map((p) => new Date(p.studied_at).toISOString().split("T")[0]) + ), + ]; + const totalDays = uniqueDays.length; + + return { + currentStreak, + quizCount, + accuracy, + avgCompletionTime, + totalDays, + }; +} + +// Helper function to get current value for a requirement type +function getCurrentValue(requirementType, userStats) { + switch (requirementType) { + case "streak_days": + return userStats.currentStreak; + case "quiz_count": + return userStats.quizCount; + case "accuracy_percentage": + return userStats.accuracy; + case "completion_time": + return userStats.avgCompletionTime; + case "total_days": + return userStats.totalDays; + default: + return 0; + } +} + +// Helper function to check and award badges +async function checkAndAwardBadges(userId) { + const userStats = await calculateUserStats(userId); + const allBadges = await Badge.findAll(); + const earnedBadges = await UserBadge.findAll({ + where: { user_id: userId }, + attributes: ["badge_id"], + }); + + const earnedBadgeIds = earnedBadges.map((ub) => ub.badge_id); + const newlyEarnedBadges = []; + + for (const badge of allBadges) { + // Skip if already earned + if (earnedBadgeIds.includes(badge.id)) { + continue; + } + + const currentValue = getCurrentValue(badge.requirement_type, userStats); + + // Check if badge should be awarded + if (currentValue >= badge.requirement_value) { + // Award the badge + const userBadge = await UserBadge.create({ + user_id: userId, + badge_id: badge.id, + progress_value: currentValue, + is_new: true, + }); + + newlyEarnedBadges.push({ + badge: { + id: badge.id, + name: badge.name, + description: badge.description, + icon: badge.icon, + category: badge.category, + rarity: badge.rarity, + points: badge.points, + }, + earned_at: userBadge.earned_at, + progress_value: currentValue, + }); + } + } + + return newlyEarnedBadges; +} + +// Protected endpoint to get current user's badge progress +router.get("/progress", authenticateJWT, async (req, res) => { + try { + const userId = req.user.id; + + // Get all badges + const allBadges = await Badge.findAll({ + order: [ + ["category", "ASC"], + ["requirement_value", "ASC"], + ], + }); + + // Get user's earned badges + const earnedBadges = await UserBadge.findAll({ + where: { user_id: userId }, + include: [ + { + model: Badge, + attributes: [ + "id", + "name", + "description", + "icon", + "category", + "rarity", + "points", + ], + }, + ], + }); + + const earnedBadgeIds = earnedBadges.map((ub) => ub.badge_id); + + // Calculate user's current stats + const userStats = await calculateUserStats(userId); + + // Build progress array + const badgeProgress = allBadges.map((badge) => { + const earned = earnedBadges.find((ub) => ub.badge_id === badge.id); + const currentValue = getCurrentValue(badge.requirement_type, userStats); + const isEarned = earned !== undefined; + const progress = Math.min( + 100, + Math.round((currentValue / Math.max(1, badge.requirement_value)) * 100) + ); + + return { + badge: { + id: badge.id, + name: badge.name, + description: badge.description, + icon: badge.icon, + category: badge.category, + rarity: badge.rarity, + points: badge.points, + requirement_type: badge.requirement_type, + requirement_value: badge.requirement_value, + }, + earned: isEarned, + earned_at: earned?.earned_at, + current_value: currentValue, + progress_percentage: progress, + is_new: earned?.is_new || false, + }; + }); + + const totalEarned = earnedBadges.length; + const totalBadges = allBadges.length; + + res.json({ + badgeProgress, + totalEarned, + totalBadges, + userStats, + }); + } catch (error) { + console.error("Error fetching badge progress:", error); + res.status(500).json({ error: "Failed to fetch badge progress" }); + } +}); + +module.exports = router; +module.exports.checkAndAwardBadges = checkAndAwardBadges; diff --git a/api/calendar.js b/api/calendar.js new file mode 100644 index 0000000..fa9a40c --- /dev/null +++ b/api/calendar.js @@ -0,0 +1,519 @@ +const express = require("express"); +const CalendarService = require("../services/CalendarService"); +const { authenticateJWT } = require("../auth"); +const { User, Tasks } = require("../database"); + +const router = express.Router(); +const calendarService = new CalendarService(); + +// Check if user has calendar permissions +router.get("/permissions", authenticateJWT, async (req, res) => { + try { + console.log("šŸ” Checking calendar permissions for user:", req.user.id); + + const user = await User.findByPk(req.user.id); + + if (!user) { + console.error("āŒ User not found:", req.user.id); + return res.status(404).json({ error: "User not found" }); + } + + console.log("šŸ“Š User calendar data:", { + id: user.id, + googleAccessToken: !!user.googleAccessToken, + googleRefreshToken: !!user.googleRefreshToken, + calendarPermissions: user.calendarPermissions, + }); + + const hasPermissions = calendarService.hasCalendarPermissions(user); + + console.log("āœ… Calendar permissions check result:", hasPermissions); + + res.json({ + hasPermissions, + calendarPermissions: user.calendarPermissions || false, + hasTokens: !!(user.googleAccessToken && user.googleRefreshToken), + }); + } catch (error) { + console.error("āŒ Error checking calendar permissions:", error); + console.error("Error details:", { + message: error.message, + stack: error.stack, + }); + res.status(500).json({ + error: "Failed to check calendar permissions", + details: error.message, + }); + } +}); + +// Get calendar permission URL +router.get("/permissions/url", authenticateJWT, (req, res) => { + try { + console.log("šŸ”— Generating calendar permission URL for user:", req.user.id); + const permissionUrl = calendarService.getCalendarPermissionUrl(req.user.id); + console.log( + "āœ… Permission URL generated:", + permissionUrl.substring(0, 100) + "..." + ); + res.json({ permissionUrl }); + } catch (error) { + console.error("āŒ Error generating calendar permission URL:", error); + console.error("Error details:", { + message: error.message, + stack: error.stack, + }); + res.status(500).json({ + error: "Failed to generate permission URL", + details: error.message, + }); + } +}); + +// Get user's calendars +router.get("/calendars", authenticateJWT, async (req, res) => { + try { + const user = await User.findByPk(req.user.id); + + if (!calendarService.hasCalendarPermissions(user)) { + return res.status(403).json({ + error: "Calendar permissions required", + needsPermission: true, + }); + } + + const calendars = await calendarService.getCalendars(user); + res.json(calendars); + } catch (error) { + console.error("Error fetching calendars:", error); + + if (error.message.includes("not authenticated")) { + return res.status(403).json({ + error: "Calendar permissions required", + needsPermission: true, + }); + } + + res.status(500).json({ error: "Failed to fetch calendars" }); + } +}); + +// Get calendar events +router.get("/events", authenticateJWT, async (req, res) => { + try { + const user = await User.findByPk(req.user.id); + + if (!calendarService.hasCalendarPermissions(user)) { + return res.status(403).json({ + error: "Calendar permissions required", + needsPermission: true, + }); + } + + const options = { + calendarId: req.query.calendarId, + timeMin: req.query.timeMin, + timeMax: req.query.timeMax, + maxResults: req.query.maxResults + ? parseInt(req.query.maxResults) + : undefined, + singleEvents: req.query.singleEvents !== "false", + orderBy: req.query.orderBy || "startTime", + }; + + const events = await calendarService.getEvents(user, options); + res.json(events); + } catch (error) { + console.error("Error fetching calendar events:", error); + + if (error.message.includes("not authenticated")) { + return res.status(403).json({ + error: "Calendar permissions required", + needsPermission: true, + }); + } + + res.status(500).json({ error: "Failed to fetch calendar events" }); + } +}); + +// Create calendar event from task +router.post("/events", authenticateJWT, async (req, res) => { + try { + const user = await User.findByPk(req.user.id); + + if (!calendarService.hasCalendarPermissions(user)) { + return res.status(403).json({ + error: "Calendar permissions required", + needsPermission: true, + }); + } + + const { + title, + description, + startTime, + endTime, + timeZone, + calendarId, + reminders, + taskId, + } = req.body; + + // Validate required fields + if (!title || !startTime || !endTime) { + return res.status(400).json({ + error: "Title, start time, and end time are required", + }); + } + + const eventData = { + title, + description, + startTime, + endTime, + timeZone, + calendarId, + reminders, + }; + + const event = await calendarService.createEvent(user, eventData); + + // If this event is for a task, update the task with the calendar event ID + if (taskId) { + const task = await Tasks.findOne({ + where: { id: taskId, user_id: user.id }, + }); + + if (task) { + task.calendarEventId = event.id; + task.hasReminder = true; + await task.save(); + } + } + + res.status(201).json({ + success: true, + event, + message: "Calendar event created successfully", + }); + } catch (error) { + console.error("Error creating calendar event:", error); + + if (error.message.includes("not authenticated")) { + return res.status(403).json({ + error: "Calendar permissions required", + needsPermission: true, + }); + } + + res.status(500).json({ error: "Failed to create calendar event" }); + } +}); + +// Update calendar event +router.put("/events/:eventId", authenticateJWT, async (req, res) => { + try { + const user = await User.findByPk(req.user.id); + + if (!calendarService.hasCalendarPermissions(user)) { + return res.status(403).json({ + error: "Calendar permissions required", + needsPermission: true, + }); + } + + const { eventId } = req.params; + const { + title, + description, + startTime, + endTime, + timeZone, + calendarId, + reminders, + } = req.body; + + // Validate required fields + if (!title || !startTime || !endTime) { + return res.status(400).json({ + error: "Title, start time, and end time are required", + }); + } + + const eventData = { + title, + description, + startTime, + endTime, + timeZone, + reminders, + }; + + const event = await calendarService.updateEvent( + user, + eventId, + eventData, + calendarId || "primary" + ); + + res.json({ + success: true, + event, + message: "Calendar event updated successfully", + }); + } catch (error) { + console.error("Error updating calendar event:", error); + + if (error.message.includes("not authenticated")) { + return res.status(403).json({ + error: "Calendar permissions required", + needsPermission: true, + }); + } + + res.status(500).json({ error: "Failed to update calendar event" }); + } +}); + +// Delete calendar event +router.delete("/events/:eventId", authenticateJWT, async (req, res) => { + try { + const user = await User.findByPk(req.user.id); + + if (!calendarService.hasCalendarPermissions(user)) { + return res.status(403).json({ + error: "Calendar permissions required", + needsPermission: true, + }); + } + + const { eventId } = req.params; + const { calendarId } = req.query; + + await calendarService.deleteEvent(user, eventId, calendarId || "primary"); + + // Remove calendar event ID from any associated task + const task = await Tasks.findOne({ + where: { calendarEventId: eventId, user_id: user.id }, + }); + + if (task) { + task.calendarEventId = null; + task.hasReminder = false; + await task.save(); + } + + res.json({ + success: true, + message: "Calendar event deleted successfully", + }); + } catch (error) { + console.error("Error deleting calendar event:", error); + + if (error.message.includes("not authenticated")) { + return res.status(403).json({ + error: "Calendar permissions required", + needsPermission: true, + }); + } + + res.status(500).json({ error: "Failed to delete calendar event" }); + } +}); + +// Sync task with calendar (create/update calendar event for a task) +router.post("/sync-task/:taskId", authenticateJWT, async (req, res) => { + try { + console.log( + "šŸ”„ Syncing task with calendar. TaskID:", + req.params.taskId, + "UserID:", + req.user.id + ); + + const user = await User.findByPk(req.user.id); + + if (!user) { + console.error("āŒ User not found for sync-task:", req.user.id); + return res.status(404).json({ error: "User not found" }); + } + + console.log("šŸ“Š User calendar permissions:", { + hasGoogleAccessToken: !!user.googleAccessToken, + hasGoogleRefreshToken: !!user.googleRefreshToken, + calendarPermissions: user.calendarPermissions, + }); + + if (!calendarService.hasCalendarPermissions(user)) { + console.log( + "āš ļø User does not have calendar permissions, returning graceful failure" + ); + return res.status(200).json({ + success: false, + error: "Calendar permissions required", + needsPermission: true, + message: + "Task created successfully, but calendar sync skipped (no permissions)", + }); + } + + const { taskId } = req.params; + const { startTime, endTime, timeZone, calendarId, reminders } = req.body; + + // Find the task + const task = await Tasks.findOne({ + where: { id: taskId, user_id: user.id }, + }); + + if (!task) { + return res.status(404).json({ error: "Task not found" }); + } + + // Validate required fields + if (!startTime || !endTime) { + console.log("āš ļø No timing data provided for calendar sync"); + return res.status(400).json({ + success: false, + error: "Start time and end time are required for calendar sync", + message: "Please provide start time and end time to sync with calendar", + needsTiming: true, + }); + } + + const eventData = { + title: task.assignment, + description: task.description || `Task: ${task.assignment}`, + startTime, + endTime, + timeZone, + calendarId, + reminders, + }; + + let event; + + // If task already has a calendar event, update it + if (task.calendarEventId) { + try { + event = await calendarService.updateEvent( + user, + task.calendarEventId, + eventData, + calendarId || "primary" + ); + } catch (error) { + // If update fails (e.g., event was deleted), create a new one + console.log( + "Failed to update existing event, creating new one:", + error.message + ); + event = await calendarService.createEvent(user, eventData); + task.calendarEventId = event.id; + } + } else { + // Create new calendar event + event = await calendarService.createEvent(user, eventData); + task.calendarEventId = event.id; + } + + task.hasReminder = true; + await task.save(); + + res.json({ + success: true, + event, + task, + message: "Task synced with calendar successfully", + }); + } catch (error) { + console.error("āŒ Error syncing task with calendar:", error); + console.error("Error details:", { + message: error.message, + stack: error.stack, + taskId: req.params.taskId, + userId: req.user.id, + }); + + if (error.message.includes("not authenticated")) { + return res.status(403).json({ + error: "Calendar permissions required", + needsPermission: true, + }); + } + + // Check if this is a token refresh failure + if ( + error.message.includes("Failed to refresh access token") || + error.message.includes("temporarily unavailable") + ) { + // Reset calendar permissions for this user since tokens are invalid + user.calendarPermissions = false; + user.googleAccessToken = null; + user.googleRefreshToken = null; + await user.save(); + + console.log( + "šŸ”„ Reset calendar permissions for user due to invalid tokens" + ); + + return res.status(200).json({ + success: false, + error: "Calendar authorization expired", + needsPermission: true, + message: + "Task created successfully, but calendar sync failed (please re-authorize Google Calendar)", + }); + } + + res.status(500).json({ + error: "Failed to sync task with calendar", + details: error.message, + }); + } +}); + +// Remove calendar sync from task +router.delete("/sync-task/:taskId", authenticateJWT, async (req, res) => { + try { + const user = await User.findByPk(req.user.id); + const { taskId } = req.params; + + // Find the task + const task = await Tasks.findOne({ + where: { id: taskId, user_id: user.id }, + }); + + if (!task) { + return res.status(404).json({ error: "Task not found" }); + } + + // Delete calendar event if it exists + if (task.calendarEventId && calendarService.hasCalendarPermissions(user)) { + try { + await calendarService.deleteEvent(user, task.calendarEventId); + } catch (error) { + console.log( + "Failed to delete calendar event (may already be deleted):", + error.message + ); + } + } + + // Remove calendar sync from task + task.calendarEventId = null; + task.hasReminder = false; + await task.save(); + + res.json({ + success: true, + task, + message: "Calendar sync removed from task", + }); + } catch (error) { + console.error("Error removing calendar sync from task:", error); + res.status(500).json({ error: "Failed to remove calendar sync from task" }); + } +}); + +module.exports = router; diff --git a/api/index.js b/api/index.js index f08162e..385122d 100644 --- a/api/index.js +++ b/api/index.js @@ -1,7 +1,21 @@ const express = require("express"); const router = express.Router(); const testDbRouter = require("./test-db"); +const taskOrganizerRouter = require("./TaskOrganizer"); +const timerData = require("./timerData"); +const CalculatorRouter = require("./Calculator"); +const chatRouter = require("./aichathistory"); +const streakSessionRouter = require("./StreakSession"); +const UserProgressRouter = require("./UserProgress"); +const badgesRouter = require("./badges"); router.use("/test-db", testDbRouter); +router.use("/", taskOrganizerRouter); +router.use("/grade-calculator", CalculatorRouter); +router.use("/", timerData); +router.use("/chat", chatRouter); +router.use("/sessions", streakSessionRouter); +router.use("/progress", UserProgressRouter); +router.use("/badges", badgesRouter); module.exports = router; diff --git a/api/timerData.js b/api/timerData.js new file mode 100644 index 0000000..7a86988 --- /dev/null +++ b/api/timerData.js @@ -0,0 +1,41 @@ +const express = require("express"); +const router = express.Router(); +const { Session } = require("../database"); +const { Sequelize } = require("sequelize"); +const { authenticateJWT } = require("../auth"); +//getting a study durations by userId(foregin key that references the id in users table) +router.get("/data", authenticateJWT, async (req, res) => { + const userId = req.user.id; + try { + const sessions = await Session.findAll({ + where: { + user_id: userId, + }, + attributes: [ + "duration", + [ + Sequelize.literal(`to_char("created_at",'MM-DD-YYYY')`), + "formattedDate", + ], + ], + }); + res.json(sessions); + } catch (error) { + console.log("ERR:", error); + res.sendStatus(501); + } +}); + +router.post("/data/:userId", async (req, res) => { + const { userId } = req.params; + const { duration } = req.body; + + const newSession = await Session.create({ + duration: duration, + user_id: userId, + }); + + res.json(newSession); +}); + +module.exports = router; diff --git a/app.js b/app.js index 5857036..d715572 100644 --- a/app.js +++ b/app.js @@ -6,19 +6,23 @@ const jwt = require("jsonwebtoken"); const cookieParser = require("cookie-parser"); const app = express(); const apiRouter = require("./api"); -const { router: authRouter } = require("./auth"); const { db } = require("./database"); const cors = require("cors"); - +const { Model } = require("sequelize"); const PORT = process.env.PORT || 8080; const FRONTEND_URL = process.env.FRONTEND_URL || "http://localhost:3000"; +const { router: authRouter, authenticateJWT } = require("./auth"); +const calendarRouter = require("./api/calendar"); // body parser middleware app.use(express.json()); - app.use( cors({ - origin: FRONTEND_URL, + origin: [ + FRONTEND_URL, + "http://localhost:3000", + "https://lock-in-front-end.vercel.app", + ], credentials: true, }) ); @@ -28,8 +32,14 @@ app.use(cookieParser()); app.use(morgan("dev")); // logging middleware app.use(express.static(path.join(__dirname, "public"))); // serve static files from public folder -app.use("/api", apiRouter); // mount api router -app.use("/auth", authRouter); // mount auth router +app.use("/auth", authRouter); // mount auth router (no auth required) +app.use("/api/calendar", authenticateJWT, calendarRouter); // mount calendar router FIRST +app.use("/api", authenticateJWT, apiRouter); // mount main api router SECOND + +// Route to serve the chart page +app.get("/chart", (req, res) => { + res.sendFile(path.join(__dirname, "public", "chart.html")); +}); // error handling middleware app.use((err, req, res, next) => { diff --git a/auth/index.js b/auth/index.js index 07968c5..b16ceee 100644 --- a/auth/index.js +++ b/auth/index.js @@ -1,5 +1,6 @@ const express = require("express"); const jwt = require("jsonwebtoken"); +const googleOAuth = require("../config/googleOAuth"); const { User } = require("../database"); const router = express.Router(); @@ -104,7 +105,15 @@ router.post("/auth0", async (req, res) => { // Signup route router.post("/signup", async (req, res) => { try { - const { username, password } = req.body; + const { username, firstName, lastName, password, email } = req.body; + + console.log("šŸ“ Signup attempt:", { + username, + firstName, + lastName, + email, + passwordProvided: !!password, + }); if (!username || !password) { return res @@ -118,15 +127,41 @@ router.post("/signup", async (req, res) => { .send({ error: "Password must be at least 6 characters long" }); } - // Check if user already exists - const existingUser = await User.findOne({ where: { username } }); - if (existingUser) { + // Check if user already exists by username + const existingUserByUsername = await User.findOne({ where: { username } }); + if (existingUserByUsername) { return res.status(409).send({ error: "Username already exists" }); } + // Check if user already exists by email (if email provided) + if (email) { + const existingUserByEmail = await User.findOne({ where: { email } }); + if (existingUserByEmail) { + return res.status(409).send({ error: "Email already exists" }); + } + } + // Create new user const passwordHash = User.hashPassword(password); - const user = await User.create({ username, passwordHash }); + const userData = { + firstName, + lastName, + username, + email: email || null, // Handle optional email + passwordHash, + }; + + console.log("šŸ”Ø Creating user with data:", { + ...userData, + passwordHash: "[HIDDEN]", + }); + + const user = await User.create(userData); + + console.log("āœ… User created successfully:", { + id: user.id, + username: user.username, + }); // Generate JWT token const token = jwt.sign( @@ -142,18 +177,38 @@ router.post("/signup", async (req, res) => { res.cookie("token", token, { httpOnly: true, - secure: true, + secure: process.env.NODE_ENV === "production", sameSite: "strict", maxAge: 24 * 60 * 60 * 1000, // 24 hours }); res.send({ message: "User created successfully", - user: { id: user.id, username: user.username }, + user: { id: user.id, username: user.username, email: user.email }, }); } catch (error) { - console.error("Signup error:", error); - res.sendStatus(500); + console.error("āŒ Signup error:", error); + console.error("āŒ Error details:", { + message: error.message, + name: error.name, + sql: error.sql, + fields: error.fields, + }); + + // Send more specific error messages + if (error.name === "SequelizeUniqueConstraintError") { + return res + .status(409) + .send({ error: "Username or email already exists" }); + } + + if (error.name === "SequelizeValidationError") { + return res + .status(400) + .send({ error: error.errors.map((e) => e.message).join(", ") }); + } + + res.status(500).send({ error: "Internal server error during signup" }); } }); @@ -169,7 +224,8 @@ router.post("/login", async (req, res) => { // Find user const user = await User.findOne({ where: { username } }); - user.checkPassword(password); + + // Check if user exists first if (!user) { return res.status(401).send({ error: "Invalid credentials" }); } @@ -230,4 +286,244 @@ router.get("/me", (req, res) => { }); }); + +// ───────────────────────────────────────────────────────────── +// GOOGLE OAUTH ROUTES (Manual Implementation) +// ───────────────────────────────────────────────────────────── + +// Initiate Google OAuth +router.get("/google", (req, res) => { + const authUrl = googleOAuth.getAuthUrl(); + console.log("šŸš€ Redirecting to Google OAuth:", authUrl); + res.redirect(authUrl); +}); + +// Initiate Google Calendar Permission Flow +router.get("/google/calendar", authenticateJWT, (req, res) => { + try { + const calendarAuthUrl = googleOAuth.getCalendarAuthUrl(req.user.id); + console.log("šŸš€ Redirecting to Google Calendar OAuth:", calendarAuthUrl); + res.redirect(calendarAuthUrl); + } catch (error) { + console.error("āŒ Calendar permission error:", error); + res.redirect( + `${ + process.env.FRONTEND_URL || "http://localhost:3000" + }/dashboard?calendar_error=permission_failed` + ); + } +}); + +// Handle Google OAuth callback +router.get("/google/callback", async (req, res) => { + try { + const { code, error } = req.query; + + if (error) { + console.error("āŒ Google OAuth error:", error); + return res.redirect( + `${ + process.env.FRONTEND_URL || "http://localhost:3000" + }/login?error=oauth_failed` + ); + } + + if (!code) { + console.error("āŒ No authorization code received"); + return res.redirect( + `${ + process.env.FRONTEND_URL || "http://localhost:3000" + }/login?error=oauth_failed` + ); + } + + console.log("šŸ”„ Processing Google OAuth callback with code..."); + + // Exchange code for access token + const tokenData = await googleOAuth.getAccessToken(code); + console.log("āœ… Access token received"); + + // Get user profile from Google + const profile = await googleOAuth.getUserProfile(tokenData.access_token); + console.log("āœ… User profile received"); + + // Process user (create/find in database) + const user = await googleOAuth.processGoogleUser(profile); + console.log("šŸŽ‰ Google OAuth successful for user:", user.username); + + // Generate JWT token (SAME format as regular login) + const token = jwt.sign( + { + id: user.id, + username: user.username, + auth0Id: user.auth0Id, + googleId: user.googleId, + email: user.email, + }, + JWT_SECRET, + { expiresIn: "24h" } + ); + + // Set SAME httpOnly cookie as regular login + res.cookie("token", token, { + httpOnly: true, + secure: process.env.NODE_ENV === "production", + sameSite: "strict", + maxAge: 24 * 60 * 60 * 1000, // 24 hours + }); + + console.log("āœ… JWT token set for Google user:", user.username); + + // Redirect to frontend homepage + res.redirect(process.env.FRONTEND_URL || "http://localhost:3000"); + } catch (error) { + console.error("āŒ Google OAuth callback error:", error); + res.redirect( + `${ + process.env.FRONTEND_URL || "http://localhost:3000" + }/login?error=oauth_error` + ); + } +}); + +// Update user profile route (protected) +router.put("/update-profile", authenticateJWT, async (req, res) => { + try { + const { username, email, studyGoal } = req.body; + const userId = req.user.id; + + // Find the user + const user = await User.findByPk(userId); + if (!user) { + return res.status(404).send({ error: "User not found" }); + } + + // Check if username is being changed and if it's already taken + if (username && username !== user.username) { + const existingUser = await User.findOne({ where: { username } }); + if (existingUser) { + return res.status(409).send({ error: "Username already exists" }); + } + } + + // Check if email is being changed and if it's already taken + if (email && email !== user.email) { + const existingUser = await User.findOne({ where: { email } }); + if (existingUser) { + return res.status(409).send({ error: "Email already exists" }); + } + } + + // Update user fields + const updateData = {}; + if (username) updateData.username = username; + if (email) updateData.email = email; + if (studyGoal !== undefined) updateData.studyGoal = studyGoal; + + await user.update(updateData); + + // Generate new JWT token with updated user info + const token = jwt.sign( + { + id: user.id, + username: user.username, + auth0Id: user.auth0Id, + googleId: user.googleId, + email: user.email, + }, + JWT_SECRET, + { expiresIn: "24h" } + ); + + // Set updated cookie + res.cookie("token", token, { + httpOnly: true, + secure: process.env.NODE_ENV === "production", + sameSite: "strict", + maxAge: 24 * 60 * 60 * 1000, // 24 hours + }); + + res.send({ + message: "Profile updated successfully", + user: { + id: user.id, + username: user.username, + email: user.email, + studyGoal: user.studyGoal, + }, + }); + } catch (error) { + console.error("Profile update error:", error); + res.status(500).send({ error: "Failed to update profile" }); + } +}); + +// Handle Google Calendar permission callback +router.get("/google/calendar/callback", async (req, res) => { + try { + const { code, error, state } = req.query; + + if (error) { + console.error("āŒ Google Calendar OAuth error:", error); + return res.redirect( + `${ + process.env.FRONTEND_URL || "http://localhost:3000" + }/Tasks?calendar_error=permission_denied` + ); + } + + if (!code || !state) { + console.error("āŒ No authorization code or state received"); + return res.redirect( + `${ + process.env.FRONTEND_URL || "http://localhost:3000" + }/Tasks?calendar_error=invalid_request` + ); + } + + console.log("šŸ”„ Processing Google Calendar permission callback..."); + + // Find user by ID from state parameter + const userId = state; + const user = await User.findByPk(userId); + + if (!user) { + console.error("āŒ User not found for calendar permission"); + return res.redirect( + `${ + process.env.FRONTEND_URL || "http://localhost:3000" + }/login?error=user_not_found` + ); + } + + // Exchange code for access token with calendar scopes + const tokenData = await googleOAuth.getAccessTokenWithCalendarScopes(code); + console.log("āœ… Calendar access token received"); + + // Update user with calendar tokens + user.googleAccessToken = tokenData.access_token; + if (tokenData.refresh_token) { + user.googleRefreshToken = tokenData.refresh_token; + } + user.calendarPermissions = true; + await user.save(); + + console.log("šŸŽ‰ Calendar permissions granted for user:", user.username); + + // Redirect to frontend with success message + res.redirect( + `${ + process.env.FRONTEND_URL || "http://localhost:3000" + }/Tasks?calendar_success=permissions_granted` + ); + } catch (error) { + console.error("āŒ Google Calendar callback error:", error); + res.redirect( + `${ + process.env.FRONTEND_URL || "http://localhost:3000" + }/Tasks?calendar_error=authorization_failed` + ); + } +}); + module.exports = { router, authenticateJWT }; diff --git a/config/googleOAuth.js b/config/googleOAuth.js new file mode 100644 index 0000000..a599665 --- /dev/null +++ b/config/googleOAuth.js @@ -0,0 +1,216 @@ +const { User } = require("../database"); + +// Manual Google OAuth helper functions +const googleOAuth = { + // Generate Google OAuth URL (basic profile + email) + getAuthUrl() { + const baseUrl = "https://accounts.google.com/o/oauth2/v2/auth"; + const params = new URLSearchParams({ + client_id: process.env.GOOGLE_CLIENT_ID, + redirect_uri: `${ + process.env.BACKEND_URL || "http://localhost:8080" + }/auth/google/callback`, + response_type: "code", + scope: "profile email", + access_type: "offline", + }); + return `${baseUrl}?${params.toString()}`; + }, + + // Generate Google OAuth URL with Calendar permissions + getCalendarAuthUrl(userId) { + const baseUrl = "https://accounts.google.com/o/oauth2/v2/auth"; + const params = new URLSearchParams({ + client_id: process.env.GOOGLE_CLIENT_ID, + redirect_uri: `${ + process.env.BACKEND_URL || "http://localhost:8080" + }/auth/google/calendar/callback`, + response_type: "code", + scope: "profile email https://www.googleapis.com/auth/calendar.events", + access_type: "offline", + prompt: "consent", // Force consent to get refresh token + state: userId, // Pass user ID to identify user in callback + }); + return `${baseUrl}?${params.toString()}`; + }, + + // Exchange authorization code for access token + async getAccessToken(code, isCalendarFlow = false) { + const redirectUri = isCalendarFlow + ? `${ + process.env.BACKEND_URL || "http://localhost:8080" + }/auth/google/calendar/callback` + : `${ + process.env.BACKEND_URL || "http://localhost:8080" + }/auth/google/callback`; + + const response = await fetch("https://oauth2.googleapis.com/token", { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + }, + body: new URLSearchParams({ + client_id: process.env.GOOGLE_CLIENT_ID, + client_secret: process.env.GOOGLE_CLIENT_SECRET, + code, + grant_type: "authorization_code", + redirect_uri: redirectUri, + }), + }); + + if (!response.ok) { + throw new Error("Failed to get access token"); + } + + return await response.json(); + }, + + // Exchange authorization code for access token specifically for calendar permissions + async getAccessTokenWithCalendarScopes(code) { + const redirectUri = `${ + process.env.BACKEND_URL || "http://localhost:8080" + }/auth/google/calendar/callback`; + + const response = await fetch("https://oauth2.googleapis.com/token", { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + }, + body: new URLSearchParams({ + client_id: process.env.GOOGLE_CLIENT_ID, + client_secret: process.env.GOOGLE_CLIENT_SECRET, + code, + grant_type: "authorization_code", + redirect_uri: redirectUri, + }), + }); + + if (!response.ok) { + const errorData = await response.json().catch(() => ({})); + throw new Error( + `Failed to get calendar access token: ${ + errorData.error || response.statusText + }` + ); + } + + return await response.json(); + }, + + // Refresh access token using refresh token + async refreshAccessToken(refreshToken) { + const response = await fetch("https://oauth2.googleapis.com/token", { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + }, + body: new URLSearchParams({ + client_id: process.env.GOOGLE_CLIENT_ID, + client_secret: process.env.GOOGLE_CLIENT_SECRET, + refresh_token: refreshToken, + grant_type: "refresh_token", + }), + }); + + if (!response.ok) { + throw new Error("Failed to refresh access token"); + } + + return await response.json(); + }, + + // Get user profile from Google + async getUserProfile(accessToken) { + const response = await fetch( + `https://www.googleapis.com/oauth2/v2/userinfo?access_token=${accessToken}` + ); + + if (!response.ok) { + throw new Error("Failed to get user profile"); + } + + return await response.json(); + }, + + // Process Google user and create/find in database + async processGoogleUser(profile) { + try { + console.log("šŸ” Google OAuth Profile:", { + id: profile.id, + email: profile.email, + name: profile.name, + firstName: profile.given_name, + lastName: profile.family_name, + picture: profile.picture, + }); + + const googleId = profile.id; + const email = profile.email; + const firstName = profile.given_name; + const lastName = profile.family_name; + const profilePicture = profile.picture; + + // 1. Check if user exists by googleId + let user = await User.findOne({ where: { googleId } }); + + if (user) { + console.log("āœ… Existing Google user found:", user.username); + return user; + } + + // 2. If not found by googleId, check by email (link existing account) + if (email) { + user = await User.findOne({ where: { email } }); + + if (user) { + console.log( + "šŸ”— Linking Google account to existing user:", + user.username + ); + // Link Google account to existing user + user.googleId = googleId; + user.profilePicture = profilePicture; + await user.save(); + return user; + } + } + + // 3. Create new user + console.log("šŸ†• Creating new Google user"); + + // Generate unique username from email or Google name + let username = + email?.split("@")[0] || + profile.name?.replace(/\s+/g, "").toLowerCase() || + `google_user_${Date.now()}`; + + // Ensure username is unique + let finalUsername = username; + let counter = 1; + while (await User.findOne({ where: { username: finalUsername } })) { + finalUsername = `${username}_${counter}`; + counter++; + } + + const userData = { + googleId, + email: email || null, + firstName: firstName || null, + lastName: lastName || null, + username: finalUsername, + profilePicture: profilePicture || null, + passwordHash: null, // Google users don't have passwords + }; + + user = await User.create(userData); + console.log("āœ… New Google user created:", user.username); + + return user; + } catch (error) { + console.error("āŒ Google OAuth error:", error); + throw error; + } + }, +}; + +module.exports = googleOAuth; diff --git a/database/Badge.js b/database/Badge.js new file mode 100644 index 0000000..30e4eb1 --- /dev/null +++ b/database/Badge.js @@ -0,0 +1,33 @@ +const { DataTypes } = require("sequelize"); +const db = require("./db"); + +const Badge = db.define("Badge", { + id: { type: DataTypes.INTEGER, autoIncrement: true, primaryKey: true }, + name: { type: DataTypes.STRING, allowNull: false, unique: true }, + description: { type: DataTypes.TEXT, allowNull: false }, + icon: { type: DataTypes.STRING, allowNull: false }, // emoji or icon name + category: { + type: DataTypes.ENUM("streak", "quiz", "accuracy", "speed", "milestone"), + allowNull: false, + }, + requirement_type: { + type: DataTypes.ENUM( + "streak_days", + "quiz_count", + "accuracy_percentage", + "completion_time", + "total_days" + ), + allowNull: false, + }, + requirement_value: { type: DataTypes.INTEGER, allowNull: false }, + rarity: { + type: DataTypes.ENUM("common", "rare", "epic", "legendary"), + allowNull: false, + defaultValue: "common", + }, + points: { type: DataTypes.INTEGER, allowNull: false, defaultValue: 0 }, // points awarded when earned + created_at: { type: DataTypes.DATE, defaultValue: DataTypes.NOW }, +}); + +module.exports = Badge; diff --git a/database/Calculator.js b/database/Calculator.js new file mode 100644 index 0000000..67c91ff --- /dev/null +++ b/database/Calculator.js @@ -0,0 +1,32 @@ +const { DataTypes } = require('sequelize'); +const db = require('./db'); + +const Calculator = db.define('Calculator', { + id: { + type: DataTypes.INTEGER, + primaryKey: true, + autoIncrement: true, + }, + user_id: { + type: DataTypes.INTEGER, + allowNull: false, + }, + assignment_type: { + type: DataTypes.ENUM('Homework', 'Quiz', 'Midterm', 'Final'), + allowNull: false, + }, + assignment_name: { + type: DataTypes.STRING, + allowNull: false, + }, + assignment_grade: { + type: DataTypes.INTEGER, + allowNull: false, + }, + assignment_weight: { + type: DataTypes.INTEGER, + allowNull: false, + }, +}); + +module.exports = Calculator; \ No newline at end of file diff --git a/database/Reminder.js b/database/Reminder.js new file mode 100644 index 0000000..c487941 --- /dev/null +++ b/database/Reminder.js @@ -0,0 +1,17 @@ +const { DataTypes } = require('sequelize'); +const db = require('./db'); + +const Reminder = db.define('Reminder', { + id: { + type: DataTypes.INTEGER, + primaryKey: true, + autoIncrement: true }, + task_id: { + type: DataTypes.INTEGER, + allowNull: false }, + + remind: DataTypes.DATE +}, +); + +module.exports = Reminder; diff --git a/database/Session.js b/database/Session.js new file mode 100644 index 0000000..4e13803 --- /dev/null +++ b/database/Session.js @@ -0,0 +1,23 @@ +const { DataTypes } = require("sequelize"); +const db = require("./db"); + +const Session = db.define( + "Session", + { + id: { + type: DataTypes.INTEGER, + primaryKey: true, + autoIncrement: true, + }, + duration: DataTypes.TIME, + user_id: { type: DataTypes.INTEGER, allowNull: false }, + started_at: DataTypes.DATE, + created_at: { type: DataTypes.DATE, defaultValue: DataTypes.NOW }, + }, + { + tableName: "session", + timestamps: false, + } +); + +module.exports = Session; diff --git a/database/StreakSession.js b/database/StreakSession.js new file mode 100644 index 0000000..69dddf9 --- /dev/null +++ b/database/StreakSession.js @@ -0,0 +1,21 @@ +const { DataTypes } = require("sequelize"); +const db = require("./db"); // your Sequelize instance + +const StreakSession = db.define( + "StreakSession", + { + id: { type: DataTypes.INTEGER, primaryKey: true, autoIncrement: true }, + user_id: { type: DataTypes.INTEGER, allowNull: false }, + startTime: { type: DataTypes.DATE, allowNull: false }, + endTime: { type: DataTypes.DATE, allowNull: true }, + session_type: { + type: DataTypes.ENUM("study", "break", "review"), + allowNull: false, + defaultValue: "study", + }, + notes: { type: DataTypes.TEXT, allowNull: true }, + }, + { timestamps: true } +); + +module.exports = StreakSession; diff --git a/database/Tasks.js b/database/Tasks.js new file mode 100644 index 0000000..b7415d9 --- /dev/null +++ b/database/Tasks.js @@ -0,0 +1,41 @@ +const { DataTypes } = require("sequelize"); +const db = require("./db"); + +const Tasks = db.define("Tasks", { + id: { + type: DataTypes.INTEGER, + primaryKey: true, + autoIncrement: true, + }, + className: DataTypes.STRING, + assignment: DataTypes.STRING, + description: DataTypes.TEXT, + status: { + type: DataTypes.ENUM("pending", "completed", "in-progress"), + allowNull: false, + }, + deadline: DataTypes.DATE, + priority: { + type: DataTypes.ENUM("high", "medium", "low"), + allowNull: false, + }, + user_id: { + type: DataTypes.INTEGER, + allowNull: false, + }, + created_at: { + type: DataTypes.DATE, + defaultValue: DataTypes.NOW, + }, + calendarEventId: { + type: DataTypes.STRING, + allowNull: true, + }, + hasReminder: { + type: DataTypes.BOOLEAN, + allowNull: false, + defaultValue: false, + }, +}); + +module.exports = Tasks; diff --git a/database/UserBadge.js b/database/UserBadge.js new file mode 100644 index 0000000..37f87f3 --- /dev/null +++ b/database/UserBadge.js @@ -0,0 +1,13 @@ +const { DataTypes } = require("sequelize"); +const db = require("./db"); + +const UserBadge = db.define("UserBadge", { + id: { type: DataTypes.INTEGER, autoIncrement: true, primaryKey: true }, + user_id: { type: DataTypes.INTEGER, allowNull: false }, // foreign key to User.id + badge_id: { type: DataTypes.INTEGER, allowNull: false }, // foreign key to Badge.id + earned_at: { type: DataTypes.DATE, defaultValue: DataTypes.NOW }, + progress_value: { type: DataTypes.INTEGER, allowNull: false }, // the value when badge was earned + is_new: { type: DataTypes.BOOLEAN, defaultValue: true }, // for showing "NEW" indicator +}); + +module.exports = UserBadge; diff --git a/database/UserProgress.js b/database/UserProgress.js new file mode 100644 index 0000000..76cf94a --- /dev/null +++ b/database/UserProgress.js @@ -0,0 +1,16 @@ +const { DataTypes } = require("sequelize"); +const db = require("./db"); + +const UserProgress = db.define("UserProgress", { + id: { type: DataTypes.INTEGER, autoIncrement: true, primaryKey: true }, // primary key + user_id: { type: DataTypes.INTEGER, allowNull: false }, // foreign key to User.id + ai_chat_history_id: { type: DataTypes.INTEGER, allowNull: false }, // foreign key to AiChatHistory.id + card_index: { type: DataTypes.INTEGER, allowNull: true }, // for flashcards only + is_correct: { type: DataTypes.BOOLEAN, allowNull: true }, // for flashcards only + score: { type: DataTypes.INTEGER, allowNull: true }, // for quizzes only + duration_ms: { type: DataTypes.INTEGER, allowNull: true }, // for flashcards only + session_id: { type: DataTypes.STRING, allowNull: true }, // for flashcards only + studied_at: { type: DataTypes.DATE, defaultValue: DataTypes.NOW }, +}); + +module.exports = UserProgress; diff --git a/database/aichathistory.js b/database/aichathistory.js new file mode 100644 index 0000000..8eaacd9 --- /dev/null +++ b/database/aichathistory.js @@ -0,0 +1,39 @@ +const { DataTypes } = require("sequelize"); +const db = require("./db"); + +const AiChatHistory = db.define("AiChatHistory", { + id: { + type: DataTypes.INTEGER, + primaryKey: true, + autoIncrement: true, + }, + user_id: { + type: DataTypes.INTEGER, + allowNull: true, + }, + user_request: { + type: DataTypes.TEXT, + allowNull: false, + }, + ai_response: { + type: DataTypes.TEXT, + allowNull: false, + }, + quiz_id: { + type: DataTypes.STRING, + allowNull: true, + unique: true, + }, + response_type: { + type: DataTypes.ENUM("flashcard", "quiz", "study_plan"), + allowNull: false, + defaultValue: "flashcard", + }, + status: { + type: DataTypes.STRING(20), + allowNull: false, + defaultValue: "success", + }, +}); + +module.exports = AiChatHistory; diff --git a/database/db.js b/database/db.js index b251a9d..3b580f1 100644 --- a/database/db.js +++ b/database/db.js @@ -3,7 +3,7 @@ const { Sequelize } = require("sequelize"); const pg = require("pg"); // Feel free to rename the database to whatever you want! -const dbName = "capstone-1"; +const dbName = "capstone-2"; const db = new Sequelize( process.env.DATABASE_URL || `postgres://localhost:5432/${dbName}`, diff --git a/database/index.js b/database/index.js index e498df6..b9ddc71 100644 --- a/database/index.js +++ b/database/index.js @@ -1,7 +1,59 @@ +const Sequelize = require("sequelize"); const db = require("./db"); + const User = require("./user"); +const Tasks = require("./Tasks"); +const Calculator = require("./Calculator"); +const Reminder = require("./Reminder"); +const Session = require("./Session"); +const AiChatHistory = require("./aichathistory"); +const StreakSession = require("./StreakSession"); +const UserProgress = require("./UserProgress"); +const Badge = require("./Badge"); +const UserBadge = require("./UserBadge"); + +// Define associations +User.hasMany(Tasks, { foreignKey: "user_id" }); // One user can have many tasks +Tasks.belongsTo(User, { foreignKey: "user_id" }); // Each task belongs to a specific user + +User.hasMany(Calculator, { foreignKey: "user_id" }); // User can have many grade calculator instances +Calculator.belongsTo(User, { foreignKey: "user_id" }); // Each calculator belongs to a user + +User.hasMany(Session, { foreignKey: "user_id" }); // Each user can have many study sessions +Session.belongsTo(User, { foreignKey: "user_id" }); // Each session belongs to a user + +Tasks.hasMany(Reminder, { foreignKey: "task_id" }); // One task can have many reminders +Reminder.belongsTo(Tasks, { foreignKey: "task_id" }); // One reminder belongs to a specific task + +User.hasMany(AiChatHistory, { foreignKey: "user_id" }); +AiChatHistory.belongsTo(User, { foreignKey: "user_id" }); + +User.hasMany(StreakSession, { foreignKey: "user_id" }); +StreakSession.belongsTo(User, { foreignKey: "user_id" }); + +UserProgress.belongsTo(User, { foreignKey: "user_id" }); +UserProgress.belongsTo(AiChatHistory, { foreignKey: "ai_chat_history_id" }); + +User.hasMany(UserProgress, { foreignKey: "user_id" }); +AiChatHistory.hasMany(UserProgress, { foreignKey: "ai_chat_history_id" }); + +// Badge associations +User.hasMany(UserBadge, { foreignKey: "user_id" }); +UserBadge.belongsTo(User, { foreignKey: "user_id" }); + +Badge.hasMany(UserBadge, { foreignKey: "badge_id" }); +UserBadge.belongsTo(Badge, { foreignKey: "badge_id" }); module.exports = { db, User, + AiChatHistory, + Tasks, + Calculator, + Reminder, + Session, + StreakSession, + UserProgress, + Badge, + UserBadge, }; diff --git a/database/migrate-calendar.js b/database/migrate-calendar.js new file mode 100644 index 0000000..bca9f7e --- /dev/null +++ b/database/migrate-calendar.js @@ -0,0 +1,64 @@ +const { db, User, Tasks } = require("./index"); + +async function migrateCalendarFields() { + try { + console.log("šŸ”„ Starting calendar fields migration..."); + + // Check if we need to sync the database (add new columns) + await db.sync({ alter: true }); + + console.log("āœ… Database schema updated with calendar fields"); + + // Update existing users to have default calendar permission values + const usersUpdated = await User.update( + { + calendarPermissions: false, + }, + { + where: { + calendarPermissions: null, + }, + } + ); + + console.log( + `šŸ“ Updated ${usersUpdated[0]} users with default calendar permissions` + ); + + // Update existing tasks to have default reminder values + const tasksUpdated = await Tasks.update( + { + hasReminder: false, + }, + { + where: { + hasReminder: null, + }, + } + ); + + console.log( + `šŸ“ Updated ${tasksUpdated[0]} tasks with default reminder values` + ); + + console.log("šŸŽ‰ Calendar fields migration completed successfully!"); + } catch (error) { + console.error("āŒ Calendar fields migration failed:", error); + throw error; + } +} + +// Run migration if this file is executed directly +if (require.main === module) { + migrateCalendarFields() + .then(() => { + console.log("āœ… Migration complete"); + process.exit(0); + }) + .catch((error) => { + console.error("āŒ Migration failed:", error); + process.exit(1); + }); +} + +module.exports = { migrateCalendarFields }; diff --git a/database/seed.js b/database/seed.js index e58b595..28e9873 100644 --- a/database/seed.js +++ b/database/seed.js @@ -1,30 +1,255 @@ const db = require("./db"); -const { User } = require("./index"); +const { nanoid } = require("nanoid"); +const { + User, + Tasks, + Calculator, + Reminder, + Session, + AiChatHistory, + UserProgress, + Badge, + UserBadge, +} = require("./index"); const seed = async () => { try { db.logging = false; await db.sync({ force: true }); // Drop and recreate tables - const users = await User.bulkCreate([ - { username: "admin", passwordHash: User.hashPassword("admin123") }, - { username: "user1", passwordHash: User.hashPassword("user111") }, - { username: "user2", passwordHash: User.hashPassword("user222") }, + const user = await User.bulkCreate([ + { + firstName: "Benjamin", + lastName: "Test", + username: "benjamin", + email: "benjamin@example.com", + passwordHash: User.hashPassword("supersecurepassword"), + role: "student", + }, + { + firstName: "David", + lastName: "Test", + username: "David", + email: "David@example.com", + passwordHash: User.hashPassword("supersecurepassword2"), + role: "student", + }, ]); - console.log(`šŸ‘¤ Created ${users.length} users`); + // Create multiple AI chat histories for different days + const aiChatHistories = await AiChatHistory.bulkCreate([ + { + user_id: 1, + user_request: "Make me flashcards", + ai_response: "JSON flashcards...", + response_type: "flashcard", + status: "success", + quiz_id: nanoid(8), // Generate unique ID for flashcards + }, + { + user_id: 1, + user_request: "Create a quiz", + ai_response: "JSON quiz...", + response_type: "quiz", + status: "success", + quiz_id: nanoid(8), // Generate unique ID for quiz + }, + { + user_id: 1, + user_request: "More flashcards", + ai_response: "JSON flashcards...", + response_type: "flashcard", + status: "success", + quiz_id: nanoid(8), // Generate unique ID for flashcards + }, + { + user_id: 1, + user_request: "Another quiz", + ai_response: "JSON quiz...", + response_type: "quiz", + status: "success", + quiz_id: nanoid(8), // Generate unique ID for quiz + }, + ]); + + // Create UserProgress entries for the last 7 days with realistic data + const today = new Date(); + const userProgressData = []; + + // Generate data for the last 7 days + for (let i = 6; i >= 0; i--) { + const date = new Date(today); + date.setDate(today.getDate() - i); + + // Each day has some flashcard attempts and quiz attempts + const flashcardAttempts = Math.floor(Math.random() * 5) + 3; // 3-7 attempts + const quizAttempts = Math.floor(Math.random() * 3) + 1; // 1-3 attempts + + // Create flashcard attempts for this day + for (let j = 0; j < flashcardAttempts; j++) { + const isCorrect = Math.random() > 0.3; // 70% accuracy on average + const duration = Math.floor(Math.random() * 30000) + 10000; // 10-40 seconds + userProgressData.push({ + user_id: 1, + ai_chat_history_id: 1, // flashcard + studied_at: new Date(date.getTime() + j * 60000), // spread throughout the day + is_correct: isCorrect, + score: null, + card_index: j, + duration_ms: duration, // āœ… Add duration + session_id: `session_${date.toISOString().split("T")[0]}_${j}`, // āœ… Add session ID + }); + } + + // Create quiz attempts for this day + for (let j = 0; j < quizAttempts; j++) { + const score = Math.floor(Math.random() * 30) + 70; // 70-100 score + const duration = Math.floor(Math.random() * 120000) + 60000; // 1-3 minutes + userProgressData.push({ + user_id: 1, + ai_chat_history_id: 2, // quiz + studied_at: new Date( + date.getTime() + (j + flashcardAttempts) * 60000 + ), + is_correct: null, + score: score, + duration_ms: duration, // āœ… Add duration + session_id: `session_${date.toISOString().split("T")[0]}_${ + j + flashcardAttempts + }`, // āœ… Add session ID + }); + } + } + + await UserProgress.bulkCreate(userProgressData); + + // Create a Task + const tasks = await Tasks.bulkCreate([ + { + className: "Math 101", + assignment: "Homework 1", + description: "Complete exercises 1–10 on page 52", + status: "in-progress", + deadline: new Date("2025-08-05"), + priority: "high", + user_id: user[0].id, + }, + { + className: "Math 201", + assignment: "Homework 2", + description: "Complete exercises 1–10 on page 52", + status: "in-progress", + deadline: new Date("2025-08-05"), + priority: "high", + user_id: user[1].id, + }, + ]); + // Add calculator entry + const calculator = await Calculator.create({ + user_id: user[0].id, + assignment_type: "Homework", + assignment_name: "First Homework", + assignment_grade: 85, + assignment_weight: 25, + }); + + // Add study session + await Session.create({ + duration: "00:45:00", + user_id: user[0].id, + started_at: new Date(), + created_at: new Date(), + }); - // Create more seed data here once you've created your models - // Seed files are a great way to test your database schema! + // Add reminder for the first task + await Reminder.create({ + task_id: tasks[0].id, // use the first task's id + remind: new Date(Date.now() + 2 * 24 * 60 * 60 * 1000), + }); + + // Create badges + const badges = await Badge.bulkCreate([ + { + name: "Week Warrior", + description: "Maintain a 7-day study streak", + icon: "šŸ”„", + category: "streak", + requirement_type: "streak_days", + requirement_value: 7, + rarity: "common", + points: 100, + }, + { + name: "Quiz Master", + description: "Complete 50 quizzes", + icon: "🧠", + category: "quiz", + requirement_type: "quiz_count", + requirement_value: 50, + rarity: "rare", + points: 250, + }, + { + name: "Accuracy Ace", + description: "Achieve 90% flashcard accuracy", + icon: "šŸŽÆ", + category: "accuracy", + requirement_type: "accuracy_percentage", + requirement_value: 90, + rarity: "epic", + points: 500, + }, + { + name: "Speed Demon", + description: + "Complete activities with fast average time (under 30 seconds)", + icon: "⚔", + category: "speed", + requirement_type: "completion_time", + requirement_value: 30000, // 30 seconds in milliseconds + rarity: "rare", + points: 300, + }, + { + name: "Century Club", + description: "Maintain a 100-day study streak", + icon: "šŸ‘‘", + category: "milestone", + requirement_type: "streak_days", + requirement_value: 100, + rarity: "legendary", + points: 1000, + }, + ]); + + // Create UserBadge entries to link users with badges + await UserBadge.bulkCreate([ + { + user_id: 1, + badge_id: 1, // Week Warrior - user has 7 day streak + earned_at: new Date(), + progress_value: 7, // 7 day streak achieved + }, + { + user_id: 1, + badge_id: 4, // Speed Demon - user has fast completion time + earned_at: new Date(), + progress_value: 52682, // completion time in ms + }, + ]); console.log("🌱 Seeded the database"); } catch (error) { - console.error("Error seeding database:", error); + console.error("āŒ Error seeding database:", error); + if (error.message.includes("does not exist")) { console.log("\nšŸ¤”šŸ¤”šŸ¤” Have you created your database??? šŸ¤”šŸ¤”šŸ¤”"); } + + process.exit(1); } - db.close(); + + await db.close(); }; seed(); diff --git a/database/user.js b/database/user.js index 755c757..e50e3a5 100644 --- a/database/user.js +++ b/database/user.js @@ -3,31 +3,62 @@ const db = require("./db"); const bcrypt = require("bcrypt"); const User = db.define("user", { + id: { + type: DataTypes.INTEGER, + autoIncrement: true, + primaryKey: true, + }, + firstName: { + type: DataTypes.STRING, + allowNull: true, + }, + lastName: { + type: DataTypes.STRING, + allowNull: true, + }, username: { type: DataTypes.STRING, allowNull: false, unique: true, - validate: { - len: [3, 20], - }, }, - email: { + + auth0Id: { type: DataTypes.STRING, allowNull: true, unique: true, - validate: { - isEmail: true, - }, }, - auth0Id: { + googleId: { type: DataTypes.STRING, allowNull: true, unique: true, }, + profilePicture: { + type: DataTypes.STRING, + allowNull: true, + }, + email: { + type: DataTypes.STRING, + allowNull: true, // Allow null for OAuth users who might not provide email + unique: true, + validate: { + isEmail: { + msg: "Must be a valid email address", + }, + }, + set(value) { + // Convert empty strings to null to avoid unique constraint issues + this.setDataValue("email", value === "" ? null : value); + }, + }, passwordHash: { type: DataTypes.STRING, allowNull: true, }, + studyGoal: { + type: DataTypes.INTEGER, + allowNull: true, + defaultValue: 30, // Default 30 minutes per day + }, }); // Instance method to check password @@ -40,7 +71,8 @@ User.prototype.checkPassword = function (password) { // Class method to hash password User.hashPassword = function (password) { - return bcrypt.hashSync(password, 10); + const saltValue = 12; + return bcrypt.hashSync(password, saltValue); }; module.exports = User; diff --git a/package-lock.json b/package-lock.json index af0cf82..61e6694 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,21 +1,29 @@ { - "name": "capstone-i-backend", + "name": "capstone-1-backend", "version": "1.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "capstone-i-backend", + "name": "capstone-1-backend", "version": "1.0.0", "license": "ISC", "dependencies": { + "@anthropic-ai/sdk": "^0.57.0", "bcrypt": "^6.0.0", "cookie-parser": "^1.4.7", "cors": "^2.8.5", - "dotenv": "^16.5.0", + "dotenv": "^16.6.1", "express": "^5.1.0", "jsonwebtoken": "^9.0.2", "morgan": "^1.10.0", +<<<<<<< HEAD + "nanoid": "^5.1.5", + "node-fetch": "^3.3.2", +======= + "nanoid": "^3.3.7", +>>>>>>> 85051c302b64a345b871706398ab251cbd2433e0 + "openai": "^5.11.0", "pg": "^8.16.2", "sequelize": "^6.37.7" }, @@ -26,6 +34,15 @@ "win-node-env": "^0.6.1" } }, + "node_modules/@anthropic-ai/sdk": { + "version": "0.57.0", + "resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.57.0.tgz", + "integrity": "sha512-z5LMy0MWu0+w2hflUgj4RlJr1R+0BxKXL7ldXTO8FasU8fu599STghO+QKwId2dAD0d464aHtU+ChWuRHw4FNw==", + "license": "MIT", + "bin": { + "anthropic-ai-sdk": "bin/cli" + } + }, "node_modules/@types/debug": { "version": "4.1.12", "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.12.tgz", @@ -156,9 +173,9 @@ } }, "node_modules/brace-expansion": { - "version": "1.1.11", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", - "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", "dev": true, "license": "MIT", "dependencies": { @@ -326,6 +343,15 @@ "node": ">= 0.10" } }, + "node_modules/data-uri-to-buffer": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz", + "integrity": "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, "node_modules/debug": { "version": "4.4.1", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz", @@ -353,9 +379,9 @@ } }, "node_modules/dotenv": { - "version": "16.5.0", - "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.5.0.tgz", - "integrity": "sha512-m/C+AwOAr9/W1UOIZUo232ejMNnJAJtYQjUbHoNTBNTJSvqzzDh7vnrei3o3r3m9blf6ZoDkvcw0VmozNRFJxg==", + "version": "16.6.1", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", + "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==", "license": "BSD-2-Clause", "engines": { "node": ">=12" @@ -495,6 +521,29 @@ "url": "https://opencollective.com/express" } }, + "node_modules/fetch-blob": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.2.0.tgz", + "integrity": "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "paypal", + "url": "https://paypal.me/jimmywarting" + } + ], + "license": "MIT", + "dependencies": { + "node-domexception": "^1.0.0", + "web-streams-polyfill": "^3.0.3" + }, + "engines": { + "node": "^12.20 || >= 14.13" + } + }, "node_modules/fill-range": { "version": "7.1.1", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", @@ -525,6 +574,18 @@ "node": ">= 0.8" } }, + "node_modules/formdata-polyfill": { + "version": "4.0.10", + "resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz", + "integrity": "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==", + "license": "MIT", + "dependencies": { + "fetch-blob": "^3.1.2" + }, + "engines": { + "node": ">=12.20.0" + } + }, "node_modules/forwarded": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", @@ -951,16 +1012,16 @@ } }, "node_modules/morgan": { - "version": "1.10.0", - "resolved": "https://registry.npmjs.org/morgan/-/morgan-1.10.0.tgz", - "integrity": "sha512-AbegBVI4sh6El+1gNwvD5YIck7nSA36weD7xvIxG4in80j/UoK8AEGaWnnz8v1GxonMCltmlNs5ZKbGvl9b1XQ==", + "version": "1.10.1", + "resolved": "https://registry.npmjs.org/morgan/-/morgan-1.10.1.tgz", + "integrity": "sha512-223dMRJtI/l25dJKWpgij2cMtywuG/WiUKXdvwfbhGKBhy1puASqXwFzmWZ7+K73vUPoR7SS2Qz2cI/g9MKw0A==", "license": "MIT", "dependencies": { "basic-auth": "~2.0.1", "debug": "2.6.9", "depd": "~2.0.0", "on-finished": "~2.3.0", - "on-headers": "~1.0.2" + "on-headers": "~1.1.0" }, "engines": { "node": ">= 0.8.0" @@ -999,6 +1060,24 @@ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "license": "MIT" }, + "node_modules/nanoid": { + "version": "3.3.7", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.7.tgz", + "integrity": "sha512-eSRppjcPIatRIMC1U6UngP8XFcz8MQWGQdt1MTBQ7NaAmvXDfvNxbvWV3x2y6CdEUciCSsDHDQZbhYaB8QEo2g==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, "node_modules/negotiator": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", @@ -1017,6 +1096,44 @@ "node": "^18 || ^20 || >= 21" } }, + "node_modules/node-domexception": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz", + "integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==", + "deprecated": "Use your platform's native DOMException instead", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "github", + "url": "https://paypal.me/jimmywarting" + } + ], + "license": "MIT", + "engines": { + "node": ">=10.5.0" + } + }, + "node_modules/node-fetch": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz", + "integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==", + "license": "MIT", + "dependencies": { + "data-uri-to-buffer": "^4.0.0", + "fetch-blob": "^3.1.4", + "formdata-polyfill": "^4.0.10" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/node-fetch" + } + }, "node_modules/node-gyp-build": { "version": "4.8.4", "resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.4.tgz", @@ -1101,9 +1218,9 @@ } }, "node_modules/on-headers": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.0.2.tgz", - "integrity": "sha512-pZAE+FJLoyITytdqK0U5s+FIpjN0JP3OzFi/u8Rx+EV5/W+JTWGXG8xFzevE7AjBfDqHv/8vL8qQsIhHnqRkrA==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.1.0.tgz", + "integrity": "sha512-737ZY3yNnXy37FHkQxPzt4UZ2UWPWiCZWLvFZ4fu5cueciegX0zGPnrlY6bwRg4FdQOe9YU8MkmJwGhoMybl8A==", "license": "MIT", "engines": { "node": ">= 0.8" @@ -1118,6 +1235,27 @@ "wrappy": "1" } }, + "node_modules/openai": { + "version": "5.11.0", + "resolved": "https://registry.npmjs.org/openai/-/openai-5.11.0.tgz", + "integrity": "sha512-+AuTc5pVjlnTuA9zvn8rA/k+1RluPIx9AD4eDcnutv6JNwHHZxIhkFy+tmMKCvmMFDQzfA/r1ujvPWB19DQkYg==", + "license": "Apache-2.0", + "bin": { + "openai": "bin/cli" + }, + "peerDependencies": { + "ws": "^8.18.0", + "zod": "^3.23.8" + }, + "peerDependenciesMeta": { + "ws": { + "optional": true + }, + "zod": { + "optional": true + } + } + }, "node_modules/parseurl": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", @@ -1740,6 +1878,15 @@ "node": ">= 0.8" } }, + "node_modules/web-streams-polyfill": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz", + "integrity": "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==", + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, "node_modules/win-node-env": { "version": "0.6.1", "resolved": "https://registry.npmjs.org/win-node-env/-/win-node-env-0.6.1.tgz", diff --git a/package.json b/package.json index 7e0a0af..809acdf 100644 --- a/package.json +++ b/package.json @@ -13,13 +13,17 @@ "license": "ISC", "description": "", "dependencies": { + "@anthropic-ai/sdk": "^0.57.0", "bcrypt": "^6.0.0", "cookie-parser": "^1.4.7", "cors": "^2.8.5", - "dotenv": "^16.5.0", + "dotenv": "^16.6.1", "express": "^5.1.0", "jsonwebtoken": "^9.0.2", "morgan": "^1.10.0", + "nanoid": "^5.1.5", + "node-fetch": "^3.3.2", + "openai": "^5.11.0", "pg": "^8.16.2", "sequelize": "^6.37.7" }, diff --git a/services/CalendarService.js b/services/CalendarService.js new file mode 100644 index 0000000..20e89cb --- /dev/null +++ b/services/CalendarService.js @@ -0,0 +1,330 @@ +const { User } = require("../database"); + +// Try to use native fetch, fallback to node-fetch for older Node versions +let fetch; +let URLSearchParams; + +try { + // Try native fetch first + if (globalThis.fetch && globalThis.URLSearchParams) { + fetch = globalThis.fetch; + URLSearchParams = globalThis.URLSearchParams; + console.log("🌐 Using native fetch and URLSearchParams"); + } else { + throw new Error("Native fetch not available"); + } +} catch (error) { + try { + // Fallback to node-fetch + fetch = require("node-fetch"); + URLSearchParams = require("url").URLSearchParams; + console.log("šŸ“¦ Using node-fetch and Node.js URLSearchParams"); + } catch (fetchError) { + console.error( + "āŒ Neither native fetch nor node-fetch available:", + fetchError + ); + throw new Error( + "Fetch not available. Please install node-fetch: npm install node-fetch" + ); + } +} + +class CalendarService { + constructor() { + this.calendarApiUrl = "https://www.googleapis.com/calendar/v3"; + } + + // Get calendar scopes for OAuth + static getCalendarScopes() { + return [ + "https://www.googleapis.com/auth/calendar", + "https://www.googleapis.com/auth/calendar.events", + ]; + } + + // Refresh access token using refresh token + async refreshAccessToken(user) { + if (!user.googleRefreshToken) { + throw new Error("No refresh token available"); + } + + try { + const response = await fetch("https://oauth2.googleapis.com/token", { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + }, + body: new URLSearchParams({ + client_id: process.env.GOOGLE_CLIENT_ID, + client_secret: process.env.GOOGLE_CLIENT_SECRET, + refresh_token: user.googleRefreshToken, + grant_type: "refresh_token", + }), + }); + + if (!response.ok) { + const errorData = await response.json().catch(() => ({})); + console.error("āŒ Token refresh failed:", { + status: response.status, + statusText: response.statusText, + error: errorData, + }); + throw new Error( + `Failed to refresh access token: ${response.status} ${ + errorData.error?.message || response.statusText + }` + ); + } + + const data = await response.json(); + + // Update user's access token + user.googleAccessToken = data.access_token; + await user.save(); + + return data.access_token; + } catch (error) { + console.error("Error refreshing access token:", error); + throw error; + } + } + + // Make authenticated request to Google Calendar API + async makeCalendarRequest(user, endpoint, options = {}) { + console.log( + `šŸ” Making calendar request: ${options.method || "GET"} ${endpoint}` + ); + + try { + let accessToken = user.googleAccessToken; + + if (!accessToken) { + throw new Error( + "User not authenticated with Google Calendar - no access token" + ); + } + + console.log(`šŸ”‘ Using access token: ${accessToken.substring(0, 20)}...`); + + // Try request with current token + let response = await this.attemptRequest(accessToken, endpoint, options); + console.log(`šŸ“” Initial response status: ${response.status}`); + + // If unauthorized, try refreshing token + if (response.status === 401) { + console.log("šŸ”„ Access token expired, refreshing..."); + accessToken = await this.refreshAccessToken(user); + console.log(`šŸ”‘ New access token: ${accessToken.substring(0, 20)}...`); + response = await this.attemptRequest(accessToken, endpoint, options); + console.log(`šŸ“” Retry response status: ${response.status}`); + } + + if (!response.ok) { + let errorData; + try { + errorData = await response.json(); + } catch (parseError) { + errorData = { error: { message: "Could not parse error response" } }; + } + + console.error(`āŒ Calendar API error response:`, { + status: response.status, + statusText: response.statusText, + errorData, + }); + + throw new Error( + `Calendar API error: ${response.status} - ${ + errorData.error?.message || response.statusText + }` + ); + } + + console.log("āœ… Calendar API request successful"); + return await response.json(); + } catch (error) { + console.error("āŒ Calendar API request failed:", { + message: error.message, + stack: error.stack, + endpoint, + method: options.method || "GET", + }); + throw new Error( + `Calendar service temporarily unavailable: ${error.message}` + ); + } + } + + // Helper method to attempt API request + async attemptRequest(accessToken, endpoint, options) { + const url = `${this.calendarApiUrl}${endpoint}`; + + return await fetch(url, { + method: options.method || "GET", + headers: { + Authorization: `Bearer ${accessToken}`, + "Content-Type": "application/json", + ...options.headers, + }, + body: options.body ? JSON.stringify(options.body) : undefined, + }); + } + + // Get user's calendars + async getCalendars(user) { + if (!user.googleAccessToken) { + throw new Error("User not authenticated with Google Calendar"); + } + + return await this.makeCalendarRequest(user, "/calendars"); + } + + // Create calendar event + async createEvent(user, eventData) { + if (!user.googleAccessToken) { + throw new Error("User not authenticated with Google Calendar"); + } + + const calendarId = eventData.calendarId || "primary"; + + // Format event for Google Calendar API + const googleEvent = { + summary: eventData.title, + description: eventData.description || "", + start: { + dateTime: eventData.startTime, + timeZone: eventData.timeZone || "America/New_York", + }, + end: { + dateTime: eventData.endTime, + timeZone: eventData.timeZone || "America/New_York", + }, + reminders: { + useDefault: false, + overrides: eventData.reminders || [ + { method: "email", minutes: 24 * 60 }, // 1 day before + { method: "popup", minutes: 10 }, // 10 minutes before + ], + }, + }; + + const response = await this.makeCalendarRequest( + user, + `/calendars/${calendarId}/events`, + { + method: "POST", + body: googleEvent, + } + ); + + return response; + } + + // Update calendar event + async updateEvent(user, eventId, eventData, calendarId = "primary") { + if (!user.googleAccessToken) { + throw new Error("User not authenticated with Google Calendar"); + } + + // Format event for Google Calendar API + const googleEvent = { + summary: eventData.title, + description: eventData.description || "", + start: { + dateTime: eventData.startTime, + timeZone: eventData.timeZone || "America/New_York", + }, + end: { + dateTime: eventData.endTime, + timeZone: eventData.timeZone || "America/New_York", + }, + reminders: { + useDefault: false, + overrides: eventData.reminders || [ + { method: "email", minutes: 24 * 60 }, + { method: "popup", minutes: 10 }, + ], + }, + }; + + const response = await this.makeCalendarRequest( + user, + `/calendars/${calendarId}/events/${eventId}`, + { + method: "PUT", + body: googleEvent, + } + ); + + return response; + } + + // Delete calendar event + async deleteEvent(user, eventId, calendarId = "primary") { + if (!user.googleAccessToken) { + throw new Error("User not authenticated with Google Calendar"); + } + + await this.makeCalendarRequest( + user, + `/calendars/${calendarId}/events/${eventId}`, + { + method: "DELETE", + } + ); + + return { success: true, message: "Event deleted successfully" }; + } + + // Get calendar events + async getEvents(user, options = {}) { + if (!user.googleAccessToken) { + throw new Error("User not authenticated with Google Calendar"); + } + + const calendarId = options.calendarId || "primary"; + const params = new URLSearchParams(); + + if (options.timeMin) params.append("timeMin", options.timeMin); + if (options.timeMax) params.append("timeMax", options.timeMax); + if (options.maxResults) params.append("maxResults", options.maxResults); + if (options.singleEvents !== undefined) + params.append("singleEvents", options.singleEvents); + if (options.orderBy) params.append("orderBy", options.orderBy); + + const endpoint = `/calendars/${calendarId}/events${ + params.toString() ? `?${params.toString()}` : "" + }`; + + const response = await this.makeCalendarRequest(user, endpoint); + return response; + } + + // Check if user has calendar permissions + hasCalendarPermissions(user) { + return !!(user.googleAccessToken && user.calendarPermissions); + } + + // Generate calendar permission URL + getCalendarPermissionUrl(userId) { + const baseUrl = "https://accounts.google.com/o/oauth2/v2/auth"; + const scopes = CalendarService.getCalendarScopes().join(" "); + + const params = new URLSearchParams({ + client_id: process.env.GOOGLE_CLIENT_ID, + redirect_uri: `${ + process.env.BACKEND_URL || "http://localhost:8080" + }/auth/google/calendar/callback`, + response_type: "code", + scope: scopes, + access_type: "offline", + prompt: "consent", // Force consent to get refresh token + state: userId, // Pass user ID to identify user in callback + }); + + return `${baseUrl}?${params.toString()}`; + } +} + +module.exports = CalendarService;