feat: enhance audit logging, add MCP settings, and production docker setup
continuous-integration/drone/push Build is passing
continuous-integration/drone/push Build is passing
- Implemented comprehensive audit logging for Tasks, Users, Settings, Goals, Labels, AI Chat, and Rewards. - Added Admin UI for MCP Server settings and Audit Logs. - Created docker-compose-production.yml with Traefik configuration. - Fixed backend bugs (missing storage methods, route closure). - Added Audit Logging Guidelines.
This commit is contained in:
+475
-153
@@ -4,11 +4,13 @@ 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 } from "./ai.js";
|
||||
|
||||
import { GamificationService } from "./gamification.js";
|
||||
|
||||
const emailService = new EmailService(storage);
|
||||
const aiService = new AiService(storage);
|
||||
const gamificationService = new GamificationService(storage);
|
||||
|
||||
import { setupAuth, hashPassword, comparePassword } from "./auth.js";
|
||||
|
||||
@@ -148,6 +150,16 @@ export async function registerRoutes(app: Express): Promise<Server> {
|
||||
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" });
|
||||
@@ -164,6 +176,16 @@ export async function registerRoutes(app: Express): Promise<Server> {
|
||||
}
|
||||
|
||||
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" });
|
||||
@@ -180,18 +202,55 @@ export async function registerRoutes(app: Express): Promise<Server> {
|
||||
}
|
||||
|
||||
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"];
|
||||
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") {
|
||||
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
|
||||
@@ -201,12 +260,27 @@ export async function registerRoutes(app: Express): Promise<Server> {
|
||||
});
|
||||
|
||||
app.post("/api/admin/settings", isAdmin, async (req, res) => {
|
||||
const keys = ["registration_enabled", "smtp_host", "smtp_port", "smtp_user", "smtp_pass", "smtp_from", "smtp_secure"];
|
||||
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"
|
||||
];
|
||||
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 });
|
||||
});
|
||||
|
||||
@@ -255,17 +329,197 @@ export async function registerRoutes(app: Express): Promise<Server> {
|
||||
}
|
||||
});
|
||||
|
||||
// --- AI Routes ---
|
||||
app.post("/api/ai/chat", async (req, res) => {
|
||||
// --- 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 (!Array.isArray(messages)) return res.status(400).json({ error: "Messages must be an array" });
|
||||
if (!messages || !Array.isArray(messages)) return res.status(400).json({ error: "Messages array is required" });
|
||||
|
||||
// Build User Context
|
||||
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
|
||||
await storage.addMessage({
|
||||
conversationId,
|
||||
role: 'user',
|
||||
content
|
||||
});
|
||||
|
||||
// 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');
|
||||
@@ -275,6 +529,7 @@ 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}
|
||||
@@ -287,14 +542,100 @@ Recent Active Tasks:
|
||||
${activeTasks.slice(0, 5).map(t => `- [${t.priority}] ${t.title}`).join('\n')}
|
||||
`;
|
||||
|
||||
const response = await aiService.chat(messages, user, context);
|
||||
res.json({ role: "assistant", content: response });
|
||||
// Call AI Service
|
||||
const responseContent = await aiService.chat(history, user, context);
|
||||
|
||||
// Store AI Response
|
||||
const botMessage = await storage.addMessage({
|
||||
conversationId,
|
||||
role: 'assistant',
|
||||
content: responseContent
|
||||
});
|
||||
|
||||
res.json(botMessage);
|
||||
} catch (e: any) {
|
||||
console.error("AI Route Error:", e);
|
||||
res.status(500).json({ error: e.message || "Failed to generate AI response" });
|
||||
}
|
||||
});
|
||||
|
||||
// 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() });
|
||||
@@ -349,6 +690,16 @@ ${activeTasks.slice(0, 5).map(t => `- [${t.priority}] ${t.title}`).join('\n')}
|
||||
...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);
|
||||
@@ -367,6 +718,16 @@ ${activeTasks.slice(0, 5).map(t => `- [${t.priority}] ${t.title}`).join('\n')}
|
||||
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" });
|
||||
@@ -379,6 +740,17 @@ ${activeTasks.slice(0, 5).map(t => `- [${t.priority}] ${t.title}`).join('\n')}
|
||||
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" });
|
||||
@@ -488,6 +860,41 @@ ${activeTasks.slice(0, 5).map(t => `- [${t.priority}] ${t.title}`).join('\n')}
|
||||
}
|
||||
});
|
||||
|
||||
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 {
|
||||
@@ -500,6 +907,21 @@ ${activeTasks.slice(0, 5).map(t => `- [${t.priority}] ${t.title}`).join('\n')}
|
||||
...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" });
|
||||
@@ -516,149 +938,33 @@ ${activeTasks.slice(0, 5).map(t => `- [${t.priority}] ${t.title}`).join('\n')}
|
||||
return res.status(400).json({ error: "Invalid task data", details: updates.error });
|
||||
}
|
||||
|
||||
// Gamification: Award XP on completion
|
||||
if (previousTask && previousTask.status !== 'done' && updates.data.status === 'done') {
|
||||
try {
|
||||
const xpEarned = calculateXP(previousTask);
|
||||
const user = await storage.getUser((req.user as User).id);
|
||||
|
||||
if (!user) throw new Error("User not found for gamification");
|
||||
|
||||
let newStreak = user.currentStreak || 0;
|
||||
let streakBonus = 0;
|
||||
let diffDays = 0;
|
||||
|
||||
if (user) {
|
||||
const now = new Date();
|
||||
const lastDate = user.lastTaskDate ? new Date(user.lastTaskDate) : null;
|
||||
|
||||
if (!lastDate) {
|
||||
newStreak = 1;
|
||||
} else {
|
||||
const diffTime = Math.abs(now.setHours(0, 0, 0, 0) - lastDate.setHours(0, 0, 0, 0));
|
||||
diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24));
|
||||
|
||||
if (diffDays === 1) {
|
||||
newStreak += 1;
|
||||
streakBonus = Math.min(newStreak * 5, 50);
|
||||
} else if (diffDays > 1) {
|
||||
newStreak = 1;
|
||||
newStreak = 1;
|
||||
}
|
||||
}
|
||||
|
||||
// --- Daily Clear Bonus Check ---
|
||||
// Check if this was the last 'todo' task for today
|
||||
const startOfDay = new Date();
|
||||
startOfDay.setHours(0, 0, 0, 0);
|
||||
const endOfDay = new Date();
|
||||
endOfDay.setHours(23, 59, 59, 999);
|
||||
|
||||
// Re-fetch all tasks (inefficient but safe for now, better: optimize storage method)
|
||||
const allTasks = await storage.getTasksForUser(user.id);
|
||||
const remainingToday = allTasks.filter(t =>
|
||||
t.id !== previousTask.id && // exclude current
|
||||
t.status !== 'done' && // is remaining
|
||||
t.dueDate && // has due date
|
||||
new Date(t.dueDate) >= startOfDay &&
|
||||
new Date(t.dueDate) <= endOfDay
|
||||
);
|
||||
|
||||
if (remainingToday.length === 0) {
|
||||
// Bonus!
|
||||
const clearBonus = 50;
|
||||
await storage.logXpEvent({
|
||||
userId: user.id,
|
||||
amount: clearBonus,
|
||||
source: 'daily_clear_bonus', // Ensure translation key exists
|
||||
});
|
||||
console.log(`[Gamification] Awarded ${clearBonus} XP for Daily Clear`);
|
||||
}
|
||||
|
||||
if (diffDays !== 0 || !lastDate) {
|
||||
await storage.updateUser(user.id, {
|
||||
currentStreak: newStreak,
|
||||
lastTaskDate: new Date()
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Log XP Event (Task)
|
||||
await storage.logXpEvent({
|
||||
userId: (req.user as User).id,
|
||||
amount: xpEarned,
|
||||
source: 'task_completion',
|
||||
taskId: previousTask.id
|
||||
});
|
||||
|
||||
// Log XP Event (Streak Bonus)
|
||||
if (streakBonus > 0) {
|
||||
await storage.logXpEvent({
|
||||
userId: (req.user as User).id,
|
||||
amount: streakBonus,
|
||||
source: 'daily_streak',
|
||||
});
|
||||
console.log(`[Gamification] Awarded ${streakBonus} XP for streak of ${newStreak}`);
|
||||
}
|
||||
|
||||
console.log(`[Gamification] Awarded ${xpEarned} XP for task ${previousTask.title}`);
|
||||
|
||||
// --- Goal Progress Check ---
|
||||
try {
|
||||
// Fetch active goals
|
||||
const goals = await storage.getGoals(); // TODO: Filter by userId in storage
|
||||
const userGoals = goals.filter(g => g.userId === user.id && !g.completed);
|
||||
|
||||
for (const goal of userGoals) {
|
||||
let progress = 0;
|
||||
// Calculate progress based on type
|
||||
if (goal.type === 'weekly_tasks') {
|
||||
// Count tasks completed this week
|
||||
// Simplified: just update goal.current + 1 for now if we don't have full count logic
|
||||
// Ideally we recount from history, but incremental update is easier
|
||||
progress = goal.current + 1;
|
||||
} else if (goal.type === 'streak') {
|
||||
progress = newStreak;
|
||||
} else if (goal.type === 'total_xp') {
|
||||
progress = user.xp + xpEarned; // XP updated via logXpEvent side-effect? No, explicitly.
|
||||
// The user obj here is stale, user.xp is old.
|
||||
// But we just added xpEarned in logXpEvent (via side effect in storage).
|
||||
// Let's assume +xpEarned.
|
||||
// A better way is to re-fetch user, or rely on client/server sync.
|
||||
progress = user.xp + xpEarned + streakBonus;
|
||||
}
|
||||
|
||||
// Update Goal
|
||||
if (progress !== goal.current) {
|
||||
await storage.updateGoal(goal.id, { current: progress, completed: progress >= goal.target });
|
||||
|
||||
if (progress >= goal.target && !goal.completed) {
|
||||
// Goal Completion Bonus
|
||||
const goalBonus = 100;
|
||||
await storage.logXpEvent({
|
||||
userId: user.id,
|
||||
amount: goalBonus,
|
||||
source: 'goal_completed'
|
||||
});
|
||||
console.log(`[Gamification] Goal "${goal.title}" Completed! +${goalBonus} XP`);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (goalErr) {
|
||||
console.error("[Gamification] Error checking goals:", goalErr);
|
||||
}
|
||||
|
||||
} catch (err) {
|
||||
console.error("[Gamification] Error processing rewards:", err);
|
||||
// Do not fail the request, just log
|
||||
// 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 task = await storage.updateTask(req.params.id, updates.data);
|
||||
if (!task) {
|
||||
const updatedTask = await storage.updateTask(req.params.id, updates.data);
|
||||
if (!updatedTask) {
|
||||
return res.status(404).json({ error: "Task not found" });
|
||||
}
|
||||
res.json(task);
|
||||
|
||||
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) });
|
||||
@@ -671,6 +977,16 @@ ${activeTasks.slice(0, 5).map(t => `- [${t.priority}] ${t.title}`).join('\n')}
|
||||
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" });
|
||||
@@ -722,6 +1038,16 @@ ${activeTasks.slice(0, 5).map(t => `- [${t.priority}] ${t.title}`).join('\n')}
|
||||
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);
|
||||
@@ -860,11 +1186,6 @@ ${activeTasks.slice(0, 5).map(t => `- [${t.priority}] ${t.title}`).join('\n')}
|
||||
isSystem: (req.user as User).role === 'admin' && req.body.isSystem !== false,
|
||||
};
|
||||
|
||||
// Force isSystem=false for non-admins
|
||||
if ((req.user as User).role !== 'admin') {
|
||||
rewardData.isSystem = false;
|
||||
}
|
||||
|
||||
const reward = await storage.createReward(rewardData);
|
||||
res.json(reward);
|
||||
} catch (err) {
|
||||
@@ -1076,6 +1397,7 @@ ${activeTasks.slice(0, 5).map(t => `- [${t.priority}] ${t.title}`).join('\n')}
|
||||
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);
|
||||
|
||||
Reference in New Issue
Block a user