feat: Implement AI Chat Agent, Email Notifications, and UI enhancements
continuous-integration/drone/push Build is passing
continuous-integration/drone/push Build is passing
This commit is contained in:
+147
@@ -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
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user