import type { Express } from "express"; import { createServer, type Server } from "http"; import { storage } from "./storage.js"; import { insertLabelSchema, insertTaskSchema, insertNoteSchema, insertXpEventSchema, insertGoalSchema, insertRewardSchema, rewards, userRewards, User } from "../shared/schema.js"; import { z } from "zod"; import { EmailService } from "./email.js"; import { AiService } from "./ai.js"; const emailService = new EmailService(storage); const aiService = new AiService(storage); import { setupAuth, hashPassword, comparePassword } from "./auth.js"; function isAdmin(req: any, res: any, next: any) { if (req.isAuthenticated() && req.user.role === 'admin') { return next(); } res.status(403).json({ error: "Unauthorized: Admin access required" }); } export async function registerRoutes(app: Express): Promise { setupAuth(app); // --- Setup Routes --- app.get("/api/setup/status", async (req, res) => { const hasAdmin = await storage.hasAdminUser(); res.json({ isSetup: hasAdmin }); }); // Public settings endpoint for auth page app.get("/api/settings/public", async (req, res) => { const regEnabled = await storage.getSystemSettings("registration_enabled"); // Default to true if not set, or specifically check for "false" res.json({ registration_enabled: regEnabled !== "false" }); }); app.post("/api/setup", async (req, res) => { const hasAdmin = await storage.hasAdminUser(); if (hasAdmin) { return res.status(403).json({ error: "Setup already completed" }); } // Create Super Admin try { const hashedPassword = await hashPassword(req.body.password); const adminUser = await storage.createUser({ username: req.body.username, email: req.body.email, password: hashedPassword, role: 'admin', isActive: true }); // Auto-enable registration by default on setup await storage.setSystemSettings("registration_enabled", "true"); req.login(adminUser, (err) => { if (err) return res.status(500).json({ error: "Login failed after setup" }); return res.json(adminUser); }); } catch (e) { res.status(500).json({ error: "Failed to create admin user" }); } }); // --- Password Reset Routes --- app.post("/api/auth/forgot-password", async (req, res) => { try { const { email } = req.body; if (!email) return res.status(400).json({ error: "Email required" }); const user = await storage.getUserByEmail(email); if (!user) { // Check security best practices: delay response or return success to avoid enumeration? // For now, let's behave nicely. return res.json({ message: "If an account exists, a reset email has been sent." }); } const tokenString = crypto.randomUUID(); // Expires in 1 hour const expiresAt = new Date(Date.now() + 60 * 60 * 1000); await storage.createPasswordResetToken({ userId: user.id, token: tokenString, expiresAt, isUsed: false }); await emailService.sendPasswordResetEmail(user, tokenString); res.json({ message: "If an account exists, a reset email has been sent." }); } catch (e) { console.error("Forgot Password Error:", e); res.status(500).json({ error: "Server error" }); } }); app.post("/api/auth/reset-password", async (req, res) => { try { const { token, newPassword } = req.body; if (!token || !newPassword) return res.status(400).json({ error: "Token and password required" }); const resetToken = await storage.getPasswordResetToken(token); if (!resetToken) { return res.status(400).json({ error: "Invalid or expired token" }); } if (resetToken.isUsed) { return res.status(400).json({ error: "Token already used" }); } if (new Date() > new Date(resetToken.expiresAt)) { return res.status(400).json({ error: "Token expired" }); } // Update User Password const hashedPassword = await hashPassword(newPassword); await storage.updateUser(resetToken.userId, { password: hashedPassword }); // Mark token used await storage.markPasswordResetTokenUsed(resetToken.id); res.json({ message: "Password reset successfully. You can now login." }); } catch (e) { console.error("Reset Password Error:", e); res.status(500).json({ error: "Server error" }); } }); // --- Admin Routes --- app.get("/api/admin/users", isAdmin, async (req, res) => { try { const users = await storage.getAllUsers(); res.json(users); } catch (e) { res.status(500).json({ error: "Failed to fetch users" }); } }); app.post("/api/admin/users", isAdmin, async (req, res) => { try { const hashedPassword = await hashPassword(req.body.password); const newUser = await storage.createUser({ username: req.body.username, email: req.body.email, password: hashedPassword, role: req.body.role || 'user', isActive: true }); res.json(newUser); } catch (e) { res.status(500).json({ error: "Failed to create user" }); } }); app.post("/api/admin/users/:id/toggle-active", isAdmin, async (req, res) => { try { const user = await storage.getUser(req.params.id); if (!user) return res.status(404).json({ error: "User not found" }); if (user.role === 'admin' && user.id === (req.user as User).id) { return res.status(400).json({ error: "Cannot deactivate yourself" }); } const updated = await storage.updateUser(user.id, { isActive: !user.isActive }); res.json(updated); } catch (e) { res.status(500).json({ error: "Failed to toggle user status" }); } }); app.delete("/api/admin/users/:id", isAdmin, async (req, res) => { try { const user = await storage.getUser(req.params.id); if (!user) return res.status(404).json({ error: "User not found" }); if (user.role === 'admin' && user.id === (req.user as User).id) { return res.status(400).json({ error: "Cannot delete yourself" }); } await storage.deleteUser(user.id); res.sendStatus(204); } catch (e) { res.status(500).json({ error: "Failed to delete user" }); } }); app.get("/api/admin/settings", isAdmin, async (req, res) => { const keys = ["registration_enabled", "smtp_host", "smtp_port", "smtp_user", "smtp_pass", "smtp_from", "smtp_secure"]; const settings: any = {}; for (const key of keys) { const val = await storage.getSystemSettings(key); if (key === "registration_enabled" || key === "smtp_secure") { settings[key] = val === "true"; } else { settings[key] = val || ""; // Return empty string if undefined for inputs } } res.json(settings); }); app.post("/api/admin/settings", isAdmin, async (req, res) => { const keys = ["registration_enabled", "smtp_host", "smtp_port", "smtp_user", "smtp_pass", "smtp_from", "smtp_secure"]; for (const key of keys) { if (req.body[key] !== undefined) { await storage.setSystemSettings(key, String(req.body[key])); } } res.json({ success: true }); }); app.post("/api/admin/ollama/tags", isAdmin, async (req, res) => { const { baseUrl } = req.body; // Default to localhost:11434 if not provided, or stored setting? // Client should send the value from the input field. const url = (baseUrl || "http://localhost:11434").replace(/\/$/, "") + "/api/tags"; try { const resp = await fetch(url); if (!resp.ok) throw new Error(`Ollama Error: ${resp.statusText}`); const data = await resp.json(); res.json(data); } catch (e: any) { console.error("Ollama Tags Error:", e); res.status(500).json({ error: "Failed to fetch Ollama tags: " + e.message }); } }); app.post("/api/admin/ollama/pull", isAdmin, async (req, res) => { const { baseUrl, model } = req.body; if (!model) return res.status(400).json({ error: "Model name required" }); const url = (baseUrl || "http://localhost:11434").replace(/\/$/, "") + "/api/pull"; console.log(`Pulling Ollama model ${model} from ${url}...`); try { // connecting to ollama const resp = await fetch(url, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name: model, stream: false }), }); if (!resp.ok) { const errText = await resp.text(); throw new Error(`Ollama Pull Error: ${errText}`); } const data = await resp.json(); res.json(data); } catch (e: any) { console.error("Ollama Pull Error:", e); res.status(500).json({ error: "Failed to pull model: " + e.message }); } }); // --- AI Routes --- app.post("/api/ai/chat", async (req, res) => { if (!req.isAuthenticated()) return res.sendStatus(401); const user = req.user as User; if (!user.aiEnabled) return res.status(403).json({ error: "AI Assistant is disabled for this user" }); try { const { messages } = req.body; if (!Array.isArray(messages)) return res.status(400).json({ error: "Messages must be an array" }); // Build User Context const tasks = await storage.getTasksForUser(user.id); const activeTasks = tasks.filter(t => t.status !== 'done'); const completedTasks = tasks.filter(t => t.status === 'done'); const context = ` User Context: - User ID: ${user.id} - Username: ${user.username} - XP: ${user.xp} (Level ${user.level}) Task Summary: - Total Active Tasks: ${activeTasks.length} - Total Completed Tasks: ${completedTasks.length} High Priority Active Tasks: ${activeTasks.filter(t => t.priority === 'high').map(t => `- ${t.title} (Due: ${t.dueDate})`).join('\n') || 'None'} Recent Active Tasks: ${activeTasks.slice(0, 5).map(t => `- [${t.priority}] ${t.title}`).join('\n')} `; const response = await aiService.chat(messages, user, context); res.json({ role: "assistant", content: response }); } catch (e: any) { console.error("AI Route Error:", e); res.status(500).json({ error: e.message || "Failed to generate AI response" }); } }); // Health check endpoint app.get("/api/health", (req, res) => { res.status(200).json({ status: "ok", timestamp: new Date().toISOString() }); }); // Labels API routes app.get("/api/labels", async (req, res) => { try { if (!req.isAuthenticated()) return res.sendStatus(401); const userId = (req.user as User).id; const allLabels = await storage.getAllLabels(); const sharedLabels = await storage.getSharedLabels(userId); const sharedLabelIds = new Set(sharedLabels.map(sl => sl.labelId)); // Filter: Created by me OR Shared with me // If creatorId is null (legacy/default), everyone sees it? Or system public? // Assumption: labels with creatorId=null are "System Defaults" visible to all. // Or we should update getAllLabels to filter in DB. const visibleLabels = allLabels.filter(l => l.creatorId === userId || sharedLabelIds.has(l.id) || l.creatorId === null ); res.json(visibleLabels); } catch (error) { res.status(500).json({ error: "Failed to fetch labels" }); } }); app.get("/api/labels/:id", async (req, res) => { try { const label = await storage.getLabel(req.params.id); if (!label) { return res.status(404).json({ error: "Label not found" }); } res.json(label); } catch (error) { res.status(500).json({ error: "Failed to fetch label" }); } }); app.post("/api/labels", async (req, res) => { try { const result = insertLabelSchema.safeParse(req.body); if (!result.success) { return res.status(400).json({ error: "Invalid label data", details: result.error }); } const label = await storage.createLabel({ ...result.data, creatorId: (req.user as User).id // Assign creator }); res.status(201).json(label); } catch (error) { console.error("Create Label Error:", error); res.status(500).json({ error: "Failed to create label" }); } }); app.patch("/api/labels/:id", async (req, res) => { try { const updates = insertLabelSchema.partial().safeParse(req.body); if (!updates.success) { return res.status(400).json({ error: "Invalid label data", details: updates.error }); } const label = await storage.updateLabel(req.params.id, updates.data); if (!label) { return res.status(404).json({ error: "Label not found" }); } res.json(label); } catch (error) { res.status(500).json({ error: "Failed to update label" }); } }); app.delete("/api/labels/:id", async (req, res) => { try { const success = await storage.deleteLabel(req.params.id); if (!success) { return res.status(404).json({ error: "Label not found" }); } res.status(204).send(); } catch (error) { res.status(500).json({ error: "Failed to delete label" }); } }); // Shared Label Routes app.get("/api/labels/:id/share", async (req, res) => { if (!req.isAuthenticated()) return res.sendStatus(401); try { const label = await storage.getLabel(req.params.id); if (!label) return res.status(404).json({ error: "Label not found" }); // Only creator or admin or write permission can view shares? // Actually creator, or anyone with 'write'/'admin' permission on the label? // For simplicity: Only creator can manage shares. if (label.creatorId && label.creatorId !== (req.user as User).id) { return res.status(403).json({ error: "Only the label owner can manage shares" }); } // const shares = await storage.getLabelShares(req.params.id); // Not needed as we fetch below // We need user details for the frontend const users = await storage.getLabelSharedUsers(req.params.id); const sharesWithDetails = await Promise.all(users.map(async u => { const shareInfos = await storage.getLabelShares(req.params.id); const specificShare = shareInfos.find(s => s.sharedWithUserId === u.id); return { userId: u.id, username: u.username, permission: specificShare?.permission || 'read' }; })); res.json(sharesWithDetails); } catch (e) { res.status(500).json({ error: "Failed to fetch label shares" }); } }); app.post("/api/labels/:id/share", async (req, res) => { if (!req.isAuthenticated()) return res.sendStatus(401); try { const { username, permission } = req.body; if (!username) return res.status(400).json({ error: "Username required" }); const label = await storage.getLabel(req.params.id); if (!label) return res.status(404).json({ error: "Label not found" }); if (label.creatorId && label.creatorId !== (req.user as User).id) { return res.status(403).json({ error: "Only owner can share label" }); } const targetUser = await storage.getUserByUsername(username); if (!targetUser) return res.status(404).json({ error: "User not found" }); if (targetUser.id === (req.user as User).id) return res.status(400).json({ error: "Cannot share with yourself" }); const share = await storage.shareLabel(label.id, targetUser.id, (req.user as User).id, permission || 'read'); res.json(share); } catch (e) { res.status(500).json({ error: "Failed to share label" }); } }); app.delete("/api/labels/:id/share/:userId", async (req, res) => { if (!req.isAuthenticated()) return res.sendStatus(401); try { const label = await storage.getLabel(req.params.id); if (!label) return res.status(404).json({ error: "Label not found" }); if (label.creatorId && label.creatorId !== (req.user as User).id) { // Also allow user to unshare themselves? if (req.params.userId !== (req.user as User).id) { return res.status(403).json({ error: "Only owner can remove other collaborators" }); } } await storage.unshareLabel(label.id, req.params.userId); res.sendStatus(204); } catch (e) { res.status(500).json({ error: "Failed to remove share" }); } }); // Tasks API routes (updated GET) app.get("/api/tasks", async (req, res) => { if (!req.isAuthenticated()) return res.sendStatus(401); try { const tasks = await storage.getTasksForUser((req.user as User).id); res.json(tasks); } catch (error) { res.status(500).json({ error: "Failed to fetch tasks" }); } }); app.get("/api/tasks/:id", async (req, res) => { // TODO: Check if user has access to this specific task (Owns it OR is Shared) // For now, simple get try { const task = await storage.getTask(req.params.id); if (!task) { return res.status(404).json({ error: "Task not found" }); } res.json(task); } catch (error) { res.status(500).json({ error: "Failed to fetch task" }); } }); app.post("/api/tasks", async (req, res) => { if (!req.isAuthenticated()) return res.sendStatus(401); try { const result = insertTaskSchema.safeParse(req.body); if (!result.success) { return res.status(400).json({ error: "Invalid task data", details: result.error }); } const task = await storage.createTask({ ...result.data, userId: (req.user as User).id }); res.status(201).json(task); } catch (error) { res.status(500).json({ error: "Failed to create task" }); } }); app.patch("/api/tasks/:id", async (req, res) => { if (!req.isAuthenticated()) return res.sendStatus(401); try { const previousTask = await storage.getTask(req.params.id); const updates = insertTaskSchema.partial().safeParse(req.body); if (!updates.success) { return res.status(400).json({ error: "Invalid task data", details: updates.error }); } // Gamification: Award XP on completion if (previousTask && previousTask.status !== 'done' && updates.data.status === 'done') { try { const xpEarned = calculateXP(previousTask); const user = await storage.getUser((req.user as User).id); if (!user) throw new Error("User not found for gamification"); let newStreak = user.currentStreak || 0; let streakBonus = 0; let diffDays = 0; if (user) { const now = new Date(); const lastDate = user.lastTaskDate ? new Date(user.lastTaskDate) : null; if (!lastDate) { newStreak = 1; } else { const diffTime = Math.abs(now.setHours(0, 0, 0, 0) - lastDate.setHours(0, 0, 0, 0)); diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24)); if (diffDays === 1) { newStreak += 1; streakBonus = Math.min(newStreak * 5, 50); } else if (diffDays > 1) { newStreak = 1; newStreak = 1; } } // --- Daily Clear Bonus Check --- // Check if this was the last 'todo' task for today const startOfDay = new Date(); startOfDay.setHours(0, 0, 0, 0); const endOfDay = new Date(); endOfDay.setHours(23, 59, 59, 999); // Re-fetch all tasks (inefficient but safe for now, better: optimize storage method) const allTasks = await storage.getTasksForUser(user.id); const remainingToday = allTasks.filter(t => t.id !== previousTask.id && // exclude current t.status !== 'done' && // is remaining t.dueDate && // has due date new Date(t.dueDate) >= startOfDay && new Date(t.dueDate) <= endOfDay ); if (remainingToday.length === 0) { // Bonus! const clearBonus = 50; await storage.logXpEvent({ userId: user.id, amount: clearBonus, source: 'daily_clear_bonus', // Ensure translation key exists }); console.log(`[Gamification] Awarded ${clearBonus} XP for Daily Clear`); } if (diffDays !== 0 || !lastDate) { await storage.updateUser(user.id, { currentStreak: newStreak, lastTaskDate: new Date() }); } } // Log XP Event (Task) await storage.logXpEvent({ userId: (req.user as User).id, amount: xpEarned, source: 'task_completion', taskId: previousTask.id }); // Log XP Event (Streak Bonus) if (streakBonus > 0) { await storage.logXpEvent({ userId: (req.user as User).id, amount: streakBonus, source: 'daily_streak', }); console.log(`[Gamification] Awarded ${streakBonus} XP for streak of ${newStreak}`); } console.log(`[Gamification] Awarded ${xpEarned} XP for task ${previousTask.title}`); // --- Goal Progress Check --- try { // Fetch active goals const goals = await storage.getGoals(); // TODO: Filter by userId in storage const userGoals = goals.filter(g => g.userId === user.id && !g.completed); for (const goal of userGoals) { let progress = 0; // Calculate progress based on type if (goal.type === 'weekly_tasks') { // Count tasks completed this week // Simplified: just update goal.current + 1 for now if we don't have full count logic // Ideally we recount from history, but incremental update is easier progress = goal.current + 1; } else if (goal.type === 'streak') { progress = newStreak; } else if (goal.type === 'total_xp') { progress = user.xp + xpEarned; // XP updated via logXpEvent side-effect? No, explicitly. // The user obj here is stale, user.xp is old. // But we just added xpEarned in logXpEvent (via side effect in storage). // Let's assume +xpEarned. // A better way is to re-fetch user, or rely on client/server sync. progress = user.xp + xpEarned + streakBonus; } // Update Goal if (progress !== goal.current) { await storage.updateGoal(goal.id, { current: progress, completed: progress >= goal.target }); if (progress >= goal.target && !goal.completed) { // Goal Completion Bonus const goalBonus = 100; await storage.logXpEvent({ userId: user.id, amount: goalBonus, source: 'goal_completed' }); console.log(`[Gamification] Goal "${goal.title}" Completed! +${goalBonus} XP`); } } } } catch (goalErr) { console.error("[Gamification] Error checking goals:", goalErr); } } catch (err) { console.error("[Gamification] Error processing rewards:", err); // Do not fail the request, just log } } const task = await storage.updateTask(req.params.id, updates.data); if (!task) { return res.status(404).json({ error: "Task not found" }); } res.json(task); } catch (error: any) { console.error("PATCH Task Error:", error); res.status(500).json({ error: "Failed to update task", details: String(error) }); } }); app.delete("/api/tasks/:id", async (req, res) => { try { const success = await storage.deleteTask(req.params.id); if (!success) { return res.status(404).json({ error: "Task not found" }); } res.status(204).send(); } catch (error) { res.status(500).json({ error: "Failed to delete task" }); } }); // Notes API routes app.get("/api/notes", async (req, res) => { try { // In a real app, filter by userId // const notes = await storage.getAllNotes(); // You'd need to implement this in storage.ts res.json([]); // Placeholder until storage implementation } catch (error) { res.status(500).json({ error: "Failed to fetch notes" }); } }); app.post("/api/notes", async (req, res) => { try { const result = insertNoteSchema.safeParse(req.body); if (!result.success) { return res.status(400).json({ error: "Invalid note data", details: result.error }); } // const note = await storage.createNote(result.data); res.status(201).json({ ...result.data, id: "placeholder" }); } catch (error) { res.status(500).json({ error: "Failed to create note" }); } }); // Goals API app.get("/api/goals", async (req, res) => { try { const goals = await storage.getGoals(); // Need to impl in storage res.json(goals); } catch (error) { res.status(500).json({ error: "Failed to fetch goals" }); } }); app.post("/api/goals", async (req, res) => { try { console.log("POST /api/goals hit", req.body); const result = insertGoalSchema.safeParse(req.body); if (!result.success) { console.error("Validation error:", result.error); return res.status(400).json(result.error); } console.log("Validation passed, creating goal..."); const goal = await storage.createGoal(result.data); console.log("Goal created:", goal); res.json(goal); } catch (error) { console.error("Error in POST /api/goals:", error); res.status(500).json({ error: "Failed to create goal" }); } }); // Analytics API app.get("/api/analytics/weekly", async (req, res) => { // Return last 7 days. Key is 0-6 (Sun-Sat) or ISO date. // For simplicity, let's return day index relative to today or just standard day index (0=Sun) // To make it look "last 7 days" we can return relative indices const keys = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']; // Better: Send localizable keys. // Day format: "day_1" (Mon) ... "day_7" (Sun) or just short codes the frontend can map // We will send standard JS Day indices adjusted: 1 (Mon) - 7 (Sun) for "ISO Week" style or just 0-6 // Let's send a `labelKey` that the frontend can translate. const data = [ { labelKey: 'mon', xp: Math.floor(Math.random() * 500) }, { labelKey: 'tue', xp: Math.floor(Math.random() * 500) }, { labelKey: 'wed', xp: Math.floor(Math.random() * 500) }, { labelKey: 'thu', xp: Math.floor(Math.random() * 500) }, { labelKey: 'fri', xp: Math.floor(Math.random() * 500) }, { labelKey: 'sat', xp: Math.floor(Math.random() * 500) }, { labelKey: 'sun', xp: Math.floor(Math.random() * 500) }, ]; res.json(data); }); app.get("/api/analytics/yearly", async (req, res) => { const data = [ { labelKey: 'jan', xp: Math.floor(Math.random() * 2000) }, { labelKey: 'feb', xp: Math.floor(Math.random() * 2000) }, { labelKey: 'mar', xp: Math.floor(Math.random() * 2000) }, { labelKey: 'apr', xp: Math.floor(Math.random() * 2000) }, { labelKey: 'may', xp: Math.floor(Math.random() * 2000) }, { labelKey: 'jun', xp: Math.floor(Math.random() * 2000) }, { labelKey: 'jul', xp: Math.floor(Math.random() * 2000) }, { labelKey: 'aug', xp: Math.floor(Math.random() * 2000) }, { labelKey: 'sep', xp: Math.floor(Math.random() * 2000) }, { labelKey: 'oct', xp: Math.floor(Math.random() * 2000) }, { labelKey: 'nov', xp: Math.floor(Math.random() * 2000) }, { labelKey: 'dec', xp: Math.floor(Math.random() * 2000) }, ]; res.json(data); }); app.get("/api/analytics/monthly", async (req, res) => { // Return last 4-5 weeks with actual Calendar Week numbers // Mocking for now: Assume current week is ~50 const currentWeek = 50; const data = [ { labelKey: (currentWeek - 3).toString(), xp: Math.floor(Math.random() * 800) }, { labelKey: (currentWeek - 2).toString(), xp: Math.floor(Math.random() * 800) }, { labelKey: (currentWeek - 1).toString(), xp: Math.floor(Math.random() * 800) }, { labelKey: currentWeek.toString(), xp: Math.floor(Math.random() * 800) }, ]; res.json(data); }); // Gamification Logic Wrapper const calculateXP = (task: any) => { let baseXP = 10; if (task.priority === 'high') baseXP += 20; if (task.priority === 'medium') baseXP += 10; if (task.energyLevel === 'high') baseXP += 30; // Bonus for high energy stuff return baseXP; }; // Rewards API app.get("/api/rewards", async (req, res) => { try { const allRewards = await storage.getAllRewards(); const userId = req.query.userId as string; // Filter rewards: System rewards OR User's own rewards const visibleRewards = allRewards.filter(r => r.isSystem || (userId && r.userId === userId)); // If userId is provided, check ownership of visible rewards if (userId) { const userRewards = await storage.getUserRewards(userId); const ownedIds = new Set(userRewards.map(ur => ur.rewardId)); return res.json(visibleRewards.map(r => ({ ...r, owned: ownedIds.has(r.id) }))); } res.json(visibleRewards); } catch (error) { res.status(500).json({ error: "Failed to fetch rewards" }); } }); app.post("/api/rewards/purchase", async (req, res) => { if (!req.isAuthenticated()) return res.sendStatus(401); try { // 1. Get User & Reward const userId = (req.user as User).id; const { rewardId } = req.body; if (!rewardId) return res.status(400).json({ error: "Missing rewardId" }); const user = await storage.getUser(userId); // Fetch user here const allRewards = await storage.getAllRewards(); const reward = allRewards.find(r => r.id === rewardId); if (!user || !reward) return res.status(404).json({ error: "User or Reward not found" }); // 2. Check Ownership (if one-time) // For now, allow multiple purchases unless type is 'feature_unlock' if (reward.type === 'feature_unlock') { const userRewards = await storage.getUserRewards(userId); if (userRewards.some(ur => ur.rewardId === rewardId)) { return res.status(400).json({ error: "Reward already owned" }); } } // 3. Check Funds if (user.xp < reward.cost) { return res.status(400).json({ error: "Insufficient XP" }); } // 4. Transaction const updatedUser = await storage.updateUserXP(userId, -reward.cost); await storage.createUserReward({ userId, rewardId }); res.json({ success: true, user: updatedUser }); } catch (e) { res.status(500).json({ error: "Purchase failed" }); } }); app.post("/api/rewards", async (req, res) => { if (!req.isAuthenticated()) return res.sendStatus(401); try { const rewardData = { ...req.body, userId: (req.user as User).id, isSystem: (req.user as User).role === 'admin' && req.body.isSystem !== false, }; // Force isSystem=false for non-admins if ((req.user as User).role !== 'admin') { rewardData.isSystem = false; } const reward = await storage.createReward(rewardData); res.json(reward); } catch (err) { res.status(500).json({ error: "Failed to create reward" }); } }); // User Gamification Endpoints app.get("/api/user/history", async (req, res) => { if (!req.isAuthenticated()) return res.sendStatus(401); try { const history = await storage.getXpEvents((req.user as User).id); res.json(history); } catch (e) { res.status(500).json({ error: "Failed to fetch history" }); } }); app.get("/api/user/inventory", async (req, res) => { if (!req.isAuthenticated()) return res.sendStatus(401); try { const userRewards = await storage.getUserRewards((req.user as User).id); // Join with rewards details const allRewards = await storage.getAllRewards(); const inventory = userRewards.map(ur => { const reward = allRewards.find(r => r.id === ur.rewardId); return { ...ur, reward // Nested details }; }).filter(item => item.reward); // Filter out any broken links res.json(inventory); } catch (e) { res.status(500).json({ error: "Failed to fetch inventory" }); } }); // --- Social & Leaderboard Routes --- app.get("/api/leaderboard", async (req, res) => { try { const users = await storage.getLeaderboard(); // Return public info only const leaderboard = users.map(u => ({ username: u.username, xp: u.xp, level: u.level, id: u.id // Needed? Maybe for linking profile })); res.json(leaderboard); } catch (e) { res.status(500).json({ error: "Failed to fetch leaderboard" }); } }); app.patch("/api/user/privacy", async (req, res) => { if (!req.isAuthenticated()) return res.sendStatus(401); try { const { showOnLeaderboard, isSearchable, aiEnabled } = req.body; const updates: any = {}; if (showOnLeaderboard !== undefined) updates.showOnLeaderboard = showOnLeaderboard; if (isSearchable !== undefined) updates.isSearchable = isSearchable; if (aiEnabled !== undefined) updates.aiEnabled = aiEnabled; const updated = await storage.updateUser((req.user as User).id, updates); res.json(updated); } catch (e) { res.status(500).json({ error: "Failed to update privacy settings" }); } }); app.patch("/api/user/profile", async (req, res) => { if (!req.isAuthenticated()) return res.sendStatus(401); try { const { email } = req.body; if (!email || !email.includes('@')) return res.status(400).json({ error: "Invalid email" }); const existing = await storage.getUserByEmail(email); if (existing && existing.id !== (req.user as User).id) { return res.status(400).json({ error: "Email already taken" }); } const updated = await storage.updateUser((req.user as User).id, { email }); res.json(updated); } catch (e) { res.status(500).json({ error: "Failed to update profile" }); } }); app.patch("/api/user/password", async (req, res) => { if (!req.isAuthenticated()) return res.sendStatus(401); try { const { currentPassword, newPassword } = req.body; if (!currentPassword || !newPassword) return res.status(400).json({ error: "Missing fields" }); const user = await storage.getUser((req.user as User).id); if (!user) return res.status(404).json({ error: "User not found" }); const isValid = await comparePassword(currentPassword, user.password); if (!isValid) return res.status(400).json({ error: "Incorrect current password" }); const hashedPassword = await hashPassword(newPassword); await storage.updateUser(user.id, { password: hashedPassword }); res.json({ message: "Password updated" }); } catch (e) { res.status(500).json({ error: "Failed to update password" }); } }); app.get("/api/users/search", async (req, res) => { if (!req.isAuthenticated()) return res.sendStatus(401); const query = req.query.q as string; try { const users = await storage.searchUsers(query); // Filter out self const others = users.filter(u => u.id !== (req.user as User).id); res.json(others.map(u => ({ id: u.id, username: u.username }))); } catch (e) { res.status(500).json({ error: "Search failed" }); } }); app.post("/api/tasks/:id/share", async (req, res) => { if (!req.isAuthenticated()) return res.sendStatus(401); try { const { targetUserId } = req.body; const taskId = req.params.id; // Verify ownership const task = await storage.getTask(taskId); if (!task) return res.status(404).json({ error: "Task not found" }); if (task.userId !== (req.user as User).id) { return res.status(403).json({ error: "Unauthorized" }); } await storage.shareTask({ taskId, sharedByUserId: (req.user as User).id, sharedWithUserId: targetUserId }); res.json({ success: true }); } catch (e) { res.status(500).json({ error: "Failed to share task" }); } }); app.get("/api/tasks/:id/shared-users", async (req, res) => { if (!req.isAuthenticated()) return res.sendStatus(401); try { const taskId = req.params.id; // Verify ownership or access? Ideally only owner can see who else sees it. const task = await storage.getTask(taskId); if (!task) return res.status(404).json({ error: "Task not found" }); if (task.userId !== (req.user as User).id) { return res.status(403).json({ error: "Unauthorized" }); } const users = await storage.getTaskSharedUsers(taskId); // Return minimal info res.json(users.map(u => ({ id: u.id, username: u.username }))); } catch (e) { res.status(500).json({ error: "Failed to fetch shared users" }); } }); app.delete("/api/tasks/:id/share/:userId", async (req, res) => { if (!req.isAuthenticated()) return res.sendStatus(401); try { const taskId = req.params.id; const targetUserId = req.params.userId; // Verify ownership const task = await storage.getTask(taskId); if (!task) return res.status(404).json({ error: "Task not found" }); if (task.userId !== (req.user as User).id) { return res.status(403).json({ error: "Unauthorized" }); } const success = await storage.unshareTask(taskId, targetUserId); if (success) { res.json({ success: true }); } else { res.status(404).json({ error: "Share not found" }); } } catch (e) { res.status(500).json({ error: "Failed to unshare task" }); } }); app.post("/api/users/share-all", async (req, res) => { if (!req.isAuthenticated()) return res.sendStatus(401); try { const { targetUserId } = req.body; await storage.shareAllTasks({ ownerId: (req.user as User).id, viewerId: targetUserId }); res.json({ success: true }); } catch (e) { res.status(500).json({ error: "Failed to share all tasks" }); } }); const httpServer = createServer(app); // Storage needs to support Goal Update app.patch("/api/goals/:id", async (req, res) => { if (!req.isAuthenticated()) return res.sendStatus(401); try { const updated = await storage.updateGoal(req.params.id, req.body); res.json(updated); } catch (e) { res.status(500).json({ error: "Failed to update goal" }); } }); return httpServer; }