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
+147
View File
@@ -0,0 +1,147 @@
import { IStorage } from "./storage";
import { User } from "../shared/schema";
interface ChatMessage {
role: "system" | "user" | "assistant";
content: string;
}
export class AiService {
constructor(private storage: IStorage) { }
async chat(messages: ChatMessage[], user: User, context: string): 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");
if (!apiKey && provider !== "ollama") {
throw new Error("AI API Key not configured");
}
const systemPrompt = `You are TaskFlow AI, an intelligent assistant for the TaskFlow application.
You have access to the user's current tasks and context.
User Name: ${user.username}
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).
`;
const fullMessages = [
{ role: "system", content: systemPrompt },
...messages
];
try {
if (provider === "openai" || provider === "ollama") {
return await this.chatOpenAI(provider, apiKey || "", model, baseUrl, fullMessages);
} else if (provider === "anthropic") {
return await this.chatAnthropic(apiKey || "", model, fullMessages);
} else if (provider === "google") {
return await this.chatGemini(apiKey || "", model, fullMessages);
} else {
throw new Error(`Unsupported AI provider: ${provider}`);
}
} catch (error: any) {
console.error("AI Chat Error:", error);
throw new Error(`AI Service Error: ${error.message}`);
}
}
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";
// Clean URL
const cleanUrl = url.replace(/([^:]\/)\/+/g, "$1"); // remove double slashes
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,
}),
});
if (!response.ok) {
const err = await response.text();
throw new Error(`OpenAI/Ollama API Error ${response.status}: ${err}`);
}
const data = await response.json();
return data.choices[0]?.message?.content || "No response generated.";
}
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");
const response = await fetch("https://api.anthropic.com/v1/messages", {
method: "POST",
headers: {
"Content-Type": "application/json",
"x-api-key": apiKey,
"anthropic-version": "2023-06-01",
},
body: JSON.stringify({
model: model,
system: systemMessage,
messages: userAssistantMessages,
max_tokens: 1024,
}),
});
if (!response.ok) {
const err = await response.text();
throw new Error(`Anthropic API Error ${response.status}: ${err}`);
}
const data = await response.json();
return data.content[0]?.text || "No response generated.";
}
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",
parts: [{ text: m.content }]
}));
if (contentMessages.length > 0 && contentMessages[0].role === "user") {
contentMessages[0].parts[0].text = `[System Instruction: ${systemMessage}]\n\nWait for user input... User Input: ` + contentMessages[0].parts[0].text;
}
const url = `https://generativelanguage.googleapis.com/v1beta/models/${model}:generateContent?key=${apiKey}`;
const response = await fetch(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
contents: contentMessages
}),
});
if (!response.ok) {
const err = await response.text();
throw new Error(`Gemini API Error ${response.status}: ${err}`);
}
const data = await response.json();
return data.candidates?.[0]?.content?.parts?.[0]?.text || "No response generated.";
}
}
+10 -4
View File
@@ -15,7 +15,7 @@ export async function hashPassword(password: string) {
return `${buf.toString("hex")}.${salt}`;
}
async function comparePassword(supplied: string, stored: string) {
export async function comparePassword(supplied: string, stored: string) {
const [hashed, salt] = stored.split(".");
const hashedBuf = Buffer.from(hashed, "hex");
const suppliedBuf = (await scryptAsync(supplied, salt, 64)) as Buffer;
@@ -28,11 +28,14 @@ export function setupAuth(app: Express) {
resave: false,
saveUninitialized: false,
store: storage.sessionStore,
cookie: {
secure: process.env.NODE_ENV === "production" && process.env.SECURE_COOKIES === "true",
sameSite: "lax",
// maxAge not set by default (session cookie)
},
};
if (app.get("env") === "production") {
app.set("trust proxy", 1);
}
app.use(session(sessionSettings));
app.use(passport.initialize());
@@ -128,6 +131,9 @@ export function setupAuth(app: Express) {
});
app.post("/api/login", passport.authenticate("local"), (req, res) => {
if (req.body.rememberMe) {
req.session.cookie.maxAge = 30 * 24 * 60 * 60 * 1000; // 30 days
}
res.status(200).json(req.user);
});
+96
View File
@@ -0,0 +1,96 @@
import nodemailer from 'nodemailer';
import { IStorage } from './storage';
import { User } from '../shared/schema';
interface EmailSettings {
host: string;
port: number;
user?: string;
pass?: string;
from: string;
secure: boolean;
}
export class EmailService {
private storage: IStorage;
constructor(storage: IStorage) {
this.storage = storage;
}
private async getTransporter() {
// Try to get settings from DB
const host = await this.storage.getSystemSettings('smtp_host');
const port = await this.storage.getSystemSettings('smtp_port');
const user = await this.storage.getSystemSettings('smtp_user');
const pass = await this.storage.getSystemSettings('smtp_pass');
const from = await this.storage.getSystemSettings('smtp_from');
const secure = await this.storage.getSystemSettings('smtp_secure');
// Fallback to Env or MailHog defaults
const settings: EmailSettings = {
host: host || process.env.SMTP_HOST || 'localhost',
port: port ? parseInt(port) : (process.env.SMTP_PORT ? parseInt(process.env.SMTP_PORT) : 1025),
user: user || process.env.SMTP_USER,
pass: pass || process.env.SMTP_PASS,
from: from || process.env.SMTP_FROM || '"TaskFlow" <noreply@taskflow.local>',
secure: secure === 'true'
};
return nodemailer.createTransport({
host: settings.host,
port: settings.port,
secure: settings.secure,
auth: settings.user ? {
user: settings.user,
pass: settings.pass
} : undefined,
ignoreTLS: !settings.secure // useful for MailHog
});
}
async sendWelcomeEmail(user: User) {
try {
const transporter = await this.getTransporter();
const info = await transporter.sendMail({
from: await this.getFromAddress(),
to: user.email,
subject: 'Welcome to TaskFlow!',
text: `Hi ${user.username},\n\nWelcome to TaskFlow! We're excited to have you on board.\n\nBest,\nThe TaskFlow Team`,
html: `<h1>Welcome to TaskFlow!</h1><p>Hi ${user.username},</p><p>We're excited to have you on board.</p><p>Best,<br>The TaskFlow Team</p>`
});
console.log(`[Email] Welcome email sent to ${user.email}: ${info.messageId}`);
return true;
} catch (error) {
console.error(`[Email] Failed to send welcome email to ${user.email}:`, error);
return false;
}
}
async sendPasswordResetEmail(user: User, token: string) {
try {
const transporter = await this.getTransporter();
// TODO: Get base URL from settings or env
const baseUrl = process.env.APP_URL || 'http://localhost:5001';
const resetLink = `${baseUrl}/reset-password?token=${token}`;
const info = await transporter.sendMail({
from: await this.getFromAddress(),
to: user.email,
subject: 'Reset your TaskFlow Password',
text: `Hi ${user.username},\n\nYou requested a password reset. Click the link below to reset your password:\n\n${resetLink}\n\nIf you didn't request this, please ignore this email.\n\nThis link expires in 1 hour.`,
html: `<h1>Reset Password</h1><p>Hi ${user.username},</p><p>You requested a password reset. Click the link below to reset your password:</p><p><a href="${resetLink}">Reset Password</a></p><p>If you didn't request this, please ignore this email.</p><p>This link expires in 1 hour.</p>`
});
console.log(`[Email] Password reset email sent to ${user.email}: ${info.messageId}`);
return true;
} catch (error) {
console.error(`[Email] Failed to send reset email to ${user.email}:`, error);
return false;
}
}
private async getFromAddress() {
const from = await this.storage.getSystemSettings('smtp_from');
return from || process.env.SMTP_FROM || '"TaskFlow" <noreply@taskflow.local>';
}
}
+2 -2
View File
@@ -3,6 +3,7 @@ import { registerRoutes } from "./routes.js";
import { initializeDatabase, closeDatabase } from "./db.js";
const app = express();
app.set("trust proxy", true);
app.use(express.json());
app.use(express.urlencoded({ extended: false }));
@@ -101,11 +102,10 @@ app.use((req, res, next) => {
// Other ports are firewalled. Default to 5000 if not specified.
// this serves both the API and the client.
// It is the only port that is not firewalled.
const port = parseInt(process.env.PORT || '5000', 10);
const port = parseInt(process.env.PORT || '5001', 10);
server.listen({
port,
host: "0.0.0.0",
reusePort: true,
}, () => {
log(`serving on port ${port}`);
});
+212
View File
@@ -0,0 +1,212 @@
import { Request, Response } from "express";
import { storage } from "./storage";
import { User } from "../shared/schema";
import { randomBytes } from "crypto";
interface JsonRpcRequest {
jsonrpc: "2.0";
method: string;
params?: any;
id: number | string;
}
interface JsonRpcResponse {
jsonrpc: "2.0";
result?: any;
error?: {
code: number;
message: string;
data?: any;
};
id: number | string | null;
}
export class McpServer {
private clients: Map<string, Response> = new Map();
async authenticate(req: Request): Promise<User | null> {
let key = req.headers["x-api-key"] as string;
if (!key && req.query.apiKey) {
key = req.query.apiKey as string;
}
// Bearer token support
if (!key && req.headers["authorization"]) {
const auth = req.headers["authorization"];
if (auth.startsWith("Bearer ")) {
key = auth.substring(7);
}
}
if (!key) return null;
return (await storage.getUserByApiKey(key)) || null;
}
async handleSse(req: Request, res: Response) {
const user = await this.authenticate(req);
if (!user) {
res.status(401).send("Unauthorized: Invalid API Key");
return;
}
res.writeHead(200, {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache",
"Connection": "keep-alive",
"Access-Control-Allow-Origin": "*", // Allow local connections
});
const sessionId = randomBytes(8).toString("hex");
this.clients.set(sessionId, res);
const endpoint = `/api/mcp/messages`;
res.write(`event: endpoint\ndata: ${endpoint}\n\n`);
// Keep alive
const interval = setInterval(() => {
res.write(": keepalive\n\n");
}, 15000);
req.on("close", () => {
clearInterval(interval);
this.clients.delete(sessionId);
});
}
async handleMessage(req: Request, res: Response) {
const user = await this.authenticate(req);
if (!user) {
res.status(401).json({ error: "Unauthorized" });
return;
}
const body = req.body as JsonRpcRequest;
try {
const result = await this.processRequest(body, user);
res.json({
jsonrpc: "2.0",
result,
id: body.id
});
} catch (err: any) {
res.json({
jsonrpc: "2.0",
error: {
code: -32000,
message: err.message || "Internal Server Error"
},
id: body.id
});
}
}
private async processRequest(req: JsonRpcRequest, user: User): Promise<any> {
switch (req.method) {
case "initialize":
return {
protocolVersion: "2024-11-05",
capabilities: {
tools: {},
resources: {}
},
serverInfo: {
name: "TaskFlow MCP",
version: "1.0.0"
}
};
case "tools/list": // MCP method
case "listTools": // Legacy fallback
return {
tools: [
{
name: "list_tasks",
description: "List all tasks for the user",
inputSchema: {
type: "object",
properties: {
status: { type: "string", enum: ["todo", "inProgress", "done"], description: "Filter by status" },
limit: { type: "number", description: "Limit number of tasks" }
}
}
},
{
name: "create_task",
description: "Create a new task",
inputSchema: {
type: "object",
properties: {
title: { type: "string", description: "Title of the task" },
description: { type: "string", description: "Description" },
priority: { type: "string", enum: ["low", "medium", "high"] }
},
required: ["title"]
}
},
{
name: "complete_task",
description: "Mark a task as completed",
inputSchema: {
type: "object",
properties: {
id: { type: "string", description: "Task ID" }
},
required: ["id"]
}
}
]
};
case "tools/call": // MCP method
case "callTool": // Legacy
return await this.handleToolCall(req.params.name, req.params.arguments, user);
case "notifications/initialized":
return true;
default:
throw new Error(`Method ${req.method} not found`);
}
}
private async handleToolCall(name: string, args: any, user: User) {
switch (name) {
case "list_tasks": {
let tasks = await storage.getTasksForUser(user.id);
if (args?.status) {
tasks = tasks.filter((t) => t.status === args.status);
}
if (args?.limit) {
tasks = tasks.slice(0, args.limit);
}
return { content: [{ type: "text", text: JSON.stringify(tasks, null, 2) }] };
}
case "create_task": {
if (!args.title) throw new Error("Title is required");
const task = await storage.createTask({
title: args.title,
description: args.description || "",
priority: args.priority || "medium",
status: "todo",
isTracking: false,
timeTracked: 0,
energyLevel: "medium",
estimatedDuration: 15,
dueDate: null,
notes: "",
labelId: null,
userId: user.id
});
return { content: [{ type: "text", text: JSON.stringify(task, null, 2) }] };
}
case "complete_task": {
if (!args.id) throw new Error("ID is required");
const task = await storage.getTask(args.id);
if (!task || task.userId !== user.id) throw new Error("Task not found");
const updated = await storage.updateTask(args.id, { status: "done" });
return { content: [{ type: "text", text: JSON.stringify(updated, null, 2) }] };
}
default:
throw new Error(`Tool ${name} not implemented`);
}
}
}
export const mcpServer = new McpServer();
+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;
}
+340 -17
View File
@@ -1,9 +1,9 @@
import { type User, type InsertUser, type Label, type InsertLabel, 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 } 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, 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 { randomUUID } from "crypto";
import session from "express-session";
import createMemoryStore from "memorystore";
import connectPg from "connect-pg-simple";
import { pool } from "./db";
import { pool } from "./db.js";
const MemoryStore = createMemoryStore(session);
const PostgresStore = connectPg(session);
@@ -13,8 +13,11 @@ export interface IStorage {
getUser(id: string): Promise<User | undefined>;
getUserByUsername(username: string): Promise<User | undefined>;
getUserByEmail(email: string): Promise<User | undefined>;
getUserByApiKey(apiKey: string): Promise<User | undefined>;
createUser(user: InsertUser & { role?: string; isActive?: boolean }): Promise<User>;
updateUserApiKey(userId: string, apiKey: string | null): Promise<User>;
updateUser(id: string, updates: Partial<User>): Promise<User>;
deleteUser(id: string): Promise<boolean>;
getAllUsers(): Promise<User[]>;
updateUserXP(id: string, xp: number): Promise<void>;
@@ -26,8 +29,18 @@ 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
unshareTask(taskId: string, userId: string): Promise<boolean>; // Unshare specific task from user
// Shared Labels
shareLabel(labelId: string, sharedWithUserId: string, sharedByUserId: string, permission?: string): Promise<SharedLabel>;
getSharedLabels(userId: string): Promise<SharedLabel[]>; // Labels shared WITH user
getLabelSharedUsers(labelId: string): Promise<User[]>; // Users label is shared WITH
unshareLabel(labelId: string, userId: string): Promise<boolean>;
getLabelShares(labelId: string): Promise<SharedLabel[]>;
// System Settings (Admin)
getSystemSettings(key: string): Promise<string | undefined>;
@@ -52,12 +65,22 @@ export interface IStorage {
logXpEvent(event: InsertXpEvent): Promise<XpEvent>;
getGoals(): Promise<Goal[]>;
createGoal(goal: InsertGoal): Promise<Goal>;
updateGoal(id: string, updates: Partial<InsertGoal & { completed?: boolean; current?: number }>): Promise<Goal>;
// Rewards
getAllRewards(): Promise<Reward[]>;
getUserRewards(userId: string): Promise<UserReward[]>;
createReward(reward: InsertReward): Promise<Reward>;
createUserReward(userReward: InsertUserReward): Promise<UserReward>;
// History
// History
getXpEvents(userId: string): Promise<XpEvent[]>;
// Auth - Password Reset
createPasswordResetToken(token: InsertPasswordResetToken): Promise<PasswordResetToken>;
getPasswordResetToken(token: string): Promise<PasswordResetToken | undefined>;
markPasswordResetTokenUsed(id: string): Promise<void>;
}
export class MemStorage implements IStorage {
@@ -72,7 +95,9 @@ export class MemStorage implements IStorage {
// Social maps
private sharedTasks: Map<string, SharedTask>;
private sharedLabels: Map<string, SharedLabel>;
private userTaskAccess: Map<string, UserTaskAccess>;
private passwordResetTokens: Map<string, PasswordResetToken>; // id -> Token
sessionStore: session.Store;
@@ -85,8 +110,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.sessionStore = new MemoryStore({
checkPeriod: 86400000,
});
@@ -101,10 +129,10 @@ export class MemStorage implements IStorage {
private async createDefaultLabels() {
// Use fixed IDs to prevent ID churn on server restarts
const defaultLabels = [
{ id: 'cb44bed1-8ba3-43fe-9498-bb28e483ed1f', name: "Work", color: "#3B82F6" },
{ id: '274f0ba4-a133-471a-bbe9-8189aa3b0106', name: "Personal", color: "#10B981" },
{ id: '2e8c3aa0-278f-4220-b2c5-aa109b4279b7', name: "Urgent", color: "#EF4444" },
{ id: 'c5eccac9-7919-4eb1-bb50-badacad6b1c1', name: "Study", color: "#8B5CF6" },
{ id: 'cb44bed1-8ba3-43fe-9498-bb28e483ed1f', name: "Work", color: "#3B82F6", creatorId: null },
{ id: '274f0ba4-a133-471a-bbe9-8189aa3b0106', name: "Personal", color: "#10B981", creatorId: null },
{ id: '2e8c3aa0-278f-4220-b2c5-aa109b4279b7', name: "Urgent", color: "#EF4444", creatorId: null },
{ id: 'c5eccac9-7919-4eb1-bb50-badacad6b1c1', name: "Study", color: "#8B5CF6", creatorId: null },
];
for (const label of defaultLabels) {
if (!this.labels.has(label.id)) {
@@ -133,6 +161,18 @@ export class MemStorage implements IStorage {
return Array.from(this.users.values());
}
async getUserByApiKey(apiKey: string): Promise<User | undefined> {
return Array.from(this.users.values()).find(u => u.apiKey === apiKey);
}
async updateUserApiKey(userId: string, apiKey: string | null): Promise<User> {
const user = this.users.get(userId);
if (!user) throw new Error("User not found");
const updated = { ...user, apiKey };
this.users.set(userId, updated);
return updated;
}
async createUser(insertUser: InsertUser & { role?: string; isActive?: boolean }): Promise<User> {
const id = randomUUID();
const user: User = {
@@ -147,6 +187,8 @@ export class MemStorage implements IStorage {
lastTaskDate: null,
showOnLeaderboard: insertUser.showOnLeaderboard ?? false,
isSearchable: insertUser.isSearchable ?? false,
apiKey: null,
aiEnabled: insertUser.aiEnabled ?? true,
};
this.users.set(id, user);
return user;
@@ -161,6 +203,10 @@ export class MemStorage implements IStorage {
return updated;
}
async deleteUser(id: string): Promise<boolean> {
return this.users.delete(id);
}
// Social Methods (MemStorage)
async getLeaderboard(): Promise<User[]> {
return Array.from(this.users.values())
@@ -206,6 +252,77 @@ export class MemStorage implements IStorage {
return Array.from(this.userTaskAccess.values()).filter(uta => uta.viewerId === viewerId);
}
async getTaskSharedUsers(taskId: string): Promise<User[]> {
const shares = Array.from(this.sharedTasks.values()).filter(st => st.taskId === taskId);
const users: User[] = [];
for (const share of shares) {
const u = this.users.get(share.sharedWithUserId);
if (u) users.push(u);
}
return users;
}
async unshareTask(taskId: string, userId: string): Promise<boolean> {
let toDeleteId: string | null = null;
for (const [id, share] of this.sharedTasks.entries()) {
if (share.taskId === taskId && share.sharedWithUserId === userId) {
toDeleteId = id;
break;
}
}
if (toDeleteId) {
return this.sharedTasks.delete(toDeleteId);
}
return false;
}
// Shared Labels (MemStorage)
async shareLabel(labelId: string, sharedWithUserId: string, sharedByUserId: string, permission: string = 'read'): Promise<SharedLabel> {
const id = randomUUID();
const share: SharedLabel = {
id,
labelId,
sharedWithUserId,
sharedByUserId,
permission,
createdAt: new Date()
};
this.sharedLabels.set(id, share);
return share;
}
async getSharedLabels(userId: string): Promise<SharedLabel[]> {
return Array.from(this.sharedLabels.values()).filter(sl => sl.sharedWithUserId === userId);
}
async getLabelSharedUsers(labelId: string): Promise<User[]> {
const shares = Array.from(this.sharedLabels.values()).filter(sl => sl.labelId === labelId);
const users: User[] = [];
for (const share of shares) {
const u = this.users.get(share.sharedWithUserId);
if (u) users.push(u);
}
return users;
}
async unshareLabel(labelId: string, userId: string): Promise<boolean> {
let toDeleteId: string | null = null;
for (const [id, share] of this.sharedLabels.entries()) {
if (share.labelId === labelId && share.sharedWithUserId === userId) {
toDeleteId = id;
break;
}
}
if (toDeleteId) {
return this.sharedLabels.delete(toDeleteId);
}
return false;
}
async getLabelShares(labelId: string): Promise<SharedLabel[]> {
return Array.from(this.sharedLabels.values()).filter(sl => sl.labelId === labelId);
}
async getSystemSettings(key: string): Promise<string | undefined> {
return this.settings.get(key);
}
@@ -229,7 +346,11 @@ export class MemStorage implements IStorage {
async createLabel(insertLabel: InsertLabel): Promise<Label> {
const id = randomUUID();
const label: Label = { ...insertLabel, id };
const label: Label = {
...insertLabel,
id,
creatorId: insertLabel.creatorId ?? null
};
this.labels.set(id, label);
return label;
}
@@ -271,8 +392,14 @@ export class MemStorage implements IStorage {
globalSharedTasks = allTasks.filter(t => t.userId && ownerIds.has(t.userId));
}
// 4. Shared Labels
// Find labels shared with me
const sharedLabels = await this.getSharedLabels(userId);
const sharedLabelIds = new Set(sharedLabels.map(sl => sl.labelId));
const tasksFromSharedLabels = allTasks.filter(t => t.labelId && sharedLabelIds.has(t.labelId));
// Merge and Dedupe
const combined = [...myTasks, ...sharedToMe, ...globalSharedTasks];
const combined = [...myTasks, ...sharedToMe, ...globalSharedTasks, ...tasksFromSharedLabels];
const unique = Array.from(new Map(combined.map(t => [t.id, t])).values());
return unique;
@@ -360,6 +487,14 @@ export class MemStorage implements IStorage {
return newGoal;
}
async updateGoal(id: string, updates: Partial<InsertGoal & { completed?: boolean; current?: number }>): Promise<Goal> {
const existing = this.goals.get(id);
if (!existing) throw new Error("Goal not found");
const updated = { ...existing, ...updates };
this.goals.set(id, updated);
return updated;
}
// Rewards
private async createDefaultRewards() {
@@ -391,7 +526,8 @@ export class MemStorage implements IStorage {
id,
type: insertReward.type || "virtual",
description: insertReward.description || null,
isSystem: false
isSystem: false,
userId: insertReward.userId || null
};
this.rewards.set(id, reward);
return reward;
@@ -409,10 +545,41 @@ export class MemStorage implements IStorage {
this.userRewards.set(id, userReward);
return userReward;
}
async getXpEvents(userId: string): Promise<XpEvent[]> {
return Array.from(this.xpEvents.values())
.filter(e => e.userId === userId)
.sort((a, b) => (b.createdAt && a.createdAt ? b.createdAt.getTime() - a.createdAt.getTime() : 0));
}
// Auth - Password Reset (MemStorage)
async createPasswordResetToken(insertToken: InsertPasswordResetToken): Promise<PasswordResetToken> {
const id = randomUUID();
const token: PasswordResetToken = {
...insertToken,
id,
isUsed: false,
createdAt: new Date()
};
this.passwordResetTokens.set(id, token);
return token;
}
async getPasswordResetToken(tokenString: string): Promise<PasswordResetToken | undefined> {
return Array.from(this.passwordResetTokens.values()).find(t => t.token === tokenString);
}
async markPasswordResetTokenUsed(id: string): Promise<void> {
const token = this.passwordResetTokens.get(id);
if (token) {
token.isUsed = true;
this.passwordResetTokens.set(id, token);
}
}
}
import { getDatabase } from './db.js';
import { eq } from 'drizzle-orm';
import { eq, sql, desc, and } from 'drizzle-orm';
import * as schema from '../shared/schema.js';
export class DbStorage implements IStorage {
@@ -445,6 +612,20 @@ export class DbStorage implements IStorage {
return await this.db.select().from(schema.users);
}
async getUserByApiKey(apiKey: string): Promise<User | undefined> {
const result = await this.db.select().from(schema.users).where(eq(schema.users.apiKey, apiKey));
return result[0];
}
async updateUserApiKey(userId: string, apiKey: string | null): Promise<User> {
const result = await this.db.update(schema.users)
.set({ apiKey })
.where(eq(schema.users.id, userId))
.returning();
if (!result[0]) throw new Error("User not found");
return result[0];
}
async createUser(insertUser: InsertUser & { role?: string; isActive?: boolean }): Promise<User> {
const result = await this.db.insert(schema.users).values({
...insertUser,
@@ -465,6 +646,14 @@ export class DbStorage implements IStorage {
return result[0];
}
async deleteUser(id: string): Promise<boolean> {
// Note: This relies on CASCADE DELETE foreign keys in schema,
// otherwise we need to manually delete related records first.
// For now, assuming schema handles it or we accept errors.
const result = await this.db.delete(schema.users).where(eq(schema.users.id, id)).returning();
return result.length > 0;
}
async getSystemSettings(key: string): Promise<string | undefined> {
const result = await this.db.select().from(schema.systemSettings).where(eq(schema.systemSettings.key, key));
return result[0]?.value;
@@ -539,8 +728,16 @@ export class DbStorage implements IStorage {
globalTasks = await this.db.select().from(schema.tasks).where(sql`${schema.tasks.userId} IN ${ownerIds}`);
}
// 4. Shared Labels
const sharedLabels = await this.db.select().from(schema.sharedLabels).where(eq(schema.sharedLabels.sharedWithUserId, userId));
const sharedLabelIds = sharedLabels.map(sl => sl.labelId);
let tasksFromSharedLabels: Task[] = [];
if (sharedLabelIds.length > 0) {
tasksFromSharedLabels = await this.db.select().from(schema.tasks).where(sql`${schema.tasks.labelId} IN ${sharedLabelIds}`);
}
// Dedupe
const combined = [...result, ...sharedTasks, ...globalTasks];
const combined = [...result, ...sharedTasks, ...globalTasks, ...tasksFromSharedLabels];
const unique = Array.from(new Map(combined.map(t => [t.id, t])).values());
return unique;
}
@@ -596,6 +793,15 @@ export class DbStorage implements IStorage {
return result[0];
}
async updateGoal(id: string, updates: Partial<InsertGoal & { completed?: boolean; current?: number }>): Promise<Goal> {
const result = await this.db.update(schema.goals)
.set(updates)
.where(eq(schema.goals.id, id))
.returning();
if (!result[0]) throw new Error("Goal not found");
return result[0];
}
// Rewards
async getAllRewards(): Promise<Reward[]> {
@@ -616,22 +822,50 @@ export class DbStorage implements IStorage {
return result[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));
}
// Social Methods (DbStorage)
async getLeaderboard(): Promise<User[]> {
return await this.db.select()
.from(schema.users)
.where(eq(schema.users.showOnLeaderboard, true))
.where(eq(schema.users.isActive, true))
.orderBy(sql`${schema.users.xp} DESC`);
.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));
return result[0];
}
async markPasswordResetTokenUsed(id: string): Promise<void> {
await this.db.update(schema.passwordResetTokens)
.set({ isUsed: true })
.where(eq(schema.passwordResetTokens.id, id));
}
async searchUsers(query: string): Promise<User[]> {
if (!query || query.length < 2) return [];
return await this.db.select()
.from(schema.users)
.where(eq(schema.users.isSearchable, true))
.where(eq(schema.users.isActive, true))
.where(sql`${schema.users.username} ILIKE ${'%' + query + '%'}`);
.where(and(
eq(schema.users.isSearchable, true),
eq(schema.users.isActive, true),
sql`${schema.users.username} ILIKE ${'%' + query + '%'}`
));
}
async shareTask(sharedTask: InsertSharedTask): Promise<SharedTask> {
@@ -664,6 +898,95 @@ export class DbStorage implements IStorage {
.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;
}
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)
))
.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
}).returning();
return result[0];
}
async getSharedLabels(userId: string): Promise<SharedLabel[]> {
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;
}
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)
))
.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));
}
}
// Export storage based on environment