diff --git a/client/src/App.js b/client/src/App.js index 90c0965..4bd85a1 100644 --- a/client/src/App.js +++ b/client/src/App.js @@ -73,11 +73,18 @@ const AppContent = () => { resetXP }); + const { addNotification } = useNotification(); + const badgeManager = useMemo( + () => new BadgeManager(setUnlockedBadges, addNotification), + [addNotification] + ); + const taskManager = new TaskManager( calculateXP, setTasks, setCompletedTasks, - setError + setError, + badgeManager ); const themeManager = useMemo(() => new ThemeManager(setIsDark), []); @@ -86,11 +93,6 @@ const AppContent = () => { () => new ViewManager(setShowCompleted, setCurrentView), [] ); - const { addNotification } = useNotification(); - const badgeManager = useMemo( - () => new BadgeManager(setUnlockedBadges, addNotification), - [addNotification] - ); const collaborationManager = useMemo( () => new CollaborationManager(setTasks, setError), [] @@ -122,13 +124,17 @@ const AppContent = () => { }, [userId, tasks, completedTasks, experience, level, unlockedBadges]); // eslint-disable-line react-hooks/exhaustive-deps useEffect(() => { - badgeManager.checkAndUpdateBadges( + const updatedBadges = badgeManager.checkForNewBadges( level, currentStreak, completedTasks, unlockedBadges ); - }, [level, currentStreak, completedTasks, unlockedBadges, badgeManager]); + + if (updatedBadges.length !== unlockedBadges.length) { + setUnlockedBadges(updatedBadges); + } + }, [level, currentStreak]); useEffect(() => { const newStreakData = streakManager.calculateStreak(completedTasks); @@ -188,6 +194,7 @@ const AppContent = () => { }; const handleConfirmClear = async () => { + badgeManager.clearNotificationHistory(); await dataManager.clearAllData(userId); setShowClearDataModal(false); }; diff --git a/client/src/components/Badge/BadgeGrid.js b/client/src/components/Badge/BadgeGrid.js index ac14284..6c6a21b 100644 --- a/client/src/components/Badge/BadgeGrid.js +++ b/client/src/components/Badge/BadgeGrid.js @@ -1,221 +1,221 @@ -import React, { useState } from 'react'; -import BadgesModal from './BadgesModal'; -import { Trophy } from 'lucide-react'; - -export const BADGES = { - NOVICE: { - id: 'novice', - name: 'Novice', - icon: '🌟', - description: 'Reach level 5', - level: 5 - }, - INTERMEDIATE: { - id: 'intermediate', - name: 'Intermediate', - icon: '⭐', - description: 'Reach level 10', - level: 10 - }, - XP_HUNTER: { - id: 'xp_hunter', - name: 'XP Hunter', - icon: '💫', - description: 'Reach level 15', - level: 15 - }, - STREAK_MASTER: { - id: 'streak_master', - name: 'Consistent', - icon: '🔥', - description: 'Reach a 5-day streak', - streakRequired: 5 - }, - TASK_ACHIEVER: { - id: 'task_achiever', - name: 'Achiever', - icon: '✅', - description: 'Complete 20 tasks', - tasksRequired: 20 - }, - TASK_MASTER: { - id: 'task_master', - name: 'Task Master', - icon: '👑', - description: 'Complete 50 tasks', - tasksRequired: 50 - }, - DEDICATION: { - id: 'dedication', - name: 'Dedication', - icon: '💪', - description: 'Reach a 10-day streak', - streakRequired: 10 - }, - ELITE: { - id: 'elite', - name: 'Elite', - icon: '🏆', - description: 'Reach level 25', - level: 25 - }, - LEGENDARY: { - id: 'legendary', - name: 'Legendary', - icon: '⚡', - description: 'Complete 100 tasks', - tasksRequired: 100 - }, - UNSTOPPABLE: { - id: 'unstoppable', - name: 'Unstoppable', - icon: '🔱', - description: 'Reach a 30-day streak', - streakRequired: 30 - }, - EARLY_BIRD: { - id: 'early_bird', - name: 'Early Bird', - icon: '🌅', - description: 'Complete 5 tasks before their deadline', - earlyCompletions: 5 - }, - NIGHT_OWL: { - id: 'night_owl', - name: 'Night Owl', - icon: '🦉', - description: 'Complete 5 tasks between 10 PM and 4 AM', - nightCompletions: 5 - }, - MULTITASKER: { - id: 'multitasker', - name: 'Multitasker', - icon: '🎯', - description: 'Complete 3 tasks in one day', - tasksPerDay: 3 - }, - WEEKEND_WARRIOR: { - id: 'weekend_warrior', - name: 'Weekend Warrior', - icon: '⚔️', - description: 'Complete 10 tasks during weekends', - weekendCompletions: 10 - }, - PERFECTIONIST: { - id: 'perfectionist', - name: 'Perfectionist', - icon: '💎', - description: 'Complete 10 tasks exactly on their deadline', - exactDeadlines: 10 - }, - MASTER_OF_TIME: { - id: 'master_of_time', - name: 'Time Lord', - icon: '⌛', - description: 'Complete 25 tasks before their deadline', - earlyCompletions: 25 - }, - NIGHT_CHAMPION: { - id: 'night_champion', - name: 'Night Champion', - icon: '🌙', - description: 'Complete 15 tasks between 10 PM and 4 AM', - nightCompletions: 15 - }, - PRODUCTIVITY_KING: { - id: 'productivity_king', - name: 'Productivity King', - icon: '👨‍💼', - description: 'Complete 5 tasks in one day', - tasksPerDay: 5 - }, - WEEKEND_MASTER: { - id: 'weekend_master', - name: 'Weekend Master', - icon: '🎯', - description: 'Complete 50 tasks during weekends', - weekendCompletions: 50 - }, - GRANDMASTER: { - id: 'grandmaster', - name: 'Grandmaster', - icon: '🎭', - description: 'Reach level 100', - level: 100 - }, - MARATHON_RUNNER: { - id: 'marathon_runner', - name: 'Marathon Runner', - icon: '🏃', - description: 'Reach a 100-day streak', - streakRequired: 100 - }, - TASK_EMPEROR: { - id: 'task_emperor', - name: 'Task Emperor', - icon: '👑', - description: 'Complete 1000 tasks', - tasksRequired: 1000 - }, - ULTIMATE_CHAMPION: { - id: 'ultimate_champion', - name: 'Ultimate Champion', - icon: '🏅', - description: 'Complete 50 tasks before their deadline', - earlyCompletions: 50 - } -}; - -const BadgeGrid = ({ unlockedBadges }) => { - const [showModal, setShowModal] = useState(false); - const totalBadges = Object.keys(BADGES).length; - const progress = (unlockedBadges.length / totalBadges) * 100; - - return ( - <> -
-
-
-

- Badges -

-

- {unlockedBadges.length} of {totalBadges} unlocked -

-
- -
- - {/* Progress Bar */} -
-
-
-
-
-
- - setShowModal(false)} - badges={BADGES} - unlockedBadges={unlockedBadges} - /> - - ); -}; - -export default BadgeGrid; +import React, { useState } from 'react'; +import BadgesModal from './BadgesModal'; +import { Trophy } from 'lucide-react'; + +export const BADGES = { + NOVICE: { + id: 'novice', + name: 'Novice', + icon: '🌟', + description: 'Reach level 5', + level: 5 + }, + INTERMEDIATE: { + id: 'intermediate', + name: 'Intermediate', + icon: '⭐', + description: 'Reach level 10', + level: 10 + }, + XP_HUNTER: { + id: 'xp_hunter', + name: 'XP Hunter', + icon: '💫', + description: 'Reach level 15', + level: 15 + }, + STREAK_MASTER: { + id: 'streak_master', + name: 'Consistent', + icon: '🔥', + description: 'Reach a 5-day streak', + streakRequired: 5 + }, + TASK_ACHIEVER: { + id: 'task_achiever', + name: 'Achiever', + icon: '✅', + description: 'Complete 20 tasks', + tasksRequired: 20 + }, + TASK_MASTER: { + id: 'task_master', + name: 'Task Master', + icon: '👑', + description: 'Complete 50 tasks', + tasksRequired: 50 + }, + DEDICATION: { + id: 'dedication', + name: 'Dedication', + icon: '💪', + description: 'Reach a 10-day streak', + streakRequired: 10 + }, + ELITE: { + id: 'elite', + name: 'Elite', + icon: '🏆', + description: 'Reach level 25', + level: 25 + }, + LEGENDARY: { + id: 'legendary', + name: 'Legendary', + icon: '⚡', + description: 'Complete 100 tasks', + tasksRequired: 100 + }, + UNSTOPPABLE: { + id: 'unstoppable', + name: 'Unstoppable', + icon: '🔱', + description: 'Reach a 30-day streak', + streakRequired: 30 + }, + EARLY_BIRD: { + id: 'early_bird', + name: 'Early Bird', + icon: '🌅', + description: 'Complete 5 tasks before their deadline', + earlyCompletions: 5 + }, + NIGHT_OWL: { + id: 'night_owl', + name: 'Night Owl', + icon: '🦉', + description: 'Complete 5 tasks between 10 PM and 4 AM', + nightCompletions: 5 + }, + MULTITASKER: { + id: 'multitasker', + name: 'Multitasker', + icon: '🎯', + description: 'Complete 3 tasks in one day', + tasksPerDay: 3 + }, + WEEKEND_WARRIOR: { + id: 'weekend_warrior', + name: 'Weekend Warrior', + icon: '⚔️', + description: 'Complete 10 tasks during weekends', + weekendCompletions: 10 + }, + PERFECTIONIST: { + id: 'perfectionist', + name: 'Perfectionist', + icon: '💎', + description: 'Complete 10 tasks exactly on their deadline', + exactDeadlines: 10 + }, + MASTER_OF_TIME: { + id: 'master_of_time', + name: 'Time Lord', + icon: '⌛', + description: 'Complete 25 tasks before their deadline', + earlyCompletions: 25 + }, + NIGHT_CHAMPION: { + id: 'night_champion', + name: 'Night Champion', + icon: '🌙', + description: 'Complete 15 tasks between 10 PM and 4 AM', + nightCompletions: 15 + }, + PRODUCTIVITY_KING: { + id: 'productivity_king', + name: 'Productivity King', + icon: '👨‍💼', + description: 'Complete 5 tasks in one day', + tasksPerDay: 5 + }, + WEEKEND_MASTER: { + id: 'weekend_master', + name: 'Weekend Master', + icon: '🎯', + description: 'Complete 50 tasks during weekends', + weekendCompletions: 50 + }, + GRANDMASTER: { + id: 'grandmaster', + name: 'Grandmaster', + icon: '🎭', + description: 'Reach level 100', + level: 100 + }, + MARATHON_RUNNER: { + id: 'marathon_runner', + name: 'Marathon Runner', + icon: '🏃', + description: 'Reach a 100-day streak', + streakRequired: 100 + }, + TASK_EMPEROR: { + id: 'task_emperor', + name: 'Task Emperor', + icon: '👑', + description: 'Complete 1000 tasks', + tasksRequired: 1000 + }, + ULTIMATE_CHAMPION: { + id: 'ultimate_champion', + name: 'Ultimate Champion', + icon: '🏅', + description: 'Complete 50 tasks before their deadline', + earlyCompletions: 50 + } +}; + +const BadgeGrid = ({ unlockedBadges }) => { + const [showModal, setShowModal] = useState(false); + const totalBadges = Object.keys(BADGES).length; + const progress = (unlockedBadges.length / totalBadges) * 100; + + return ( + <> +
+
+
+

+ Badges +

+

+ {unlockedBadges.length} of {totalBadges} unlocked +

+
+ +
+ + {/* Progress Bar */} +
+
+
+
+
+
+ + setShowModal(false)} + badges={BADGES} + unlockedBadges={unlockedBadges} + /> + + ); +}; + +export default BadgeGrid; diff --git a/client/src/services/badge/BadgeManager.js b/client/src/services/badge/BadgeManager.js index 3de1848..018d1f8 100644 --- a/client/src/services/badge/BadgeManager.js +++ b/client/src/services/badge/BadgeManager.js @@ -1,56 +1,69 @@ -import { checkBadgeUnlocks } from '../../utils/badges/badgeUtils'; -import { BADGES } from '../../components/Badge/BadgeGrid'; - -class BadgeManager { - constructor(setUnlockedBadges, addNotification) { - this.setUnlockedBadges = setUnlockedBadges; - this.addNotification = addNotification; - this.notifiedBadges = new Set( - JSON.parse(localStorage.getItem('notifiedBadges') || '[]') - ); - } - - notifyNewBadge(badgeId) { - if (this.notifiedBadges.has(badgeId)) return; - - const badge = BADGES[badgeId.toUpperCase()]; - if (badge && this.addNotification) { - this.addNotification( - `🏆 New Badge Unlocked: ${badge.icon} ${badge.name}!`, - 'achievement', - `badge_${badgeId}` - ); - this.notifiedBadges.add(badgeId); - localStorage.setItem( - 'notifiedBadges', - JSON.stringify([...this.notifiedBadges]) - ); - } - } - - checkAndUpdateBadges( - level, - currentStreak, - completedTasks, - currentUnlockedBadges = [] - ) { - const tasksLength = Array.isArray(completedTasks) - ? completedTasks.length - : 0; - - const newUnlockedBadges = - checkBadgeUnlocks(level, currentStreak, tasksLength, completedTasks) || - []; - - newUnlockedBadges.forEach(this.notifyNewBadge.bind(this)); - - if ( - JSON.stringify(newUnlockedBadges) !== - JSON.stringify(currentUnlockedBadges) - ) { - this.setUnlockedBadges(newUnlockedBadges); - } - } -} - -export default BadgeManager; +import { checkBadgeUnlocks } from '../../utils/badges/badgeUtils'; +import { BADGES } from '../../components/Badge/BadgeGrid'; + +class BadgeManager { + constructor(setUnlockedBadges, addNotification) { + this.setUnlockedBadges = setUnlockedBadges; + this.addNotification = addNotification; + this.notifiedBadges = new Set( + JSON.parse(localStorage.getItem('notifiedBadges') || '[]') + ); + + this.notifyNewBadge = this.notifyNewBadge.bind(this); + } + + notifyNewBadge(badgeId) { + if (this.notifiedBadges.has(badgeId)) return; + + const badge = BADGES[badgeId.toUpperCase()]; + if (badge && this.addNotification) { + this.addNotification( + `🏆 New Badge Unlocked: ${badge.icon} ${badge.name}!`, + 'achievement', + `badge_${badgeId}` + ); + this.notifiedBadges.add(badgeId); + localStorage.setItem( + 'notifiedBadges', + JSON.stringify([...this.notifiedBadges]) + ); + } + } + + checkForNewBadges( + level, + currentStreak, + completedTasks, + currentUnlockedBadges = [] + ) { + const tasksLength = Array.isArray(completedTasks) ? completedTasks.length : 0; + + const newlyUnlockedBadges = checkBadgeUnlocks( + level, + currentStreak, + tasksLength, + completedTasks + ) || []; + + const uniqueNewBadges = newlyUnlockedBadges.filter( + (badge) => !currentUnlockedBadges.includes(badge) + ); + + if (uniqueNewBadges.length > 0) { + uniqueNewBadges.forEach(this.notifyNewBadge); + + const updatedBadges = [...new Set([...currentUnlockedBadges, ...uniqueNewBadges])]; + this.setUnlockedBadges(updatedBadges); + return updatedBadges; + } + + return currentUnlockedBadges; + } + + clearNotificationHistory() { + this.notifiedBadges.clear(); + localStorage.removeItem('notifiedBadges'); + } +} + +export default BadgeManager; diff --git a/client/src/services/badge/__tests__/BadgeManager.test.js b/client/src/services/badge/__tests__/BadgeManager.test.js index d59a419..09cf577 100644 --- a/client/src/services/badge/__tests__/BadgeManager.test.js +++ b/client/src/services/badge/__tests__/BadgeManager.test.js @@ -6,14 +6,16 @@ jest.mock('../../../utils/badges/badgeUtils'); describe('BadgeManager', () => { let badgeManager; let mockSetUnlockedBadges; + let mockAddNotification; beforeEach(() => { mockSetUnlockedBadges = jest.fn(); - badgeManager = new BadgeManager(mockSetUnlockedBadges); + mockAddNotification = jest.fn(); + badgeManager = new BadgeManager(mockSetUnlockedBadges, mockAddNotification); checkBadgeUnlocks.mockReset(); }); - describe('checkAndUpdateBadges', () => { + describe('checkForNewBadges', () => { it('should update badges when new badges are unlocked', () => { const currentUnlockedBadges = ['badge1']; const newUnlockedBadges = ['badge1', 'badge2']; @@ -21,18 +23,18 @@ describe('BadgeManager', () => { checkBadgeUnlocks.mockReturnValue(newUnlockedBadges); - badgeManager.checkAndUpdateBadges( - 5, // level - 3, // currentStreak - completedTasks, // completedTasks array - currentUnlockedBadges // current badges + badgeManager.checkForNewBadges( + 5, + 3, + completedTasks, + currentUnlockedBadges ); expect(checkBadgeUnlocks).toHaveBeenCalledWith( - 5, // level - 3, // streak - 10, // tasks length - completedTasks // tasks array + 5, + 3, + 10, + completedTasks ); expect(mockSetUnlockedBadges).toHaveBeenCalledWith(newUnlockedBadges); }); @@ -42,7 +44,7 @@ describe('BadgeManager', () => { checkBadgeUnlocks.mockReturnValue(['badge1']); - badgeManager.checkAndUpdateBadges( + badgeManager.checkForNewBadges( 5, 3, Array(10).fill({ id: 'task' }), @@ -57,13 +59,13 @@ describe('BadgeManager', () => { const emptyTasks = []; checkBadgeUnlocks.mockReturnValue([]); - badgeManager.checkAndUpdateBadges(1, 0, emptyTasks, []); + badgeManager.checkForNewBadges(1, 0, emptyTasks, []); expect(checkBadgeUnlocks).toHaveBeenCalledWith( - 1, // level - 0, // streak - 0, // tasks length - emptyTasks // tasks array + 1, + 0, + 0, + emptyTasks ); expect(mockSetUnlockedBadges).not.toHaveBeenCalled(); }); @@ -71,17 +73,17 @@ describe('BadgeManager', () => { it('should pass correct parameters to checkBadgeUnlocks', () => { const completedTasks = Array(5).fill({ id: 'task' }); - badgeManager.checkAndUpdateBadges( - 10, // level - 7, // streak + badgeManager.checkForNewBadges( + 10, + 7, completedTasks, - [] // current badges + [] ); expect(checkBadgeUnlocks).toHaveBeenCalledWith( - 10, // level - 7, // streak - 5, // completed tasks count + 10, + 7, + 5, completedTasks ); }); @@ -90,7 +92,7 @@ describe('BadgeManager', () => { const newUnlockedBadges = ['badge1']; checkBadgeUnlocks.mockReturnValue(newUnlockedBadges); - badgeManager.checkAndUpdateBadges(1, 0, [], undefined); + badgeManager.checkForNewBadges(1, 0, [], undefined); expect(checkBadgeUnlocks).toHaveBeenCalled(); expect(mockSetUnlockedBadges).toHaveBeenCalledWith(newUnlockedBadges); @@ -118,7 +120,7 @@ describe('BadgeManager', () => { scenarios.forEach((scenario) => { checkBadgeUnlocks.mockReturnValue(scenario.new); - badgeManager.checkAndUpdateBadges(10, 5, [], scenario.current); + badgeManager.checkForNewBadges(10, 5, [], scenario.current); if (scenario.shouldUpdate) { expect(mockSetUnlockedBadges).toHaveBeenCalledWith(scenario.new); diff --git a/client/src/services/task/TaskManager.js b/client/src/services/task/TaskManager.js index 8b359b5..cfca3ff 100644 --- a/client/src/services/task/TaskManager.js +++ b/client/src/services/task/TaskManager.js @@ -1,236 +1,248 @@ -import { v4 as uuidv4 } from 'uuid'; -import { startConfetti } from '../../utils/other/confettiUtils'; - -class TaskManager { - constructor(calculateXP, setTasks, setCompletedTasks, setError) { - this.calculateXP = calculateXP; - this.setTasks = setTasks; - this.setCompletedTasks = setCompletedTasks; - this.setError = setError; - } - - handleError(error, message) { - console.error(message, error); - this.setError(error.message); - } - - addTask = async (taskData) => { - try { - const tasksToAdd = Array.isArray(taskData) ? taskData : [taskData]; - - const newTasks = tasksToAdd.map((task) => ({ - ...task, - id: uuidv4(), - createdAt: new Date().toISOString(), - label: task.label || null - })); - - this.setTasks((currentTasks) => [...currentTasks, ...newTasks]); - } catch (error) { - this.handleError(error, 'Error adding task:'); - } - }; - - completeTask = async (task) => { - try { - startConfetti(); - this.setTasks((currentTasks) => - currentTasks.filter((t) => t.id !== task.id) - ); - - const completedTask = { - ...task, - completedAt: new Date().toISOString() // Store UTC timestamp - }; - - const xpResult = this.calculateXP(task.experience, task.deadline); - completedTask.earlyBonus = xpResult.earlyBonus; - completedTask.overduePenalty = xpResult.overduePenalty; - - this.setCompletedTasks((prev) => [...prev, completedTask]); - } catch (error) { - this.handleError(error, 'Error completing task:'); - } - }; - - removeTask = (taskId, isCompleted) => { - try { - if (isCompleted) { - this.setCompletedTasks((prev) => { - const taskToRemove = prev.find((t) => t.id === taskId); - if (taskToRemove) { - let totalXPToRemove = taskToRemove.experience; - if (taskToRemove.earlyBonus) - totalXPToRemove += taskToRemove.earlyBonus; - if (taskToRemove.overduePenalty) - totalXPToRemove += taskToRemove.overduePenalty; - this.calculateXP(-totalXPToRemove); - } - return prev.filter((t) => t.id !== taskId); - }); - } else { - this.setTasks((prev) => prev.filter((t) => t.id !== taskId)); - } - } catch (error) { - this.handleError(error, 'Error removing task:'); - } - }; - - updateTask = async (taskId, updatedTask) => { - try { - // First check if this is a shared project update - if (updatedTask.isShared) { - const baseUrl = process.env.REACT_APP_PROD || 'http://localhost:3001/api'; - - // Sync the update with server first - const response = await fetch( - `${baseUrl}/collaboration/projects/${taskId}/details`, - { - method: 'PUT', - credentials: 'include', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify(updatedTask), - } - ); - - if (!response.ok) { - const errorText = await response.text(); - console.error('[UPDATE-TASK] Server error:', errorText); - throw new Error(`Failed to sync project update: ${response.status}`); - } - } - - // Update local state after successful sync (or if not shared) - this.setTasks((currentTasks) => - currentTasks.map((task) => { - if (task.id === taskId) { - return { ...task, ...updatedTask }; - } - return task; - }) - ); - } catch (error) { - this.handleError(error, '[UPDATE-TASK] Error updating task:'); - throw error; // Re-throw to let caller handle the error - } - }; - - async joinProject(shareCode) { - try { - const baseUrl = process.env.REACT_APP_PROD || 'http://localhost:3001/api'; - const response = await fetch(`${baseUrl}/collaboration/projects/${shareCode}`, { - method: 'GET', - credentials: 'include', - headers: { - 'Accept': 'application/json', - 'Content-Type': 'application/json', - } - }); - - if (!response.ok) { - return false; - } - - const project = await response.json(); - - this.setTasks(currentTasks => { - const exists = currentTasks.some(t => t.id === project.id); - if (!exists) { - return [...currentTasks, project]; - } - return currentTasks; - }); - - return true; - } catch (error) { - console.error('[JOIN] Error joining project:', error); - return false; - } - } - - startProjectSync(taskId) { - console.log(`[SYNC] Starting sync for project: ${taskId}`); - let syncAttempts = 0; - const maxAttempts = 3; - - const syncInterval = setInterval(async () => { - try { - console.log(`[SYNC] Polling updates for project ${taskId}`); - const baseUrl = process.env.REACT_APP_PROD || 'http://localhost:3001/api'; - const url = `${baseUrl}/collaboration/projects/${taskId}`; - console.log(`[SYNC] Making request to: ${url}`); - - const response = await fetch(url, { - method: 'GET', - credentials: 'include', - headers: { - 'Accept': 'application/json', - 'Content-Type': 'application/json', - } - }); - - if (!response.ok) { - syncAttempts++; - const errorText = await response.text(); - console.error(`[SYNC] Server error response (attempt ${syncAttempts}/${maxAttempts}):`, errorText); - - if (syncAttempts >= maxAttempts) { - console.error(`[SYNC] Max retry attempts reached for project ${taskId}`); - clearInterval(syncInterval); - return; - } - - throw new Error(`Failed to sync project: ${response.status}`); - } - - // Reset attempts on successful response - syncAttempts = 0; - - const rawResponse = await response.text(); - console.log('[SYNC] Raw server response:', rawResponse); - - let project; - try { - project = JSON.parse(rawResponse); - } catch (e) { - console.error('[SYNC] Failed to parse response as JSON:', e); - throw new Error('Invalid response format from server'); - } - - console.log(`[SYNC] Received project update:`, project); - - // Update the task in state if it exists and has changed - this.setTasks(currentTasks => { - const taskIndex = currentTasks.findIndex(t => t.id === taskId); - if (taskIndex === -1) { - console.log(`[SYNC] Task ${taskId} not found in current tasks`); - return currentTasks; - } - - const currentTask = currentTasks[taskIndex]; - if (JSON.stringify(currentTask) === JSON.stringify(project)) { - console.log(`[SYNC] No changes detected for project ${taskId}`); - return currentTasks; - } - - console.log(`[SYNC] Updating local state for project ${taskId}`); - const newTasks = [...currentTasks]; - newTasks[taskIndex] = project; - return newTasks; - }); - - } catch (error) { - console.error('[SYNC] Project sync error:', error); - } - }, 5000); - - return () => { - console.log(`[SYNC] Stopping sync for project ${taskId}`); - clearInterval(syncInterval); - }; - } -} - -export default TaskManager; +import { v4 as uuidv4 } from 'uuid'; +import { startConfetti } from '../../utils/other/confettiUtils'; + +class TaskManager { + constructor(calculateXP, setTasks, setCompletedTasks, setError, badgeManager = null) { + this.calculateXP = calculateXP; + this.setTasks = setTasks; + this.setCompletedTasks = setCompletedTasks; + this.setError = setError; + this.badgeManager = badgeManager; + } + + handleError(error, message) { + console.error(message, error); + this.setError(error.message); + } + + addTask = async (taskData) => { + try { + const tasksToAdd = Array.isArray(taskData) ? taskData : [taskData]; + + const newTasks = tasksToAdd.map((task) => ({ + ...task, + id: uuidv4(), + createdAt: new Date().toISOString(), + label: task.label || null + })); + + this.setTasks((currentTasks) => [...currentTasks, ...newTasks]); + } catch (error) { + this.handleError(error, 'Error adding task:'); + } + }; + + completeTask = async (task) => { + try { + startConfetti(); + this.setTasks((currentTasks) => + currentTasks.filter((t) => t.id !== task.id) + ); + + const completedTask = { + ...task, + completedAt: new Date().toISOString() // Store UTC timestamp + }; + + const xpResult = this.calculateXP(task.experience, task.deadline); + completedTask.earlyBonus = xpResult.earlyBonus; + completedTask.overduePenalty = xpResult.overduePenalty; + + // Update completed tasks and check for badges + this.setCompletedTasks((prev) => { + const updatedTasks = [...prev, completedTask]; + if (this.badgeManager) { + this.badgeManager.checkForNewBadges( + undefined, + undefined, + updatedTasks + ); + } + return updatedTasks; + }); + } catch (error) { + this.handleError(error, 'Error completing task:'); + } + }; + + removeTask = (taskId, isCompleted) => { + try { + if (isCompleted) { + this.setCompletedTasks((prev) => { + const taskToRemove = prev.find((t) => t.id === taskId); + if (taskToRemove) { + let totalXPToRemove = taskToRemove.experience; + if (taskToRemove.earlyBonus) + totalXPToRemove += taskToRemove.earlyBonus; + if (taskToRemove.overduePenalty) + totalXPToRemove += taskToRemove.overduePenalty; + this.calculateXP(-totalXPToRemove); + } + return prev.filter((t) => t.id !== taskId); + }); + } else { + this.setTasks((prev) => prev.filter((t) => t.id !== taskId)); + } + } catch (error) { + this.handleError(error, 'Error removing task:'); + } + }; + + updateTask = async (taskId, updatedTask) => { + try { + // First check if this is a shared project update + if (updatedTask.isShared) { + const baseUrl = process.env.REACT_APP_PROD || 'http://localhost:3001/api'; + + // Sync the update with server first + const response = await fetch( + `${baseUrl}/collaboration/projects/${taskId}/details`, + { + method: 'PUT', + credentials: 'include', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(updatedTask), + } + ); + + if (!response.ok) { + const errorText = await response.text(); + console.error('[UPDATE-TASK] Server error:', errorText); + throw new Error(`Failed to sync project update: ${response.status}`); + } + } + + // Update local state after successful sync (or if not shared) + this.setTasks((currentTasks) => + currentTasks.map((task) => { + if (task.id === taskId) { + return { ...task, ...updatedTask }; + } + return task; + }) + ); + } catch (error) { + this.handleError(error, '[UPDATE-TASK] Error updating task:'); + throw error; // Re-throw to let caller handle the error + } + }; + + async joinProject(shareCode) { + try { + const baseUrl = process.env.REACT_APP_PROD || 'http://localhost:3001/api'; + const response = await fetch(`${baseUrl}/collaboration/projects/${shareCode}`, { + method: 'GET', + credentials: 'include', + headers: { + 'Accept': 'application/json', + 'Content-Type': 'application/json', + } + }); + + if (!response.ok) { + return false; + } + + const project = await response.json(); + + this.setTasks(currentTasks => { + const exists = currentTasks.some(t => t.id === project.id); + if (!exists) { + return [...currentTasks, project]; + } + return currentTasks; + }); + + return true; + } catch (error) { + console.error('[JOIN] Error joining project:', error); + return false; + } + } + + startProjectSync(taskId) { + console.log(`[SYNC] Starting sync for project: ${taskId}`); + let syncAttempts = 0; + const maxAttempts = 3; + + const syncInterval = setInterval(async () => { + try { + console.log(`[SYNC] Polling updates for project ${taskId}`); + const baseUrl = process.env.REACT_APP_PROD || 'http://localhost:3001/api'; + const url = `${baseUrl}/collaboration/projects/${taskId}`; + console.log(`[SYNC] Making request to: ${url}`); + + const response = await fetch(url, { + method: 'GET', + credentials: 'include', + headers: { + 'Accept': 'application/json', + 'Content-Type': 'application/json', + } + }); + + if (!response.ok) { + syncAttempts++; + const errorText = await response.text(); + console.error(`[SYNC] Server error response (attempt ${syncAttempts}/${maxAttempts}):`, errorText); + + if (syncAttempts >= maxAttempts) { + console.error(`[SYNC] Max retry attempts reached for project ${taskId}`); + clearInterval(syncInterval); + return; + } + + throw new Error(`Failed to sync project: ${response.status}`); + } + + // Reset attempts on successful response + syncAttempts = 0; + + const rawResponse = await response.text(); + console.log('[SYNC] Raw server response:', rawResponse); + + let project; + try { + project = JSON.parse(rawResponse); + } catch (e) { + console.error('[SYNC] Failed to parse response as JSON:', e); + throw new Error('Invalid response format from server'); + } + + console.log(`[SYNC] Received project update:`, project); + + // Update the task in state if it exists and has changed + this.setTasks(currentTasks => { + const taskIndex = currentTasks.findIndex(t => t.id === taskId); + if (taskIndex === -1) { + console.log(`[SYNC] Task ${taskId} not found in current tasks`); + return currentTasks; + } + + const currentTask = currentTasks[taskIndex]; + if (JSON.stringify(currentTask) === JSON.stringify(project)) { + console.log(`[SYNC] No changes detected for project ${taskId}`); + return currentTasks; + } + + console.log(`[SYNC] Updating local state for project ${taskId}`); + const newTasks = [...currentTasks]; + newTasks[taskIndex] = project; + return newTasks; + }); + + } catch (error) { + console.error('[SYNC] Project sync error:', error); + } + }, 5000); + + return () => { + console.log(`[SYNC] Stopping sync for project ${taskId}`); + clearInterval(syncInterval); + }; + } +} + +export default TaskManager; diff --git a/client/src/services/user/DataManager.js b/client/src/services/user/DataManager.js index 82eb79c..25e4c13 100644 --- a/client/src/services/user/DataManager.js +++ b/client/src/services/user/DataManager.js @@ -1,151 +1,150 @@ -import { validateUserId } from '../validationservice'; - -class DataManager { - static API_BASE_URL = - process.env.REACT_APP_PROD || 'http://localhost:3001/api'; - - constructor(setters) { - this.setters = setters; - } - - handleError(error, message, setError) { - console.error(message, error); - setError(`${message} ${error.message}`); - } - - handleAuthChange = (id) => { - this.setters.setUserId(id); - }; - - handleUserDataLoad = (userData) => { - // pull data from server - if (!userData) return; - - const { - setUserId, - setTotalExperience, - setTasks, - setCompletedTasks, - setUserName, - setUnlockedBadges - } = this.setters; - - setUserId(userData.userId); - setTotalExperience(userData.xp || 0); - setTasks(userData.tasks || []); - setCompletedTasks(userData.completedTasks || []); - setUserName(userData.name || null); - setUnlockedBadges(userData.unlockedBadges || []); - }; - - async checkAuth(setLoading) { - try { - const response = await fetch( - `${DataManager.API_BASE_URL}/auth/current_user`, - { - credentials: 'include' - } - ); - const data = await response.json(); - if (data && data.userId) { - this.handleAuthChange(data.userId); - this.handleUserDataLoad(data); - } - } catch (error) { - console.error('Auth check failed:', error); - } finally { - setLoading(false); - } - } - - async updateUserData(userId, userData) { - try { - validateUserId(userId); - const response = await fetch( - `${DataManager.API_BASE_URL}/users/${userId}`, - { - method: 'PUT', - headers: { - 'Content-Type': 'application/json' - }, - credentials: 'include', - body: JSON.stringify(userData) - } - ); - - if (!response.ok) { - throw new Error(`Failed to update user data: ${response.status}`); - } - } catch (error) { - this.handleError( - error, - 'Error updating user data:', - this.setters.setError - ); - } - } - - async clearAllData(userId) { - try { - const { setTasks, setCompletedTasks, resetXP } = this.setters; - - setTasks([]); - setCompletedTasks([]); - resetXP(); - - await this.updateUserData(userId, { - xp: 0, - level: 1, - tasks: [], - completedTasks: [] - }); - } catch (error) { - this.handleError(error, 'Error clearing data:', this.setters.setError); - } - } - - async syncUserData(userData) { - // send local data to server - if (!userData.userId) return; - - try { - await this.updateUserData(userData.userId, { - xp: userData.getTotalXP(), - level: userData.level, - tasksCompleted: userData.completedTasks.length, - tasks: userData.tasks, - completedTasks: userData.completedTasks, - unlockedBadges: userData.unlockedBadges - }); - } catch (error) { - this.handleError( - error, - 'Error syncing user data:', - this.setters.setError - ); - } - } - - async checkAndHandleAuth(setLoading) { - if (!setLoading) return; - try { - const response = await fetch( - `${DataManager.API_BASE_URL}/auth/current_user`, - { - credentials: 'include' - } - ); - const data = await response.json(); - if (data && data.userId) { - this.handleAuthChange(data.userId); - this.handleUserDataLoad(data); - } - } catch (error) { - console.error('Auth check failed:', error); - } finally { - setLoading(false); - } - } -} - -export default DataManager; +import { validateUserId } from '../validationservice'; + +class DataManager { + static API_BASE_URL = + process.env.REACT_APP_PROD || 'http://localhost:3001/api'; + + constructor(setters) { + this.setters = setters; + } + + handleError(error, message, setError) { + console.error(message, error); + setError(`${message} ${error.message}`); + } + + handleAuthChange = (id) => { + this.setters.setUserId(id); + }; + + handleUserDataLoad = (userData) => { + if (!userData) return; + const { + setUserId, + setTotalExperience, + setTasks, + setCompletedTasks, + setUserName, + setUnlockedBadges + } = this.setters; + + setUserId(userData.userId); + setTotalExperience(userData.xp || 0); + setTasks(userData.tasks || []); + setCompletedTasks(userData.completedTasks || []); + setUserName(userData.name || null); + if (Array.isArray(userData.unlockedBadges)) { + setUnlockedBadges(userData.unlockedBadges); + } + }; + + async checkAuth(setLoading) { + try { + const response = await fetch( + `${DataManager.API_BASE_URL}/auth/current_user`, + { + credentials: 'include' + } + ); + const data = await response.json(); + if (data && data.userId) { + this.handleAuthChange(data.userId); + this.handleUserDataLoad(data); + } + } catch (error) { + console.error('Auth check failed:', error); + } finally { + setLoading(false); + } + } + + async updateUserData(userId, userData) { + try { + validateUserId(userId); + const response = await fetch( + `${DataManager.API_BASE_URL}/users/${userId}`, + { + method: 'PUT', + headers: { + 'Content-Type': 'application/json' + }, + credentials: 'include', + body: JSON.stringify(userData) + } + ); + + if (!response.ok) { + throw new Error(`Failed to update user data: ${response.status}`); + } + } catch (error) { + this.handleError( + error, + 'Error updating user data:', + this.setters.setError + ); + } + } + + async clearAllData(userId) { + try { + const { setTasks, setCompletedTasks, resetXP, setUnlockedBadges } = this.setters; + + setTasks([]); + setCompletedTasks([]); + resetXP(); + setUnlockedBadges([]); + + await this.updateUserData(userId, { + xp: 0, + level: 1, + tasks: [], + completedTasks: [], + unlockedBadges: [] + }); + } catch (error) { + this.handleError(error, 'Error clearing data:', this.setters.setError); + } + } + + async syncUserData(userData) { + if (!userData.userId) return; + + try { + const currentBadges = userData.unlockedBadges || []; + + await this.updateUserData(userData.userId, { + xp: userData.getTotalXP(), + level: userData.level, + tasksCompleted: userData.completedTasks.length, + tasks: userData.tasks, + completedTasks: userData.completedTasks, + ...(currentBadges.length > 0 && { unlockedBadges: currentBadges }) + }); + } catch (error) { + this.handleError(error, 'Error syncing user data:', this.setters.setError); + } + } + + async checkAndHandleAuth(setLoading) { + if (!setLoading) return; + try { + const response = await fetch( + `${DataManager.API_BASE_URL}/auth/current_user`, + { + credentials: 'include' + } + ); + const data = await response.json(); + if (data && data.userId) { + this.handleAuthChange(data.userId); + this.handleUserDataLoad(data); + } + } catch (error) { + console.error('Auth check failed:', error); + } finally { + setLoading(false); + } + } +} + +export default DataManager; diff --git a/client/src/services/user/__tests__/DataManager.test.js b/client/src/services/user/__tests__/DataManager.test.js index 14e036a..a51236f 100644 --- a/client/src/services/user/__tests__/DataManager.test.js +++ b/client/src/services/user/__tests__/DataManager.test.js @@ -139,6 +139,8 @@ describe('DataManager', () => { expect(mockSetters.setTasks).toHaveBeenCalledWith([]); expect(mockSetters.setCompletedTasks).toHaveBeenCalledWith([]); expect(mockSetters.resetXP).toHaveBeenCalled(); + expect(mockSetters.setUnlockedBadges).toHaveBeenCalledWith([]); + expect(fetch).toHaveBeenCalledWith( expect.stringContaining('/users/test-123'), expect.objectContaining({ @@ -147,7 +149,8 @@ describe('DataManager', () => { xp: 0, level: 1, tasks: [], - completedTasks: [] + completedTasks: [], + unlockedBadges: [] }) }) ); diff --git a/client/src/utils/badges/badgeUtils.js b/client/src/utils/badges/badgeUtils.js index fd604f9..149940f 100644 --- a/client/src/utils/badges/badgeUtils.js +++ b/client/src/utils/badges/badgeUtils.js @@ -1,109 +1,109 @@ -import { BADGES } from '../../components/Badge/BadgeGrid'; - -/** - * Badge Unlock Manager - * Checks various achievement conditions and returns unlocked badge IDs - * - * Handles multiple badge types: - * - Level-based badges - * - Streak-based achievements - * - Task completion milestones - * - Time-sensitive completions (early/night/weekend) - * - Daily completion challenges - * - Precision timing achievements - * - * @param {number} level - Current user level - * @param {number} streak - Current streak count - * @param {number} completedTasksCount - Total completed tasks - * @param {Array} completedTasks - Array of task objects with completion details - * @returns {Array} Array of unlocked badge IDs - */ -export const checkBadgeUnlocks = ( - level, - streak = 0, - completedTasksCount = 0, - completedTasks = [] -) => { - const unlockedBadges = []; - - Object.values(BADGES).forEach((badge) => { - // Level achievements - if (badge.level && level >= badge.level) { - unlockedBadges.push(badge.id); - } - - // Streak achievements - if (badge.streakRequired && streak >= badge.streakRequired) { - unlockedBadges.push(badge.id); - } - - // Task count achievements - if (badge.tasksRequired && completedTasksCount >= badge.tasksRequired) { - unlockedBadges.push(badge.id); - } - - // Early completion badges - if (badge.earlyCompletions) { - const earlyCount = completedTasks.filter((task) => { - const completedDate = new Date(task.completedAt); - const deadline = new Date(task.deadline); - return completedDate < deadline; - }).length; - if (earlyCount >= badge.earlyCompletions) { - unlockedBadges.push(badge.id); - } - } - - // Night owl badges (10 PM - 4 AM) - if (badge.nightCompletions) { - const nightCount = completedTasks.filter((task) => { - const completedHour = new Date(task.completedAt).getHours(); - return completedHour >= 22 || completedHour <= 4; - }).length; - if (nightCount >= badge.nightCompletions) { - unlockedBadges.push(badge.id); - } - } - - // Daily achievement badges - if (badge.tasksPerDay) { - const tasksPerDayMap = completedTasks.reduce((acc, task) => { - const date = new Date(task.completedAt).toDateString(); - acc[date] = (acc[date] || 0) + 1; - return acc; - }, {}); - if ( - Object.values(tasksPerDayMap).some( - (count) => count >= badge.tasksPerDay - ) - ) { - unlockedBadges.push(badge.id); - } - } - - // Weekend warrior badges - if (badge.weekendCompletions) { - const weekendCount = completedTasks.filter((task) => { - const day = new Date(task.completedAt).getDay(); - return day === 0 || day === 6; // Sunday or Saturday - }).length; - if (weekendCount >= badge.weekendCompletions) { - unlockedBadges.push(badge.id); - } - } - - // Deadline precision badges (within 1 hour) - if (badge.exactDeadlines) { - const exactCount = completedTasks.filter((task) => { - const completedDate = new Date(task.completedAt); - const deadline = new Date(task.deadline); - return Math.abs(completedDate - deadline) < 1000 * 60 * 60; - }).length; - if (exactCount >= badge.exactDeadlines) { - unlockedBadges.push(badge.id); - } - } - }); - - return [...new Set(unlockedBadges)]; -}; +import { BADGES } from '../../components/Badge/BadgeGrid'; + +/** + * Badge Unlock Manager + * Checks various achievement conditions and returns unlocked badge IDs + * + * Handles multiple badge types: + * - Level-based badges + * - Streak-based achievements + * - Task completion milestones + * - Time-sensitive completions (early/night/weekend) + * - Daily completion challenges + * - Precision timing achievements + * + * @param {number} level - Current user level + * @param {number} streak - Current streak count + * @param {number} completedTasksCount - Total completed tasks + * @param {Array} completedTasks - Array of task objects with completion details + * @returns {Array} Array of unlocked badge IDs + */ +export const checkBadgeUnlocks = ( + level, + streak = 0, + completedTasksCount = 0, + completedTasks = [] +) => { + const unlockedBadges = []; + + Object.values(BADGES).forEach((badge) => { + // Level achievements + if (badge.level && level >= badge.level) { + unlockedBadges.push(badge.id); + } + + // Streak achievements + if (badge.streakRequired && streak >= badge.streakRequired) { + unlockedBadges.push(badge.id); + } + + // Task count achievements + if (badge.tasksRequired && completedTasksCount >= badge.tasksRequired) { + unlockedBadges.push(badge.id); + } + + // Early completion badges + if (badge.earlyCompletions) { + const earlyCount = completedTasks.filter((task) => { + const completedDate = new Date(task.completedAt); + const deadline = new Date(task.deadline); + return completedDate < deadline; + }).length; + if (earlyCount >= badge.earlyCompletions) { + unlockedBadges.push(badge.id); + } + } + + // Night owl badges (10 PM - 4 AM) + if (badge.nightCompletions) { + const nightCount = completedTasks.filter((task) => { + const completedHour = new Date(task.completedAt).getHours(); + return completedHour >= 22 || completedHour <= 4; + }).length; + if (nightCount >= badge.nightCompletions) { + unlockedBadges.push(badge.id); + } + } + + // Daily achievement badges + if (badge.tasksPerDay) { + const tasksPerDayMap = completedTasks.reduce((acc, task) => { + const date = new Date(task.completedAt).toDateString(); + acc[date] = (acc[date] || 0) + 1; + return acc; + }, {}); + if ( + Object.values(tasksPerDayMap).some( + (count) => count >= badge.tasksPerDay + ) + ) { + unlockedBadges.push(badge.id); + } + } + + // Weekend warrior badges + if (badge.weekendCompletions) { + const weekendCount = completedTasks.filter((task) => { + const day = new Date(task.completedAt).getDay(); + return day === 0 || day === 6; // Sunday or Saturday + }).length; + if (weekendCount >= badge.weekendCompletions) { + unlockedBadges.push(badge.id); + } + } + + // Deadline precision badges (within 1 hour) + if (badge.exactDeadlines) { + const exactCount = completedTasks.filter((task) => { + const completedDate = new Date(task.completedAt); + const deadline = new Date(task.deadline); + return Math.abs(completedDate - deadline) < 1000 * 60 * 60; + }).length; + if (exactCount >= badge.exactDeadlines) { + unlockedBadges.push(badge.id); + } + } + }); + + return [...new Set(unlockedBadges)]; +}; diff --git a/server/config/passport-setup.js b/server/config/passport-setup.js index 91db28e..a3c6b03 100644 --- a/server/config/passport-setup.js +++ b/server/config/passport-setup.js @@ -62,6 +62,7 @@ passport.use( tasksCompleted: 0, tasks: [], completedTasks: [], + unlockedBadges: [], isOptIn: false, createdAt: new Date(), lastLogin: new Date() diff --git a/server/controllers/users/users.controller.js b/server/controllers/users/users.controller.js index 4d9a11f..86397b2 100644 --- a/server/controllers/users/users.controller.js +++ b/server/controllers/users/users.controller.js @@ -1,143 +1,151 @@ -const { ObjectId } = require('mongodb'); -const { connectToDatabase } = require('../../db'); - -async function updateUser(req, res) { - if (!req.params.id || !ObjectId.isValid(req.params.id)) { - return res.status(400).json({ error: 'Invalid user ID format' }); - } - - const { xp, tasksCompleted, level, tasks, completedTasks, unlockedBadges } = - req.body; - - const numXP = Number(xp) || 0; - const numTasksCompleted = Number(tasksCompleted) || 0; - const numLevel = Number(level) || 1; - - const sanitizedTasks = Array.isArray(tasks) - ? tasks.map((task) => ({ - ...task, - deadline: task.deadline || null - })) - : []; - - const sanitizedCompletedTasks = Array.isArray(completedTasks) - ? completedTasks.map((task) => ({ - ...task, - deadline: task.deadline || null - })) - : []; - - if (isNaN(numXP) || isNaN(numTasksCompleted) || isNaN(numLevel)) { - return res - .status(400) - .json({ error: 'Invalid xp, tasksCompleted, or level value' }); - } - - try { - const db = await connectToDatabase(); - const usersCollection = db.collection('users'); - - const userId = ObjectId.isValid(req.params.id) - ? new ObjectId(req.params.id) - : null; - if (!userId) { - return res.status(400).json({ error: 'Invalid user ID' }); - } - - const result = await usersCollection.updateOne( - { _id: userId }, - { - $set: { - xp: numXP, - tasksCompleted: numTasksCompleted, - level: numLevel, - tasks: sanitizedTasks, - completedTasks: sanitizedCompletedTasks, - unlockedBadges: Array.isArray(unlockedBadges) ? unlockedBadges : [], - updatedAt: new Date() - } - } - ); - - if (result.matchedCount === 0) { - return res.status(404).json({ error: 'User not found' }); - } - - res.json({ message: 'User updated successfully' }); - } catch (error) { - console.error('Error updating user:', error); - res.status(500).json({ error: 'Internal server error' }); - } -} - -async function getUser(req, res) { - try { - // Validate user ID first - if (!req.params.id || !ObjectId.isValid(req.params.id)) { - return res.status(400).json({ error: 'Invalid user ID format' }); - } - - const db = await connectToDatabase(); - const usersCollection = db.collection('users'); - - const user = await usersCollection.findOne( - { _id: new ObjectId(req.params.id) }, - { - projection: { - name: 1, - xp: 1, - level: 1, - isOptIn: 1, - unlockedBadges: 1, - _id: 1 - } - } - ); - - if (!user) { - return res.status(404).json({ error: 'User not found' }); - } - - console.log(`Retrieved user: ${req.params.id}`); - res.json(user); - } catch (error) { - console.error('Error fetching user:', error); - res.status(500).json({ error: 'Internal server error' }); - } -} - -async function updateOptInStatus(req, res) { - try { - const db = await connectToDatabase(); - const usersCollection = db.collection('users'); - - const user = await usersCollection.findOne({ - _id: new ObjectId(req.params.id) - }); - - if (!user) { - return res.status(404).json({ error: 'User not found' }); - } - - const newStatus = !user.isOptIn; - - await usersCollection.updateOne( - { _id: new ObjectId(req.params.id) }, - { $set: { isOptIn: newStatus } } - ); - - res.json({ - message: `Opt-in status updated successfully`, - isOptIn: newStatus - }); - } catch (error) { - console.error('Error updating opt-in status:', error); - res.status(500).json({ error: 'Internal server error' }); - } -} - -module.exports = { - updateUser, - getUser, - updateOptInStatus -}; +const { ObjectId } = require('mongodb'); +const { connectToDatabase } = require('../../db'); + +async function updateUser(req, res) { + if (!req.params.id || !ObjectId.isValid(req.params.id)) { + return res.status(400).json({ error: 'Invalid user ID format' }); + } + + const { xp, tasksCompleted, level, tasks, completedTasks, unlockedBadges } = + req.body; + + const numXP = Number(xp) || 0; + const numTasksCompleted = Number(tasksCompleted) || 0; + const numLevel = Number(level) || 1; + + const sanitizedTasks = Array.isArray(tasks) + ? tasks.map((task) => ({ + ...task, + deadline: task.deadline || null + })) + : []; + + const sanitizedCompletedTasks = Array.isArray(completedTasks) + ? completedTasks.map((task) => ({ + ...task, + deadline: task.deadline || null + })) + : []; + + if (isNaN(numXP) || isNaN(numTasksCompleted) || isNaN(numLevel)) { + return res + .status(400) + .json({ error: 'Invalid xp, tasksCompleted, or level value' }); + } + + try { + const db = await connectToDatabase(); + const usersCollection = db.collection('users'); + + const userId = ObjectId.isValid(req.params.id) + ? new ObjectId(req.params.id) + : null; + if (!userId) { + return res.status(400).json({ error: 'Invalid user ID' }); + } + + // Get existing user data + const existingUser = await usersCollection.findOne({ _id: userId }); + const existingBadges = existingUser?.unlockedBadges || []; + + // Only merge badges if they're not being explicitly cleared + const mergedBadges = req.body.unlockedBadges === undefined + ? [...new Set([...existingBadges, ...(req.body.unlockedBadges || [])])] + : req.body.unlockedBadges; + + const result = await usersCollection.updateOne( + { _id: userId }, + { + $set: { + xp: numXP, + tasksCompleted: numTasksCompleted, + level: numLevel, + tasks: sanitizedTasks, + completedTasks: sanitizedCompletedTasks, + unlockedBadges: mergedBadges, + updatedAt: new Date() + } + } + ); + + if (result.matchedCount === 0) { + return res.status(404).json({ error: 'User not found' }); + } + + res.json({ message: 'User updated successfully' }); + } catch (error) { + console.error('Error updating user:', error); + res.status(500).json({ error: 'Internal server error' }); + } +} + +async function getUser(req, res) { + try { + // Validate user ID first + if (!req.params.id || !ObjectId.isValid(req.params.id)) { + return res.status(400).json({ error: 'Invalid user ID format' }); + } + + const db = await connectToDatabase(); + const usersCollection = db.collection('users'); + + const user = await usersCollection.findOne( + { _id: new ObjectId(req.params.id) }, + { + projection: { + name: 1, + xp: 1, + level: 1, + isOptIn: 1, + unlockedBadges: 1, + _id: 1 + } + } + ); + + if (!user) { + return res.status(404).json({ error: 'User not found' }); + } + + res.json(user); + } catch (error) { + console.error('Error fetching user:', error); + res.status(500).json({ error: 'Internal server error' }); + } +} + +async function updateOptInStatus(req, res) { + try { + const db = await connectToDatabase(); + const usersCollection = db.collection('users'); + + const user = await usersCollection.findOne({ + _id: new ObjectId(req.params.id) + }); + + if (!user) { + return res.status(404).json({ error: 'User not found' }); + } + + const newStatus = !user.isOptIn; + + await usersCollection.updateOne( + { _id: new ObjectId(req.params.id) }, + { $set: { isOptIn: newStatus } } + ); + + res.json({ + message: `Opt-in status updated successfully`, + isOptIn: newStatus + }); + } catch (error) { + console.error('Error updating opt-in status:', error); + res.status(500).json({ error: 'Internal server error' }); + } +} + +module.exports = { + updateUser, + getUser, + updateOptInStatus +}; diff --git a/server/routes/auth/auth.routes.js b/server/routes/auth/auth.routes.js index 8e49c88..9acbc78 100644 --- a/server/routes/auth/auth.routes.js +++ b/server/routes/auth/auth.routes.js @@ -62,7 +62,8 @@ router.get('/current_user', authenticateToken, (req, res) => { level: req.user.level || 1, tasks: req.user.tasks || [], completedTasks: req.user.completedTasks || [], - isOptIn: req.user.isOptIn || false + isOptIn: req.user.isOptIn || false, + unlockedBadges: req.user.unlockedBadges || [] }); });