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
+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