2352 lines
78 KiB
TypeScript
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;
|
|
}
|