Files
task-manager/server/routes.ts
T
2025-12-15 21:52:36 +01:00

1556 lines
53 KiB
TypeScript

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<Server> {
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);
}
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);
} 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');
}
}
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) => {
// Return last 7 days. Key is 0-6 (Sun-Sat) or ISO date.
// For simplicity, let's return day index relative to today or just standard day index (0=Sun)
// To make it look "last 7 days" we can return relative indices
const keys = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'];
// Better: Send localizable keys.
// Day format: "day_1" (Mon) ... "day_7" (Sun) or just short codes the frontend can map
// We will send standard JS Day indices adjusted: 1 (Mon) - 7 (Sun) for "ISO Week" style or just 0-6
// Let's send a `labelKey` that the frontend can translate.
const data = [
{ labelKey: 'mon', xp: Math.floor(Math.random() * 500) },
{ labelKey: 'tue', xp: Math.floor(Math.random() * 500) },
{ labelKey: 'wed', xp: Math.floor(Math.random() * 500) },
{ labelKey: 'thu', xp: Math.floor(Math.random() * 500) },
{ labelKey: 'fri', xp: Math.floor(Math.random() * 500) },
{ labelKey: 'sat', xp: Math.floor(Math.random() * 500) },
{ labelKey: 'sun', xp: Math.floor(Math.random() * 500) },
];
res.json(data);
});
app.get("/api/analytics/yearly", async (req, res) => {
const data = [
{ labelKey: 'jan', xp: Math.floor(Math.random() * 2000) },
{ labelKey: 'feb', xp: Math.floor(Math.random() * 2000) },
{ labelKey: 'mar', xp: Math.floor(Math.random() * 2000) },
{ labelKey: 'apr', xp: Math.floor(Math.random() * 2000) },
{ labelKey: 'may', xp: Math.floor(Math.random() * 2000) },
{ labelKey: 'jun', xp: Math.floor(Math.random() * 2000) },
{ labelKey: 'jul', xp: Math.floor(Math.random() * 2000) },
{ labelKey: 'aug', xp: Math.floor(Math.random() * 2000) },
{ labelKey: 'sep', xp: Math.floor(Math.random() * 2000) },
{ labelKey: 'oct', xp: Math.floor(Math.random() * 2000) },
{ labelKey: 'nov', xp: Math.floor(Math.random() * 2000) },
{ labelKey: 'dec', xp: Math.floor(Math.random() * 2000) },
];
res.json(data);
});
app.get("/api/analytics/monthly", async (req, res) => {
// Return last 4-5 weeks with actual Calendar Week numbers
// Mocking for now: Assume current week is ~50
const currentWeek = 50;
const data = [
{ labelKey: (currentWeek - 3).toString(), xp: Math.floor(Math.random() * 800) },
{ labelKey: (currentWeek - 2).toString(), xp: Math.floor(Math.random() * 800) },
{ labelKey: (currentWeek - 1).toString(), xp: Math.floor(Math.random() * 800) },
{ labelKey: currentWeek.toString(), xp: Math.floor(Math.random() * 800) },
];
res.json(data);
});
// Gamification Logic Wrapper
const calculateXP = (task: any) => {
let baseXP = 10;
if (task.priority === 'high') baseXP += 20;
if (task.priority === 'medium') baseXP += 10;
if (task.energyLevel === 'high') baseXP += 30; // Bonus for high energy stuff
return baseXP;
};
// Rewards API
app.get("/api/rewards", async (req, res) => {
try {
const allRewards = await storage.getAllRewards();
const userId = req.query.userId as string;
// Filter rewards: System rewards OR User's own rewards
const visibleRewards = allRewards.filter(r => r.isSystem || (userId && r.userId === userId));
// If userId is provided, check ownership of visible rewards
if (userId) {
const userRewards = await storage.getUserRewards(userId);
const ownedIds = new Set(userRewards.map(ur => ur.rewardId));
return res.json(visibleRewards.map(r => ({ ...r, owned: ownedIds.has(r.id) })));
}
res.json(visibleRewards);
} catch (error) {
res.status(500).json({ error: "Failed to fetch rewards" });
}
});
app.post("/api/rewards/purchase", async (req, res) => {
if (!req.isAuthenticated()) return res.sendStatus(401);
try {
// 1. Get User & Reward
const userId = (req.user as User).id;
const { rewardId } = req.body;
if (!rewardId) return res.status(400).json({ error: "Missing rewardId" });
const user = await storage.getUser(userId); // Fetch user here
const allRewards = await storage.getAllRewards();
const reward = allRewards.find(r => r.id === rewardId);
if (!user || !reward) return res.status(404).json({ error: "User or Reward not found" });
// 2. Check Ownership (if one-time)
// For now, allow multiple purchases unless type is 'feature_unlock'
if (reward.type === 'feature_unlock') {
const userRewards = await storage.getUserRewards(userId);
if (userRewards.some(ur => ur.rewardId === rewardId)) {
return res.status(400).json({ error: "Reward already owned" });
}
}
// 3. Check Funds
if (user.xp < reward.cost) {
return res.status(400).json({ error: "Insufficient XP" });
}
// 4. Transaction
const updatedUser = await storage.updateUserXP(userId, -reward.cost);
await storage.createUserReward({ userId, rewardId });
res.json({ success: true, user: updatedUser });
} catch (e) {
res.status(500).json({ error: "Purchase failed" });
}
});
app.post("/api/rewards", async (req, res) => {
if (!req.isAuthenticated()) return res.sendStatus(401);
try {
const rewardData = {
...req.body,
userId: (req.user as User).id,
isSystem: (req.user as User).role === 'admin' && req.body.isSystem !== false,
};
const reward = await storage.createReward(rewardData);
res.json(reward);
} catch (err) {
res.status(500).json({ error: "Failed to create reward" });
}
});
// User Gamification Endpoints
app.get("/api/user/history", async (req, res) => {
if (!req.isAuthenticated()) return res.sendStatus(401);
try {
const history = await storage.getXpEvents((req.user as User).id);
res.json(history);
} catch (e) {
res.status(500).json({ error: "Failed to fetch history" });
}
});
app.get("/api/user/inventory", async (req, res) => {
if (!req.isAuthenticated()) return res.sendStatus(401);
try {
const userRewards = await storage.getUserRewards((req.user as User).id);
// Join with rewards details
const allRewards = await storage.getAllRewards();
const inventory = userRewards.map(ur => {
const reward = allRewards.find(r => r.id === ur.rewardId);
return {
...ur,
reward // Nested details
};
}).filter(item => item.reward); // Filter out any broken links
res.json(inventory);
} catch (e) {
res.status(500).json({ error: "Failed to fetch inventory" });
}
});
// --- Social & Leaderboard Routes ---
app.get("/api/leaderboard", async (req, res) => {
try {
const users = await storage.getLeaderboard();
// Return public info only
const leaderboard = users.map(u => ({
username: u.username,
xp: u.xp,
level: u.level,
id: u.id // Needed? Maybe for linking profile
}));
res.json(leaderboard);
} catch (e) {
res.status(500).json({ error: "Failed to fetch leaderboard" });
}
});
app.patch("/api/user/privacy", async (req, res) => {
if (!req.isAuthenticated()) return res.sendStatus(401);
try {
const { showOnLeaderboard, isSearchable, aiEnabled } = req.body;
const updates: any = {};
if (showOnLeaderboard !== undefined) updates.showOnLeaderboard = showOnLeaderboard;
if (isSearchable !== undefined) updates.isSearchable = isSearchable;
if (aiEnabled !== undefined) updates.aiEnabled = aiEnabled;
const updated = await storage.updateUser((req.user as User).id, updates);
res.json(updated);
} catch (e) {
res.status(500).json({ error: "Failed to update privacy settings" });
}
});
app.patch("/api/user/profile", async (req, res) => {
if (!req.isAuthenticated()) return res.sendStatus(401);
try {
const { email } = req.body;
if (!email || !email.includes('@')) return res.status(400).json({ error: "Invalid email" });
const existing = await storage.getUserByEmail(email);
if (existing && existing.id !== (req.user as User).id) {
return res.status(400).json({ error: "Email already taken" });
}
const updated = await storage.updateUser((req.user as User).id, { email });
res.json(updated);
} catch (e) {
res.status(500).json({ error: "Failed to update profile" });
}
});
app.patch("/api/user/password", async (req, res) => {
if (!req.isAuthenticated()) return res.sendStatus(401);
try {
const { currentPassword, newPassword } = req.body;
if (!currentPassword || !newPassword) return res.status(400).json({ error: "Missing fields" });
const user = await storage.getUser((req.user as User).id);
if (!user) return res.status(404).json({ error: "User not found" });
const isValid = await comparePassword(currentPassword, user.password);
if (!isValid) return res.status(400).json({ error: "Incorrect current password" });
const hashedPassword = await hashPassword(newPassword);
await storage.updateUser(user.id, { password: hashedPassword });
res.json({ message: "Password updated" });
} catch (e) {
res.status(500).json({ error: "Failed to update password" });
}
});
app.get("/api/users/search", async (req, res) => {
if (!req.isAuthenticated()) return res.sendStatus(401);
const query = req.query.q as string;
try {
const users = await storage.searchUsers(query);
// Filter out self
const others = users.filter(u => u.id !== (req.user as User).id);
res.json(others.map(u => ({ id: u.id, username: u.username })));
} catch (e) {
res.status(500).json({ error: "Search failed" });
}
});
app.post("/api/tasks/:id/share", async (req, res) => {
if (!req.isAuthenticated()) return res.sendStatus(401);
try {
const { targetUserId } = req.body;
const taskId = req.params.id;
// Verify ownership
const task = await storage.getTask(taskId);
if (!task) return res.status(404).json({ error: "Task not found" });
if (task.userId !== (req.user as User).id) {
return res.status(403).json({ error: "Unauthorized" });
}
await storage.shareTask({
taskId,
sharedByUserId: (req.user as User).id,
sharedWithUserId: targetUserId
});
res.json({ success: true });
} catch (e) {
res.status(500).json({ error: "Failed to share task" });
}
});
app.get("/api/tasks/:id/shared-users", async (req, res) => {
if (!req.isAuthenticated()) return res.sendStatus(401);
try {
const taskId = req.params.id;
// Verify ownership or access? Ideally only owner can see who else sees it.
const task = await storage.getTask(taskId);
if (!task) return res.status(404).json({ error: "Task not found" });
if (task.userId !== (req.user as User).id) {
return res.status(403).json({ error: "Unauthorized" });
}
const users = await storage.getTaskSharedUsers(taskId);
// Return minimal info
res.json(users.map(u => ({ id: u.id, username: u.username })));
} catch (e) {
res.status(500).json({ error: "Failed to fetch shared users" });
}
});
app.delete("/api/tasks/:id/share/:userId", async (req, res) => {
if (!req.isAuthenticated()) return res.sendStatus(401);
try {
const taskId = req.params.id;
const targetUserId = req.params.userId;
// Verify ownership
const task = await storage.getTask(taskId);
if (!task) return res.status(404).json({ error: "Task not found" });
if (task.userId !== (req.user as User).id) {
return res.status(403).json({ error: "Unauthorized" });
}
const success = await storage.unshareTask(taskId, targetUserId);
if (success) {
res.json({ success: true });
} else {
res.status(404).json({ error: "Share not found" });
}
} catch (e) {
res.status(500).json({ error: "Failed to unshare task" });
}
});
app.post("/api/users/share-all", async (req, res) => {
if (!req.isAuthenticated()) return res.sendStatus(401);
try {
const { targetUserId } = req.body;
await storage.shareAllTasks({
ownerId: (req.user as User).id,
viewerId: targetUserId
});
res.json({ success: true });
} catch (e) {
res.status(500).json({ error: "Failed to share all tasks" });
}
});
const httpServer = createServer(app);
// Storage needs to support Goal Update
// 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<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" });
}
}); // End of routes
return httpServer;
}