feat: Implement AI Chat Agent, Email Notifications, and UI enhancements
continuous-integration/drone/push Build is passing

This commit is contained in:
2025-12-12 08:35:48 +01:00
parent ccfb674318
commit 5d8976b1cd
58 changed files with 7867 additions and 844 deletions
+568 -109
View File
@@ -1,10 +1,16 @@
import type { Express } from "express";
import { createServer, type Server } from "http";
import { storage } from "./storage.js";
import { insertLabelSchema, insertTaskSchema, insertNoteSchema, insertXpEventSchema, insertGoalSchema } from "../shared/schema.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 { AiService } from "./ai.js";
import { setupAuth, hashPassword } from "./auth.js";
const emailService = new EmailService(storage);
const aiService = new AiService(storage);
import { setupAuth, hashPassword, comparePassword } from "./auth.js";
function isAdmin(req: any, res: any, next: any) {
if (req.isAuthenticated() && req.user.role === 'admin') {
@@ -58,6 +64,70 @@ export async function registerRoutes(app: Express): Promise<Server> {
}
});
// --- Password Reset Routes ---
app.post("/api/auth/forgot-password", async (req, res) => {
try {
const { email } = req.body;
if (!email) return res.status(400).json({ error: "Email required" });
const user = await storage.getUserByEmail(email);
if (!user) {
// Check security best practices: delay response or return success to avoid enumeration?
// For now, let's behave nicely.
return res.json({ message: "If an account exists, a reset email has been sent." });
}
const tokenString = crypto.randomUUID();
// Expires in 1 hour
const expiresAt = new Date(Date.now() + 60 * 60 * 1000);
await storage.createPasswordResetToken({
userId: user.id,
token: tokenString,
expiresAt,
isUsed: false
});
await emailService.sendPasswordResetEmail(user, tokenString);
res.json({ message: "If an account exists, a reset email has been sent." });
} catch (e) {
console.error("Forgot Password Error:", e);
res.status(500).json({ error: "Server error" });
}
});
app.post("/api/auth/reset-password", async (req, res) => {
try {
const { token, newPassword } = req.body;
if (!token || !newPassword) return res.status(400).json({ error: "Token and password required" });
const resetToken = await storage.getPasswordResetToken(token);
if (!resetToken) {
return res.status(400).json({ error: "Invalid or expired token" });
}
if (resetToken.isUsed) {
return res.status(400).json({ error: "Token already used" });
}
if (new Date() > new Date(resetToken.expiresAt)) {
return res.status(400).json({ error: "Token expired" });
}
// Update User Password
const hashedPassword = await hashPassword(newPassword);
await storage.updateUser(resetToken.userId, { password: hashedPassword });
// Mark token used
await storage.markPasswordResetTokenUsed(resetToken.id);
res.json({ message: "Password reset successfully. You can now login." });
} catch (e) {
console.error("Reset Password Error:", e);
res.status(500).json({ error: "Server error" });
}
});
// --- Admin Routes ---
app.get("/api/admin/users", isAdmin, async (req, res) => {
try {
@@ -89,7 +159,7 @@ export async function registerRoutes(app: Express): Promise<Server> {
const user = await storage.getUser(req.params.id);
if (!user) return res.status(404).json({ error: "User not found" });
if (user.role === 'admin' && user.id === req.user.id) {
if (user.role === 'admin' && user.id === (req.user as User).id) {
return res.status(400).json({ error: "Cannot deactivate yourself" });
}
@@ -100,19 +170,84 @@ export async function registerRoutes(app: Express): Promise<Server> {
}
});
app.delete("/api/admin/users/:id", isAdmin, async (req, res) => {
try {
const user = await storage.getUser(req.params.id);
if (!user) return res.status(404).json({ error: "User not found" });
if (user.role === 'admin' && user.id === (req.user as User).id) {
return res.status(400).json({ error: "Cannot delete yourself" });
}
await storage.deleteUser(user.id);
res.sendStatus(204);
} catch (e) {
res.status(500).json({ error: "Failed to delete user" });
}
});
app.get("/api/admin/settings", isAdmin, async (req, res) => {
const regEnabled = await storage.getSystemSettings("registration_enabled");
res.json({ registration_enabled: regEnabled === "true" });
const keys = ["registration_enabled", "smtp_host", "smtp_port", "smtp_user", "smtp_pass", "smtp_from", "smtp_secure"];
const settings: any = {};
for (const key of keys) {
const val = await storage.getSystemSettings(key);
if (key === "registration_enabled" || key === "smtp_secure") {
settings[key] = val === "true";
} else {
settings[key] = val || ""; // Return empty string if undefined for inputs
}
}
res.json(settings);
});
app.post("/api/admin/settings", isAdmin, async (req, res) => {
await storage.setSystemSettings("registration_enabled", String(req.body.registration_enabled));
const keys = ["registration_enabled", "smtp_host", "smtp_port", "smtp_user", "smtp_pass", "smtp_from", "smtp_secure"];
for (const key of keys) {
if (req.body[key] !== undefined) {
await storage.setSystemSettings(key, String(req.body[key]));
}
}
res.json({ success: true });
});
app.post("/api/admin/settings", isAdmin, async (req, res) => {
await storage.setSystemSettings("registration_enabled", String(req.body.registration_enabled));
res.json({ success: true });
// --- AI Routes ---
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 { messages } = req.body;
if (!Array.isArray(messages)) return res.status(400).json({ error: "Messages must be an array" });
// Build User Context
const tasks = await storage.getTasksForUser(user.id);
const activeTasks = tasks.filter(t => t.status !== 'done');
const completedTasks = tasks.filter(t => t.status === 'done');
const context = `
User Context:
- User ID: ${user.id}
- Username: ${user.username}
- XP: ${user.xp} (Level ${user.level})
Task Summary:
- Total Active Tasks: ${activeTasks.length}
- Total Completed Tasks: ${completedTasks.length}
High Priority Active Tasks:
${activeTasks.filter(t => t.priority === 'high').map(t => `- ${t.title} (Due: ${t.dueDate})`).join('\n') || 'None'}
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 });
} catch (e: any) {
console.error("AI Route Error:", e);
res.status(500).json({ error: e.message || "Failed to generate AI response" });
}
});
// Health check endpoint
@@ -123,8 +258,24 @@ export async function registerRoutes(app: Express): Promise<Server> {
// Labels API routes
app.get("/api/labels", async (req, res) => {
try {
const labels = await storage.getAllLabels();
res.json(labels);
if (!req.isAuthenticated()) return res.sendStatus(401);
const userId = (req.user as User).id;
const allLabels = await storage.getAllLabels();
const sharedLabels = await storage.getSharedLabels(userId);
const sharedLabelIds = new Set(sharedLabels.map(sl => sl.labelId));
// Filter: Created by me OR Shared with me
// If creatorId is null (legacy/default), everyone sees it? Or system public?
// Assumption: labels with creatorId=null are "System Defaults" visible to all.
// Or we should update getAllLabels to filter in DB.
const visibleLabels = allLabels.filter(l =>
l.creatorId === userId ||
sharedLabelIds.has(l.id) ||
l.creatorId === null
);
res.json(visibleLabels);
} catch (error) {
res.status(500).json({ error: "Failed to fetch labels" });
}
@@ -149,9 +300,13 @@ export async function registerRoutes(app: Express): Promise<Server> {
return res.status(400).json({ error: "Invalid label data", details: result.error });
}
const label = await storage.createLabel(result.data);
const label = await storage.createLabel({
...result.data,
creatorId: (req.user as User).id // Assign creator
});
res.status(201).json(label);
} catch (error) {
console.error("Create Label Error:", error);
res.status(500).json({ error: "Failed to create label" });
}
});
@@ -185,11 +340,89 @@ export async function registerRoutes(app: Express): Promise<Server> {
}
});
// Tasks API routes
// Shared Label Routes
app.get("/api/labels/:id/share", async (req, res) => {
if (!req.isAuthenticated()) return res.sendStatus(401);
try {
const label = await storage.getLabel(req.params.id);
if (!label) return res.status(404).json({ error: "Label not found" });
// Only creator or admin or write permission can view shares?
// Actually creator, or anyone with 'write'/'admin' permission on the label?
// For simplicity: Only creator can manage shares.
if (label.creatorId && label.creatorId !== (req.user as User).id) {
return res.status(403).json({ error: "Only the label owner can manage shares" });
}
// const shares = await storage.getLabelShares(req.params.id); // Not needed as we fetch below
// We need user details for the frontend
const users = await storage.getLabelSharedUsers(req.params.id);
const sharesWithDetails = await Promise.all(users.map(async u => {
const shareInfos = await storage.getLabelShares(req.params.id);
const specificShare = shareInfos.find(s => s.sharedWithUserId === u.id);
return {
userId: u.id,
username: u.username,
permission: specificShare?.permission || 'read'
};
}));
res.json(sharesWithDetails);
} catch (e) {
res.status(500).json({ error: "Failed to fetch label shares" });
}
});
app.post("/api/labels/:id/share", async (req, res) => {
if (!req.isAuthenticated()) return res.sendStatus(401);
try {
const { username, permission } = req.body;
if (!username) return res.status(400).json({ error: "Username required" });
const label = await storage.getLabel(req.params.id);
if (!label) return res.status(404).json({ error: "Label not found" });
if (label.creatorId && label.creatorId !== (req.user as User).id) {
return res.status(403).json({ error: "Only owner can share label" });
}
const targetUser = await storage.getUserByUsername(username);
if (!targetUser) return res.status(404).json({ error: "User not found" });
if (targetUser.id === (req.user as User).id) return res.status(400).json({ error: "Cannot share with yourself" });
const share = await storage.shareLabel(label.id, targetUser.id, (req.user as User).id, permission || 'read');
res.json(share);
} catch (e) {
res.status(500).json({ error: "Failed to share label" });
}
});
app.delete("/api/labels/:id/share/:userId", async (req, res) => {
if (!req.isAuthenticated()) return res.sendStatus(401);
try {
const label = await storage.getLabel(req.params.id);
if (!label) return res.status(404).json({ error: "Label not found" });
if (label.creatorId && label.creatorId !== (req.user as User).id) {
// Also allow user to unshare themselves?
if (req.params.userId !== (req.user as User).id) {
return res.status(403).json({ error: "Only owner can remove other collaborators" });
}
}
await storage.unshareLabel(label.id, req.params.userId);
res.sendStatus(204);
} catch (e) {
res.status(500).json({ error: "Failed to remove share" });
}
});
// Tasks API routes (updated GET)
app.get("/api/tasks", async (req, res) => {
if (!req.isAuthenticated()) return res.sendStatus(401);
try {
const tasks = await storage.getTasksForUser(req.user.id);
const tasks = await storage.getTasksForUser((req.user as User).id);
res.json(tasks);
} catch (error) {
res.status(500).json({ error: "Failed to fetch tasks" });
@@ -220,7 +453,7 @@ export async function registerRoutes(app: Express): Promise<Server> {
const task = await storage.createTask({
...result.data,
userId: req.user.id
userId: (req.user as User).id
});
res.status(201).json(task);
} catch (error) {
@@ -229,6 +462,7 @@ export async function registerRoutes(app: Express): Promise<Server> {
});
app.patch("/api/tasks/:id", async (req, res) => {
if (!req.isAuthenticated()) return res.sendStatus(401);
try {
const previousTask = await storage.getTask(req.params.id);
const updates = insertTaskSchema.partial().safeParse(req.body);
@@ -239,15 +473,140 @@ export async function registerRoutes(app: Express): Promise<Server> {
// Gamification: Award XP on completion
if (previousTask && previousTask.status !== 'done' && updates.data.status === 'done') {
const xpEarned = calculateXP(previousTask);
// await storage.addXP(userId, xpEarned);
await storage.logXpEvent({
userId: "mock-user-id", // Middleware usually handles this
amount: xpEarned,
source: 'task_completion',
taskId: previousTask.id
});
console.log(`[Gamification] Awarded ${xpEarned} XP for task ${previousTask.title}`);
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
}
}
const task = await storage.updateTask(req.params.id, updates.data);
@@ -255,8 +614,9 @@ export async function registerRoutes(app: Express): Promise<Server> {
return res.status(404).json({ error: "Task not found" });
}
res.json(task);
} catch (error) {
res.status(500).json({ error: "Failed to update task" });
} catch (error: any) {
console.error("PATCH Task Error:", error);
res.status(500).json({ error: "Failed to update task", details: String(error) });
}
});
@@ -330,7 +690,7 @@ export async function registerRoutes(app: Express): Promise<Server> {
// For simplicity, let's return day index relative to today or just standard day index (0=Sun)
// To make it look "last 7 days" we can return relative indices
const keys = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'];
// Better: Send localizable keys.
// Better: Send localizable keys.
// Day format: "day_1" (Mon) ... "day_7" (Sun) or just short codes the frontend can map
// We will send standard JS Day indices adjusted: 1 (Mon) - 7 (Sun) for "ISO Week" style or just 0-6
@@ -390,77 +750,113 @@ export async function registerRoutes(app: Express): Promise<Server> {
// Rewards API
app.get("/api/rewards", async (req, res) => {
try {
const userId = req.query.userId as string; // Optional context
const allRewards = await storage.getAllRewards();
const userId = req.query.userId as string;
let responseData: any[] = allRewards;
// Filter rewards: System rewards OR User's own rewards
const visibleRewards = allRewards.filter(r => r.isSystem || (userId && r.userId === userId));
// If userId is provided, check ownership of visible rewards
if (userId) {
const userRewards = await storage.getUserRewards(userId);
const ownedRewardIds = new Set(userRewards.map(ur => ur.rewardId));
responseData = allRewards.map(reward => ({
...reward,
owned: ownedRewardIds.has(reward.id)
}));
const ownedIds = new Set(userRewards.map(ur => ur.rewardId));
return res.json(visibleRewards.map(r => ({ ...r, owned: ownedIds.has(r.id) })));
}
res.json(responseData);
res.json(visibleRewards);
} catch (error) {
res.status(500).json({ error: "Failed to fetch rewards" });
}
});
app.post("/api/rewards/buy", async (req, res) => {
const { rewardId, userId } = req.body;
if (!rewardId || !userId) {
return res.status(400).json({ error: "Missing rewardId or userId" });
}
app.post("/api/rewards/purchase", async (req, res) => {
if (!req.isAuthenticated()) return res.sendStatus(401);
try {
const user = await storage.getUser(userId); // In real app, user is from session
if (!user) return res.status(404).json({ error: "User not found" });
// 1. Get User & Reward
const userId = (req.user as User).id;
const { rewardId } = req.body;
if (!rewardId) return res.status(400).json({ error: "Missing rewardId" });
const user = await storage.getUser(userId); // Fetch user here
const allRewards = await storage.getAllRewards();
const reward = allRewards.find(r => r.id === rewardId);
if (!reward) return res.status(404).json({ error: "Reward not found" });
// Check balance
if (user.xp < reward.cost) {
return res.status(400).json({ error: "Not enough XP" });
}
if (!user || !reward) return res.status(404).json({ error: "User or Reward not found" });
// Check one-time
// 2. Check Ownership (if one-time)
// For now, allow multiple purchases unless type is 'feature_unlock'
if (reward.type === 'feature_unlock') {
const userRewards = await storage.getUserRewards(userId);
if (userRewards.some(ur => ur.rewardId === rewardId)) {
return res.status(400).json({ error: "Already owned" });
return res.status(400).json({ error: "Reward already owned" });
}
}
// Execute transaction
await storage.updateUserXP(userId, -reward.cost);
await storage.createUserReward({
userId,
rewardId,
purchasedAt: new Date()
});
// 3. Check Funds
if (user.xp < reward.cost) {
return res.status(400).json({ error: "Insufficient XP" });
}
const updatedUser = await storage.getUser(userId);
res.json(updatedUser);
} catch (err) {
res.status(500).json({ error: "Failed to buy reward" });
// 4. Transaction
const updatedUser = await storage.updateUserXP(userId, -reward.cost);
await storage.createUserReward({ userId, rewardId });
res.json({ success: true, user: updatedUser });
} catch (e) {
res.status(500).json({ error: "Purchase failed" });
}
});
app.post("/api/rewards", async (req, res) => {
if (!req.isAuthenticated()) return res.sendStatus(401);
try {
const reward = await storage.createReward(req.body);
const rewardData = {
...req.body,
userId: (req.user as User).id,
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) {
res.status(500).json({ error: "Failed to create reward" });
}
});
// User Gamification Endpoints
app.get("/api/user/history", async (req, res) => {
if (!req.isAuthenticated()) return res.sendStatus(401);
try {
const history = await storage.getXpEvents((req.user as User).id);
res.json(history);
} catch (e) {
res.status(500).json({ error: "Failed to fetch history" });
}
});
app.get("/api/user/inventory", async (req, res) => {
if (!req.isAuthenticated()) return res.sendStatus(401);
try {
const userRewards = await storage.getUserRewards((req.user as User).id);
// Join with rewards details
const allRewards = await storage.getAllRewards();
const inventory = userRewards.map(ur => {
const reward = allRewards.find(r => r.id === ur.rewardId);
return {
...ur,
reward // Nested details
};
}).filter(item => item.reward); // Filter out any broken links
res.json(inventory);
} catch (e) {
res.status(500).json({ error: "Failed to fetch inventory" });
}
});
// --- Social & Leaderboard Routes ---
app.get("/api/leaderboard", async (req, res) => {
@@ -482,24 +878,65 @@ export async function registerRoutes(app: Express): Promise<Server> {
app.patch("/api/user/privacy", async (req, res) => {
if (!req.isAuthenticated()) return res.sendStatus(401);
try {
const { showOnLeaderboard, isSearchable } = req.body;
const updated = await storage.updateUser(req.user.id, {
showOnLeaderboard,
isSearchable
});
const { showOnLeaderboard, isSearchable, aiEnabled } = req.body;
const updates: any = {};
if (showOnLeaderboard !== undefined) updates.showOnLeaderboard = showOnLeaderboard;
if (isSearchable !== undefined) updates.isSearchable = isSearchable;
if (aiEnabled !== undefined) updates.aiEnabled = aiEnabled;
const updated = await storage.updateUser((req.user as User).id, updates);
res.json(updated);
} catch (e) {
res.status(500).json({ error: "Failed to update privacy settings" });
}
});
app.patch("/api/user/profile", async (req, res) => {
if (!req.isAuthenticated()) return res.sendStatus(401);
try {
const { email } = req.body;
if (!email || !email.includes('@')) return res.status(400).json({ error: "Invalid email" });
const existing = await storage.getUserByEmail(email);
if (existing && existing.id !== (req.user as User).id) {
return res.status(400).json({ error: "Email already taken" });
}
const updated = await storage.updateUser((req.user as User).id, { email });
res.json(updated);
} catch (e) {
res.status(500).json({ error: "Failed to update profile" });
}
});
app.patch("/api/user/password", async (req, res) => {
if (!req.isAuthenticated()) return res.sendStatus(401);
try {
const { currentPassword, newPassword } = req.body;
if (!currentPassword || !newPassword) return res.status(400).json({ error: "Missing fields" });
const user = await storage.getUser((req.user as User).id);
if (!user) return res.status(404).json({ error: "User not found" });
const isValid = await comparePassword(currentPassword, user.password);
if (!isValid) return res.status(400).json({ error: "Incorrect current password" });
const hashedPassword = await hashPassword(newPassword);
await storage.updateUser(user.id, { password: hashedPassword });
res.json({ message: "Password updated" });
} catch (e) {
res.status(500).json({ error: "Failed to update password" });
}
});
app.get("/api/users/search", async (req, res) => {
if (!req.isAuthenticated()) return res.sendStatus(401);
const query = req.query.q as string;
try {
const users = await storage.searchUsers(query);
// Filter out self
const others = users.filter(u => u.id !== req.user.id);
const others = users.filter(u => u.id !== (req.user as User).id);
res.json(others.map(u => ({ id: u.id, username: u.username })));
} catch (e) {
res.status(500).json({ error: "Search failed" });
@@ -514,49 +951,15 @@ export async function registerRoutes(app: Express): Promise<Server> {
// Verify ownership
const task = await storage.getTask(taskId);
// In a real app we check if task.userId === req.user.id (if tasks had owners linked directly in schema or via strict checks)
// Current schema: tasks dont have userId explicit column in the CREATE table snippet I saw earlier?
// Wait, let me check schema again. tasks table has projectId, labelId... but where is userId?
// Notes table has userId. Goals has userId. UserRewards has userId.
// TASKS TABLE DOES NOT HAVE USERID IN THE SCHEMA I VIEWED.
// This is a major oversight in the original schema if true.
// Oh, wait. `tasks` table definition in schema.ts:
// export const tasks = pgTable("tasks", { ... })
// It DOES NOT have userId.
// How does the app know whose task is whose?
// `getAllTasks` in `routes.ts` returns ALL tasks from storage.
// `storage.getAllTasks()` returns logic.
// `routes.ts` `GET /api/tasks` calls `storage.getAllTasks()`. it does NOT filter by user.
// This means currently ALL tasks are shared/global in this MVP?!
// If so, sharing is redundant?
// "implement the feature of be able to share a single task... but only if different users are also allowing me to see them publicly"
// If the User is asking for sharing, they imply they CANNOT see them right now?
// Or maybe they see EVERYTHING now and want to RESTRICT it?
// "implement the feature of be able to share a single task with different users... "
// If `GET /api/tasks` returns everything, then everyone sees everything.
// I should verified this.
// Converting to PER-USER tasks is a HUGE refactor if missing.
if (!task) return res.status(404).json({ error: "Task not found" });
// Checking `server/routes.ts` line 177: `const tasks = await storage.getAllTasks();`
// Yes, it returns everything.
// However, usually in these generated MVPs, we assume single user or shared workspace.
// BUT, the User Request explicitly says "share a single task with different users".
// This implies tasks should be private by default.
// I MUST Add `userId` to `tasks` table to support this feature properly.
// And filter `GET /api/tasks` to only show MY tasks + SHARED tasks.
// I will proceed with adding userId to tasks as part of this feature.
// Re-reading Plan: "Share specific tasks... respecting visibility".
// If I don't add userId, I can't implement "private by default".
// So steps:
// 1. Add userId to tasks.
// 2. Logic for sharing.
if (task.userId !== (req.user as User).id) {
return res.status(403).json({ error: "Unauthorized" });
}
await storage.shareTask({
taskId,
sharedByUserId: req.user.id,
sharedByUserId: (req.user as User).id,
sharedWithUserId: targetUserId
});
res.json({ success: true });
@@ -565,12 +968,57 @@ export async function registerRoutes(app: Express): Promise<Server> {
}
});
app.get("/api/tasks/:id/shared-users", async (req, res) => {
if (!req.isAuthenticated()) return res.sendStatus(401);
try {
const taskId = req.params.id;
// Verify ownership or access? Ideally only owner can see who else sees it.
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.status(403).json({ error: "Unauthorized" });
}
const users = await storage.getTaskSharedUsers(taskId);
// Return minimal info
res.json(users.map(u => ({ id: u.id, username: u.username })));
} catch (e) {
res.status(500).json({ error: "Failed to fetch shared users" });
}
});
app.delete("/api/tasks/:id/share/:userId", async (req, res) => {
if (!req.isAuthenticated()) return res.sendStatus(401);
try {
const taskId = req.params.id;
const targetUserId = req.params.userId;
// Verify ownership
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.status(403).json({ error: "Unauthorized" });
}
const success = await storage.unshareTask(taskId, targetUserId);
if (success) {
res.json({ success: true });
} else {
res.status(404).json({ error: "Share not found" });
}
} catch (e) {
res.status(500).json({ error: "Failed to unshare task" });
}
});
app.post("/api/users/share-all", async (req, res) => {
if (!req.isAuthenticated()) return res.sendStatus(401);
try {
const { targetUserId } = req.body;
await storage.shareAllTasks({
ownerId: req.user.id,
ownerId: (req.user as User).id,
viewerId: targetUserId
});
res.json({ success: true });
@@ -583,5 +1031,16 @@ export async function registerRoutes(app: Express): Promise<Server> {
const httpServer = createServer(app);
// Storage needs to support Goal Update
app.patch("/api/goals/:id", async (req, res) => {
if (!req.isAuthenticated()) return res.sendStatus(401);
try {
const updated = await storage.updateGoal(req.params.id, req.body);
res.json(updated);
} catch (e) {
res.status(500).json({ error: "Failed to update goal" });
}
});
return httpServer;
}