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 { mcpServer } from "./mcp"; import { AiService, DEFAULT_SYSTEM_PROMPT } from "./ai.js"; import { RecurrenceService } from "./services/recurrence.js"; import { GamificationService } from "./gamification.js"; const emailService = new EmailService(storage); const aiService = new AiService(storage); const recurrenceService = new RecurrenceService(storage); const gamificationService = new GamificationService(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" }); req.session.save(() => { return res.json(adminUser); }); }); } catch (e) { res.status(500).json({ error: "Failed to create admin user" }); } }); // Check if AI is configured (for UI empty states) app.get("/api/ai/status", async (req, res) => { if (!req.isAuthenticated()) return res.sendStatus(401); const key = await storage.getSystemSettings("ai_api_key"); const provider = await storage.getSystemSettings("ai_provider"); // Consider configured if we have a key OR if provider is 'ollama' const isConfigured = (provider === 'ollama') || (!!key && key.length > 0); res.json({ configured: isConfigured, provider }); }); // --- 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 }); await storage.createAuditLog({ userId: (req.user as User).id, action: "CREATE", entityType: "USER", entityId: newUser.id, details: { username: newUser.username, role: newUser.role }, source: "USER" }); 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 }); await storage.createAuditLog({ userId: (req.user as User).id, action: "UPDATE", entityType: "USER", entityId: user.id, details: { isActive: !user.isActive }, source: "USER" }); 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); await storage.createAuditLog({ userId: (req.user as User).id, action: "DELETE", entityType: "USER", entityId: user.id, details: { username: user.username }, source: "USER" }); res.sendStatus(204); } catch (e) { res.status(500).json({ error: "Failed to delete user" }); } }); // --- MCP Routes --- app.get("/api/mcp/sse", async (req, res) => { const enabled = await storage.getSystemSettings("mcp_enabled"); if (enabled !== "true") return res.status(503).send("MCP Server Disabled"); await mcpServer.handleSse(req, res); }); app.post("/api/mcp/messages", async (req, res) => { const enabled = await storage.getSystemSettings("mcp_enabled"); if (enabled !== "true") return res.status(503).json({ error: "MCP Server Disabled" }); await mcpServer.handleMessage(req, res); }); app.get("/api/admin/audit-logs", isAdmin, async (req, res) => { try { const logs = await storage.getAuditLogs(); res.json(logs); } catch (e) { res.status(500).json({ error: "Failed to fetch audit logs" }); } }); 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", "ai_provider", "ai_api_key", "ai_model", "ai_base_url", "mcp_enabled", "mcp_port" ]; const settings: any = {}; for (const key of keys) { const val = await storage.getSystemSettings(key); if (key === "registration_enabled" || key === "smtp_secure" || key === "mcp_enabled") { settings[key] = val === "true"; } else { settings[key] = val || ""; // Return empty string if undefined for inputs } } // Inject System Prompt with Default Fallback const storedPrompt = await storage.getSystemSettings("ai_system_prompt"); settings["ai_system_prompt"] = storedPrompt || DEFAULT_SYSTEM_PROMPT; res.json(settings); }); app.post("/api/admin/settings", isAdmin, async (req, res) => { const updates = req.body; const currentPrompt = await storage.getSystemSettings("ai_system_prompt"); // Check for System Prompt changes & Log if (updates.ai_system_prompt && updates.ai_system_prompt !== currentPrompt) { await storage.createAuditLog({ userId: (req.user as User).id, action: "UPDATE", entityType: "SYSTEM_SETTINGS", entityId: "ai_system_prompt", details: { message: "AI System Prompt updated" }, source: "ADMIN" }); } const keys = [ "registration_enabled", "smtp_host", "smtp_port", "smtp_user", "smtp_pass", "smtp_from", "smtp_secure", "ai_provider", "ai_api_key", "ai_model", "ai_base_url", "ai_system_prompt", "mcp_enabled", "mcp_port" ]; for (const key of keys) { if (req.body[key] !== undefined) { await storage.setSystemSettings(key, String(req.body[key])); } } await storage.createAuditLog({ userId: (req.user as User).id, action: "UPDATE", entityType: "SYSTEM_SETTINGS", entityId: null, details: req.body, source: "USER" }); 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 Chat History Routes --- // Get all conversations for user app.get("/api/ai/conversations", async (req, res) => { if (!req.isAuthenticated()) return res.sendStatus(401); try { const conversations = await storage.getConversations((req.user as User).id); res.json(conversations); } catch (e) { res.status(500).json({ error: "Failed to fetch conversations" }); } }); // Create new conversation app.post("/api/ai/conversations", async (req, res) => { if (!req.isAuthenticated()) return res.sendStatus(401); const user = req.user as User; try { const { title } = req.body; const conversation = await storage.createConversation(user.id, title); await storage.createAuditLog({ userId: user.id, action: "CREATE", entityType: "CONVERSATION", entityId: conversation.id, details: { title }, source: "USER" }); res.json(conversation); } catch (e) { res.status(500).json({ error: "Failed to create conversation" }); } }); // Rename conversation app.patch("/api/ai/conversations/:id", async (req, res) => { if (!req.isAuthenticated()) return res.sendStatus(401); const user = req.user as User; try { const { title } = req.body; if (!title) return res.status(400).json({ error: "Title is required" }); const conv = await storage.getConversation(req.params.id); if (!conv) return res.status(404).json({ error: "Conversation not found" }); if (conv.userId !== user.id) return res.sendStatus(403); const updated = await storage.updateConversation(req.params.id, title); await storage.createAuditLog({ userId: user.id, action: "UPDATE", entityType: "CONVERSATION", entityId: req.params.id, details: { title }, source: "USER" }); res.json(updated); } catch (e) { res.status(500).json({ error: "Failed to update conversation" }); } }); // Purchase Reward app.post("/api/rewards/:rewardId/purchase", async (req, res) => { if (!req.isAuthenticated()) return res.sendStatus(401); const userId = (req.user as User).id; const rewardId = req.params.rewardId; try { const reward = await storage.getReward(rewardId); if (!reward) return res.status(404).json({ error: "Reward not found" }); const purchase = await storage.purchaseReward(userId, reward.id, reward.cost); await storage.createAuditLog({ userId: userId, action: "PURCHASE", entityType: "REWARD", entityId: reward.id, details: { name: reward.title, cost: reward.cost }, source: "USER" }); res.json(purchase); } catch (e) { res.status(500).json({ error: "Purchase failed" }); } }); // Get single conversation app.get("/api/ai/conversations/:id", async (req, res) => { if (!req.isAuthenticated()) return res.sendStatus(401); try { const conv = await storage.getConversation(req.params.id); if (!conv) return res.status(404).json({ error: "Conversation not found" }); if (conv.userId !== (req.user as User).id) return res.sendStatus(403); res.json(conv); } catch (e) { res.status(500).json({ error: "Failed to fetch conversation" }); } }); // Delete conversation app.delete("/api/ai/conversations/:id", async (req, res) => { if (!req.isAuthenticated()) return res.sendStatus(401); try { const conv = await storage.getConversation(req.params.id); if (!conv) return res.status(404).json({ error: "Conversation not found" }); if (conv.userId !== (req.user as User).id) return res.sendStatus(403); await storage.deleteConversation(req.params.id); await storage.createAuditLog({ userId: (req.user as User).id, action: "DELETE", entityType: "CONVERSATION", entityId: req.params.id, details: null, source: "USER" }); res.sendStatus(204); } catch (e) { res.status(500).json({ error: "Failed to delete conversation" }); } }); // Get messages for conversation app.get("/api/ai/conversations/:id/messages", async (req, res) => { if (!req.isAuthenticated()) return res.sendStatus(401); try { const conv = await storage.getConversation(req.params.id); if (!conv) return res.status(404).json({ error: "Conversation not found" }); if (conv.userId !== (req.user as User).id) return res.sendStatus(403); const messages = await storage.getMessages(req.params.id); res.json(messages); } catch (e) { res.status(500).json({ error: "Failed to fetch messages" }); } }); // Generate conversation title app.post("/api/ai/generate-title", 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 (!messages || !Array.isArray(messages)) return res.status(400).json({ error: "Messages array is required" }); const title = await aiService.generateTitle(messages); res.json({ title }); } catch (e: any) { console.error("Generate Title Error:", e); res.status(500).json({ error: e.message }); } }); // Send message (Chat) 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 { conversationId, content, clientTime } = req.body; if (!conversationId || !content) return res.status(400).json({ error: "Missing conversationId or content" }); // Verify ownership const conv = await storage.getConversation(conversationId); if (!conv) return res.status(404).json({ error: "Conversation not found" }); if (conv.userId !== user.id) return res.sendStatus(403); // Store User Message const userMessage = await storage.addMessage({ conversationId, role: 'user', content }); // Send immediate response to client (Session Aware: request completes) res.json(userMessage); // Background Processing (Fire & Forget) (async () => { try { // Fetch history for context const dbMessages = await storage.getMessages(conversationId); // Convert to format expected by AiService (role, content) const history = dbMessages.map(m => ({ role: m.role as 'user' | 'assistant' | 'system', content: m.content })); // Build User Context (Tasks etc) const tasks = await storage.getTasksForUser(user.id); const activeTasks = tasks.filter(t => t.status !== 'done'); const completedTasks = tasks.filter(t => t.status === 'done'); const currentClientTime = clientTime || new Date().toLocaleString(); const context = ` User Context: - User ID: ${user.id} - Username: ${user.username} - XP: ${user.xp} (Level ${user.level}) - Current Date/Time: ${currentClientTime} 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')} `; // Call AI Service const responseContent = await aiService.chat(history, user, context); // Store AI Response await storage.addMessage({ conversationId, role: 'assistant', content: responseContent }); } catch (bgError) { console.error("Background AI Processing Error:", bgError); // Optional: Add a system message saying it failed? } })(); } catch (e: any) { console.error("AI Route Error:", e); res.status(500).json({ error: e.message || "Failed to generate AI response" }); } }); // Analyze completed task for follow-up app.post("/api/ai/analyze-completion", async (req, res) => { if (!req.isAuthenticated()) return res.sendStatus(401); const user = req.user as User; if (!user.aiEnabled) return res.json({ needed: false }); // Silently fail/return false try { const { taskId } = req.body; const task = await storage.getTask(taskId); if (!task) return res.status(404).json({ error: "Task not found" }); // Build context const context = ` User Context: - User ID: ${user.id} - Username: ${user.username} - Current Date/Time: ${new Date().toLocaleString()} `; const analysis = await aiService.analyzeTaskCompletion(task, context); res.json(analysis); } catch (e: any) { console.error("Analysis Error:", e); res.status(500).json({ error: "Failed to analyze task" }); } }); // Edit message and regenerate (Regenerate Response) app.put("/api/ai/chat/:messageId", 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 { messageId } = req.params; const { content, clientTime } = req.body; if (!content) return res.status(400).json({ error: "Content is required" }); // Verify message and ownership const message = await storage.getMessage(messageId); if (!message) return res.status(404).json({ error: "Message not found" }); const conv = await storage.getConversation(message.conversationId); if (!conv || conv.userId !== user.id) return res.sendStatus(403); if (message.role !== 'user') return res.status(400).json({ error: "Can only edit user messages" }); // 1. Update the message content const updatedMessage = await storage.updateMessage(messageId, content); // 2. Delete all subsequent messages (history truncation) await storage.deleteMessagesAfter(message.conversationId, message.createdAt as Date, message.id); // 3. Prepare context for regeneration const dbMessages = await storage.getMessages(message.conversationId); const history = dbMessages.map(m => ({ role: m.role as 'user' | 'assistant' | 'system', content: m.content })); const tasks = await storage.searchTasks("", user.id); // Get all tasks const activeTasks = tasks.filter(t => t.status !== 'done'); const completedTasks = tasks.filter(t => t.status === 'done'); // Fetch all labels to map IDs to names const labels = await storage.getAllLabels(); const labelMap = new Map(labels.map(l => [l.id, l.name])); const activeTasksWithLabels = activeTasks.map(t => ({ ...t, label: t.labelId ? labelMap.get(t.labelId) || "No Label" : "No Label" })); const context = ` User Context: - User ID: ${user.id} - Username: ${user.username} - XP: ${user.xp} (Level ${user.level}) - Current Date/Time: ${clientTime || new Date().toLocaleString()} Task Summary: - Total Active Tasks: ${activeTasks.length} - Total Completed Tasks: ${completedTasks.length} High Priority Active Tasks: ${activeTasksWithLabels.filter(t => t.priority === 'high').map(t => `- [${t.label}] ${t.title} (Due: ${t.dueDate})`).join('\n') || 'None'} Recent Active Tasks: ${activeTasksWithLabels.slice(0, 5).map(t => `- [${t.priority}] [${t.label}] ${t.title}`).join('\n')} `; // 4. Call AI Service (Regenerate) const responseContent = await aiService.chat(history, user, context); // 5. Store AI Response const botMessage = await storage.addMessage({ conversationId: message.conversationId, role: 'assistant', content: responseContent }); res.json(botMessage); } catch (e: any) { console.error("AI Edit Error:", e); res.status(500).json({ error: e.message || "Failed to regenerate 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 }); await storage.createAuditLog({ userId: (req.user as User).id, action: "CREATE", entityType: "LABEL", entityId: label.id, details: { name: label.name }, source: "USER" }); 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" }); } await storage.createAuditLog({ userId: (req.user as User).id, action: "UPDATE", entityType: "LABEL", entityId: label.id, details: updates.data, source: "USER" }); 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" }); } await storage.deleteLabel(req.params.id); await storage.createAuditLog({ userId: (req.user as User).id, action: "DELETE", entityType: "LABEL", entityId: req.params.id, details: null, source: "USER" }); 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/:id/schedule", async (req, res) => { if (!req.isAuthenticated()) return res.sendStatus(401); try { const taskId = req.params.id; 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.sendStatus(403); const result = await aiService.scheduleTask(taskId, (req.user as User).id); if (result.success) { res.json(result); // Award XP for using AI scheduling if (req.user) { await gamificationService.awardXP((req.user as User).id, 'ai_action'); } await storage.createAuditLog({ userId: (req.user as User).id, action: "UPDATE", entityType: "TASK", entityId: taskId, details: { action: "AI_SCHEDULE" }, source: "AI" }); } else { res.status(400).json(result); } } catch (e: any) { console.error("Schedule Task Error:", e); res.status(500).json({ error: e.message || "Failed to schedule 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 }); // Award XP for creating a task if (req.user) { const source = req.body.parentTaskId ? 'create_subtask' : 'create_task'; await gamificationService.awardXP((req.user as User).id, source, undefined, { taskId: task.id, taskTitle: task.title }); } await storage.createAuditLog({ userId: (req.user as User).id, action: "CREATE", entityType: "TASK", entityId: task.id, details: { title: task.title }, source: "USER" }); 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 }); } // Award XP using GamificationService if (req.user && previousTask) { // Ensure previousTask exists for comparison if (updates.data.status === 'done' && previousTask.status !== 'done') { const isLate = previousTask.dueDate && new Date(previousTask.dueDate) < new Date(); const source = isLate ? 'complete_task_late' : 'complete_task'; await gamificationService.awardXP((req.user as User).id, source, undefined, { taskId: previousTask.id, taskTitle: previousTask.title }); } else if (Object.keys(updates.data).length > 0) { // Only award if there are actual updates // Small points for any other update (title, description, etc) await gamificationService.awardXP((req.user as User).id, 'update_task', undefined, { taskId: previousTask.id, taskTitle: previousTask.title }); } } const updatedTask = await storage.updateTask(req.params.id, updates.data); if (!updatedTask) { return res.status(404).json({ error: "Task not found" }); } await storage.createAuditLog({ userId: (req.user as User).id, action: "UPDATE", entityType: "TASK", entityId: updatedTask.id, details: updates.data, source: "USER" }); res.json(updatedTask); } 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" }); } await storage.createAuditLog({ userId: (req.user as User).id, action: "DELETE", entityType: "TASK", entityId: req.params.id, details: null, source: "USER" }); 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); await storage.createAuditLog({ userId: (req.user as User).id || null, action: "CREATE", entityType: "GOAL", entityId: goal.id, details: { title: goal.title }, source: "USER" }); 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) => { if (!req.isAuthenticated()) return res.sendStatus(401); try { const data = await gamificationService.getWeeklyAnalytics((req.user as User).id); res.json(data); } catch (e) { res.status(500).json({ error: "Failed to fetch analytics" }); } }); app.get("/api/analytics/yearly", async (req, res) => { if (!req.isAuthenticated()) return res.sendStatus(401); try { const data = await gamificationService.getYearlyAnalytics((req.user as User).id); res.json(data); } catch (e) { res.status(500).json({ error: "Failed to fetch analytics" }); } }); app.get("/api/analytics/monthly", async (req, res) => { if (!req.isAuthenticated()) return res.sendStatus(401); try { const data = await gamificationService.getMonthlyAnalytics((req.user as User).id); res.json(data); } catch (e) { res.status(500).json({ error: "Failed to fetch analytics" }); } }); // 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, }; 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.post("/api/user/routine/:type/complete", async (req, res) => { if (!req.isAuthenticated()) return res.sendStatus(401); const type = req.params.type; if (type !== 'morning' && type !== 'evening') return res.status(400).json({ error: "Invalid routine type" }); try { const updates: any = {}; const now = new Date(); if (type === 'morning') { updates.lastMorningRoutine = now; } else { updates.lastEveningRoutine = now; } const updatedUser = await storage.updateUser((req.user as User).id, updates); res.json(updatedUser); } catch (e) { res.status(500).json({ error: "Failed to complete routine" }); } }); 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 // 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.updateTask(req.params.id, req.body); // Check for Recurrence if task is marked done if (updated && updated.status === 'done' && updated.isRecurring && req.body.status === 'done') { // Fire and forget, or await? Await to ensure it happens. console.log(`[Recurrence] Checking recurrence for task ${updated.id}`); try { const nextTask = await recurrenceService.handleTaskCompletion(updated); if (nextTask) { console.log(`[Recurrence] Created next task: ${nextTask.id} (${nextTask.title})`); } } catch (err) { console.error("[Recurrence] Error creating next task:", err); } } res.json(updated); } catch (e) { res.status(500).json({ error: "Failed to update task" }); } }); // Export User Data app.post("/api/user/export", async (req, res) => { if (!req.isAuthenticated()) return res.sendStatus(401); try { const user = req.user as User; const { includeTasks, includeLabels, includeSettings } = req.body; const exportData: any = { exportedAt: new Date(), user: { username: user.username, email: user.email } }; if (includeTasks) { exportData.tasks = await storage.getTasksForUser(user.id); } if (includeLabels) { exportData.labels = await storage.getLabels(user.id); } if (includeSettings && user.role === 'admin') { // Only admins can request system settings dump, though arguably they might mean "User Settings" // The prompt said "system settings". I will include system settings if they are admin. // But maybe they meant user preferences? The prompt specifically said "system settings". const allSettings: Record = {}; const keys = [ "registration_enabled", "smtp_host", "smtp_port", "smtp_user", "smtp_from", "ai_provider", "ai_model", "ai_base_url", "ai_system_prompt", "mcp_enabled" ]; for (const key of keys) { const val = await storage.getSystemSettings(key); if (val) allSettings[key] = val; } exportData.systemSettings = allSettings; } await storage.createAuditLog({ userId: user.id, action: "EXPORT_DATA", entityType: "USER_DATA", entityId: user.id.toString(), source: "USER", details: { includeTasks, includeLabels, includeSettings } }); res.setHeader('Content-Type', 'application/json'); res.setHeader('Content-Disposition', `attachment; filename="taskflow_export_${user.username}.json"`); res.json(exportData); } catch (e) { console.error("Export Failed:", e); res.status(500).json({ error: "Failed to export data" }); } }); // End of routes return httpServer; }