feat: enhance audit logging, add MCP settings, and production docker setup
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:
2025-12-15 15:53:31 +01:00
parent fdf321cde9
commit d1736c5991
35 changed files with 5165 additions and 499 deletions
+512 -34
View File
@@ -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",
+68
View File
@@ -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
View File
@@ -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
View File
@@ -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);
}
}