feat: Enhance task filtering, smart scheduling, audit logs and translations
continuous-integration/drone/push Build is passing

This commit is contained in:
2025-12-17 14:26:54 +01:00
parent 9819d8db0b
commit 2579df0b89
32 changed files with 2219 additions and 456 deletions
+305 -28
View File
@@ -1,7 +1,7 @@
import type { Express } from "express";
import { createServer, type Server } from "http";
import { storage } from "./storage.js";
import { insertLabelSchema, insertTaskSchema, insertNoteSchema, insertXpEventSchema, insertGoalSchema, insertRewardSchema, rewards, userRewards, User } from "../shared/schema.js";
import { insertLabelSchema, insertUserSchema, insertTaskSchema, insertNoteSchema, insertGoalSchema, insertRewardSchema, insertUserRewardSchema, User, Task } from "../shared/schema.js";
import { z } from "zod";
import { EmailService } from "./email.js";
import { mcpServer } from "./mcp";
@@ -14,7 +14,7 @@ const aiService = new AiService(storage);
const recurrenceService = new RecurrenceService(storage);
const gamificationService = new GamificationService(storage);
import { setupAuth, hashPassword, comparePassword } from "./auth_debug.js";
import { setupAuth, hashPassword, comparePassword } from "./auth.js";
function isAdmin(req: any, res: any, next: any) {
if (req.isAuthenticated() && req.user.role === 'admin') {
@@ -26,6 +26,57 @@ function isAdmin(req: any, res: any, next: any) {
export async function registerRoutes(app: Express): Promise<Server> {
setupAuth(app);
// Update user schedule
app.patch("/api/user/schedule", async (req, res) => {
if (!req.isAuthenticated()) return res.sendStatus(401);
const userId = (req.user as User).id;
try {
const { start, end, days, availability } = req.body;
const updates: Partial<User> = {};
// Backward compatibility / Simple Mode
if (start && end && days) {
updates.workHours = { start, end, days };
// Sync to availability.work if availability not explicitly provided?
if (!availability) {
updates.availability = {
work: { start, end, days },
personal: (req.user as User).availability?.personal || { start: "18:00", end: "22:00", days: [1, 2, 3, 4, 5, 0, 6] }
};
}
}
// New Mode
if (availability) {
updates.availability = availability;
// Sync workHours to availability.work for legacy support
if (availability.work) {
updates.workHours = availability.work;
}
}
if (Object.keys(updates).length === 0) {
return res.status(400).json({ error: "No schedule data provided" });
}
const updated = await storage.updateUser(userId, updates);
await storage.createAuditLog({
userId,
action: "UPDATE",
entityType: "USER",
entityId: userId,
details: { action: "UPDATE_SCHEDULE", updates },
source: "USER"
});
res.json(updated);
} catch (e) {
res.status(500).json({ error: "Failed to update schedule" });
}
});
// --- Setup Routes ---
app.get("/api/setup/status", async (req, res) => {
const hasAdmin = await storage.hasAdminUser();
@@ -676,6 +727,37 @@ User Context:
}
});
// Schedule a task using AI
app.post("/api/ai/schedule", async (req, res) => {
if (!req.isAuthenticated()) return res.sendStatus(401);
const user = req.user as User;
if (!user.aiEnabled) return res.status(403).json({ error: "AI Assistant is disabled for this user" });
try {
const { taskId } = req.body;
if (!taskId) return res.status(400).json({ error: "Task ID is required" });
const result = await aiService.scheduleTask(taskId, user.id);
if (result.success && result.scheduledDate) {
// Log it
await storage.createAuditLog({
userId: user.id,
action: "UPDATE",
entityType: "TASK",
entityId: taskId,
details: { action: "AUTO_SCHEDULE", date: result.scheduledDate },
source: "AI"
});
}
res.json(result);
} catch (e: any) {
console.error("Scheduling Error:", e);
res.status(500).json({ error: e.message || "Failed to schedule task" });
}
});
// Edit message and regenerate (Regenerate Response)
app.put("/api/ai/chat/:messageId", async (req, res) => {
if (!req.isAuthenticated()) return res.sendStatus(401);
@@ -796,6 +878,28 @@ ${activeTasksWithLabels.slice(0, 5).map(t => `- [${t.priority}] [${t.label}] ${t
}
});
app.delete("/api/tasks/:id", async (req, res) => {
if (!req.isAuthenticated()) return res.sendStatus(401);
try {
const { hasAccess, task } = await checkTaskAccess(req.user as User, req.params.id, 'write');
if (!task) return res.status(404).json({ error: "Task not found" });
// DELETE usually requires ownership or explicit 'write' permission.
// Shared read-only users should NOT be able to delete.
// checkTaskAccess('write') should cover this if we implement strict permissions in sharedTasks later.
// For now, let's assume if they have 'write', they can delete (or we restrict delete to Owner).
// Let's restrict DELETE to Owner for safety unless specifically allowed.
if (task.userId !== (req.user as User).id) {
return res.status(403).json({ error: "Only the owner can delete a task" });
}
await storage.deleteTask(req.params.id);
res.sendStatus(204);
} catch (error) {
res.status(500).json({ error: "Failed to delete task" });
}
});
app.post("/api/labels", async (req, res) => {
try {
const result = insertLabelSchema.safeParse(req.body);
@@ -824,6 +928,44 @@ ${activeTasksWithLabels.slice(0, 5).map(t => `- [${t.priority}] [${t.label}] ${t
}
});
app.patch("/api/tasks/:id", async (req, res) => {
if (!req.isAuthenticated()) return res.sendStatus(401);
// Validate request body against schema
const cleanBody = insertTaskSchema.partial().safeParse(req.body);
if (!cleanBody.success) {
return res.status(400).json({ error: cleanBody.error });
}
try {
const { hasAccess, task } = await checkTaskAccess(req.user as User, req.params.id, 'write');
if (!task) return res.status(404).json({ error: "Task not found" });
if (!hasAccess) return res.status(403).json({ error: "Access denied" });
const updatedTask = await storage.updateTask(task.id, cleanBody.data);
if (cleanBody.data.status === 'done' && task.status !== 'done') {
if (req.user) {
// Check if late
const now = new Date();
const isLate = task.dueDate && new Date(task.dueDate) < now;
const xpSource = isLate ? "complete_task_late" : "complete_task";
// Award XP for completion with Task Title context
await gamificationService.awardXP(
(req.user as User).id,
xpSource,
undefined,
{ taskId: task.id, taskTitle: task.title }
);
}
}
res.json(updatedTask);
} catch (error) {
res.status(500).json({ error: "Failed to update task" });
}
});
app.patch("/api/labels/:id", async (req, res) => {
try {
const updates = insertLabelSchema.partial().safeParse(req.body);
@@ -963,14 +1105,51 @@ ${activeTasksWithLabels.slice(0, 5).map(t => `- [${t.priority}] [${t.label}] ${t
}
});
// Helper for RBAC
const checkTaskAccess = async (user: User, taskId: string, requiredPermission: 'read' | 'write' = 'read'): Promise<{ hasAccess: boolean, task?: Task }> => {
const task = await storage.getTask(taskId);
if (!task) return { hasAccess: false };
// 1. Ownership
if (task.userId === user.id) return { hasAccess: true, task };
// 2. Shared Task (Direct)
// We need a storage method for this efficiently, but for now we might need to query
// Since storage interface is generic, let's assume valid access if we can find a record
// Optimization: Add storage.hasTaskAccess(userId, taskId)?
// For now, let's fallback to checking if the task is in the user's "visible" list or simple logic
// Implementation Plan Step: "Shared Access (query sharedTasks table)"
// We'll trust the current `storage.getTask` usually returns raw task.
// But we need to verify IF the user is allowed.
// Check Shared Tasks
const shared = await storage.getSharedTask(taskId, user.id);
if (shared) {
// Shared tasks currently imply 'read'. If we need 'write', we might need more fields.
// For now, let's assume shared = read/write or just read.
// The schema `sharedTasks` doesn't have permissions, so full access?
// Start with READ access for shared. WRITE might need schema update.
// Let's assume shared tasks are R/W for now for simplicity unless specified.
return { hasAccess: true, task };
}
// 3. Global Access (UserTaskAccess)
// Check if user has access to the owner's tasks
if (!task.userId) return { hasAccess: false, task }; // Should not happen for user tasks
const hasGlobalAccess = await storage.checkUserTaskAccess(task.userId, user.id);
if (hasGlobalAccess) return { hasAccess: true, task }; // "Share All"
return { hasAccess: false, task }; // Task exists but no access
};
app.get("/api/tasks/:id", async (req, res) => {
// TODO: Check if user has access to this specific task (Owns it OR is Shared)
// For now, simple get
if (!req.isAuthenticated()) return res.sendStatus(401);
try {
const task = await storage.getTask(req.params.id);
if (!task) {
return res.status(404).json({ error: "Task not found" });
}
const { hasAccess, task } = await checkTaskAccess(req.user as User, req.params.id, 'read');
if (!task) return res.status(404).json({ error: "Task not found" });
if (!hasAccess) return res.status(403).json({ error: "Access denied" });
res.json(task);
} catch (error) {
res.status(500).json({ error: "Failed to fetch task" });
@@ -1265,6 +1444,15 @@ ${activeTasksWithLabels.slice(0, 5).map(t => `- [${t.priority}] [${t.label}] ${t
const updatedUser = await storage.updateUserXP(userId, -reward.cost);
await storage.createUserReward({ userId, rewardId });
await storage.createAuditLog({
userId,
action: "PURCHASE",
entityType: "REWARD",
entityId: rewardId.toString(),
source: "USER",
details: { cost: reward.cost, rewardName: reward.title }
});
res.json({ success: true, user: updatedUser });
} catch (e) {
res.status(500).json({ error: "Purchase failed" });
@@ -1281,6 +1469,16 @@ ${activeTasksWithLabels.slice(0, 5).map(t => `- [${t.priority}] [${t.label}] ${t
};
const reward = await storage.createReward(rewardData);
await storage.createAuditLog({
userId: (req.user as User).id,
action: "CREATE",
entityType: "REWARD",
entityId: reward.id.toString(),
source: "ADMIN",
details: { title: reward.title, cost: reward.cost }
});
res.json(reward);
} catch (err) {
res.status(500).json({ error: "Failed to create reward" });
@@ -1347,6 +1545,16 @@ ${activeTasksWithLabels.slice(0, 5).map(t => `- [${t.priority}] [${t.label}] ${t
if (language !== undefined) updates.language = language;
const updated = await storage.updateUser((req.user as User).id, updates);
await storage.createAuditLog({
userId: (req.user as User).id,
action: "UPDATE",
entityType: "USER_PRIVACY",
entityId: (req.user as User).id.toString(),
source: "USER",
details: updates
});
res.json(updated);
} catch (e) {
res.status(500).json({ error: "Failed to update privacy settings" });
@@ -1365,32 +1573,23 @@ ${activeTasksWithLabels.slice(0, 5).map(t => `- [${t.priority}] [${t.label}] ${t
}
const updated = await storage.updateUser((req.user as User).id, { email });
await storage.createAuditLog({
userId: (req.user as User).id,
action: "UPDATE",
entityType: "USER_PROFILE",
entityId: (req.user as User).id.toString(),
source: "USER",
details: { change: "email" }
});
res.json(updated);
} catch (e) {
res.status(500).json({ error: "Failed to update profile" });
}
});
app.post("/api/user/routine/:type/complete", async (req, res) => {
if (!req.isAuthenticated()) return res.sendStatus(401);
const type = req.params.type;
if (type !== 'morning' && type !== 'evening') return res.status(400).json({ error: "Invalid routine type" });
try {
const updates: any = {};
const now = new Date();
if (type === 'morning') {
updates.lastMorningRoutine = now;
} else {
updates.lastEveningRoutine = now;
}
const updatedUser = await storage.updateUser((req.user as User).id, updates);
res.json(updatedUser);
} catch (e) {
res.status(500).json({ error: "Failed to complete routine" });
}
});
app.patch("/api/user/password", async (req, res) => {
if (!req.isAuthenticated()) return res.sendStatus(401);
@@ -1407,6 +1606,15 @@ ${activeTasksWithLabels.slice(0, 5).map(t => `- [${t.priority}] [${t.label}] ${t
const hashedPassword = await hashPassword(newPassword);
await storage.updateUser(user.id, { password: hashedPassword });
await storage.createAuditLog({
userId: user.id,
action: "UPDATE",
entityType: "USER_PASSWORD",
entityId: user.id.toString(),
source: "USER",
details: {}
});
res.json({ message: "Password updated" });
} catch (e) {
res.status(500).json({ error: "Failed to update password" });
@@ -1445,6 +1653,15 @@ ${activeTasksWithLabels.slice(0, 5).map(t => `- [${t.priority}] [${t.label}] ${t
sharedByUserId: (req.user as User).id,
sharedWithUserId: targetUserId
});
await storage.createAuditLog({
userId: (req.user as User).id,
action: "SHARE",
entityType: "TASK",
entityId: taskId,
source: "USER",
details: { sharedWith: targetUserId }
});
res.json({ success: true });
} catch (e) {
res.status(500).json({ error: "Failed to share task" });
@@ -1487,6 +1704,14 @@ ${activeTasksWithLabels.slice(0, 5).map(t => `- [${t.priority}] [${t.label}] ${t
const success = await storage.unshareTask(taskId, targetUserId);
if (success) {
await storage.createAuditLog({
userId: (req.user as User).id,
action: "UNSHARE",
entityType: "TASK",
entityId: taskId,
source: "USER",
details: { unsharedWith: targetUserId }
});
res.json({ success: true });
} else {
res.status(404).json({ error: "Share not found" });
@@ -1504,6 +1729,15 @@ ${activeTasksWithLabels.slice(0, 5).map(t => `- [${t.priority}] [${t.label}] ${t
ownerId: (req.user as User).id,
viewerId: targetUserId
});
await storage.createAuditLog({
userId: (req.user as User).id,
action: "SHARE",
entityType: "ALL_TASKS",
entityId: "0",
source: "USER",
details: { sharedWith: targetUserId }
});
res.json({ success: true });
} catch (e) {
res.status(500).json({ error: "Failed to share all tasks" });
@@ -1521,6 +1755,17 @@ ${activeTasksWithLabels.slice(0, 5).map(t => `- [${t.priority}] [${t.label}] ${t
try {
const updated = await storage.updateTask(req.params.id, req.body);
if (updated) {
await storage.createAuditLog({
userId: (req.user as User).id,
action: "UPDATE",
entityType: "GOAL",
entityId: updated.id.toString(),
source: "USER",
details: req.body
});
}
// Check for Recurrence if task is marked done
if (updated && updated.status === 'done' && updated.isRecurring && req.body.status === 'done') {
// Fire and forget, or await? Await to ensure it happens.
@@ -1541,8 +1786,40 @@ ${activeTasksWithLabels.slice(0, 5).map(t => `- [${t.priority}] [${t.label}] ${t
}
});
// Routine Completion Endpoint
app.post("/api/user/routine/:type/complete", async (req, res) => {
if (!req.isAuthenticated()) return res.sendStatus(401);
const user = req.user as User;
const type = req.params.type;
try {
if (type === 'morning') {
await storage.updateUser(user.id, { lastMorningRoutine: new Date() });
await gamificationService.awardXP(user.id, 'morning_routine', 20); // Bonus
} else if (type === 'evening') {
await storage.updateUser(user.id, { lastEveningRoutine: new Date() });
await gamificationService.awardXP(user.id, 'evening_routine', 20); // Bonus
} else {
return res.status(400).json({ error: "Invalid routine type" });
}
await storage.createAuditLog({
userId: user.id,
action: "COMPLETE",
entityType: "ROUTINE",
entityId: type,
source: "USER",
details: { type }
});
res.json({ success: true });
} catch (e) {
res.status(500).json({ error: "Failed to complete routine" });
}
});
// Export User Data
app.post("/api/user/export", async (req, res) => {
app.post("/api/user/data-export", async (req, res) => {
if (!req.isAuthenticated()) return res.sendStatus(401);
try {
const user = req.user as User;