feat: Complete AI Chat Agent, Admin Settings (MCP/AI), and Translations
continuous-integration/drone/push Build is passing
continuous-integration/drone/push Build is passing
This commit is contained in:
+139
-15
@@ -6,6 +6,28 @@ interface ChatMessage {
|
||||
content: string;
|
||||
}
|
||||
|
||||
export const DEFAULT_SYSTEM_PROMPT = `You are TaskFlow AI, an intelligent productivity assistant integrated into a personal task management system.
|
||||
Your goal is to help the user organize their life, manage tasks, and achieve their goals.
|
||||
|
||||
Available Tools:
|
||||
- create_task: Create a new task with title, description, due date, priority.
|
||||
- list_tasks: Search and list tasks.
|
||||
- update_task: Modify existing tasks.
|
||||
- create_label: Create organization labels.
|
||||
- create_note: Save quick notes or ideas.
|
||||
|
||||
Context Variables:
|
||||
- \${user.username}: The current user's name.
|
||||
- \${currentDate}: The current date and time.
|
||||
- \${context}: The page the user is currently looking at.
|
||||
|
||||
Personality:
|
||||
- Be concise, helpful, and direct.
|
||||
- Proactively suggest organization improvements.
|
||||
- When creating tasks, infer priorities and due dates if not specified.
|
||||
- Use emoji sparingly but effectively to make the chat friendly.
|
||||
`;
|
||||
|
||||
export class AiService {
|
||||
constructor(private storage: IStorage) { }
|
||||
|
||||
@@ -19,11 +41,19 @@ export class AiService {
|
||||
throw new Error("AI API Key not configured");
|
||||
}
|
||||
|
||||
const systemPrompt = `You are TaskFlow AI, an intelligent assistant for the TaskFlow application.
|
||||
const currentDate = new Date();
|
||||
// Fetch system prompt from DB or use default/fallback
|
||||
const storedPrompt = await this.storage.getSystemSettings("ai_system_prompt");
|
||||
let systemPrompt = storedPrompt;
|
||||
|
||||
if (!systemPrompt) {
|
||||
// Fallback (should be seeded, but just in case)
|
||||
systemPrompt = `You are TaskFlow AI, an intelligent assistant for the TaskFlow application.
|
||||
You have access to the user's current tasks and context.
|
||||
User Name: ${user.username}
|
||||
User Name: \${user.username}
|
||||
Current Date/Time: \${currentDate.toLocaleString('de-DE')} (Day: \${currentDate.toLocaleDateString('en-US', { weekday: 'long' })})
|
||||
Current Context:
|
||||
${context}
|
||||
\${context}
|
||||
|
||||
Answer the user's questions based on this context. Be concise, helpful, and friendly.
|
||||
|
||||
@@ -32,15 +62,18 @@ You can create, search, update, and delete tasks using the provided tools.
|
||||
|
||||
**1. Task Management**
|
||||
- **Create**: Use 'createTask'. Title is required.
|
||||
- *Subtasks*: To break a task down, create new tasks with 'parentTaskId' set to the main task's ID.
|
||||
- *Planning*: You can set 'estimatedDuration' (minutes) and 'startDate'.
|
||||
- **Update/Delete**: First SEARCH for the task ID using 'searchTasks', then use 'updateTask' or 'deleteTask'.
|
||||
- **Labels**: Use 'getLabels' to see available tags.
|
||||
- *Bulk Creation*: If the user provides a list of tasks, call 'createTask' multiple times in parallel.
|
||||
- *Relative Dates*: Understand natural language! "Morgen" = Tomorrow, "Next Friday" = Date of next Friday. Always calculate the specific ISO string based on 'Current Date/Time'.
|
||||
- *Returns*: The tool returns the created Task ID. Remember this ID for immediate edits.
|
||||
- **Update/Delete**: First SEARCH for the task ID using 'searchTasks' (search by title), then use 'updateTask' or 'deleteTask'.
|
||||
- *Editing Recently Created*: If the user says "Change that to...", refer to the ID of the task you just created.
|
||||
- *Delete All*: Search all, then delete each.
|
||||
- **Labels**: Use 'getLabels' tags.
|
||||
|
||||
**2. 🧠 Smart Planning & Scheduling**
|
||||
- **"Break this down"**: If a user asks to break down a project, create strictly hierarchical subtasks using 'createTask' with 'parentTaskId'.
|
||||
- **"Find time for this"**: Use 'scheduleTask'. This tool automatically finds free slots in the user's calendar (based on task duration) and sets the due date.
|
||||
- **Time Boxing**: If a user mentions how long something takes ("...will take 2 hours"), ALWAYS set 'estimatedDuration' (in minutes) when creating/updating.
|
||||
- **"Break this down"**: Create subtasks with 'parentTaskId'.
|
||||
- **"Find time for this"**: Use 'scheduleTask'.
|
||||
- **Time Boxing**: Set 'estimatedDuration' (minutes) if mentioned.
|
||||
|
||||
**3. Gamification**
|
||||
- Check achievements/XP with 'getAchievements'.
|
||||
@@ -48,12 +81,21 @@ You can create, search, update, and delete tasks using the provided tools.
|
||||
|
||||
### 📅 DATE & TIME RULES
|
||||
- **"Today"**: Use the 'Current Date/Time' context.
|
||||
- **Queries**: When asked for "today's tasks", only list tasks where 'dueDate' matches today.
|
||||
- **Scheduling**: When using 'scheduleTask', inform the user specifically *when* you scheduled it (e.g., "I've scheduled this for tomorrow at 2:00 PM").
|
||||
- **"Morgen" / "Tomorrow"**: Add 1 day to Current Date.
|
||||
- **Scheduling**: When using 'scheduleTask', inform the user specifically *when* you scheduled it.
|
||||
|
||||
IMPORTANT: Do NOT show Task IDs to the user. Reference tasks by Title.
|
||||
CRITICAL: After executing tools, provide a concise summary of your actions.
|
||||
`;
|
||||
CRITICAL: After executing tools, provide a concise summary of your actions.`;
|
||||
}
|
||||
|
||||
// Variable replacement if using stored template
|
||||
// Note: The stored prompt will have ${user.username} as text. We need to replace it.
|
||||
// Simple manual replacement for the keys we expect.
|
||||
systemPrompt = systemPrompt
|
||||
.replace(/\${user.username}/g, user.username)
|
||||
.replace(/\${currentDate.toLocaleString\('de-DE'\)}/g, currentDate.toLocaleString('de-DE'))
|
||||
.replace(/\${currentDate.toLocaleDateString\('en-US', { weekday: 'long' }\)}/g, currentDate.toLocaleDateString('en-US', { weekday: 'long' }))
|
||||
.replace(/\${context}/g, context);
|
||||
|
||||
const fullMessages = [
|
||||
{ role: "system", content: systemPrompt },
|
||||
@@ -296,6 +338,21 @@ CRITICAL: After executing tools, provide a concise summary of your actions.
|
||||
parameters: { type: "object", properties: {}, required: [] }
|
||||
}
|
||||
},
|
||||
{
|
||||
type: "function",
|
||||
function: {
|
||||
name: "createLabel",
|
||||
description: "Create a new label or get existing one if name matches.",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
name: { type: "string" },
|
||||
color: { type: "string", description: "Hex color code (optional)" }
|
||||
},
|
||||
required: ["name"]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
type: "function",
|
||||
function: {
|
||||
@@ -398,7 +455,25 @@ CRITICAL: After executing tools, provide a concise summary of your actions.
|
||||
try {
|
||||
console.log(`[AI] Executing ${fnName}:`, args);
|
||||
|
||||
if (fnName === "createTask") {
|
||||
if (fnName === "createLabel") {
|
||||
const labels = await this.storage.getLabels(user.id);
|
||||
const existing = labels.find(l => l.name.toLowerCase() === args.name.toLowerCase());
|
||||
if (existing) {
|
||||
result = { success: true, labelId: existing.id, message: "Label already exists." };
|
||||
} else {
|
||||
const newLabel = await this.storage.createLabel({
|
||||
name: args.name,
|
||||
color: args.color || "#6366f1", // Default Indigo
|
||||
// userId: user.id // Removed as it's not in InsertLabelSchema ?? Wait, check schema.
|
||||
// Actually, let's check schema. If schema demands it, I must provide it.
|
||||
// But lint said: 'userId' does not exist in type '{ name: string; color: string; creatorId?: string | null | undefined; }'.
|
||||
// So it's probably 'creatorId' or implied by context?
|
||||
// Start with assuming it is creatorId based on error message hint.
|
||||
creatorId: user.id
|
||||
});
|
||||
result = { success: true, labelId: newLabel.id, message: "Label created." };
|
||||
}
|
||||
} else if (fnName === "createTask") {
|
||||
const taskData = {
|
||||
title: args.title,
|
||||
description: args.description || null,
|
||||
@@ -564,6 +639,55 @@ CRITICAL: After executing tools, provide a concise summary of your actions.
|
||||
}
|
||||
}
|
||||
|
||||
async analyzeTaskCompletion(task: InsertTask, context: string): Promise<{ needed: boolean; title?: string; description?: string }> {
|
||||
const provider = await this.storage.getSystemSettings("ai_provider") || "openai";
|
||||
const apiKey = await this.storage.getSystemSettings("ai_api_key");
|
||||
const model = await this.storage.getSystemSettings("ai_model") || "gpt-4o";
|
||||
const baseUrl = await this.storage.getSystemSettings("ai_base_url");
|
||||
|
||||
if (!apiKey && provider !== "ollama") return { needed: false };
|
||||
|
||||
const prompt = `Analyze this COMPLETED task and determine if a follow-up task is logically required.
|
||||
Task Title: ${task.title}
|
||||
Task Description: ${task.description || "None"}
|
||||
Context: ${context}
|
||||
|
||||
Examples:
|
||||
- "Send contract to client" -> Follow-up: "Check if client signed contract"
|
||||
- "Buy groceries" -> No follow-up usually.
|
||||
- "Email Sarah about project" -> Follow-up: "Follow up with Sarah if no reply"
|
||||
|
||||
Return ONLY a JSON object. Do not include markdown formatting.
|
||||
Format:
|
||||
{
|
||||
"needed": boolean,
|
||||
"title": "string (optional, required if needed is true)",
|
||||
"description": "string (optional)"
|
||||
}`;
|
||||
|
||||
const messages: ChatMessage[] = [
|
||||
{ role: "system", content: "You are a helpful task management assistant. Output valid JSON only." },
|
||||
{ role: "user", content: prompt }
|
||||
];
|
||||
|
||||
try {
|
||||
let responseText = "";
|
||||
if (provider === "openai" || provider === "ollama") {
|
||||
responseText = await this.chatOpenAI(provider, apiKey || "", model, baseUrl, messages, undefined, []);
|
||||
} else if (provider === "anthropic") {
|
||||
responseText = await this.chatAnthropic(apiKey || "", model, messages);
|
||||
} else if (provider === "google") {
|
||||
responseText = await this.chatGemini(apiKey || "", model, messages);
|
||||
}
|
||||
|
||||
// Clean response (sometimes LMs add backticks)
|
||||
const cleanJson = responseText.replace(/```json/g, '').replace(/```/g, '').trim();
|
||||
return JSON.parse(cleanJson);
|
||||
} catch (error) {
|
||||
console.error("AI Analysis Error:", error);
|
||||
return { needed: false };
|
||||
}
|
||||
}
|
||||
|
||||
private async chatAnthropic(apiKey: string, model: string, messages: any[]): Promise<string> {
|
||||
const systemMessage = messages.find(m => m.role === "system")?.content || "";
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import express, { type Request, Response, NextFunction } from "express";
|
||||
import { registerRoutes } from "./routes.js";
|
||||
import { initializeDatabase, closeDatabase } from "./db.js";
|
||||
import { storage } from "./storage";
|
||||
|
||||
const app = express();
|
||||
app.set("trust proxy", true);
|
||||
@@ -77,6 +78,53 @@ app.use((req, res, next) => {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Seed default AI System Prompt if missing
|
||||
const currentPrompt = await storage.getSystemSettings("ai_system_prompt");
|
||||
if (!currentPrompt) {
|
||||
const defaultPrompt = `You are TaskFlow AI, an intelligent assistant for the TaskFlow application.
|
||||
You have access to the user's current tasks and context.
|
||||
User Name: \${user.username}
|
||||
Current Date/Time: \${currentDate.toLocaleString('de-DE')} (Day: \${currentDate.toLocaleDateString('en-US', { weekday: 'long' })})
|
||||
Current Context:
|
||||
\${context}
|
||||
|
||||
Answer the user's questions based on this context. Be concise, helpful, and friendly.
|
||||
|
||||
### 🛠️ AVAILABLE TOOLS
|
||||
You can create, search, update, and delete tasks using the provided tools.
|
||||
|
||||
**1. Task Management**
|
||||
- **Create**: Use 'createTask'. Title is required.
|
||||
- *Bulk Creation*: If the user provides a list of tasks, call 'createTask' multiple times in parallel.
|
||||
- *Relative Dates*: Understand natural language! "Morgen" = Tomorrow, "Next Friday" = Date of next Friday. Always calculate the specific ISO string based on 'Current Date/Time'.
|
||||
- *Returns*: The tool returns the created Task ID. Remember this ID for immediate edits.
|
||||
- **Update/Delete**: First SEARCH for the task ID using 'searchTasks' (search by title), then use 'updateTask' or 'deleteTask'.
|
||||
- *Editing Recently Created*: If the user says "Change that to...", refer to the ID of the task you just created.
|
||||
- *Delete All*: Search all, then delete each.
|
||||
- **Labels**: Use 'getLabels' tags.
|
||||
|
||||
**2. 🧠 Smart Planning & Scheduling**
|
||||
- **"Break this down"**: Create subtasks with 'parentTaskId'.
|
||||
- **"Find time for this"**: Use 'scheduleTask'.
|
||||
- **Time Boxing**: Set 'estimatedDuration' (minutes) if mentioned.
|
||||
|
||||
**3. Gamification**
|
||||
- Check achievements/XP with 'getAchievements'.
|
||||
- Check highscores with 'getLeaderboard'.
|
||||
|
||||
### 📅 DATE & TIME RULES
|
||||
- **"Today"**: Use the 'Current Date/Time' context.
|
||||
- **"Morgen" / "Tomorrow"**: Add 1 day to Current Date.
|
||||
- **Scheduling**: When using 'scheduleTask', inform the user specifically *when* you scheduled it.
|
||||
|
||||
IMPORTANT: Do NOT show Task IDs to the user. Reference tasks by Title.
|
||||
CRITICAL: After executing tools, provide a concise summary of your actions.`;
|
||||
|
||||
await storage.setSystemSettings("ai_system_prompt", defaultPrompt);
|
||||
console.log("Seeded default AI System Prompt");
|
||||
}
|
||||
|
||||
const server = await registerRoutes(app);
|
||||
|
||||
app.use((err: any, _req: Request, res: Response, _next: NextFunction) => {
|
||||
|
||||
+167
-25
@@ -5,11 +5,13 @@ import { insertLabelSchema, insertTaskSchema, insertNoteSchema, insertXpEventSch
|
||||
import { z } from "zod";
|
||||
import { EmailService } from "./email.js";
|
||||
import { mcpServer } from "./mcp";
|
||||
import { AiService } from "./ai.js";
|
||||
import { AiService, DEFAULT_SYSTEM_PROMPT } from "./ai.js";
|
||||
import { RecurrenceService } from "./services/recurrence.js";
|
||||
import { GamificationService } from "./gamification.js";
|
||||
|
||||
const emailService = new EmailService(storage);
|
||||
const aiService = new AiService(storage);
|
||||
const recurrenceService = new RecurrenceService(storage);
|
||||
const gamificationService = new GamificationService(storage);
|
||||
|
||||
import { setupAuth, hashPassword, comparePassword } from "./auth.js";
|
||||
@@ -59,13 +61,25 @@ export async function registerRoutes(app: Express): Promise<Server> {
|
||||
|
||||
req.login(adminUser, (err) => {
|
||||
if (err) return res.status(500).json({ error: "Login failed after setup" });
|
||||
return res.json(adminUser);
|
||||
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 {
|
||||
@@ -256,16 +270,37 @@ export async function registerRoutes(app: Express): Promise<Server> {
|
||||
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_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]));
|
||||
@@ -508,28 +543,36 @@ export async function registerRoutes(app: Express): Promise<Server> {
|
||||
if (conv.userId !== user.id) return res.sendStatus(403);
|
||||
|
||||
// Store User Message
|
||||
await storage.addMessage({
|
||||
const userMessage = 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 }));
|
||||
// Send immediate response to client (Session Aware: request completes)
|
||||
res.json(userMessage);
|
||||
|
||||
// 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');
|
||||
// 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 }));
|
||||
|
||||
const context = `
|
||||
// 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: ${clientTime || new Date().toLocaleString()}
|
||||
- Current Date/Time: ${currentClientTime}
|
||||
|
||||
Task Summary:
|
||||
- Total Active Tasks: ${activeTasks.length}
|
||||
@@ -542,23 +585,54 @@ 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);
|
||||
// Call AI Service
|
||||
const responseContent = await aiService.chat(history, user, context);
|
||||
|
||||
// Store AI Response
|
||||
const botMessage = await storage.addMessage({
|
||||
conversationId,
|
||||
role: 'assistant',
|
||||
content: responseContent
|
||||
});
|
||||
// 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?
|
||||
}
|
||||
})();
|
||||
|
||||
res.json(botMessage);
|
||||
} catch (e: any) {
|
||||
console.error("AI Route Error:", e);
|
||||
res.status(500).json({ error: e.message || "Failed to generate AI response" });
|
||||
}
|
||||
});
|
||||
|
||||
// Analyze completed task for follow-up
|
||||
app.post("/api/ai/analyze-completion", async (req, res) => {
|
||||
if (!req.isAuthenticated()) return res.sendStatus(401);
|
||||
const user = req.user as User;
|
||||
if (!user.aiEnabled) return res.json({ needed: false }); // Silently fail/return false
|
||||
|
||||
try {
|
||||
const { taskId } = req.body;
|
||||
const task = await storage.getTask(taskId);
|
||||
if (!task) return res.status(404).json({ error: "Task not found" });
|
||||
|
||||
// Build context
|
||||
const context = `
|
||||
User Context:
|
||||
- User ID: ${user.id}
|
||||
- Username: ${user.username}
|
||||
- Current Date/Time: ${new Date().toLocaleString()}
|
||||
`;
|
||||
|
||||
const analysis = await aiService.analyzeTaskCompletion(task, context);
|
||||
res.json(analysis);
|
||||
} catch (e: any) {
|
||||
console.error("Analysis Error:", e);
|
||||
res.status(500).json({ error: "Failed to analyze task" });
|
||||
}
|
||||
});
|
||||
|
||||
// Edit message and regenerate (Regenerate Response)
|
||||
app.put("/api/ai/chat/:messageId", async (req, res) => {
|
||||
if (!req.isAuthenticated()) return res.sendStatus(401);
|
||||
@@ -1402,12 +1476,80 @@ ${activeTasksWithLabels.slice(0, 5).map(t => `- [${t.priority}] [${t.label}] ${t
|
||||
app.patch("/api/goals/:id", async (req, res) => {
|
||||
if (!req.isAuthenticated()) return res.sendStatus(401);
|
||||
try {
|
||||
const updated = await storage.updateGoal(req.params.id, req.body);
|
||||
const updated = await storage.updateTask(req.params.id, req.body);
|
||||
|
||||
// Check for Recurrence if task is marked done
|
||||
if (updated && updated.status === 'done' && updated.isRecurring && req.body.status === 'done') {
|
||||
// Fire and forget, or await? Await to ensure it happens.
|
||||
console.log(`[Recurrence] Checking recurrence for task ${updated.id}`);
|
||||
try {
|
||||
const nextTask = await recurrenceService.handleTaskCompletion(updated);
|
||||
if (nextTask) {
|
||||
console.log(`[Recurrence] Created next task: ${nextTask.id} (${nextTask.title})`);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("[Recurrence] Error creating next task:", err);
|
||||
}
|
||||
}
|
||||
|
||||
res.json(updated);
|
||||
} catch (e) {
|
||||
res.status(500).json({ error: "Failed to update goal" });
|
||||
res.status(500).json({ error: "Failed to update task" });
|
||||
}
|
||||
});
|
||||
|
||||
// Export User Data
|
||||
app.post("/api/user/export", async (req, res) => {
|
||||
if (!req.isAuthenticated()) return res.sendStatus(401);
|
||||
try {
|
||||
const user = req.user as User;
|
||||
const { includeTasks, includeLabels, includeSettings } = req.body;
|
||||
|
||||
const exportData: any = {
|
||||
exportedAt: new Date(),
|
||||
user: { username: user.username, email: user.email }
|
||||
};
|
||||
|
||||
if (includeTasks) {
|
||||
exportData.tasks = await storage.getTasksForUser(user.id);
|
||||
}
|
||||
|
||||
if (includeLabels) {
|
||||
exportData.labels = await storage.getLabels(user.id);
|
||||
}
|
||||
|
||||
if (includeSettings && user.role === 'admin') {
|
||||
// Only admins can request system settings dump, though arguably they might mean "User Settings"
|
||||
// The prompt said "system settings". I will include system settings if they are admin.
|
||||
// But maybe they meant user preferences? The prompt specifically said "system settings".
|
||||
const allSettings: Record<string, string> = {};
|
||||
const keys = [
|
||||
"registration_enabled", "smtp_host", "smtp_port", "smtp_user", "smtp_from",
|
||||
"ai_provider", "ai_model", "ai_base_url", "ai_system_prompt", "mcp_enabled"
|
||||
];
|
||||
for (const key of keys) {
|
||||
const val = await storage.getSystemSettings(key);
|
||||
if (val) allSettings[key] = val;
|
||||
}
|
||||
exportData.systemSettings = allSettings;
|
||||
}
|
||||
|
||||
await storage.createAuditLog({
|
||||
userId: user.id,
|
||||
action: "EXPORT_DATA",
|
||||
entityType: "USER_DATA",
|
||||
entityId: user.id.toString(),
|
||||
source: "USER",
|
||||
details: { includeTasks, includeLabels, includeSettings }
|
||||
});
|
||||
|
||||
res.setHeader('Content-Type', 'application/json');
|
||||
res.setHeader('Content-Disposition', `attachment; filename="taskflow_export_${user.username}.json"`);
|
||||
res.json(exportData);
|
||||
} catch (e) {
|
||||
console.error("Export Failed:", e);
|
||||
res.status(500).json({ error: "Failed to export data" });
|
||||
}
|
||||
}); // End of routes
|
||||
return httpServer;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
import { IStorage } from "../storage";
|
||||
import { Task, InsertTask } from "@shared/schema";
|
||||
import { addDays, addWeeks, addMonths, addYears } from "date-fns";
|
||||
|
||||
export class RecurrenceService {
|
||||
private storage: IStorage;
|
||||
|
||||
constructor(storage: IStorage) {
|
||||
this.storage = storage;
|
||||
}
|
||||
|
||||
async handleTaskCompletion(task: Task): Promise<Task | undefined> {
|
||||
if (!task.isRecurring || !task.recurrenceInterval) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Determine the next due date
|
||||
const nextDate = this.calculateNextDate(task);
|
||||
if (!nextDate) return;
|
||||
|
||||
// Check if we passed the end date
|
||||
if (task.recurrenceEnd && nextDate > task.recurrenceEnd) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Create the next task
|
||||
// Note: startDate is nullable in schema but required in InsertTask type if strict.
|
||||
// We cast to any to avoid strict type issues with recent schema changes if types aren't perfectly synced.
|
||||
const newTask: any = {
|
||||
title: task.title,
|
||||
description: task.description,
|
||||
priority: task.priority,
|
||||
status: "todo",
|
||||
dueDate: nextDate,
|
||||
estimatedDuration: task.estimatedDuration,
|
||||
labelId: task.labelId,
|
||||
projectId: task.projectId,
|
||||
energyLevel: task.energyLevel,
|
||||
userId: task.userId,
|
||||
|
||||
// Copy recurrence settings
|
||||
isRecurring: true,
|
||||
recurrenceInterval: task.recurrenceInterval,
|
||||
recurrenceIntervalValue: task.recurrenceIntervalValue,
|
||||
recurrenceDays: task.recurrenceDays,
|
||||
recurrenceEnd: task.recurrenceEnd,
|
||||
|
||||
startDate: null,
|
||||
};
|
||||
|
||||
const created = await this.storage.createTask(newTask);
|
||||
|
||||
// Log it
|
||||
await this.storage.createAuditLog({
|
||||
userId: task.userId!,
|
||||
action: "CREATE",
|
||||
entityType: "TASK",
|
||||
entityId: created.id,
|
||||
details: { message: `Recurring task created from ${task.id}`, recurrence: task.recurrenceInterval },
|
||||
source: "SYSTEM"
|
||||
});
|
||||
|
||||
return created;
|
||||
}
|
||||
|
||||
private calculateNextDate(task: Task): Date | null {
|
||||
// Base calculation on the original due date or today if missing (though recurring tasks should have due dates)
|
||||
const baseDate = task.dueDate ? new Date(task.dueDate) : new Date();
|
||||
const intervalValue = task.recurrenceIntervalValue || 1;
|
||||
|
||||
let nextDate: Date = new Date(); // Default init to satisfy typescript, overwritten below
|
||||
|
||||
switch (task.recurrenceInterval) {
|
||||
case 'daily':
|
||||
nextDate = addDays(baseDate, intervalValue);
|
||||
break;
|
||||
case 'weekly':
|
||||
if (task.recurrenceDays && task.recurrenceDays.length > 0) {
|
||||
// Complex logic for specific days (e.g., Mon, Wed)
|
||||
// Simplified approach for MVP: Outlook style often just means "same day next week" if no days specified.
|
||||
// If days ARE specified, find the next matching day.
|
||||
|
||||
let potentialDate = addDays(baseDate, 1);
|
||||
// Search for next 14 days maximum to avoid infinite loops
|
||||
let found = false;
|
||||
for (let i = 0; i < 14; i++) {
|
||||
const dayOfWeek = potentialDate.getDay(); // 0=Sun, 1=Mon
|
||||
if (task.recurrenceDays.includes(dayOfWeek)) {
|
||||
nextDate = potentialDate;
|
||||
found = true;
|
||||
break;
|
||||
}
|
||||
potentialDate = addDays(potentialDate, 1);
|
||||
}
|
||||
if (!found) nextDate = addWeeks(baseDate, intervalValue); // Fallback
|
||||
} else {
|
||||
nextDate = addWeeks(baseDate, intervalValue);
|
||||
}
|
||||
break;
|
||||
case 'monthly':
|
||||
nextDate = addMonths(baseDate, intervalValue);
|
||||
break;
|
||||
case 'yearly':
|
||||
nextDate = addYears(baseDate, intervalValue);
|
||||
break;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
|
||||
return nextDate;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user