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:
+512
-34
@@ -1,5 +1,5 @@
|
||||
import { IStorage } from "./storage";
|
||||
import { User } from "../shared/schema";
|
||||
import { User, InsertTask, insertTaskSchema } from "../shared/schema";
|
||||
|
||||
interface ChatMessage {
|
||||
role: "system" | "user" | "assistant";
|
||||
@@ -26,7 +26,33 @@ Current Context:
|
||||
${context}
|
||||
|
||||
Answer the user's questions based on this context. Be concise, helpful, and friendly.
|
||||
If needed, suggest they create tasks or manage their schedule (you cannot perform actions yet, only advise).
|
||||
|
||||
### 🛠️ AVAILABLE TOOLS
|
||||
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.
|
||||
|
||||
**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.
|
||||
|
||||
**3. Gamification**
|
||||
- Check achievements/XP with 'getAchievements'.
|
||||
- Check highscores with 'getLeaderboard'.
|
||||
|
||||
### 📅 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").
|
||||
|
||||
IMPORTANT: Do NOT show Task IDs to the user. Reference tasks by Title.
|
||||
CRITICAL: After executing tools, provide a concise summary of your actions.
|
||||
`;
|
||||
|
||||
const fullMessages = [
|
||||
@@ -36,7 +62,7 @@ If needed, suggest they create tasks or manage their schedule (you cannot perfor
|
||||
|
||||
try {
|
||||
if (provider === "openai" || provider === "ollama") {
|
||||
return await this.chatOpenAI(provider, apiKey || "", model, baseUrl, fullMessages);
|
||||
return await this.chatOpenAI(provider, apiKey || "", model, baseUrl, fullMessages, user);
|
||||
} else if (provider === "anthropic") {
|
||||
return await this.chatAnthropic(apiKey || "", model, fullMessages);
|
||||
} else if (provider === "google") {
|
||||
@@ -50,36 +76,496 @@ If needed, suggest they create tasks or manage their schedule (you cannot perfor
|
||||
}
|
||||
}
|
||||
|
||||
private async chatOpenAI(provider: string, apiKey: string, model: string, baseUrl: string | undefined, messages: any[]): Promise<string> {
|
||||
const url = baseUrl || (provider === "ollama" ? "http://localhost:11434/v1" : "https://api.openai.com/v1") + "/chat/completions";
|
||||
async generateTitle(messages: ChatMessage[]): Promise<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");
|
||||
|
||||
// Clean URL
|
||||
const cleanUrl = url.replace(/([^:]\/)\/+/g, "$1"); // remove double slashes
|
||||
if (!apiKey && provider !== "ollama") return "New Conversation";
|
||||
|
||||
const response = await fetch(cleanUrl, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": `Bearer ${apiKey}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: model,
|
||||
messages: messages,
|
||||
temperature: 0.7,
|
||||
}),
|
||||
});
|
||||
const conversationText = messages.slice(0, 3).map(m => `${m.role}: ${m.content}`).join('\n');
|
||||
const prompt = `Based on the following conversation, generate a short, concise title (max 5 words). Return ONLY the title text, no quotes or labels.\n\nConversation:\n${conversationText}`;
|
||||
|
||||
if (!response.ok) {
|
||||
const err = await response.text();
|
||||
throw new Error(`OpenAI/Ollama API Error ${response.status}: ${err}`);
|
||||
const contextMessages = [
|
||||
{ role: "user", content: prompt }
|
||||
];
|
||||
|
||||
try {
|
||||
let generatedTitle = "";
|
||||
if (provider === "openai" || provider === "ollama") {
|
||||
generatedTitle = await this.chatOpenAI(provider, apiKey || "", model, baseUrl, contextMessages, undefined, []);
|
||||
} else if (provider === "anthropic") {
|
||||
generatedTitle = await this.chatAnthropic(apiKey || "", model, contextMessages);
|
||||
} else if (provider === "google") {
|
||||
generatedTitle = await this.chatGemini(apiKey || "", model, contextMessages);
|
||||
}
|
||||
|
||||
generatedTitle = generatedTitle.trim();
|
||||
if (generatedTitle && generatedTitle !== "No response generated." && generatedTitle.length < 60) {
|
||||
return generatedTitle.replace(/^["']|["']$/g, '').replace(/[*_#`]/g, '').trim();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Title generation failed:", error);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
return data.choices[0]?.message?.content || "No response generated.";
|
||||
return "New Conversation";
|
||||
}
|
||||
|
||||
// New Public Method for Smart Scheduling
|
||||
async scheduleTask(taskId: string, userId: string, startAfterStr?: string): Promise<{ success: boolean, scheduledDate?: string, message?: string, error?: string }> {
|
||||
const task = await this.storage.getTask(taskId);
|
||||
if (!task) {
|
||||
return { success: false, error: "Task not found." };
|
||||
}
|
||||
|
||||
const startAfter = startAfterStr ? new Date(startAfterStr) : new Date();
|
||||
const durationMins = task.estimatedDuration || 60; // Default to 1h if not set
|
||||
|
||||
const workStartHour = 9;
|
||||
// PRIORITY LOGIC: High priority tasks can be scheduled until 20:00 (8 PM)
|
||||
const workEndHour = task.priority === 'high' ? 20 : 18;
|
||||
|
||||
let scheduledDate: Date | null = null;
|
||||
|
||||
// PLANNED TIME LOGIC: If startDate is set, do not schedule before it.
|
||||
// If starteAfterStr is provided (e.g. "tomorrow"), use the max of both.
|
||||
let effectiveStart = startAfter;
|
||||
if (task.startDate) {
|
||||
const plannedStart = new Date(task.startDate);
|
||||
if (plannedStart > effectiveStart) {
|
||||
effectiveStart = plannedStart;
|
||||
}
|
||||
}
|
||||
|
||||
let currentDay = new Date(effectiveStart);
|
||||
|
||||
// Reset to next slot if passed
|
||||
if (currentDay.getHours() >= workEndHour) {
|
||||
currentDay.setDate(currentDay.getDate() + 1);
|
||||
currentDay.setHours(workStartHour, 0, 0, 0);
|
||||
} else if (currentDay.getHours() < workStartHour) {
|
||||
currentDay.setHours(workStartHour, 0, 0, 0);
|
||||
}
|
||||
|
||||
for (let dayOffset = 0; dayOffset < 3; dayOffset++) { // Look ahead 3 days
|
||||
const dayStart = new Date(currentDay);
|
||||
dayStart.setHours(workStartHour, 0, 0, 0);
|
||||
const dayEnd = new Date(currentDay);
|
||||
dayEnd.setHours(workEndHour, 0, 0, 0);
|
||||
|
||||
// Get all tasks for this day that have a due date (and time)
|
||||
const allTasks = await this.storage.searchTasks("", userId);
|
||||
|
||||
// Filter for tasks on this day
|
||||
const dayTasks = allTasks.filter(t => {
|
||||
if (!t.dueDate) return false;
|
||||
const d = new Date(t.dueDate);
|
||||
return d.getDate() === currentDay.getDate() &&
|
||||
d.getMonth() === currentDay.getMonth() &&
|
||||
d.getFullYear() === currentDay.getFullYear();
|
||||
});
|
||||
|
||||
// Find gaps
|
||||
// Sort by time
|
||||
dayTasks.sort((a, b) => (a.dueDate!.getTime() - b.dueDate!.getTime()));
|
||||
|
||||
// Check slots
|
||||
// Start checking from 'currentDay' time (if today) or 9am
|
||||
let attemptTime = new Date(currentDay);
|
||||
if (attemptTime < dayStart) attemptTime = dayStart;
|
||||
|
||||
while (attemptTime.getTime() + (durationMins * 60000) <= dayEnd.getTime()) {
|
||||
const attemptEnd = new Date(attemptTime.getTime() + (durationMins * 60000));
|
||||
|
||||
// Check collision
|
||||
const hasCollision = dayTasks.some(t => {
|
||||
const tStart = new Date(t.dueDate!);
|
||||
const tDuration = t.estimatedDuration || 60;
|
||||
const tEnd = new Date(tStart.getTime() + (tDuration * 60000));
|
||||
|
||||
return (attemptTime < tEnd && attemptEnd > tStart);
|
||||
});
|
||||
|
||||
if (!hasCollision) {
|
||||
scheduledDate = attemptTime;
|
||||
break;
|
||||
}
|
||||
|
||||
// specific increment? 30 mins
|
||||
attemptTime = new Date(attemptTime.getTime() + 30 * 60000);
|
||||
}
|
||||
|
||||
if (scheduledDate) break;
|
||||
|
||||
// Move to next day
|
||||
currentDay.setDate(currentDay.getDate() + 1);
|
||||
currentDay.setHours(workStartHour, 0, 0, 0);
|
||||
}
|
||||
|
||||
if (scheduledDate) {
|
||||
await this.storage.updateTask(taskId, { dueDate: scheduledDate });
|
||||
return {
|
||||
success: true,
|
||||
scheduledDate: scheduledDate.toISOString(),
|
||||
message: `Scheduled for ${scheduledDate.toLocaleDateString()} at ${scheduledDate.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}.`
|
||||
};
|
||||
} else {
|
||||
return { success: false, error: "Could not find a free slot in the next 3 days." };
|
||||
}
|
||||
}
|
||||
|
||||
private getTaskTools() {
|
||||
return [
|
||||
{
|
||||
type: "function",
|
||||
function: {
|
||||
name: "createTask",
|
||||
description: "Create a new task for the user.",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
title: { type: "string", description: "The title of the task (required)." },
|
||||
description: { type: "string" },
|
||||
priority: { type: "string", enum: ["low", "medium", "high"] },
|
||||
status: { type: "string", enum: ["todo", "inProgress", "done"] },
|
||||
dueDate: { type: "string", description: "ISO 8601 format (YYYY-MM-DD)." },
|
||||
labelId: { type: "string" },
|
||||
estimatedDuration: { type: "integer", description: "Estimated duration in minutes." },
|
||||
parentTaskId: { type: "string" },
|
||||
startDate: { type: "string" }
|
||||
},
|
||||
required: ["title"]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
type: "function",
|
||||
function: {
|
||||
name: "searchTasks",
|
||||
description: "Search for tasks by title, description, or label.",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
query: { type: "string" },
|
||||
label: { type: "string" }
|
||||
},
|
||||
required: ["query"]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
type: "function",
|
||||
function: {
|
||||
name: "updateTask",
|
||||
description: "Update an existing task.",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
id: { type: "string" },
|
||||
title: { type: "string" },
|
||||
description: { type: "string" },
|
||||
priority: { type: "string", enum: ["low", "medium", "high"] },
|
||||
status: { type: "string", enum: ["todo", "inProgress", "done"] },
|
||||
dueDate: { type: "string" },
|
||||
timeTracked: { type: "number" },
|
||||
labelId: { type: "string" },
|
||||
estimatedDuration: { type: "integer" },
|
||||
startDate: { type: "string" }
|
||||
},
|
||||
required: ["id"]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
type: "function",
|
||||
function: {
|
||||
name: "deleteTask",
|
||||
description: "Delete an existing task.",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: { id: { type: "string" } },
|
||||
required: ["id"]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
type: "function",
|
||||
function: {
|
||||
name: "getLabels",
|
||||
description: "Get all available labels.",
|
||||
parameters: { type: "object", properties: {}, required: [] }
|
||||
}
|
||||
},
|
||||
{
|
||||
type: "function",
|
||||
function: {
|
||||
name: "getAchievements",
|
||||
description: "Get user's XP and rewards.",
|
||||
parameters: { type: "object", properties: {}, required: [] }
|
||||
}
|
||||
},
|
||||
{
|
||||
type: "function",
|
||||
function: {
|
||||
name: "getLeaderboard",
|
||||
description: "Get the highscore leaderboard.",
|
||||
parameters: { type: "object", properties: {}, required: [] }
|
||||
}
|
||||
},
|
||||
{
|
||||
type: "function",
|
||||
function: {
|
||||
name: "scheduleTask",
|
||||
description: "Finds the first available time slot for a task based on its duration and existing schedule.",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
taskId: { type: "string", description: "The ID of the task to schedule." },
|
||||
startAfter: { type: "string", description: "ISO 8601 Date to start searching from (default: now)." }
|
||||
},
|
||||
required: ["taskId"]
|
||||
}
|
||||
}
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
private async chatOpenAI(provider: string, apiKey: string, model: string, baseUrl: string | undefined, messages: any[], user?: User, tools: any[] = this.getTaskTools()): Promise<string> {
|
||||
let apiBase = baseUrl;
|
||||
|
||||
if (!apiBase) {
|
||||
if (provider === "ollama") {
|
||||
apiBase = "http://host.docker.internal:11434/v1";
|
||||
} else {
|
||||
apiBase = "https://api.openai.com/v1";
|
||||
}
|
||||
}
|
||||
|
||||
if (apiBase.endsWith('/')) {
|
||||
apiBase = apiBase.slice(0, -1);
|
||||
}
|
||||
|
||||
if (provider === "ollama" && !apiBase.includes("/v1") && !apiBase.includes("/api")) {
|
||||
apiBase += "/v1";
|
||||
}
|
||||
|
||||
const url = `${apiBase}/chat/completions`;
|
||||
console.log(`[AI] Sending Chat Request: provider=${provider} url=${url} model=${model}`);
|
||||
|
||||
const body: any = {
|
||||
model: model,
|
||||
messages: messages,
|
||||
temperature: 0.7,
|
||||
};
|
||||
|
||||
if (tools && tools.length > 0) {
|
||||
body.tools = tools;
|
||||
body.tool_choice = "auto";
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": `Bearer ${apiKey}`,
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const err = await response.text();
|
||||
console.error(`[AI] API Error: ${response.status} - ${err}`);
|
||||
throw new Error(`${provider} API Error ${response.status}: ${err}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
const choice = data.choices[0];
|
||||
const message = choice.message;
|
||||
|
||||
if (choice.finish_reason === "tool_calls" && message.tool_calls) {
|
||||
console.log(`[AI] Tool Calls detected: ${message.tool_calls.length}`);
|
||||
|
||||
if (!user) throw new Error("Tools invoked but no user context provided.");
|
||||
|
||||
messages.push(message);
|
||||
|
||||
for (const toolCall of message.tool_calls) {
|
||||
const fnName = toolCall.function.name;
|
||||
const args = JSON.parse(toolCall.function.arguments);
|
||||
let result: any = {};
|
||||
|
||||
try {
|
||||
console.log(`[AI] Executing ${fnName}:`, args);
|
||||
|
||||
if (fnName === "createTask") {
|
||||
const taskData = {
|
||||
title: args.title,
|
||||
description: args.description || null,
|
||||
priority: args.priority || "medium",
|
||||
status: args.status || "todo",
|
||||
dueDate: args.dueDate ? new Date(args.dueDate) : null,
|
||||
labelId: args.labelId || null,
|
||||
estimatedDuration: args.estimatedDuration || null,
|
||||
parentTaskId: args.parentTaskId || null,
|
||||
startDate: args.startDate ? new Date(args.startDate) : null,
|
||||
userId: user.id
|
||||
};
|
||||
const createdTask = await this.storage.createTask(taskData);
|
||||
await this.storage.createAuditLog({
|
||||
userId: user.id,
|
||||
action: "CREATE",
|
||||
entityType: "TASK",
|
||||
entityId: createdTask.id,
|
||||
details: { title: createdTask.title },
|
||||
source: "AI"
|
||||
});
|
||||
result = { success: true, taskId: createdTask.id, message: "Task created." };
|
||||
} else if (fnName === "searchTasks") {
|
||||
const tasks = await this.storage.searchTasks(args.query, user.id);
|
||||
const labels = await this.storage.getLabels(user.id);
|
||||
const labelMap = new Map(labels.map(l => [l.id, l.name]));
|
||||
|
||||
let filteredTasks = tasks;
|
||||
if (args.label) {
|
||||
const labelId = labels.find(l => l.name.toLowerCase() === args.label.toLowerCase())?.id;
|
||||
if (labelId) {
|
||||
filteredTasks = tasks.filter(t => t.labelId === labelId);
|
||||
}
|
||||
}
|
||||
|
||||
result = {
|
||||
success: true,
|
||||
tasks: filteredTasks.map(t => ({
|
||||
id: t.id,
|
||||
title: t.title,
|
||||
status: t.status,
|
||||
priority: t.priority,
|
||||
dueDate: t.dueDate,
|
||||
label: t.labelId ? labelMap.get(t.labelId) || "No Label" : "No Label"
|
||||
}))
|
||||
};
|
||||
} else if (fnName === "updateTask") {
|
||||
const updates: any = {};
|
||||
if (args.title) updates.title = args.title;
|
||||
if (args.description) updates.description = args.description;
|
||||
if (args.priority) updates.priority = args.priority;
|
||||
if (args.status) updates.status = args.status;
|
||||
if (args.dueDate !== undefined) updates.dueDate = args.dueDate ? new Date(args.dueDate) : null;
|
||||
if (args.timeTracked !== undefined) updates.timeTracked = args.timeTracked;
|
||||
if (args.labelId !== undefined) updates.labelId = args.labelId;
|
||||
if (args.estimatedDuration !== undefined) updates.estimatedDuration = args.estimatedDuration;
|
||||
if (args.startDate !== undefined) updates.startDate = args.startDate ? new Date(args.startDate) : null;
|
||||
|
||||
const updatedTask = await this.storage.updateTask(args.id, updates);
|
||||
if (updatedTask) {
|
||||
await this.storage.createAuditLog({
|
||||
userId: user.id,
|
||||
action: "UPDATE",
|
||||
entityType: "TASK",
|
||||
entityId: updatedTask.id,
|
||||
details: updates,
|
||||
source: "AI"
|
||||
});
|
||||
result = { success: true, message: "Task updated successfully." };
|
||||
} else {
|
||||
result = { success: false, error: "Task not found." };
|
||||
}
|
||||
} else if (fnName === "deleteTask") {
|
||||
const deleted = await this.storage.deleteTask(args.id);
|
||||
if (deleted) {
|
||||
await this.storage.createAuditLog({
|
||||
userId: user.id,
|
||||
action: "DELETE",
|
||||
entityType: "TASK",
|
||||
entityId: args.id,
|
||||
details: null,
|
||||
source: "AI"
|
||||
});
|
||||
result = { success: true, message: "Task deleted successfully." };
|
||||
} else {
|
||||
result = { success: false, error: "Task not found or could not be deleted." };
|
||||
}
|
||||
} else if (fnName === "getLabels") {
|
||||
const labels = await this.storage.getLabels(user.id);
|
||||
result = {
|
||||
success: true,
|
||||
labels: labels.map(l => ({ id: l.id, name: l.name, color: l.color }))
|
||||
};
|
||||
} else if (fnName === "getAchievements") {
|
||||
const goals = await this.storage.getGoals();
|
||||
const userGoals = goals.filter(g => g.userId === user.id);
|
||||
const rewards = await this.storage.getUserRewards(user.id);
|
||||
const xpEvents = await this.storage.getXpEvents(user.id);
|
||||
|
||||
result = {
|
||||
success: true,
|
||||
xp: user.xp,
|
||||
level: user.level,
|
||||
goals: userGoals.map(g => ({ title: g.title, current: g.current, target: g.target, completed: g.completed })),
|
||||
rewards: rewards.length,
|
||||
recentXp: xpEvents.slice(0, 5).map(e => ({ source: e.source, amount: e.amount }))
|
||||
};
|
||||
} else if (fnName === "getLeaderboard") {
|
||||
const leaderboard = await this.storage.getLeaderboard();
|
||||
result = {
|
||||
success: true,
|
||||
topUsers: leaderboard.slice(0, 10).map((u, index) => ({
|
||||
rank: index + 1,
|
||||
username: u.username,
|
||||
xp: u.xp,
|
||||
level: u.level
|
||||
}))
|
||||
};
|
||||
} else if (fnName === "scheduleTask") {
|
||||
// Call the reusable public method from inside the tool
|
||||
result = await this.scheduleTask(args.taskId, user.id, args.startAfter);
|
||||
} else {
|
||||
result = { success: false, error: "Unknown tool function." };
|
||||
}
|
||||
|
||||
messages.push({
|
||||
role: "tool",
|
||||
tool_call_id: toolCall.id,
|
||||
name: fnName,
|
||||
content: JSON.stringify(result)
|
||||
});
|
||||
|
||||
} catch (err: any) {
|
||||
console.error(`[AI] Tool Execution Error:`, err);
|
||||
messages.push({
|
||||
role: "tool",
|
||||
tool_call_id: toolCall.id,
|
||||
name: fnName,
|
||||
content: JSON.stringify({ success: false, error: err.message })
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`[AI] Sending follow-up request with tool results...`);
|
||||
return await this.chatOpenAI(provider, apiKey, model, baseUrl, messages, user, tools);
|
||||
}
|
||||
|
||||
if (!message.content) {
|
||||
console.warn("[AI] Warning: Empty content received from OpenAI.");
|
||||
const hasExecutedTools = messages.some(m => m.role === 'tool');
|
||||
if (hasExecutedTools) {
|
||||
return "I have successfully processed your request and updated your tasks.";
|
||||
}
|
||||
}
|
||||
return message.content || "No response generated.";
|
||||
|
||||
} catch (error: any) {
|
||||
console.error(`[AI] Request Failed: ${error.message}`);
|
||||
if (error.code === 'ECONNREFUSED' && url.includes('localhost')) {
|
||||
throw new Error("Connection refused. If running in Docker, try using 'http://host.docker.internal:11434/v1' as Base URL.");
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private async chatAnthropic(apiKey: string, model: string, messages: any[]): Promise<string> {
|
||||
// Anthropic doesn't support "system" role in messages list in the same way, need to extract it
|
||||
const systemMessage = messages.find(m => m.role === "system")?.content || "";
|
||||
const userAssistantMessages = messages.filter(m => m.role !== "system");
|
||||
|
||||
@@ -108,14 +594,6 @@ If needed, suggest they create tasks or manage their schedule (you cannot perfor
|
||||
}
|
||||
|
||||
private async chatGemini(apiKey: string, model: string, messages: any[]): Promise<string> {
|
||||
// Google Generative AI (Gemini)
|
||||
// POST https://generativelanguage.googleapis.com/v1beta/models/gemini-pro:generateContent?key=YOUR_API_KEY
|
||||
|
||||
// Mapping messages to Gemini format (contents: [{ role, parts: [{ text }] }])
|
||||
// System instruction is supported in v1beta/models/...:generateContent?
|
||||
// Gemini 1.5 Pro supports systemInstructions.
|
||||
// For simplicity, I'll prepend system prompt to first user message.
|
||||
|
||||
const systemMessage = messages.find(m => m.role === "system")?.content || "";
|
||||
const contentMessages = messages.filter(m => m.role !== "system").map(m => ({
|
||||
role: m.role === "user" ? "user" : "model",
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import { IStorage } from "./storage";
|
||||
import { User, InsertXpEvent } from "@shared/schema";
|
||||
import { getLevelFromXP } from "@shared/gamification";
|
||||
|
||||
// Constants for XP Actions
|
||||
export const XP_RULES = {
|
||||
CREATE_TASK: 10,
|
||||
CREATE_SUBTASK: 5,
|
||||
UPDATE_TASK: 2, // Small amount for tweaking/updating
|
||||
COMPLETE_TASK: 50,
|
||||
COMPLETE_TASK_LATE: 20, // Reduced for overdue
|
||||
AI_ACTION: 5, // For using AI features
|
||||
LOGIN_STREAK: 100
|
||||
};
|
||||
|
||||
export class GamificationService {
|
||||
private storage: IStorage;
|
||||
|
||||
constructor(storage: IStorage) {
|
||||
this.storage = storage;
|
||||
}
|
||||
|
||||
async awardXP(userId: string, source: string, amount?: number, description?: string): Promise<{ user: User, levelUp: boolean, oldLevel: number, newLevel: number }> {
|
||||
const user = await this.storage.getUser(userId);
|
||||
if (!user) throw new Error("User not found");
|
||||
|
||||
const xpAmount = amount || this.getXPForSource(source);
|
||||
const newTotalXP = (user.xp || 0) + xpAmount;
|
||||
|
||||
// Check for level up
|
||||
const oldLevel = getLevelFromXP(user.xp || 0);
|
||||
const newLevel = getLevelFromXP(newTotalXP);
|
||||
const levelUp = newLevel > oldLevel;
|
||||
|
||||
// Update User
|
||||
await this.storage.updateUserXP(userId, newTotalXP);
|
||||
|
||||
// Log Event
|
||||
await this.storage.logXpEvent({
|
||||
userId,
|
||||
amount: xpAmount,
|
||||
source,
|
||||
});
|
||||
|
||||
// If Level Up, we could log a special event or notification here?
|
||||
|
||||
const updatedUser = await this.storage.getUser(userId);
|
||||
return {
|
||||
user: updatedUser!,
|
||||
levelUp,
|
||||
oldLevel,
|
||||
newLevel
|
||||
};
|
||||
}
|
||||
|
||||
private getXPForSource(source: string): number {
|
||||
switch (source) {
|
||||
case 'create_task': return XP_RULES.CREATE_TASK;
|
||||
case 'create_subtask': return XP_RULES.CREATE_SUBTASK;
|
||||
case 'update_task': return XP_RULES.UPDATE_TASK;
|
||||
case 'complete_task': return XP_RULES.COMPLETE_TASK;
|
||||
case 'complete_task_late': return XP_RULES.COMPLETE_TASK_LATE;
|
||||
case 'ai_action': return XP_RULES.AI_ACTION;
|
||||
case 'daily_streak': return XP_RULES.LOGIN_STREAK;
|
||||
default: return 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
+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);
|
||||
|
||||
+305
-111
@@ -1,9 +1,11 @@
|
||||
import { type User, type InsertUser, type Label, type InsertLabel, type SharedLabel, type Task, type InsertTask, type XpEvent, type InsertXpEvent, type Goal, type InsertGoal, xpEvents, goals, type Reward, type InsertReward, type UserReward, type InsertUserReward, rewards, userRewards, systemSettings, type SystemSettings, type InsertSystemSettings, type SharedTask, type InsertSharedTask, type UserTaskAccess, type InsertUserTaskAccess, sharedTasks, userTaskAccess, type InsertPasswordResetToken, type PasswordResetToken } from "../shared/schema.js";
|
||||
import { type User, type InsertUser, type Label, type InsertLabel, type SharedLabel, type Task, type InsertTask, type XpEvent, type InsertXpEvent, type Goal, type InsertGoal, type Reward, type InsertReward, type UserReward, type InsertUserReward, type SystemSettings, type InsertSystemSettings, type SharedTask, type InsertSharedTask, type UserTaskAccess, type InsertUserTaskAccess, type InsertPasswordResetToken, type PasswordResetToken, type Conversation, type InsertConversation, type Message, type InsertMessage, type AuditLog, type InsertAuditLog } from "@shared/schema";
|
||||
import * as schema from "@shared/schema";
|
||||
import { getDatabase, pool } from "./db";
|
||||
import { eq, sql, and, desc, asc, gt, ne } from "drizzle-orm";
|
||||
import { randomUUID } from "crypto";
|
||||
import session from "express-session";
|
||||
import createMemoryStore from "memorystore";
|
||||
import connectPg from "connect-pg-simple";
|
||||
import { pool } from "./db.js";
|
||||
|
||||
const MemoryStore = createMemoryStore(session);
|
||||
const PostgresStore = connectPg(session);
|
||||
@@ -29,7 +31,6 @@ export interface IStorage {
|
||||
createSharedTask(sharedTask: InsertSharedTask): Promise<SharedTask>; // Alias for shareTask standard naming
|
||||
createUserTaskAccess(access: InsertUserTaskAccess): Promise<UserTaskAccess>; // Alias
|
||||
|
||||
getSharedTasks(userId: string): Promise<SharedTask[]>; // Tasks shared WITH user
|
||||
getSharedTasks(userId: string): Promise<SharedTask[]>; // Tasks shared WITH user
|
||||
getUserTaskAccess(viewerId: string): Promise<UserTaskAccess[]>; // Access Viewer has to Owners
|
||||
getTaskSharedUsers(taskId: string): Promise<User[]>; // Get users a task is shared WITH
|
||||
@@ -49,6 +50,7 @@ export interface IStorage {
|
||||
|
||||
// Labels
|
||||
getAllLabels(): Promise<Label[]>;
|
||||
getLabels(userId: string): Promise<Label[]>;
|
||||
getLabel(id: string): Promise<Label | undefined>;
|
||||
createLabel(label: InsertLabel): Promise<Label>;
|
||||
updateLabel(id: string, updates: Partial<InsertLabel>): Promise<Label | undefined>;
|
||||
@@ -60,6 +62,8 @@ export interface IStorage {
|
||||
createTask(task: InsertTask & { userId?: string }): Promise<Task>;
|
||||
updateTask(id: string, updates: Partial<InsertTask>): Promise<Task | undefined>;
|
||||
deleteTask(id: string): Promise<boolean>;
|
||||
searchTasks(query: string, userId: string): Promise<Task[]>;
|
||||
getSubtasks(parentTaskId: string): Promise<Task[]>;
|
||||
|
||||
// Gamification
|
||||
logXpEvent(event: InsertXpEvent): Promise<XpEvent>;
|
||||
@@ -71,9 +75,11 @@ export interface IStorage {
|
||||
getAllRewards(): Promise<Reward[]>;
|
||||
getUserRewards(userId: string): Promise<UserReward[]>;
|
||||
createReward(reward: InsertReward): Promise<Reward>;
|
||||
createReward(reward: InsertReward): Promise<Reward>;
|
||||
createUserReward(userReward: InsertUserReward): Promise<UserReward>;
|
||||
getReward(id: string): Promise<Reward | undefined>;
|
||||
purchaseReward(userId: string, rewardId: string, cost: number): Promise<{ success: boolean; user: User; userReward: UserReward }>;
|
||||
|
||||
// History
|
||||
// History
|
||||
getXpEvents(userId: string): Promise<XpEvent[]>;
|
||||
|
||||
@@ -81,6 +87,22 @@ export interface IStorage {
|
||||
createPasswordResetToken(token: InsertPasswordResetToken): Promise<PasswordResetToken>;
|
||||
getPasswordResetToken(token: string): Promise<PasswordResetToken | undefined>;
|
||||
markPasswordResetTokenUsed(id: string): Promise<void>;
|
||||
|
||||
// AI Chat
|
||||
createConversation(userId: string, title?: string): Promise<Conversation>;
|
||||
getConversations(userId: string): Promise<Conversation[]>;
|
||||
getConversation(id: string): Promise<Conversation | undefined>;
|
||||
updateConversation(id: string, title: string): Promise<Conversation | undefined>;
|
||||
deleteConversation(id: string): Promise<boolean>;
|
||||
addMessage(message: InsertMessage): Promise<Message>;
|
||||
getMessages(conversationId: string): Promise<Message[]>;
|
||||
getMessage(id: string): Promise<Message | undefined>;
|
||||
updateMessage(id: string, content: string): Promise<Message>;
|
||||
deleteMessagesAfter(conversationId: string, after: Date, excludeMessageId?: string): Promise<void>;
|
||||
|
||||
// Audit Logs
|
||||
createAuditLog(log: InsertAuditLog): Promise<AuditLog>;
|
||||
getAuditLogs(limit?: number): Promise<AuditLog[]>;
|
||||
}
|
||||
|
||||
export class MemStorage implements IStorage {
|
||||
@@ -98,6 +120,7 @@ export class MemStorage implements IStorage {
|
||||
private sharedLabels: Map<string, SharedLabel>;
|
||||
private userTaskAccess: Map<string, UserTaskAccess>;
|
||||
private passwordResetTokens: Map<string, PasswordResetToken>; // id -> Token
|
||||
private auditLogs: Map<string, AuditLog>;
|
||||
|
||||
sessionStore: session.Store;
|
||||
|
||||
@@ -110,11 +133,11 @@ export class MemStorage implements IStorage {
|
||||
this.goals = new Map();
|
||||
this.rewards = new Map();
|
||||
this.userRewards = new Map();
|
||||
this.userRewards = new Map();
|
||||
this.sharedTasks = new Map();
|
||||
this.sharedLabels = new Map();
|
||||
this.userTaskAccess = new Map();
|
||||
this.passwordResetTokens = new Map();
|
||||
this.auditLogs = new Map();
|
||||
this.sessionStore = new MemoryStore({
|
||||
checkPeriod: 86400000,
|
||||
});
|
||||
@@ -340,6 +363,12 @@ export class MemStorage implements IStorage {
|
||||
return Array.from(this.labels.values());
|
||||
}
|
||||
|
||||
async getLabels(userId: string): Promise<Label[]> {
|
||||
return Array.from(this.labels.values()).filter(
|
||||
l => l.creatorId === null || l.creatorId === userId
|
||||
);
|
||||
}
|
||||
|
||||
async getLabel(id: string): Promise<Label | undefined> {
|
||||
return this.labels.get(id);
|
||||
}
|
||||
@@ -401,10 +430,23 @@ export class MemStorage implements IStorage {
|
||||
// Merge and Dedupe
|
||||
const combined = [...myTasks, ...sharedToMe, ...globalSharedTasks, ...tasksFromSharedLabels];
|
||||
const unique = Array.from(new Map(combined.map(t => [t.id, t])).values());
|
||||
|
||||
return unique;
|
||||
}
|
||||
|
||||
async searchTasks(query: string, userId: string): Promise<Task[]> {
|
||||
const allTasks = await this.getTasksForUser(userId);
|
||||
if (!query) return allTasks;
|
||||
const lowerQuery = query.toLowerCase();
|
||||
return allTasks.filter(t =>
|
||||
t.title.toLowerCase().includes(lowerQuery) ||
|
||||
(t.description && t.description.toLowerCase().includes(lowerQuery))
|
||||
);
|
||||
}
|
||||
|
||||
async getSubtasks(parentTaskId: string): Promise<Task[]> {
|
||||
return Array.from(this.tasks.values()).filter(t => t.parentTaskId === parentTaskId);
|
||||
}
|
||||
|
||||
async getTask(id: string): Promise<Task | undefined> {
|
||||
return this.tasks.get(id);
|
||||
}
|
||||
@@ -424,7 +466,10 @@ export class MemStorage implements IStorage {
|
||||
notes: insertTask.notes || null,
|
||||
labelId: insertTask.labelId || null,
|
||||
energyLevel: insertTask.energyLevel || "medium",
|
||||
|
||||
estimatedDuration: insertTask.estimatedDuration || null,
|
||||
parentTaskId: insertTask.parentTaskId || null,
|
||||
startDate: insertTask.startDate || null,
|
||||
dependencies: insertTask.dependencies || null,
|
||||
userId: insertTask.userId || null // Set ownership
|
||||
};
|
||||
@@ -546,6 +591,25 @@ export class MemStorage implements IStorage {
|
||||
return userReward;
|
||||
}
|
||||
|
||||
async getReward(id: string): Promise<Reward | undefined> {
|
||||
return this.rewards.get(id);
|
||||
}
|
||||
|
||||
async purchaseReward(userId: string, rewardId: string, cost: number): Promise<{ success: boolean; user: User; userReward: UserReward }> {
|
||||
const user = this.users.get(userId);
|
||||
if (!user) throw new Error("User not found");
|
||||
if (user.xp < cost) throw new Error("Insufficient XP");
|
||||
|
||||
// Deduct XP
|
||||
user.xp -= cost;
|
||||
this.users.set(userId, user);
|
||||
|
||||
// Create User Reward
|
||||
const userReward = await this.createUserReward({ userId, rewardId });
|
||||
|
||||
return { success: true, user, userReward };
|
||||
}
|
||||
|
||||
async getXpEvents(userId: string): Promise<XpEvent[]> {
|
||||
return Array.from(this.xpEvents.values())
|
||||
.filter(e => e.userId === userId)
|
||||
@@ -576,11 +640,63 @@ export class MemStorage implements IStorage {
|
||||
this.passwordResetTokens.set(id, token);
|
||||
}
|
||||
}
|
||||
|
||||
// AI Chat Stubs
|
||||
async createConversation(userId: string, title?: string): Promise<Conversation> {
|
||||
throw new Error("MemStorage: AI Chat not implemented.");
|
||||
}
|
||||
async getConversations(userId: string): Promise<Conversation[]> {
|
||||
return [];
|
||||
}
|
||||
async getConversation(id: string): Promise<Conversation | undefined> {
|
||||
return undefined;
|
||||
}
|
||||
async deleteConversation(id: string): Promise<boolean> {
|
||||
return false;
|
||||
}
|
||||
async addMessage(message: InsertMessage): Promise<Message> {
|
||||
throw new Error("MemStorage: AI Chat not implemented.");
|
||||
}
|
||||
async getMessages(conversationId: string): Promise<Message[]> {
|
||||
return [];
|
||||
}
|
||||
async getMessage(id: string): Promise<Message | undefined> {
|
||||
return undefined;
|
||||
}
|
||||
async updateMessage(id: string, content: string): Promise<Message> {
|
||||
throw new Error("Not implemented");
|
||||
}
|
||||
async deleteMessagesAfter(conversationId: string, after: Date): Promise<void> {
|
||||
// No-op
|
||||
}
|
||||
async updateConversation(id: string, title: string): Promise<Conversation | undefined> {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Audit Logs (MemStorage)
|
||||
async createAuditLog(insertLog: InsertAuditLog): Promise<AuditLog> {
|
||||
const id = randomUUID();
|
||||
const log: AuditLog = {
|
||||
...insertLog,
|
||||
id,
|
||||
userId: insertLog.userId || null,
|
||||
entityId: insertLog.entityId || null,
|
||||
details: insertLog.details || null,
|
||||
source: insertLog.source || "USER",
|
||||
createdAt: new Date(),
|
||||
};
|
||||
this.auditLogs.set(id, log);
|
||||
return log;
|
||||
}
|
||||
|
||||
async getAuditLogs(limit = 100): Promise<AuditLog[]> {
|
||||
return Array.from(this.auditLogs.values())
|
||||
.sort((a, b) => (b.createdAt && a.createdAt ? b.createdAt.getTime() - a.createdAt.getTime() : 0))
|
||||
.slice(0, limit);
|
||||
}
|
||||
}
|
||||
|
||||
import { getDatabase } from './db.js';
|
||||
import { eq, sql, desc, and } from 'drizzle-orm';
|
||||
import * as schema from '../shared/schema.js';
|
||||
|
||||
|
||||
export class DbStorage implements IStorage {
|
||||
private db = getDatabase();
|
||||
@@ -671,11 +787,16 @@ export class DbStorage implements IStorage {
|
||||
return result.length > 0;
|
||||
}
|
||||
|
||||
// ... (rest of DbStorage labels, tasks, etc. implementation - unchanged mostly)
|
||||
async getAllLabels(): Promise<Label[]> {
|
||||
return await this.db.select().from(schema.labels);
|
||||
}
|
||||
|
||||
async getLabels(userId: string): Promise<Label[]> {
|
||||
return await this.db.select().from(schema.labels).where(
|
||||
sql`${schema.labels.creatorId} IS NULL OR ${schema.labels.creatorId} = ${userId}`
|
||||
);
|
||||
}
|
||||
|
||||
async getLabel(id: string): Promise<Label | undefined> {
|
||||
const result = await this.db.select().from(schema.labels).where(eq(schema.labels.id, id));
|
||||
return result[0];
|
||||
@@ -701,14 +822,6 @@ export class DbStorage implements IStorage {
|
||||
}
|
||||
|
||||
async getTasksForUser(userId: string): Promise<Task[]> {
|
||||
// Complex query:
|
||||
// (tasks.userId = current)
|
||||
// OR (id IN (select taskId from sharedTasks where sharedWith = current))
|
||||
// OR (userId IN (select ownerId from userTaskAccess where viewerId = current))
|
||||
|
||||
// For simplicity in this generated code, we can do parallel queries or use `or`.
|
||||
// Drizzle's `or` and `inArray` can be used.
|
||||
|
||||
// 1. My tasks
|
||||
const result = await this.db.select().from(schema.tasks).where(eq(schema.tasks.userId, userId));
|
||||
|
||||
@@ -736,12 +849,25 @@ export class DbStorage implements IStorage {
|
||||
tasksFromSharedLabels = await this.db.select().from(schema.tasks).where(sql`${schema.tasks.labelId} IN ${sharedLabelIds}`);
|
||||
}
|
||||
|
||||
// Dedupe
|
||||
const combined = [...result, ...sharedTasks, ...globalTasks, ...tasksFromSharedLabels];
|
||||
const unique = Array.from(new Map(combined.map(t => [t.id, t])).values());
|
||||
return unique;
|
||||
}
|
||||
|
||||
async searchTasks(query: string, userId: string): Promise<Task[]> {
|
||||
const allTasks = await this.getTasksForUser(userId);
|
||||
if (!query) return allTasks;
|
||||
const lowerQuery = query.toLowerCase();
|
||||
return allTasks.filter(t =>
|
||||
t.title.toLowerCase().includes(lowerQuery) ||
|
||||
(t.description && t.description.toLowerCase().includes(lowerQuery))
|
||||
);
|
||||
}
|
||||
|
||||
async getSubtasks(parentTaskId: string): Promise<Task[]> {
|
||||
return await this.db.select().from(schema.tasks).where(eq(schema.tasks.parentTaskId, parentTaskId));
|
||||
}
|
||||
|
||||
async getTask(id: string): Promise<Task | undefined> {
|
||||
const result = await this.db.select().from(schema.tasks).where(eq(schema.tasks.id, id));
|
||||
return result[0];
|
||||
@@ -802,8 +928,6 @@ export class DbStorage implements IStorage {
|
||||
return result[0];
|
||||
}
|
||||
|
||||
|
||||
// Rewards
|
||||
async getAllRewards(): Promise<Reward[]> {
|
||||
return await this.db.select().from(schema.rewards);
|
||||
}
|
||||
@@ -822,32 +946,47 @@ export class DbStorage implements IStorage {
|
||||
return result[0];
|
||||
}
|
||||
|
||||
async getReward(id: string): Promise<Reward | undefined> {
|
||||
const result = await this.db.select().from(schema.rewards).where(eq(schema.rewards.id, id));
|
||||
return result[0];
|
||||
}
|
||||
|
||||
async purchaseReward(userId: string, rewardId: string, cost: number): Promise<{ success: boolean; user: User; userReward: UserReward }> {
|
||||
return await this.db.transaction(async (tx) => {
|
||||
// 1. Get User and verify XP
|
||||
const userRes = await tx.select().from(schema.users).where(eq(schema.users.id, userId));
|
||||
const user = userRes[0];
|
||||
if (!user) throw new Error("User not found");
|
||||
if (user.xp < cost) throw new Error("Insufficient XP");
|
||||
|
||||
// 2. Deduct XP
|
||||
const updatedUserRes = await tx.update(schema.users)
|
||||
.set({ xp: user.xp - cost })
|
||||
.where(eq(schema.users.id, userId))
|
||||
.returning();
|
||||
|
||||
// 3. Create User Reward
|
||||
const urRes = await tx.insert(schema.userRewards).values({
|
||||
userId,
|
||||
rewardId,
|
||||
purchasedAt: new Date()
|
||||
}).returning();
|
||||
|
||||
return { success: true, user: updatedUserRes[0], userReward: urRes[0] };
|
||||
});
|
||||
}
|
||||
|
||||
async getXpEvents(userId: string): Promise<XpEvent[]> {
|
||||
return await this.db.select()
|
||||
.from(schema.xpEvents)
|
||||
.where(eq(schema.xpEvents.userId, userId))
|
||||
.orderBy(desc(schema.xpEvents.createdAt));
|
||||
return await this.db.select().from(schema.xpEvents).where(eq(schema.xpEvents.userId, userId));
|
||||
}
|
||||
|
||||
// Social Methods (DbStorage)
|
||||
async getLeaderboard(): Promise<User[]> {
|
||||
return await this.db.select()
|
||||
.from(schema.users)
|
||||
.where(and(
|
||||
eq(schema.users.showOnLeaderboard, true),
|
||||
eq(schema.users.isActive, true)
|
||||
))
|
||||
.orderBy(desc(schema.users.xp));
|
||||
}
|
||||
|
||||
// Auth - Password Reset (DbStorage)
|
||||
async createPasswordResetToken(insertToken: InsertPasswordResetToken): Promise<PasswordResetToken> {
|
||||
const result = await this.db.insert(schema.passwordResetTokens).values(insertToken).returning();
|
||||
return result[0];
|
||||
}
|
||||
|
||||
async getPasswordResetToken(tokenString: string): Promise<PasswordResetToken | undefined> {
|
||||
const result = await this.db.select().from(schema.passwordResetTokens).where(eq(schema.passwordResetTokens.token, tokenString));
|
||||
async getPasswordResetToken(token: string): Promise<PasswordResetToken | undefined> {
|
||||
const result = await this.db.select().from(schema.passwordResetTokens).where(eq(schema.passwordResetTokens.token, token));
|
||||
return result[0];
|
||||
}
|
||||
|
||||
@@ -857,10 +996,57 @@ export class DbStorage implements IStorage {
|
||||
.where(eq(schema.passwordResetTokens.id, id));
|
||||
}
|
||||
|
||||
// AI Chat Implementation
|
||||
async createConversation(userId: string, title?: string): Promise<Conversation> {
|
||||
const result = await this.db.insert(schema.conversations).values({
|
||||
userId,
|
||||
title: title || "New Chat",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date()
|
||||
}).returning();
|
||||
return result[0];
|
||||
}
|
||||
|
||||
async getConversations(userId: string): Promise<Conversation[]> {
|
||||
return await this.db.select()
|
||||
.from(schema.conversations)
|
||||
.where(eq(schema.conversations.userId, userId))
|
||||
.orderBy(desc(schema.conversations.updatedAt));
|
||||
}
|
||||
|
||||
async getConversation(id: string): Promise<Conversation | undefined> {
|
||||
const result = await this.db.select().from(schema.conversations).where(eq(schema.conversations.id, id));
|
||||
return result[0];
|
||||
}
|
||||
|
||||
async deleteConversation(id: string): Promise<boolean> {
|
||||
await this.db.delete(schema.messages).where(eq(schema.messages.conversationId, id));
|
||||
const result = await this.db.delete(schema.conversations).where(eq(schema.conversations.id, id)).returning();
|
||||
return result.length > 0;
|
||||
}
|
||||
|
||||
async addMessage(message: InsertMessage): Promise<Message> {
|
||||
const result = await this.db.insert(schema.messages).values(message).returning();
|
||||
if (message.conversationId) {
|
||||
await this.db.update(schema.conversations)
|
||||
.set({ updatedAt: new Date() })
|
||||
.where(eq(schema.conversations.id, message.conversationId));
|
||||
}
|
||||
return result[0];
|
||||
}
|
||||
|
||||
|
||||
|
||||
// Social & Leaderboard
|
||||
async getLeaderboard(): Promise<User[]> {
|
||||
return await this.db.select().from(schema.users)
|
||||
.where(and(eq(schema.users.isActive, true), eq(schema.users.showOnLeaderboard, true)))
|
||||
.orderBy(desc(schema.users.xp));
|
||||
}
|
||||
|
||||
async searchUsers(query: string): Promise<User[]> {
|
||||
if (!query || query.length < 2) return [];
|
||||
return await this.db.select()
|
||||
.from(schema.users)
|
||||
return await this.db.select().from(schema.users)
|
||||
.where(and(
|
||||
eq(schema.users.isSearchable, true),
|
||||
eq(schema.users.isActive, true),
|
||||
@@ -869,123 +1055,131 @@ export class DbStorage implements IStorage {
|
||||
}
|
||||
|
||||
async shareTask(sharedTask: InsertSharedTask): Promise<SharedTask> {
|
||||
return this.createSharedTask(sharedTask);
|
||||
}
|
||||
|
||||
async createSharedTask(sharedTask: InsertSharedTask): Promise<SharedTask> {
|
||||
const result = await this.db.insert(schema.sharedTasks).values(sharedTask).returning();
|
||||
return result[0];
|
||||
}
|
||||
|
||||
async shareAllTasks(access: InsertUserTaskAccess): Promise<UserTaskAccess> {
|
||||
return this.createUserTaskAccess(access);
|
||||
}
|
||||
|
||||
async createUserTaskAccess(access: InsertUserTaskAccess): Promise<UserTaskAccess> {
|
||||
// Upsert or simple insert. Let's assume one Record per pair
|
||||
const result = await this.db.insert(schema.userTaskAccess).values(access).returning();
|
||||
return result[0];
|
||||
}
|
||||
|
||||
async createSharedTask(sharedTask: InsertSharedTask): Promise<SharedTask> {
|
||||
return this.shareTask(sharedTask);
|
||||
}
|
||||
|
||||
async createUserTaskAccess(access: InsertUserTaskAccess): Promise<UserTaskAccess> {
|
||||
return this.shareAllTasks(access);
|
||||
}
|
||||
|
||||
async getSharedTasks(userId: string): Promise<SharedTask[]> {
|
||||
return await this.db.select()
|
||||
.from(schema.sharedTasks)
|
||||
.where(eq(schema.sharedTasks.sharedWithUserId, userId));
|
||||
return await this.db.select().from(schema.sharedTasks).where(eq(schema.sharedTasks.sharedWithUserId, userId));
|
||||
}
|
||||
|
||||
async getUserTaskAccess(viewerId: string): Promise<UserTaskAccess[]> {
|
||||
return await this.db.select()
|
||||
.from(schema.userTaskAccess)
|
||||
.where(eq(schema.userTaskAccess.viewerId, viewerId));
|
||||
return await this.db.select().from(schema.userTaskAccess).where(eq(schema.userTaskAccess.viewerId, viewerId));
|
||||
}
|
||||
|
||||
async getTaskSharedUsers(taskId: string): Promise<User[]> {
|
||||
const result = await this.db.select({
|
||||
id: schema.users.id,
|
||||
username: schema.users.username,
|
||||
email: schema.users.email,
|
||||
role: schema.users.role,
|
||||
isActive: schema.users.isActive,
|
||||
xp: schema.users.xp,
|
||||
level: schema.users.level,
|
||||
currentStreak: schema.users.currentStreak,
|
||||
lastTaskDate: schema.users.lastTaskDate,
|
||||
showOnLeaderboard: schema.users.showOnLeaderboard,
|
||||
isSearchable: schema.users.isSearchable,
|
||||
apiKey: schema.users.apiKey,
|
||||
aiEnabled: schema.users.aiEnabled,
|
||||
password: schema.users.password // Generally shouldn't return this, but following pattern
|
||||
})
|
||||
.from(schema.sharedTasks)
|
||||
.innerJoin(schema.users, eq(schema.sharedTasks.sharedWithUserId, schema.users.id))
|
||||
.where(eq(schema.sharedTasks.taskId, taskId));
|
||||
return result;
|
||||
const shares = await this.db.select().from(schema.sharedTasks).where(eq(schema.sharedTasks.taskId, taskId));
|
||||
if (shares.length === 0) return [];
|
||||
|
||||
return await this.db.select().from(schema.users)
|
||||
.where(sql`${schema.users.id} IN ${shares.map(s => s.sharedWithUserId)}`);
|
||||
}
|
||||
|
||||
async unshareTask(taskId: string, userId: string): Promise<boolean> {
|
||||
const result = await this.db.delete(schema.sharedTasks)
|
||||
.where(and(
|
||||
eq(schema.sharedTasks.taskId, taskId),
|
||||
eq(schema.sharedTasks.sharedWithUserId, userId)
|
||||
))
|
||||
.where(and(eq(schema.sharedTasks.taskId, taskId), eq(schema.sharedTasks.sharedWithUserId, userId)))
|
||||
.returning();
|
||||
return result.length > 0;
|
||||
}
|
||||
|
||||
// Shared Labels (DbStorage)
|
||||
async shareLabel(labelId: string, sharedWithUserId: string, sharedByUserId: string, permission: string = 'read'): Promise<SharedLabel> {
|
||||
const result = await this.db.insert(schema.sharedLabels).values({
|
||||
labelId,
|
||||
sharedWithUserId,
|
||||
sharedByUserId,
|
||||
permission
|
||||
permission,
|
||||
createdAt: new Date()
|
||||
}).returning();
|
||||
return result[0];
|
||||
}
|
||||
|
||||
async getSharedLabels(userId: string): Promise<SharedLabel[]> {
|
||||
return await this.db.select()
|
||||
.from(schema.sharedLabels)
|
||||
.where(eq(schema.sharedLabels.sharedWithUserId, userId));
|
||||
return await this.db.select().from(schema.sharedLabels).where(eq(schema.sharedLabels.sharedWithUserId, userId));
|
||||
}
|
||||
|
||||
async getLabelSharedUsers(labelId: string): Promise<User[]> {
|
||||
const result = await this.db.select({
|
||||
id: schema.users.id,
|
||||
username: schema.users.username,
|
||||
email: schema.users.email,
|
||||
role: schema.users.role,
|
||||
isActive: schema.users.isActive,
|
||||
xp: schema.users.xp,
|
||||
level: schema.users.level,
|
||||
currentStreak: schema.users.currentStreak,
|
||||
lastTaskDate: schema.users.lastTaskDate,
|
||||
showOnLeaderboard: schema.users.showOnLeaderboard,
|
||||
isSearchable: schema.users.isSearchable,
|
||||
apiKey: schema.users.apiKey,
|
||||
aiEnabled: schema.users.aiEnabled,
|
||||
password: schema.users.password
|
||||
})
|
||||
.from(schema.sharedLabels)
|
||||
.innerJoin(schema.users, eq(schema.sharedLabels.sharedWithUserId, schema.users.id))
|
||||
.where(eq(schema.sharedLabels.labelId, labelId));
|
||||
return result;
|
||||
const shares = await this.db.select().from(schema.sharedLabels).where(eq(schema.sharedLabels.labelId, labelId));
|
||||
if (shares.length === 0) return [];
|
||||
return await this.db.select().from(schema.users)
|
||||
.where(sql`${schema.users.id} IN ${shares.map(s => s.sharedWithUserId)}`);
|
||||
}
|
||||
|
||||
async unshareLabel(labelId: string, userId: string): Promise<boolean> {
|
||||
const result = await this.db.delete(schema.sharedLabels)
|
||||
.where(and(
|
||||
eq(schema.sharedLabels.labelId, labelId),
|
||||
eq(schema.sharedLabels.sharedWithUserId, userId)
|
||||
))
|
||||
.where(and(eq(schema.sharedLabels.labelId, labelId), eq(schema.sharedLabels.sharedWithUserId, userId)))
|
||||
.returning();
|
||||
return result.length > 0;
|
||||
}
|
||||
|
||||
async getLabelShares(labelId: string): Promise<SharedLabel[]> {
|
||||
return await this.db.select().from(schema.sharedLabels).where(eq(schema.sharedLabels.labelId, labelId));
|
||||
}
|
||||
|
||||
async updateConversation(id: string, title: string): Promise<Conversation | undefined> {
|
||||
const result = await this.db.update(schema.conversations)
|
||||
.set({ title, updatedAt: new Date() })
|
||||
.where(eq(schema.conversations.id, id))
|
||||
.returning();
|
||||
return result[0];
|
||||
}
|
||||
async getMessages(conversationId: string): Promise<Message[]> {
|
||||
return await this.db.select()
|
||||
.from(schema.sharedLabels)
|
||||
.where(eq(schema.sharedLabels.labelId, labelId));
|
||||
.from(schema.messages)
|
||||
.where(eq(schema.messages.conversationId, conversationId))
|
||||
.orderBy(asc(schema.messages.createdAt));
|
||||
}
|
||||
|
||||
async getMessage(id: string): Promise<Message | undefined> {
|
||||
const result = await this.db.select().from(schema.messages).where(eq(schema.messages.id, id));
|
||||
return result[0];
|
||||
}
|
||||
|
||||
async updateMessage(id: string, content: string): Promise<Message> {
|
||||
const result = await this.db.update(schema.messages)
|
||||
.set({ content })
|
||||
.where(eq(schema.messages.id, id))
|
||||
.returning();
|
||||
return result[0];
|
||||
}
|
||||
|
||||
async deleteMessagesAfter(conversationId: string, after: Date, excludeMessageId?: string): Promise<void> {
|
||||
const filters = [
|
||||
eq(schema.messages.conversationId, conversationId),
|
||||
gt(schema.messages.createdAt, after)
|
||||
];
|
||||
|
||||
if (excludeMessageId) {
|
||||
filters.push(ne(schema.messages.id, excludeMessageId));
|
||||
}
|
||||
|
||||
await this.db.delete(schema.messages)
|
||||
.where(and(...filters));
|
||||
}
|
||||
|
||||
// Audit Logs (DbStorage)
|
||||
async createAuditLog(insertLog: InsertAuditLog): Promise<AuditLog> {
|
||||
const result = await this.db.insert(schema.auditLogs).values(insertLog).returning();
|
||||
return result[0];
|
||||
}
|
||||
|
||||
async getAuditLogs(limit = 100): Promise<AuditLog[]> {
|
||||
return await this.db.select()
|
||||
.from(schema.auditLogs)
|
||||
.orderBy(desc(schema.auditLogs.createdAt))
|
||||
.limit(limit);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user