Files
task-manager/server/routes.ts
T
2026-02-03 10:48:39 +01:00

2352 lines
78 KiB
TypeScript

import type { Express } from "express";
import { createServer, type Server } from "http";
import { storage } from "./storage.js";
import { insertLabelSchema, insertUserSchema, insertTaskSchema, insertNoteSchema, insertGoalSchema, insertRewardSchema, insertUserRewardSchema, User, Task } 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";
import { getVapidPublicKey, savePushSubscription, removePushSubscription, removeUserSubscriptions, sendPushNotification } from "./push.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";
import { apiKeyAuth } from "./api-key-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<Server> {
setupAuth(app);
// API Key auth middleware - must be after session setup
// Allows external tools to authenticate via X-API-Key header
app.use("/api", apiKeyAuth());
// Update user schedule
app.patch("/api/user/schedule", async (req, res) => {
if (!req.isAuthenticated()) return res.sendStatus(401);
const userId = (req.user as User).id;
try {
const { start, end, days, availability } = req.body;
const updates: Partial<User> = {};
// Backward compatibility / Simple Mode
if (start && end && days) {
updates.workHours = { start, end, days };
// Sync to availability.work if availability not explicitly provided?
if (!availability) {
updates.availability = {
work: { start, end, days },
personal: (req.user as User).availability?.personal || { start: "18:00", end: "22:00", days: [1, 2, 3, 4, 5, 0, 6] }
};
}
}
// New Mode
if (availability) {
updates.availability = availability;
// Sync workHours to availability.work for legacy support
if (availability.work) {
updates.workHours = availability.work;
}
}
if (Object.keys(updates).length === 0) {
return res.status(400).json({ error: "No schedule data provided" });
}
const updated = await storage.updateUser(userId, updates);
await storage.createAuditLog({
userId,
action: "UPDATE",
entityType: "USER",
entityId: userId,
details: { action: "UPDATE_SCHEDULE", updates },
source: "USER"
});
res.json(updated);
} catch (e) {
res.status(500).json({ error: "Failed to update schedule" });
}
});
// --- Setup Routes ---
app.get("/api/setup/status", async (req, res) => {
const hasAdmin = await storage.hasAdminUser();
res.json({ isSetup: hasAdmin });
});
// Debug endpoints removed (SEC-1) - were accessible without auth
// Use admin settings panel or /api/admin/settings instead
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" });
}
});
app.post("/api/admin/users/:id/reset-xp", isAdmin, async (req, res) => {
try {
const user = await storage.getUser(req.params.id);
if (!user) return res.status(404).json({ error: "User not found" });
const updated = await storage.updateUser(user.id, { xp: 0, level: 1 });
await storage.createAuditLog({
userId: (req.user as User).id,
action: "UPDATE",
entityType: "USER",
entityId: user.id,
details: { action: "RESET_XP", previousXp: user.xp, previousLevel: user.level },
source: "ADMIN"
});
res.json(updated);
} catch (e) {
res.status(500).json({ error: "Failed to reset user XP" });
}
});
// --- 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" });
}
});
// Schedule a task using AI
app.post("/api/ai/schedule", 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 { taskId } = req.body;
if (!taskId) return res.status(400).json({ error: "Task ID is required" });
const result = await aiService.scheduleTask(taskId, user.id);
if (result.success && result.scheduledDate) {
// Log it
await storage.createAuditLog({
userId: user.id,
action: "UPDATE",
entityType: "TASK",
entityId: taskId,
details: { action: "AUTO_SCHEDULE", date: result.scheduledDate },
source: "AI"
});
}
res.json(result);
} catch (e: any) {
console.error("Scheduling Error:", e);
res.status(500).json({ error: e.message || "Failed to schedule 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" });
}
});
// --- API Key Management Routes ---
// Get current API key (masked)
app.get("/api/user/api-key", async (req, res) => {
if (!req.isAuthenticated()) return res.sendStatus(401);
const user = req.user as User;
if (user.apiKey) {
// Show first 8 and last 4 chars
const masked = user.apiKey.substring(0, 8) + "..." + user.apiKey.substring(user.apiKey.length - 4);
res.json({ hasKey: true, maskedKey: masked });
} else {
res.json({ hasKey: false, maskedKey: null });
}
});
// Generate new API key
app.post("/api/user/api-key", async (req, res) => {
if (!req.isAuthenticated()) return res.sendStatus(401);
const user = req.user as User;
const crypto = await import("crypto");
const newKey = "tf_" + crypto.randomBytes(32).toString("hex");
await storage.updateUserApiKey(user.id, newKey);
await storage.createAuditLog({
userId: user.id,
action: "CREATE",
entityType: "API_KEY",
entityId: user.id,
details: { message: "API key generated" },
source: "USER"
});
// Return full key ONCE (user must save it)
res.json({ apiKey: newKey, message: "Save this key - it won't be shown again in full." });
});
// Revoke API key
app.delete("/api/user/api-key", async (req, res) => {
if (!req.isAuthenticated()) return res.sendStatus(401);
const user = req.user as User;
await storage.updateUserApiKey(user.id, null);
await storage.createAuditLog({
userId: user.id,
action: "DELETE",
entityType: "API_KEY",
entityId: user.id,
details: { message: "API key revoked" },
source: "USER"
});
res.json({ message: "API key revoked" });
});
// 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) => {
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" });
}
res.json(label);
} catch (error) {
res.status(500).json({ error: "Failed to fetch label" });
}
});
app.delete("/api/tasks/:id", async (req, res) => {
if (!req.isAuthenticated()) return res.sendStatus(401);
try {
const { hasAccess, task } = await checkTaskAccess(req.user as User, req.params.id, 'write');
if (!task) return res.status(404).json({ error: "Task not found" });
// DELETE usually requires ownership or explicit 'write' permission.
// Shared read-only users should NOT be able to delete.
// checkTaskAccess('write') should cover this if we implement strict permissions in sharedTasks later.
// For now, let's assume if they have 'write', they can delete (or we restrict delete to Owner).
// Let's restrict DELETE to Owner for safety unless specifically allowed.
if (task.userId !== (req.user as User).id) {
return res.status(403).json({ error: "Only the owner can delete a task" });
}
await storage.deleteTask(req.params.id);
res.sendStatus(204);
} catch (error) {
res.status(500).json({ error: "Failed to delete task" });
}
});
app.post("/api/labels", async (req, res) => {
if (!req.isAuthenticated()) return res.sendStatus(401);
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) => {
if (!req.isAuthenticated()) return res.sendStatus(401);
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) => {
if (!req.isAuthenticated()) return res.sendStatus(401);
try {
const success = await storage.deleteLabel(req.params.id);
if (!success) {
return res.status(404).json({ error: "Label not found" });
}
// BUG FIX: removed duplicate deleteLabel() call (DUP-2)
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" });
}
});
// Helper for RBAC
const checkTaskAccess = async (user: User, taskId: string, requiredPermission: 'read' | 'write' = 'read'): Promise<{ hasAccess: boolean, task?: Task }> => {
const task = await storage.getTask(taskId);
if (!task) return { hasAccess: false };
// 1. Ownership
if (task.userId === user.id) return { hasAccess: true, task };
// 2. Shared Task (Direct)
// We need a storage method for this efficiently, but for now we might need to query
// Since storage interface is generic, let's assume valid access if we can find a record
// Optimization: Add storage.hasTaskAccess(userId, taskId)?
// For now, let's fallback to checking if the task is in the user's "visible" list or simple logic
// Implementation Plan Step: "Shared Access (query sharedTasks table)"
// We'll trust the current `storage.getTask` usually returns raw task.
// But we need to verify IF the user is allowed.
// Check Shared Tasks
const shared = await storage.getSharedTask(taskId, user.id);
if (shared) {
// Shared tasks currently imply 'read'. If we need 'write', we might need more fields.
// For now, let's assume shared = read/write or just read.
// The schema `sharedTasks` doesn't have permissions, so full access?
// Start with READ access for shared. WRITE might need schema update.
// Let's assume shared tasks are R/W for now for simplicity unless specified.
return { hasAccess: true, task };
}
// 3. Global Access (UserTaskAccess)
// Check if user has access to the owner's tasks
if (!task.userId) return { hasAccess: false, task }; // Should not happen for user tasks
const hasGlobalAccess = await storage.checkUserTaskAccess(task.userId, user.id);
if (hasGlobalAccess) return { hasAccess: true, task }; // "Share All"
return { hasAccess: false, task }; // Task exists but no access
};
app.get("/api/tasks/:id", async (req, res) => {
if (!req.isAuthenticated()) return res.sendStatus(401);
try {
const { hasAccess, task } = await checkTaskAccess(req.user as User, req.params.id, 'read');
if (!task) return res.status(404).json({ error: "Task not found" });
if (!hasAccess) return res.status(403).json({ error: "Access denied" });
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 {
// 1. Check Access (Security Fix + Fetch)
// We use checkTaskAccess to ensure the user has write permissions (ownership or shared write access)
const { hasAccess, task: previousTask } = await checkTaskAccess(req.user as User, req.params.id, 'write');
if (!previousTask) return res.status(404).json({ error: "Task not found" });
if (!hasAccess) return res.status(403).json({ error: "Access denied" });
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) {
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) {
// 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" });
}
// 2. Time Tracking Logic
// If timeTracked has increased, log the delta
if (updates.data.timeTracked && updatedTask.timeTracked > previousTask.timeTracked) {
const delta = updatedTask.timeTracked - previousTask.timeTracked;
await storage.logTaskTime({
taskId: updatedTask.id,
userId: (req.user as User).id,
timeSpent: delta
});
}
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) });
}
});
// Time Analytics Endpoint
app.get("/api/analytics/time-distribution", async (req, res) => {
if (!req.isAuthenticated()) return res.sendStatus(401);
const period = req.query.period as 'day' | 'week' | 'month' | 'year';
if (!['day', 'week', 'month', 'year'].includes(period)) {
return res.status(400).json({ error: "Invalid period. Must be day, week, month, or year." });
}
try {
const distribution = await storage.getAnalyticsTimeDistribution((req.user as User).id, period);
res.json(distribution);
} catch (error) {
console.error("Analytics Error:", error);
res.status(500).json({ error: "Failed to fetch time distribution" });
}
});
// BUG FIX: Removed duplicate DELETE /api/tasks/:id route (DUP-1)
// The correct one with auth + ownership check is at line ~925
// Notes API routes
app.get("/api/notes", async (req, res) => {
if (!req.isAuthenticated()) return res.sendStatus(401);
try {
res.json([]); // Placeholder until storage implementation
} catch (error) {
res.status(500).json({ error: "Failed to fetch notes" });
}
});
app.post("/api/notes", async (req, res) => {
if (!req.isAuthenticated()) return res.sendStatus(401);
try {
const result = insertNoteSchema.safeParse(req.body);
if (!result.success) {
return res.status(400).json({ error: "Invalid note data", details: result.error });
}
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) => {
if (!req.isAuthenticated()) return res.sendStatus(401);
try {
const goals = await storage.getGoals();
res.json(goals);
} catch (error) {
res.status(500).json({ error: "Failed to fetch goals" });
}
});
app.post("/api/goals", async (req, res) => {
if (!req.isAuthenticated()) return res.sendStatus(401);
try {
const result = insertGoalSchema.safeParse(req.body);
if (!result.success) {
return res.status(400).json(result.error);
}
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" });
}
});
// Dead code removed (DEAD-1): calculateXP was unused, GamificationService handles XP
// Rewards API
app.get("/api/rewards", async (req, res) => {
if (!req.isAuthenticated()) return res.sendStatus(401);
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 });
await storage.createAuditLog({
userId,
action: "PURCHASE",
entityType: "REWARD",
entityId: rewardId.toString(),
source: "USER",
details: { cost: reward.cost, rewardName: reward.title }
});
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);
await storage.createAuditLog({
userId: (req.user as User).id,
action: "CREATE",
entityType: "REWARD",
entityId: reward.id.toString(),
source: "ADMIN",
details: { title: reward.title, cost: reward.cost }
});
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, is2faEnabled, language } = req.body;
const updates: any = {};
if (showOnLeaderboard !== undefined) updates.showOnLeaderboard = showOnLeaderboard;
if (isSearchable !== undefined) updates.isSearchable = isSearchable;
if (aiEnabled !== undefined) updates.aiEnabled = aiEnabled;
if (is2faEnabled !== undefined) updates.is2faEnabled = is2faEnabled;
if (language !== undefined) updates.language = language;
const updated = await storage.updateUser((req.user as User).id, updates);
await storage.createAuditLog({
userId: (req.user as User).id,
action: "UPDATE",
entityType: "USER_PRIVACY",
entityId: (req.user as User).id.toString(),
source: "USER",
details: 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 });
await storage.createAuditLog({
userId: (req.user as User).id,
action: "UPDATE",
entityType: "USER_PROFILE",
entityId: (req.user as User).id.toString(),
source: "USER",
details: { change: "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 });
await storage.createAuditLog({
userId: user.id,
action: "UPDATE",
entityType: "USER_PASSWORD",
entityId: user.id.toString(),
source: "USER",
details: {}
});
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
});
await storage.createAuditLog({
userId: (req.user as User).id,
action: "SHARE",
entityType: "TASK",
entityId: taskId,
source: "USER",
details: { sharedWith: 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) {
await storage.createAuditLog({
userId: (req.user as User).id,
action: "UNSHARE",
entityType: "TASK",
entityId: taskId,
source: "USER",
details: { unsharedWith: targetUserId }
});
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
});
await storage.createAuditLog({
userId: (req.user as User).id,
action: "SHARE",
entityType: "ALL_TASKS",
entityId: "0",
source: "USER",
details: { sharedWith: 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 {
// BUG FIX: was calling updateTask() instead of updateGoal() (DEAD-4)
const updated = await storage.updateGoal(req.params.id, req.body);
if (updated) {
await storage.createAuditLog({
userId: (req.user as User).id,
action: "UPDATE",
entityType: "GOAL",
entityId: updated.id.toString(),
source: "USER",
details: req.body
});
}
} catch (e) {
res.status(500).json({ error: "Failed to update goal" });
}
});
// Routine Completion Endpoint
app.post("/api/user/routine/:type/complete", async (req, res) => {
if (!req.isAuthenticated()) return res.sendStatus(401);
const user = req.user as User;
const type = req.params.type;
try {
if (type === 'morning') {
await storage.updateUser(user.id, { lastMorningRoutine: new Date() });
await gamificationService.awardXP(user.id, 'morning_routine', 20); // Bonus
} else if (type === 'evening') {
await storage.updateUser(user.id, { lastEveningRoutine: new Date() });
await gamificationService.awardXP(user.id, 'evening_routine', 20); // Bonus
} else {
return res.status(400).json({ error: "Invalid routine type" });
}
await storage.createAuditLog({
userId: user.id,
action: "COMPLETE",
entityType: "ROUTINE",
entityId: type,
source: "USER",
details: { type }
});
res.json({ success: true });
} catch (e) {
res.status(500).json({ error: "Failed to complete routine" });
}
});
// Export User Data
app.post("/api/user/data-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<string, string> = {};
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" });
}
});
// ============================================
// ADHD MODE ROUTES
// ============================================
// Update ADHD settings
app.patch("/api/user/adhd-settings", async (req, res) => {
if (!req.isAuthenticated()) return res.sendStatus(401);
const userId = (req.user as User).id;
try {
const { adhdMode, adhdSettings } = req.body;
const updated = await storage.updateUser(userId, {
adhdMode,
adhdSettings,
});
await storage.createAuditLog({
userId,
action: "UPDATE",
entityType: "USER",
entityId: userId,
details: { action: "UPDATE_ADHD_SETTINGS", adhdMode },
source: "USER"
});
res.json(updated);
} catch (e) {
console.error("Update ADHD settings failed:", e);
res.status(500).json({ error: "Failed to update ADHD settings" });
}
});
// Log a break
app.post("/api/user/log-break", async (req, res) => {
if (!req.isAuthenticated()) return res.sendStatus(401);
const userId = (req.user as User).id;
try {
const { breakType, durationMinutes } = req.body;
// Log the break
await storage.createBreakLog({
userId,
breakType,
durationMinutes: durationMinutes || 5,
});
// Update user's lastBreakAt
await storage.updateUser(userId, {
lastBreakAt: new Date(),
});
// Award XP for taking a break (micro-XP)
// BUG FIX: Parameters were swapped (TYPE-2) - source and amount
await gamificationService.awardXP(userId, 'break_taken', 10, { breakType });
res.json({ success: true });
} catch (e) {
console.error("Log break failed:", e);
res.status(500).json({ error: "Failed to log break" });
}
});
// Get break stats
app.get("/api/user/break-stats", async (req, res) => {
if (!req.isAuthenticated()) return res.sendStatus(401);
const userId = (req.user as User).id;
try {
const stats = await storage.getBreakStats(userId);
res.json(stats);
} catch (e) {
res.status(500).json({ error: "Failed to get break stats" });
}
});
// Log energy level
app.post("/api/energy/log", async (req, res) => {
if (!req.isAuthenticated()) return res.sendStatus(401);
const userId = (req.user as User).id;
try {
const { energyLevel, notes } = req.body;
await storage.createEnergyLog({
userId,
energyLevel,
notes,
});
// Update user's current energy level
await storage.updateUser(userId, {
currentEnergyLevel: energyLevel,
todayEnergyCheckedIn: true,
});
// Award XP for checking in
// BUG FIX: Parameters were swapped (TYPE-2)
await gamificationService.awardXP(userId, 'energy_checkin', 5, { energyLevel });
res.json({ success: true });
} catch (e) {
console.error("Log energy failed:", e);
res.status(500).json({ error: "Failed to log energy" });
}
});
// Get energy history
app.get("/api/energy/history", async (req, res) => {
if (!req.isAuthenticated()) return res.sendStatus(401);
const userId = (req.user as User).id;
try {
const history = await storage.getEnergyHistory(userId);
res.json(history);
} catch (e) {
res.status(500).json({ error: "Failed to get energy history" });
}
});
// Get energy-based task suggestions
app.get("/api/tasks/energy-based", async (req, res) => {
if (!req.isAuthenticated()) return res.sendStatus(401);
const userId = (req.user as User).id;
try {
const user = req.user as User;
const tasks = await storage.getTasksForUser(userId);
const energyLevel = user.currentEnergyLevel || 'medium';
// Filter tasks based on energy level
let recommended = tasks.filter(t => t.status !== 'done');
if (energyLevel === 'low') {
// Prefer low-energy tasks for low energy
recommended = recommended.filter(t =>
t.energyLevel === 'low' ||
(t.estimatedDuration && t.estimatedDuration <= 15) ||
t.priority === 'low'
);
} else if (energyLevel === 'high') {
// Prefer high-energy/challenging tasks
recommended = recommended.filter(t =>
t.energyLevel === 'high' ||
t.priority === 'high' ||
(t.estimatedDuration && t.estimatedDuration > 30)
);
}
res.json(recommended.slice(0, 10));
} catch (e) {
res.status(500).json({ error: "Failed to get energy-based tasks" });
}
});
// Get quick wins
app.get("/api/tasks/quick-wins", async (req, res) => {
if (!req.isAuthenticated()) return res.sendStatus(401);
const userId = (req.user as User).id;
try {
const user = req.user as User;
const tasks = await storage.getTasksForUser(userId);
const threshold = (user.adhdSettings as any)?.quickWinThreshold || 10;
const quickWins = tasks
.filter(t => {
if (t.status === 'done') return false;
if (t.estimatedDuration && t.estimatedDuration > threshold * 1.5) return false;
return true;
})
.slice(0, 10);
res.json(quickWins);
} catch (e) {
res.status(500).json({ error: "Failed to get quick wins" });
}
});
// AI Task Breakdown
app.post("/api/ai/breakdown-task", async (req, res) => {
if (!req.isAuthenticated()) return res.sendStatus(401);
const userId = (req.user as User).id;
try {
const { taskId, title, description, estimatedDuration } = req.body;
const breakdown = await aiService.breakdownTask({
title,
description,
estimatedDuration,
});
await storage.createAuditLog({
userId,
action: "AI_BREAKDOWN",
entityType: "TASK",
entityId: taskId,
details: { title, subtaskCount: breakdown.subtasks?.length },
source: "AI"
});
res.json(breakdown);
} catch (e) {
console.error("AI breakdown failed:", e);
res.status(500).json({ error: "Failed to break down task" });
}
});
// ============================================
// BODY DOUBLING SESSION ROUTES
// ============================================
// Get all sessions
app.get("/api/sessions", async (req, res) => {
if (!req.isAuthenticated()) return res.sendStatus(401);
const userId = (req.user as User).id;
try {
const sessions = await storage.getBodyDoublingSessions(userId);
res.json(sessions);
} catch (e) {
res.status(500).json({ error: "Failed to get sessions" });
}
});
// Create session
app.post("/api/sessions", async (req, res) => {
if (!req.isAuthenticated()) return res.sendStatus(401);
const userId = (req.user as User).id;
try {
const session = await storage.createBodyDoublingSession({
hostId: userId,
...req.body,
});
// Auto-join as participant
await storage.joinSession(session.id, userId);
res.json(session);
} catch (e) {
console.error("Create session failed:", e);
res.status(500).json({ error: "Failed to create session" });
}
});
// Join session
app.post("/api/sessions/:id/join", async (req, res) => {
if (!req.isAuthenticated()) return res.sendStatus(401);
const userId = (req.user as User).id;
const sessionId = req.params.id;
try {
await storage.joinSession(sessionId, userId);
res.json({ success: true });
} catch (e) {
console.error("Join session failed:", e);
res.status(500).json({ error: "Failed to join session" });
}
});
// Leave session
app.post("/api/sessions/:id/leave", async (req, res) => {
if (!req.isAuthenticated()) return res.sendStatus(401);
const userId = (req.user as User).id;
const sessionId = req.params.id;
try {
await storage.leaveSession(sessionId, userId);
res.json({ success: true });
} catch (e) {
console.error("Leave session failed:", e);
res.status(500).json({ error: "Failed to leave session" });
}
});
// ============================================
// FOCUS SESSION ROUTES
// ============================================
// Start focus session
app.post("/api/focus-sessions", async (req, res) => {
if (!req.isAuthenticated()) return res.sendStatus(401);
const userId = (req.user as User).id;
try {
const session = await storage.createFocusSession({
userId,
...req.body,
});
res.json(session);
} catch (e) {
res.status(500).json({ error: "Failed to start focus session" });
}
});
// Complete focus session
app.patch("/api/focus-sessions/:id/complete", async (req, res) => {
if (!req.isAuthenticated()) return res.sendStatus(401);
const userId = (req.user as User).id;
try {
const { actualMinutes, wasCompleted } = req.body;
const session = await storage.completeFocusSession(req.params.id, actualMinutes, wasCompleted);
// Award XP based on completion
const xpAmount = wasCompleted ? 15 : 5;
// BUG FIX: Parameters were swapped (TYPE-2)
await gamificationService.awardXP(userId, 'focus_session', xpAmount, {
sessionType: session?.sessionType,
actualMinutes,
});
res.json(session);
} catch (e) {
res.status(500).json({ error: "Failed to complete focus session" });
}
});
// ============================================
// DAILY CHALLENGES ROUTES
// ============================================
// Get today's challenges
app.get("/api/challenges/today", async (req, res) => {
if (!req.isAuthenticated()) return res.sendStatus(401);
const userId = (req.user as User).id;
try {
let challenges = await storage.getTodaysChallenges(userId);
// Generate new challenges if none exist for today
if (challenges.length === 0) {
challenges = await storage.generateDailyChallenges(userId);
}
res.json(challenges);
} catch (e) {
res.status(500).json({ error: "Failed to get challenges" });
}
});
// Update challenge progress
app.post("/api/challenges/:id/progress", async (req, res) => {
if (!req.isAuthenticated()) return res.sendStatus(401);
const userId = (req.user as User).id;
try {
const { increment } = req.body;
const challenge = await storage.updateChallengeProgress(req.params.id, increment || 1);
// Award XP if completed
if (challenge?.completedAt && challenge.xpReward) {
await gamificationService.awardXP(userId, 'daily_challenge', challenge.xpReward, {
challengeType: challenge.challengeType,
});
}
res.json(challenge);
} catch (e) {
res.status(500).json({ error: "Failed to update challenge progress" });
}
});
// ============================================
// PUSH NOTIFICATION ROUTES
// ============================================
// Get VAPID public key (needed by client to subscribe)
app.get("/api/push/vapid-public-key", async (req, res) => {
try {
const publicKey = await getVapidPublicKey();
if (!publicKey) {
return res.status(503).json({ error: "Push notifications not configured" });
}
res.json({ publicKey });
} catch (e) {
console.error("Get VAPID key failed:", e);
res.status(500).json({ error: "Failed to get VAPID key" });
}
});
// Subscribe to push notifications
app.post("/api/push/subscribe", async (req, res) => {
if (!req.isAuthenticated()) return res.sendStatus(401);
const userId = (req.user as User).id;
try {
const { subscription } = req.body;
if (!subscription || !subscription.endpoint || !subscription.keys) {
return res.status(400).json({ error: "Invalid subscription data" });
}
const userAgent = req.headers['user-agent'];
const success = await savePushSubscription(userId, subscription, userAgent);
if (success) {
// Send a welcome notification to confirm it works
await sendPushNotification(userId, {
title: "Notifications Enabled",
body: "You'll now receive task reminders even when the app is closed!",
tag: "welcome"
});
res.json({ success: true });
} else {
res.status(500).json({ error: "Failed to save subscription" });
}
} catch (e) {
console.error("Subscribe failed:", e);
res.status(500).json({ error: "Failed to subscribe" });
}
});
// Unsubscribe from push notifications
app.post("/api/push/unsubscribe", async (req, res) => {
if (!req.isAuthenticated()) return res.sendStatus(401);
try {
const { endpoint } = req.body;
if (!endpoint) {
return res.status(400).json({ error: "Endpoint required" });
}
const success = await removePushSubscription(endpoint);
res.json({ success });
} catch (e) {
console.error("Unsubscribe failed:", e);
res.status(500).json({ error: "Failed to unsubscribe" });
}
});
// Unsubscribe all devices for current user
app.delete("/api/push/subscriptions", async (req, res) => {
if (!req.isAuthenticated()) return res.sendStatus(401);
const userId = (req.user as User).id;
try {
const success = await removeUserSubscriptions(userId);
res.json({ success });
} catch (e) {
console.error("Remove subscriptions failed:", e);
res.status(500).json({ error: "Failed to remove subscriptions" });
}
});
// Handle subscription changes (from service worker)
app.post("/api/push/resubscribe", async (req, res) => {
if (!req.isAuthenticated()) return res.sendStatus(401);
const userId = (req.user as User).id;
try {
const { oldEndpoint, newSubscription } = req.body;
// Remove old subscription
if (oldEndpoint) {
await removePushSubscription(oldEndpoint);
}
// Save new subscription
if (newSubscription) {
const userAgent = req.headers['user-agent'];
await savePushSubscription(userId, newSubscription, userAgent);
}
res.json({ success: true });
} catch (e) {
console.error("Resubscribe failed:", e);
res.status(500).json({ error: "Failed to resubscribe" });
}
});
// Test notification (useful for debugging)
app.post("/api/push/test", async (req, res) => {
if (!req.isAuthenticated()) return res.sendStatus(401);
const userId = (req.user as User).id;
try {
const result = await sendPushNotification(userId, {
title: "Test Notification",
body: "Push notifications are working!",
tag: "test"
});
res.json(result);
} catch (e) {
console.error("Test notification failed:", e);
res.status(500).json({ error: "Failed to send test notification" });
}
});
// End of routes
return httpServer;
}