1650 lines
60 KiB
TypeScript
1650 lines
60 KiB
TypeScript
import { type User, type InsertUser, type Label, type InsertLabel, type SharedLabel, type Task, type InsertTask, type XpEvent, type InsertXpEvent, type Goal, type InsertGoal, type Reward, type InsertReward, type UserReward, type InsertUserReward, type SystemSettings, type InsertSystemSettings, type SharedTask, type InsertSharedTask, type UserTaskAccess, type InsertUserTaskAccess, type InsertPasswordResetToken, type PasswordResetToken, type Conversation, type InsertConversation, type Message, type InsertMessage, type AuditLog, type InsertAuditLog, type TaskTimeLog, type InsertTaskTimeLog, type BreakLog, type EnergyLog, type BodyDoublingSession, type FocusSession, type DailyChallenge } from "@shared/schema";
|
|
import * as schema from "@shared/schema";
|
|
import { getDatabase, pool } from "./db";
|
|
import { eq, sql, and, desc, asc, gt, ne, or, isNull } from "drizzle-orm";
|
|
import { randomUUID } from "crypto";
|
|
import session from "express-session";
|
|
import createMemoryStore from "memorystore";
|
|
import connectPg from "connect-pg-simple";
|
|
|
|
const MemoryStore = createMemoryStore(session);
|
|
const PostgresStore = connectPg(session);
|
|
|
|
export interface IStorage {
|
|
sessionStore: session.Store;
|
|
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>;
|
|
|
|
// Social & Leaderboard
|
|
getLeaderboard(): Promise<User[]>;
|
|
searchUsers(query: string): Promise<User[]>;
|
|
shareTask(sharedTask: InsertSharedTask): Promise<SharedTask>;
|
|
shareAllTasks(access: InsertUserTaskAccess): Promise<UserTaskAccess>;
|
|
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
|
|
getSharedTask(taskId: string, userId: string): Promise<SharedTask | undefined>; // Check specific share
|
|
getUserTaskAccess(viewerId: string): Promise<UserTaskAccess[]>; // Access Viewer has to Owners
|
|
checkUserTaskAccess(ownerId: string, viewerId: string): Promise<boolean>; // Check specific access
|
|
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>;
|
|
setSystemSettings(key: string, value: string): Promise<void>;
|
|
hasAdminUser(): Promise<boolean>;
|
|
|
|
// Labels
|
|
getAllLabels(): Promise<Label[]>;
|
|
getLabels(userId: string): Promise<Label[]>;
|
|
getLabel(id: string): Promise<Label | undefined>;
|
|
createLabel(label: InsertLabel): Promise<Label>;
|
|
updateLabel(id: string, updates: Partial<InsertLabel>): Promise<Label | undefined>;
|
|
deleteLabel(id: string): Promise<boolean>;
|
|
|
|
// Tasks
|
|
getTasksForUser(userId: string): Promise<Task[]>; // Replaces getAllTasks
|
|
getTask(id: string): Promise<Task | undefined>;
|
|
createTask(task: InsertTask & { userId?: string }): Promise<Task>;
|
|
updateTask(id: string, updates: Partial<InsertTask>): Promise<Task | undefined>;
|
|
deleteTask(id: string): Promise<boolean>;
|
|
searchTasks(query: string, userId: string): Promise<Task[]>;
|
|
getSubtasks(parentTaskId: string): Promise<Task[]>;
|
|
|
|
// Gamification
|
|
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>;
|
|
createReward(reward: InsertReward): Promise<Reward>;
|
|
createUserReward(userReward: InsertUserReward): Promise<UserReward>;
|
|
getReward(id: string): Promise<Reward | undefined>;
|
|
purchaseReward(userId: string, rewardId: string, cost: number): Promise<{ success: boolean; user: User; userReward: UserReward }>;
|
|
|
|
// 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>;
|
|
|
|
// AI Chat
|
|
createConversation(userId: string, title?: string): Promise<Conversation>;
|
|
getConversations(userId: string): Promise<Conversation[]>;
|
|
getConversation(id: string): Promise<Conversation | undefined>;
|
|
updateConversation(id: string, title: string): Promise<Conversation | undefined>;
|
|
deleteConversation(id: string): Promise<boolean>;
|
|
addMessage(message: InsertMessage): Promise<Message>;
|
|
getMessages(conversationId: string): Promise<Message[]>;
|
|
getMessage(id: string): Promise<Message | undefined>;
|
|
updateMessage(id: string, content: string): Promise<Message>;
|
|
deleteMessagesAfter(conversationId: string, after: Date, excludeMessageId?: string): Promise<void>;
|
|
|
|
// Audit Logs
|
|
createAuditLog(log: InsertAuditLog): Promise<AuditLog>;
|
|
getAuditLogs(limit?: number): Promise<AuditLog[]>;
|
|
|
|
// Time Tracking
|
|
logTaskTime(log: InsertTaskTimeLog): Promise<TaskTimeLog>;
|
|
getAnalyticsTimeDistribution(userId: string, period: 'day' | 'week' | 'month' | 'year'): Promise<{ labelId: string | null, labelName: string | null, labelColor: string | null, timeSpent: number }[]>;
|
|
|
|
// ADHD Features
|
|
createBreakLog(log: { userId: string; breakType: string; durationMinutes: number }): Promise<any>;
|
|
getBreakStats(userId: string): Promise<{ todayBreaks: number; totalMinutes: number; lastBreak: Date | null }>;
|
|
createEnergyLog(log: { userId: string; energyLevel: string; notes?: string }): Promise<any>;
|
|
getEnergyHistory(userId: string): Promise<any[]>;
|
|
|
|
// Body Doubling Sessions
|
|
getBodyDoublingSessions(userId: string): Promise<any[]>;
|
|
createBodyDoublingSession(session: any): Promise<any>;
|
|
joinSession(sessionId: string, userId: string): Promise<void>;
|
|
leaveSession(sessionId: string, userId: string): Promise<void>;
|
|
|
|
// Focus Sessions
|
|
createFocusSession(session: any): Promise<any>;
|
|
completeFocusSession(sessionId: string, actualMinutes: number, wasCompleted: boolean): Promise<any>;
|
|
|
|
// Daily Challenges
|
|
getTodaysChallenges(userId: string): Promise<any[]>;
|
|
generateDailyChallenges(userId: string): Promise<any[]>;
|
|
updateChallengeProgress(challengeId: string, increment: number): Promise<any>;
|
|
}
|
|
|
|
export class MemStorage implements IStorage {
|
|
private users: Map<string, User>;
|
|
private labels: Map<string, Label>;
|
|
private tasks: Map<string, Task>;
|
|
private xpEvents: Map<string, XpEvent>;
|
|
private settings: Map<string, string>;
|
|
private goals: Map<string, Goal>;
|
|
private rewards: Map<string, Reward>;
|
|
private userRewards: Map<string, UserReward>;
|
|
|
|
// Social maps
|
|
private sharedTasks: Map<string, SharedTask>;
|
|
private sharedLabels: Map<string, SharedLabel>;
|
|
private userTaskAccess: Map<string, UserTaskAccess>;
|
|
private passwordResetTokens: Map<string, PasswordResetToken>; // id -> Token
|
|
private auditLogs: Map<string, AuditLog>;
|
|
private taskTimeLogs: Map<string, TaskTimeLog>;
|
|
|
|
sessionStore: session.Store;
|
|
|
|
constructor() {
|
|
this.users = new Map();
|
|
this.labels = new Map();
|
|
this.tasks = new Map();
|
|
this.xpEvents = new Map();
|
|
this.settings = new Map();
|
|
this.goals = new Map();
|
|
this.rewards = new Map();
|
|
this.userRewards = new Map();
|
|
this.sharedTasks = new Map();
|
|
this.sharedLabels = new Map();
|
|
this.userTaskAccess = new Map();
|
|
this.passwordResetTokens = new Map();
|
|
this.auditLogs = new Map();
|
|
this.taskTimeLogs = new Map();
|
|
this.sessionStore = new MemoryStore({
|
|
checkPeriod: 86400000,
|
|
});
|
|
|
|
// Create some default labels
|
|
this.createDefaultLabels();
|
|
this.createDefaultRewards();
|
|
}
|
|
|
|
// ... (createDefaultLabels)
|
|
|
|
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", creatorId: null, domain: "work" },
|
|
{ id: '274f0ba4-a133-471a-bbe9-8189aa3b0106', name: "Personal", color: "#10B981", creatorId: null, domain: "personal" },
|
|
{ id: '2e8c3aa0-278f-4220-b2c5-aa109b4279b7', name: "Urgent", color: "#EF4444", creatorId: null, domain: "neutral" },
|
|
{ id: 'c5eccac9-7919-4eb1-bb50-badacad6b1c1', name: "Study", color: "#8B5CF6", creatorId: null, domain: "personal" },
|
|
];
|
|
for (const label of defaultLabels) {
|
|
if (!this.labels.has(label.id)) {
|
|
this.labels.set(label.id, label);
|
|
}
|
|
}
|
|
}
|
|
|
|
async getUser(id: string): Promise<User | undefined> {
|
|
return this.users.get(id);
|
|
}
|
|
|
|
async getUserByUsername(username: string): Promise<User | undefined> {
|
|
return Array.from(this.users.values()).find(
|
|
(user) => user.username === username,
|
|
);
|
|
}
|
|
|
|
async getUserByEmail(email: string): Promise<User | undefined> {
|
|
return Array.from(this.users.values()).find(
|
|
(user) => user.email === email,
|
|
);
|
|
}
|
|
|
|
async getAllUsers(): Promise<User[]> {
|
|
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 = {
|
|
...insertUser,
|
|
id,
|
|
role: insertUser.role || 'user',
|
|
isActive: insertUser.isActive ?? true,
|
|
email: insertUser.email || `missing_${id}@example.com`,
|
|
xp: 0,
|
|
level: 1,
|
|
currentStreak: 0,
|
|
lastTaskDate: null,
|
|
showOnLeaderboard: insertUser.showOnLeaderboard ?? false,
|
|
isSearchable: insertUser.isSearchable ?? false,
|
|
workHours: insertUser.workHours || null,
|
|
availability: insertUser.availability || null,
|
|
aiEnabled: insertUser.aiEnabled ?? false,
|
|
adhdMode: insertUser.adhdMode ?? false,
|
|
adhdSettings: insertUser.adhdSettings ?? {
|
|
reducedAnimations: false,
|
|
largerTargets: true,
|
|
breakReminderInterval: 45,
|
|
singleTaskModeDefault: false,
|
|
positiveMessagingLevel: 'normal' as const,
|
|
hyperfocusProtection: true,
|
|
hyperfocusMaxMinutes: 90,
|
|
visualTimerEnabled: true,
|
|
quickWinThreshold: 10,
|
|
energyCheckInsEnabled: true,
|
|
},
|
|
language: insertUser.language ?? 'en',
|
|
is2faEnabled: false,
|
|
otpCode: null,
|
|
otpExpiresAt: null,
|
|
lastActive: null,
|
|
routineConfig: { morningTime: "09:00", eveningTime: "17:00", enabled: true },
|
|
lastMorningRoutine: null,
|
|
lastEveningRoutine: null,
|
|
apiKey: null,
|
|
lastBreakAt: null,
|
|
currentEnergyLevel: null,
|
|
todayEnergyCheckedIn: null,
|
|
};
|
|
this.users.set(id, user);
|
|
return user;
|
|
}
|
|
|
|
async updateUser(id: string, updates: Partial<User>): Promise<User> {
|
|
const user = this.users.get(id);
|
|
if (!user) throw new Error("User not found");
|
|
// Ensure we don't accidentally override with undefined
|
|
const updated = { ...user, ...updates };
|
|
this.users.set(id, updated);
|
|
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())
|
|
.filter(u => u.showOnLeaderboard && u.isActive)
|
|
.sort((a, b) => b.xp - a.xp);
|
|
}
|
|
|
|
async searchUsers(query: string): Promise<User[]> {
|
|
if (!query || query.length < 2) return [];
|
|
const lowerQ = query.toLowerCase();
|
|
return Array.from(this.users.values()).filter(u =>
|
|
u.isSearchable && u.isActive && u.username.toLowerCase().includes(lowerQ)
|
|
);
|
|
}
|
|
|
|
async shareTask(sharedTask: InsertSharedTask): Promise<SharedTask> {
|
|
return this.createSharedTask(sharedTask);
|
|
}
|
|
|
|
async createSharedTask(sharedTask: InsertSharedTask): Promise<SharedTask> {
|
|
const id = randomUUID();
|
|
const newItem: SharedTask = { ...sharedTask, id, createdAt: new Date() };
|
|
this.sharedTasks.set(id, newItem);
|
|
return newItem;
|
|
}
|
|
|
|
async shareAllTasks(access: InsertUserTaskAccess): Promise<UserTaskAccess> {
|
|
return this.createUserTaskAccess(access);
|
|
}
|
|
|
|
async createUserTaskAccess(access: InsertUserTaskAccess): Promise<UserTaskAccess> {
|
|
const id = randomUUID();
|
|
const newItem: UserTaskAccess = { ...access, id, createdAt: new Date() };
|
|
this.userTaskAccess.set(id, newItem);
|
|
return newItem;
|
|
}
|
|
|
|
async getSharedTasks(userId: string): Promise<SharedTask[]> {
|
|
return Array.from(this.sharedTasks.values()).filter(st => st.sharedWithUserId === userId);
|
|
}
|
|
|
|
async getSharedTask(taskId: string, userId: string): Promise<SharedTask | undefined> {
|
|
return Array.from(this.sharedTasks.values()).find(st => st.taskId === taskId && st.sharedWithUserId === userId);
|
|
}
|
|
|
|
async getUserTaskAccess(viewerId: string): Promise<UserTaskAccess[]> {
|
|
return Array.from(this.userTaskAccess.values()).filter(uta => uta.viewerId === viewerId);
|
|
}
|
|
|
|
async checkUserTaskAccess(ownerId: string, viewerId: string): Promise<boolean> {
|
|
return Array.from(this.userTaskAccess.values()).some(uta => uta.ownerId === ownerId && 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);
|
|
}
|
|
|
|
async setSystemSettings(key: string, value: string): Promise<void> {
|
|
this.settings.set(key, value);
|
|
}
|
|
|
|
async hasAdminUser(): Promise<boolean> {
|
|
return Array.from(this.users.values()).some(u => u.role === 'admin');
|
|
}
|
|
|
|
// Labels
|
|
async getAllLabels(): Promise<Label[]> {
|
|
return Array.from(this.labels.values());
|
|
}
|
|
|
|
async getLabels(userId: string): Promise<Label[]> {
|
|
return Array.from(this.labels.values()).filter(
|
|
l => l.creatorId === null || l.creatorId === userId
|
|
);
|
|
}
|
|
|
|
async getLabel(id: string): Promise<Label | undefined> {
|
|
return this.labels.get(id);
|
|
}
|
|
|
|
async createLabel(insertLabel: InsertLabel): Promise<Label> {
|
|
const id = randomUUID();
|
|
const label: Label = {
|
|
...insertLabel,
|
|
id,
|
|
creatorId: insertLabel.creatorId ?? null,
|
|
domain: insertLabel.domain || "personal"
|
|
};
|
|
this.labels.set(id, label);
|
|
return label;
|
|
}
|
|
|
|
async updateLabel(id: string, updates: Partial<InsertLabel>): Promise<Label | undefined> {
|
|
const existing = this.labels.get(id);
|
|
if (!existing) return undefined;
|
|
|
|
const updated: Label = { ...existing, ...updates };
|
|
this.labels.set(id, updated);
|
|
return updated;
|
|
}
|
|
|
|
async deleteLabel(id: string): Promise<boolean> {
|
|
return this.labels.delete(id);
|
|
}
|
|
|
|
// Tasks
|
|
async getTasksForUser(userId: string): Promise<Task[]> {
|
|
const allTasks = Array.from(this.tasks.values());
|
|
|
|
// 1. My tasks
|
|
const myTasks = allTasks.filter(t => t.userId === userId);
|
|
|
|
// 2. Explicitly shared tasks (Single Task Share)
|
|
const sharedToMe = Array.from(this.sharedTasks.values())
|
|
.filter(st => st.sharedWithUserId === userId)
|
|
.map(st => this.tasks.get(st.taskId))
|
|
.filter((t): t is Task => !!t);
|
|
|
|
// 3. Global Share Access (Share All)
|
|
// Find users who have shared everything with ME (valid viewer)
|
|
const accessGrants = Array.from(this.userTaskAccess.values())
|
|
.filter(uta => uta.viewerId === userId);
|
|
|
|
let globalSharedTasks: Task[] = [];
|
|
if (accessGrants.length > 0) {
|
|
const ownerIds = new Set(accessGrants.map(uta => uta.ownerId));
|
|
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, ...tasksFromSharedLabels];
|
|
const unique = Array.from(new Map(combined.map(t => [t.id, t])).values());
|
|
return unique;
|
|
}
|
|
|
|
async searchTasks(query: string, userId: string): Promise<Task[]> {
|
|
const allTasks = await this.getTasksForUser(userId);
|
|
if (!query) return allTasks;
|
|
const lowerQuery = query.toLowerCase();
|
|
return allTasks.filter(t =>
|
|
t.title.toLowerCase().includes(lowerQuery) ||
|
|
(t.description && t.description.toLowerCase().includes(lowerQuery))
|
|
);
|
|
}
|
|
|
|
async getSubtasks(parentTaskId: string): Promise<Task[]> {
|
|
return Array.from(this.tasks.values()).filter(t => t.parentTaskId === parentTaskId);
|
|
}
|
|
|
|
async getTask(id: string): Promise<Task | undefined> {
|
|
return this.tasks.get(id);
|
|
}
|
|
|
|
async createTask(insertTask: InsertTask & { userId?: string }): Promise<Task> {
|
|
const id = randomUUID();
|
|
const task: Task = {
|
|
id,
|
|
title: insertTask.title,
|
|
description: insertTask.description || null,
|
|
status: insertTask.status || "todo",
|
|
priority: insertTask.priority || "medium",
|
|
dueDate: insertTask.dueDate || null,
|
|
timeTracked: insertTask.timeTracked || 0,
|
|
isTracking: insertTask.isTracking || false,
|
|
projectId: insertTask.projectId || null,
|
|
notes: insertTask.notes || null,
|
|
labelId: insertTask.labelId || null,
|
|
energyLevel: insertTask.energyLevel || "medium",
|
|
|
|
estimatedDuration: insertTask.estimatedDuration || null,
|
|
parentTaskId: insertTask.parentTaskId || null,
|
|
startDate: insertTask.startDate || null,
|
|
dependencies: insertTask.dependencies || [],
|
|
userId: insertTask.userId || null, // Allow null for system/orphaned tasks support
|
|
isRecurring: false,
|
|
recurrenceInterval: null,
|
|
recurrenceIntervalValue: 1,
|
|
recurrenceDays: [],
|
|
recurrenceEnd: null
|
|
};
|
|
this.tasks.set(id, task);
|
|
return task;
|
|
}
|
|
|
|
async updateTask(id: string, updates: Partial<InsertTask>): Promise<Task | undefined> {
|
|
const existing = this.tasks.get(id);
|
|
if (!existing) return undefined;
|
|
|
|
const updated: Task = { ...existing, ...updates };
|
|
this.tasks.set(id, updated);
|
|
return updated;
|
|
}
|
|
|
|
async deleteTask(id: string): Promise<boolean> {
|
|
return this.tasks.delete(id);
|
|
}
|
|
|
|
async updateUserXP(id: string, xp: number): Promise<void> {
|
|
const user = this.users.get(id);
|
|
if (user) {
|
|
user.xp += xp;
|
|
this.users.set(id, user);
|
|
}
|
|
}
|
|
|
|
async logXpEvent(event: InsertXpEvent): Promise<XpEvent> {
|
|
const id = randomUUID();
|
|
const xpEvent: XpEvent = {
|
|
...event,
|
|
id,
|
|
userId: event.userId || null,
|
|
taskId: event.taskId || null,
|
|
createdAt: new Date(),
|
|
details: event.details || null
|
|
};
|
|
this.xpEvents.set(id, xpEvent);
|
|
// Also update user XP
|
|
console.log("Mock update XP for user:", event.userId);
|
|
return xpEvent;
|
|
}
|
|
|
|
async getGoals(): Promise<Goal[]> {
|
|
return Array.from(this.goals.values());
|
|
}
|
|
|
|
async createGoal(goal: InsertGoal): Promise<Goal> {
|
|
const id = randomUUID();
|
|
const newGoal: Goal = {
|
|
...goal,
|
|
id,
|
|
userId: goal.userId || null,
|
|
deadline: goal.deadline || null,
|
|
current: 0,
|
|
completed: false,
|
|
createdAt: new Date()
|
|
};
|
|
this.goals.set(id, newGoal);
|
|
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() {
|
|
const defaultRewards = [
|
|
{ id: 'r1', title: "rewards.defaults.coffee.title", description: "rewards.defaults.coffee.description", cost: 50, icon: "coffee", type: "real_world", isSystem: true },
|
|
{ id: 'r2', title: "rewards.defaults.gaming.title", description: "rewards.defaults.gaming.description", cost: 100, icon: "gamepad-2", type: "real_world", isSystem: true },
|
|
{ id: 'r3', title: "rewards.defaults.theme.title", description: "rewards.defaults.theme.description", cost: 500, icon: "palette", type: "feature_unlock", isSystem: true },
|
|
];
|
|
|
|
for (const r of defaultRewards) {
|
|
if (!this.rewards.has(r.id)) {
|
|
this.rewards.set(r.id, r as Reward);
|
|
}
|
|
}
|
|
}
|
|
|
|
async getAllRewards(): Promise<Reward[]> {
|
|
return Array.from(this.rewards.values());
|
|
}
|
|
|
|
async getUserRewards(userId: string): Promise<UserReward[]> {
|
|
return Array.from(this.userRewards.values()).filter(ur => ur.userId === userId);
|
|
}
|
|
|
|
async createReward(insertReward: InsertReward): Promise<Reward> {
|
|
const id = randomUUID();
|
|
const reward: Reward = {
|
|
...insertReward,
|
|
id,
|
|
type: insertReward.type || "virtual",
|
|
description: insertReward.description || null,
|
|
isSystem: false,
|
|
userId: insertReward.userId || null
|
|
};
|
|
this.rewards.set(id, reward);
|
|
return reward;
|
|
}
|
|
|
|
async createUserReward(insertUserReward: InsertUserReward): Promise<UserReward> {
|
|
const id = randomUUID();
|
|
const userReward: UserReward = {
|
|
...insertUserReward,
|
|
id,
|
|
userId: insertUserReward.userId || null,
|
|
rewardId: insertUserReward.rewardId || null,
|
|
purchasedAt: new Date()
|
|
};
|
|
this.userRewards.set(id, userReward);
|
|
return userReward;
|
|
}
|
|
|
|
async getReward(id: string): Promise<Reward | undefined> {
|
|
return this.rewards.get(id);
|
|
}
|
|
|
|
async purchaseReward(userId: string, rewardId: string, cost: number): Promise<{ success: boolean; user: User; userReward: UserReward }> {
|
|
const user = this.users.get(userId);
|
|
if (!user) throw new Error("User not found");
|
|
if (user.xp < cost) throw new Error("Insufficient XP");
|
|
|
|
// Deduct XP
|
|
user.xp -= cost;
|
|
this.users.set(userId, user);
|
|
|
|
// Create User Reward
|
|
const userReward = await this.createUserReward({ userId, rewardId });
|
|
|
|
return { success: true, user, 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);
|
|
}
|
|
}
|
|
|
|
// AI Chat Stubs
|
|
async createConversation(userId: string, title?: string): Promise<Conversation> {
|
|
throw new Error("MemStorage: AI Chat not implemented.");
|
|
}
|
|
async getConversations(userId: string): Promise<Conversation[]> {
|
|
return [];
|
|
}
|
|
async getConversation(id: string): Promise<Conversation | undefined> {
|
|
return undefined;
|
|
}
|
|
async deleteConversation(id: string): Promise<boolean> {
|
|
return false;
|
|
}
|
|
async addMessage(message: InsertMessage): Promise<Message> {
|
|
throw new Error("MemStorage: AI Chat not implemented.");
|
|
}
|
|
async getMessages(conversationId: string): Promise<Message[]> {
|
|
return [];
|
|
}
|
|
async getMessage(id: string): Promise<Message | undefined> {
|
|
return undefined;
|
|
}
|
|
async updateMessage(id: string, content: string): Promise<Message> {
|
|
throw new Error("Not implemented");
|
|
}
|
|
async deleteMessagesAfter(conversationId: string, after: Date): Promise<void> {
|
|
// No-op
|
|
}
|
|
async updateConversation(id: string, title: string): Promise<Conversation | undefined> {
|
|
return undefined;
|
|
}
|
|
|
|
// Audit Logs (MemStorage)
|
|
async createAuditLog(insertLog: InsertAuditLog): Promise<AuditLog> {
|
|
const id = randomUUID();
|
|
const log: AuditLog = {
|
|
...insertLog,
|
|
id,
|
|
userId: insertLog.userId || null,
|
|
entityId: insertLog.entityId || null,
|
|
details: insertLog.details || null,
|
|
source: insertLog.source || "USER",
|
|
createdAt: new Date(),
|
|
};
|
|
this.auditLogs.set(id, log);
|
|
return log;
|
|
}
|
|
|
|
async getAuditLogs(limit = 100): Promise<AuditLog[]> {
|
|
return Array.from(this.auditLogs.values())
|
|
.sort((a, b) => (b.createdAt && a.createdAt ? b.createdAt.getTime() - a.createdAt.getTime() : 0))
|
|
.slice(0, limit);
|
|
}
|
|
|
|
async logTaskTime(insertLog: InsertTaskTimeLog): Promise<TaskTimeLog> {
|
|
const id = randomUUID();
|
|
const log: TaskTimeLog = { ...insertLog, id, createdAt: new Date() };
|
|
this.taskTimeLogs.set(id, log);
|
|
return log;
|
|
}
|
|
|
|
async getAnalyticsTimeDistribution(userId: string, period: 'day' | 'week' | 'month' | 'year'): Promise<{ labelId: string | null, labelName: string | null, labelColor: string | null, timeSpent: number }[]> {
|
|
const now = new Date();
|
|
let startDate = new Date();
|
|
|
|
switch (period) {
|
|
case 'day':
|
|
startDate.setHours(0, 0, 0, 0); // Start of today
|
|
break;
|
|
case 'week':
|
|
const day = startDate.getDay() || 7; // 1 (Mon) to 7 (Sun)
|
|
if (day !== 1) startDate.setHours(-24 * (day - 1)); // Go back to Monday
|
|
startDate.setHours(0, 0, 0, 0);
|
|
break;
|
|
case 'month':
|
|
startDate.setDate(1); // 1st of month
|
|
startDate.setHours(0, 0, 0, 0);
|
|
break;
|
|
case 'year':
|
|
startDate.setMonth(0, 1); // Jan 1st
|
|
startDate.setHours(0, 0, 0, 0);
|
|
break;
|
|
}
|
|
|
|
const logs = Array.from(this.taskTimeLogs.values()).filter(log => {
|
|
const logDate = log.createdAt ? new Date(log.createdAt) : new Date(0);
|
|
return log.userId === userId && logDate >= startDate;
|
|
});
|
|
|
|
const distribution = new Map<string, { labelId: string | null, labelName: string | null, labelColor: string | null, timeSpent: number }>();
|
|
|
|
for (const log of logs) {
|
|
const task = this.tasks.get(log.taskId);
|
|
if (!task) continue;
|
|
|
|
const labelId = task.labelId || 'no_label';
|
|
let labelName = 'No Label';
|
|
let labelColor = '#808080';
|
|
|
|
if (task.labelId) {
|
|
const label = this.labels.get(task.labelId);
|
|
if (label) {
|
|
labelName = label.name;
|
|
labelColor = label.color;
|
|
}
|
|
}
|
|
|
|
const existing = distribution.get(labelId) || { labelId: task.labelId || null, labelName, labelColor, timeSpent: 0 };
|
|
existing.timeSpent += log.timeSpent;
|
|
distribution.set(labelId, existing);
|
|
}
|
|
|
|
return Array.from(distribution.values());
|
|
}
|
|
|
|
// ADHD Feature stubs for MemStorage (minimal implementation)
|
|
async createBreakLog(_log: any): Promise<any> { return {}; }
|
|
async getBreakStats(_userId: string): Promise<any> { return { todayBreaks: 0, totalMinutes: 0, lastBreak: null }; }
|
|
async createEnergyLog(_log: any): Promise<any> { return {}; }
|
|
async getEnergyHistory(_userId: string): Promise<any[]> { return []; }
|
|
async getBodyDoublingSessions(_userId: string): Promise<any[]> { return []; }
|
|
async createBodyDoublingSession(_session: any): Promise<any> { return {}; }
|
|
async joinSession(_sessionId: string, _userId: string): Promise<void> {}
|
|
async leaveSession(_sessionId: string, _userId: string): Promise<void> {}
|
|
async createFocusSession(_session: any): Promise<any> { return {}; }
|
|
async completeFocusSession(_sessionId: string, _actualMinutes: number, _wasCompleted: boolean): Promise<any> { return undefined; }
|
|
async getTodaysChallenges(_userId: string): Promise<any[]> { return []; }
|
|
async generateDailyChallenges(_userId: string): Promise<any[]> { return []; }
|
|
async updateChallengeProgress(_challengeId: string, _increment: number): Promise<any> { return undefined; }
|
|
}
|
|
|
|
|
|
|
|
export class DbStorage implements IStorage {
|
|
private db = getDatabase();
|
|
sessionStore: session.Store;
|
|
|
|
constructor() {
|
|
this.sessionStore = new PostgresStore({
|
|
pool: pool ?? undefined,
|
|
createTableIfMissing: true,
|
|
});
|
|
}
|
|
|
|
async getUser(id: string): Promise<User | undefined> {
|
|
const result = await this.db.select().from(schema.users).where(eq(schema.users.id, id));
|
|
return result[0];
|
|
}
|
|
|
|
async getUserByUsername(username: string): Promise<User | undefined> {
|
|
const result = await this.db.select().from(schema.users).where(eq(schema.users.username, username));
|
|
return result[0];
|
|
}
|
|
|
|
async getUserByEmail(email: string): Promise<User | undefined> {
|
|
const result = await this.db.select().from(schema.users).where(eq(schema.users.email, email));
|
|
return result[0];
|
|
}
|
|
|
|
async getAllUsers(): Promise<User[]> {
|
|
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,
|
|
role: insertUser.role || 'user',
|
|
isActive: insertUser.isActive ?? true,
|
|
showOnLeaderboard: insertUser.showOnLeaderboard ?? false,
|
|
isSearchable: insertUser.isSearchable ?? false,
|
|
workHours: insertUser.workHours || null,
|
|
availability: insertUser.availability || null,
|
|
}).returning();
|
|
return result[0];
|
|
}
|
|
|
|
async updateUser(id: string, updates: Partial<User>): Promise<User> {
|
|
const result = await this.db.update(schema.users)
|
|
.set(updates)
|
|
.where(eq(schema.users.id, id))
|
|
.returning();
|
|
if (!result[0]) throw new Error("User not found");
|
|
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;
|
|
}
|
|
|
|
async setSystemSettings(key: string, value: string): Promise<void> {
|
|
// Upsert
|
|
await this.db.insert(schema.systemSettings)
|
|
.values({ key, value })
|
|
.onConflictDoUpdate({ target: schema.systemSettings.key, set: { value, updatedAt: new Date() } });
|
|
}
|
|
|
|
async hasAdminUser(): Promise<boolean> {
|
|
const result = await this.db.select().from(schema.users).where(eq(schema.users.role, 'admin')).limit(1);
|
|
return result.length > 0;
|
|
}
|
|
|
|
async getAllLabels(): Promise<Label[]> {
|
|
return await this.db.select().from(schema.labels);
|
|
}
|
|
|
|
async getLabels(userId: string): Promise<Label[]> {
|
|
return await this.db.select().from(schema.labels).where(
|
|
sql`${schema.labels.creatorId} IS NULL OR ${schema.labels.creatorId} = ${userId}`
|
|
);
|
|
}
|
|
|
|
async getLabel(id: string): Promise<Label | undefined> {
|
|
const result = await this.db.select().from(schema.labels).where(eq(schema.labels.id, id));
|
|
return result[0];
|
|
}
|
|
|
|
async createLabel(insertLabel: InsertLabel): Promise<Label> {
|
|
const result = await this.db.insert(schema.labels).values(insertLabel).returning();
|
|
return result[0];
|
|
}
|
|
|
|
async updateLabel(id: string, updates: Partial<InsertLabel>): Promise<Label | undefined> {
|
|
const result = await this.db
|
|
.update(schema.labels)
|
|
.set(updates)
|
|
.where(eq(schema.labels.id, id))
|
|
.returning();
|
|
return result[0];
|
|
}
|
|
|
|
async deleteLabel(id: string): Promise<boolean> {
|
|
const result = await this.db.delete(schema.labels).where(eq(schema.labels.id, id)).returning();
|
|
return result.length > 0;
|
|
}
|
|
|
|
async getTasksForUser(userId: string): Promise<Task[]> {
|
|
// 1. My tasks + Orphans (invalid/null userId)
|
|
// We use leftJoin to users to identify tasks with non-existent userIds (orphans)
|
|
const resultRaw = await this.db.select({ task: schema.tasks })
|
|
.from(schema.tasks)
|
|
.leftJoin(schema.users, eq(schema.tasks.userId, schema.users.id))
|
|
.where(or(
|
|
eq(schema.tasks.userId, userId), // Owned by me
|
|
isNull(schema.tasks.userId), // No owner
|
|
isNull(schema.users.id) // Owner ID exists but User doesn't (deleted user)
|
|
));
|
|
const result = resultRaw.map(r => r.task);
|
|
|
|
// 2. Shared Tasks
|
|
const sharedLinks = await this.db.select().from(schema.sharedTasks).where(eq(schema.sharedTasks.sharedWithUserId, userId));
|
|
const sharedTaskIds = sharedLinks.map(s => s.taskId);
|
|
let sharedTasks: Task[] = [];
|
|
if (sharedTaskIds.length > 0) {
|
|
sharedTasks = await this.db.select().from(schema.tasks).where(sql`${schema.tasks.id} IN ${sharedTaskIds}`);
|
|
}
|
|
|
|
// 3. Global Access
|
|
const accessGrants = await this.db.select().from(schema.userTaskAccess).where(eq(schema.userTaskAccess.viewerId, userId));
|
|
const ownerIds = accessGrants.map(a => a.ownerId);
|
|
let globalTasks: Task[] = [];
|
|
if (ownerIds.length > 0) {
|
|
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}`);
|
|
}
|
|
|
|
const combined = [...result, ...sharedTasks, ...globalTasks, ...tasksFromSharedLabels];
|
|
const unique = Array.from(new Map(combined.map(t => [t.id, t])).values());
|
|
return unique;
|
|
}
|
|
|
|
async searchTasks(query: string, userId: string): Promise<Task[]> {
|
|
const allTasks = await this.getTasksForUser(userId);
|
|
if (!query) return allTasks;
|
|
const lowerQuery = query.toLowerCase();
|
|
return allTasks.filter(t =>
|
|
t.title.toLowerCase().includes(lowerQuery) ||
|
|
(t.description && t.description.toLowerCase().includes(lowerQuery))
|
|
);
|
|
}
|
|
|
|
async getSubtasks(parentTaskId: string): Promise<Task[]> {
|
|
return await this.db.select().from(schema.tasks).where(eq(schema.tasks.parentTaskId, parentTaskId));
|
|
}
|
|
|
|
async getTask(id: string): Promise<Task | undefined> {
|
|
const result = await this.db.select().from(schema.tasks).where(eq(schema.tasks.id, id));
|
|
return result[0];
|
|
}
|
|
|
|
async createTask(insertTask: InsertTask & { userId?: string }): Promise<Task> {
|
|
const result = await this.db.insert(schema.tasks).values(insertTask).returning();
|
|
return result[0];
|
|
}
|
|
|
|
async updateTask(id: string, updates: Partial<InsertTask>): Promise<Task | undefined> {
|
|
const result = await this.db
|
|
.update(schema.tasks)
|
|
.set(updates)
|
|
.where(eq(schema.tasks.id, id))
|
|
.returning();
|
|
return result[0];
|
|
}
|
|
|
|
async deleteTask(id: string): Promise<boolean> {
|
|
const result = await this.db.delete(schema.tasks).where(eq(schema.tasks.id, id)).returning();
|
|
return result.length > 0;
|
|
}
|
|
|
|
async updateUserXP(id: string, xp: number): Promise<void> {
|
|
const user = await this.getUser(id);
|
|
if (user) {
|
|
await this.db.update(schema.users)
|
|
.set({ xp: user.xp + xp })
|
|
.where(eq(schema.users.id, id));
|
|
}
|
|
}
|
|
|
|
async logXpEvent(event: InsertXpEvent): Promise<XpEvent> {
|
|
const result = await this.db.insert(schema.xpEvents).values(event).returning();
|
|
// BUG FIX: Removed duplicate updateUserXP call (MISC-3)
|
|
// XP is already updated in GamificationService.awardXP() before logXpEvent is called
|
|
return result[0];
|
|
}
|
|
|
|
async getGoals(): Promise<Goal[]> {
|
|
return await this.db.select().from(schema.goals);
|
|
}
|
|
|
|
async createGoal(goal: InsertGoal): Promise<Goal> {
|
|
const result = await this.db.insert(schema.goals).values(goal).returning();
|
|
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];
|
|
}
|
|
|
|
async getAllRewards(): Promise<Reward[]> {
|
|
return await this.db.select().from(schema.rewards);
|
|
}
|
|
|
|
async getUserRewards(userId: string): Promise<UserReward[]> {
|
|
return await this.db.select().from(schema.userRewards).where(eq(schema.userRewards.userId, userId));
|
|
}
|
|
|
|
async createReward(insertReward: InsertReward): Promise<Reward> {
|
|
const result = await this.db.insert(schema.rewards).values(insertReward).returning();
|
|
return result[0];
|
|
}
|
|
|
|
async createUserReward(insertUserReward: InsertUserReward): Promise<UserReward> {
|
|
const result = await this.db.insert(schema.userRewards).values(insertUserReward).returning();
|
|
return result[0];
|
|
}
|
|
|
|
async getReward(id: string): Promise<Reward | undefined> {
|
|
const result = await this.db.select().from(schema.rewards).where(eq(schema.rewards.id, id));
|
|
return result[0];
|
|
}
|
|
|
|
async purchaseReward(userId: string, rewardId: string, cost: number): Promise<{ success: boolean; user: User; userReward: UserReward }> {
|
|
return await this.db.transaction(async (tx) => {
|
|
// 1. Get User and verify XP
|
|
const userRes = await tx.select().from(schema.users).where(eq(schema.users.id, userId));
|
|
const user = userRes[0];
|
|
if (!user) throw new Error("User not found");
|
|
if (user.xp < cost) throw new Error("Insufficient XP");
|
|
|
|
// 2. Deduct XP
|
|
const updatedUserRes = await tx.update(schema.users)
|
|
.set({ xp: user.xp - cost })
|
|
.where(eq(schema.users.id, userId))
|
|
.returning();
|
|
|
|
// 3. Create User Reward
|
|
const urRes = await tx.insert(schema.userRewards).values({
|
|
userId,
|
|
rewardId,
|
|
purchasedAt: new Date()
|
|
}).returning();
|
|
|
|
return { success: true, user: updatedUserRes[0], userReward: urRes[0] };
|
|
});
|
|
}
|
|
|
|
async getXpEvents(userId: string): Promise<XpEvent[]> {
|
|
return await this.db.select().from(schema.xpEvents).where(eq(schema.xpEvents.userId, userId));
|
|
}
|
|
|
|
async createPasswordResetToken(insertToken: InsertPasswordResetToken): Promise<PasswordResetToken> {
|
|
const result = await this.db.insert(schema.passwordResetTokens).values(insertToken).returning();
|
|
return result[0];
|
|
}
|
|
|
|
async getPasswordResetToken(token: string): Promise<PasswordResetToken | undefined> {
|
|
const result = await this.db.select().from(schema.passwordResetTokens).where(eq(schema.passwordResetTokens.token, token));
|
|
return result[0];
|
|
}
|
|
|
|
async markPasswordResetTokenUsed(id: string): Promise<void> {
|
|
await this.db.update(schema.passwordResetTokens)
|
|
.set({ isUsed: true })
|
|
.where(eq(schema.passwordResetTokens.id, id));
|
|
}
|
|
|
|
// AI Chat Implementation
|
|
async createConversation(userId: string, title?: string): Promise<Conversation> {
|
|
const result = await this.db.insert(schema.conversations).values({
|
|
userId,
|
|
title: title || "New Chat",
|
|
createdAt: new Date(),
|
|
updatedAt: new Date()
|
|
}).returning();
|
|
return result[0];
|
|
}
|
|
|
|
async getConversations(userId: string): Promise<Conversation[]> {
|
|
return await this.db.select()
|
|
.from(schema.conversations)
|
|
.where(eq(schema.conversations.userId, userId))
|
|
.orderBy(desc(schema.conversations.updatedAt));
|
|
}
|
|
|
|
async getConversation(id: string): Promise<Conversation | undefined> {
|
|
const result = await this.db.select().from(schema.conversations).where(eq(schema.conversations.id, id));
|
|
return result[0];
|
|
}
|
|
|
|
async deleteConversation(id: string): Promise<boolean> {
|
|
await this.db.delete(schema.messages).where(eq(schema.messages.conversationId, id));
|
|
const result = await this.db.delete(schema.conversations).where(eq(schema.conversations.id, id)).returning();
|
|
return result.length > 0;
|
|
}
|
|
|
|
async addMessage(message: InsertMessage): Promise<Message> {
|
|
const result = await this.db.insert(schema.messages).values(message).returning();
|
|
if (message.conversationId) {
|
|
await this.db.update(schema.conversations)
|
|
.set({ updatedAt: new Date() })
|
|
.where(eq(schema.conversations.id, message.conversationId));
|
|
}
|
|
return result[0];
|
|
}
|
|
|
|
|
|
|
|
// Social & Leaderboard
|
|
async getLeaderboard(): Promise<User[]> {
|
|
return await this.db.select().from(schema.users)
|
|
.where(and(eq(schema.users.isActive, true), eq(schema.users.showOnLeaderboard, true)))
|
|
.orderBy(desc(schema.users.xp));
|
|
}
|
|
|
|
async searchUsers(query: string): Promise<User[]> {
|
|
if (!query || query.length < 2) return [];
|
|
return await this.db.select().from(schema.users)
|
|
.where(and(
|
|
eq(schema.users.isSearchable, true),
|
|
eq(schema.users.isActive, true),
|
|
sql`${schema.users.username} ILIKE ${'%' + query + '%'}`
|
|
));
|
|
}
|
|
|
|
async shareTask(sharedTask: InsertSharedTask): Promise<SharedTask> {
|
|
const result = await this.db.insert(schema.sharedTasks).values(sharedTask).returning();
|
|
return result[0];
|
|
}
|
|
|
|
async shareAllTasks(access: InsertUserTaskAccess): Promise<UserTaskAccess> {
|
|
const result = await this.db.insert(schema.userTaskAccess).values(access).returning();
|
|
return result[0];
|
|
}
|
|
|
|
async createSharedTask(sharedTask: InsertSharedTask): Promise<SharedTask> {
|
|
return this.shareTask(sharedTask);
|
|
}
|
|
|
|
async createUserTaskAccess(access: InsertUserTaskAccess): Promise<UserTaskAccess> {
|
|
return this.shareAllTasks(access);
|
|
}
|
|
|
|
async getSharedTasks(userId: string): Promise<SharedTask[]> {
|
|
return await this.db.select().from(schema.sharedTasks).where(eq(schema.sharedTasks.sharedWithUserId, userId));
|
|
}
|
|
|
|
async getSharedTask(taskId: string, userId: string): Promise<SharedTask | undefined> {
|
|
const [share] = await this.db.select().from(schema.sharedTasks)
|
|
.where(and(
|
|
eq(schema.sharedTasks.taskId, taskId),
|
|
eq(schema.sharedTasks.sharedWithUserId, userId)
|
|
));
|
|
return share;
|
|
}
|
|
|
|
async getUserTaskAccess(viewerId: string): Promise<UserTaskAccess[]> {
|
|
return await this.db.select().from(schema.userTaskAccess).where(eq(schema.userTaskAccess.viewerId, viewerId));
|
|
}
|
|
|
|
async checkUserTaskAccess(ownerId: string, viewerId: string): Promise<boolean> {
|
|
const [access] = await this.db.select().from(schema.userTaskAccess)
|
|
.where(and(
|
|
eq(schema.userTaskAccess.ownerId, ownerId),
|
|
eq(schema.userTaskAccess.viewerId, viewerId)
|
|
));
|
|
return !!access;
|
|
}
|
|
|
|
async getTaskSharedUsers(taskId: string): Promise<User[]> {
|
|
const shares = await this.db.select().from(schema.sharedTasks).where(eq(schema.sharedTasks.taskId, taskId));
|
|
if (shares.length === 0) return [];
|
|
|
|
return await this.db.select().from(schema.users)
|
|
.where(sql`${schema.users.id} IN ${shares.map(s => s.sharedWithUserId)}`);
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
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,
|
|
createdAt: new Date()
|
|
}).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 shares = await this.db.select().from(schema.sharedLabels).where(eq(schema.sharedLabels.labelId, labelId));
|
|
if (shares.length === 0) return [];
|
|
return await this.db.select().from(schema.users)
|
|
.where(sql`${schema.users.id} IN ${shares.map(s => s.sharedWithUserId)}`);
|
|
}
|
|
|
|
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));
|
|
}
|
|
|
|
async updateConversation(id: string, title: string): Promise<Conversation | undefined> {
|
|
const result = await this.db.update(schema.conversations)
|
|
.set({ title, updatedAt: new Date() })
|
|
.where(eq(schema.conversations.id, id))
|
|
.returning();
|
|
return result[0];
|
|
}
|
|
async getMessages(conversationId: string): Promise<Message[]> {
|
|
return await this.db.select()
|
|
.from(schema.messages)
|
|
.where(eq(schema.messages.conversationId, conversationId))
|
|
.orderBy(asc(schema.messages.createdAt));
|
|
}
|
|
|
|
async getMessage(id: string): Promise<Message | undefined> {
|
|
const result = await this.db.select().from(schema.messages).where(eq(schema.messages.id, id));
|
|
return result[0];
|
|
}
|
|
|
|
async updateMessage(id: string, content: string): Promise<Message> {
|
|
const result = await this.db.update(schema.messages)
|
|
.set({ content })
|
|
.where(eq(schema.messages.id, id))
|
|
.returning();
|
|
return result[0];
|
|
}
|
|
|
|
async deleteMessagesAfter(conversationId: string, after: Date, excludeMessageId?: string): Promise<void> {
|
|
const filters = [
|
|
eq(schema.messages.conversationId, conversationId),
|
|
gt(schema.messages.createdAt, after)
|
|
];
|
|
|
|
if (excludeMessageId) {
|
|
filters.push(ne(schema.messages.id, excludeMessageId));
|
|
}
|
|
|
|
await this.db.delete(schema.messages)
|
|
.where(and(...filters));
|
|
}
|
|
|
|
// Audit Logs (DbStorage)
|
|
async createAuditLog(insertLog: InsertAuditLog): Promise<AuditLog> {
|
|
const result = await this.db.insert(schema.auditLogs).values(insertLog).returning();
|
|
return result[0];
|
|
}
|
|
|
|
async getAuditLogs(limit = 100): Promise<AuditLog[]> {
|
|
return await this.db.select()
|
|
.from(schema.auditLogs)
|
|
.orderBy(desc(schema.auditLogs.createdAt))
|
|
.limit(limit);
|
|
}
|
|
|
|
// Time Tracking (DbStorage)
|
|
async logTaskTime(insertLog: InsertTaskTimeLog): Promise<TaskTimeLog> {
|
|
const result = await this.db.insert(schema.taskTimeLogs).values(insertLog).returning();
|
|
return result[0];
|
|
}
|
|
|
|
async getAnalyticsTimeDistribution(userId: string, period: 'day' | 'week' | 'month' | 'year'): Promise<{ labelId: string | null, labelName: string | null, labelColor: string | null, timeSpent: number }[]> {
|
|
// Calculate start date based on period
|
|
// Simple approach: standard SQL date truncation or JS date calculation passed as param
|
|
// Drizzle doesn't have easy interval manipulation across specific drivers universally, but for PG we can use sql
|
|
|
|
let timeFilter;
|
|
const now = new Date();
|
|
|
|
// We can filter in JS or SQL. SQL is better.
|
|
// period logic:
|
|
// day: >= start of today
|
|
// week: >= start of this week (Monday)
|
|
// month: >= start of this month
|
|
// year: >= start of this year
|
|
|
|
let startDate = new Date();
|
|
startDate.setHours(0, 0, 0, 0); // reset time
|
|
|
|
if (period === 'week') {
|
|
const day = startDate.getDay() || 7;
|
|
startDate.setDate(startDate.getDate() - (day - 1));
|
|
} else if (period === 'month') {
|
|
startDate.setDate(1);
|
|
} else if (period === 'year') {
|
|
startDate.setMonth(0, 1);
|
|
}
|
|
|
|
// If period is 'day', startDate is already today 00:00
|
|
|
|
// Join taskTimeLogs -> tasks -> labels
|
|
// Sum timeSpent by label
|
|
|
|
const logs = await this.db.select({
|
|
labelId: schema.labels.id,
|
|
labelName: schema.labels.name,
|
|
labelColor: schema.labels.color,
|
|
timeSpent: sql<number>`sum(${schema.taskTimeLogs.timeSpent})::int`
|
|
})
|
|
.from(schema.taskTimeLogs)
|
|
.innerJoin(schema.tasks, eq(schema.taskTimeLogs.taskId, schema.tasks.id))
|
|
.leftJoin(schema.labels, eq(schema.tasks.labelId, schema.labels.id))
|
|
.where(and(
|
|
eq(schema.taskTimeLogs.userId, userId),
|
|
gt(schema.taskTimeLogs.createdAt, startDate)
|
|
))
|
|
.groupBy(schema.labels.id, schema.labels.name, schema.labels.color);
|
|
|
|
// Drizzle returns { labelId: ..., timeSpent: ... }
|
|
// We need to handle null labels too (left join) -> "No Label" logic
|
|
// Actually the left join returns null for label fields if tasks.labelId is null or invalid
|
|
// We can map the result
|
|
|
|
return logs.map(log => ({
|
|
labelId: log.labelId || null,
|
|
labelName: log.labelName || "No Label",
|
|
labelColor: log.labelColor || "#808080",
|
|
timeSpent: log.timeSpent || 0
|
|
}));
|
|
}
|
|
|
|
// ============================================
|
|
// ADHD FEATURE IMPLEMENTATIONS
|
|
// ============================================
|
|
|
|
async createBreakLog(log: { userId: string; breakType: string; durationMinutes: number }): Promise<BreakLog> {
|
|
const [result] = await this.db.insert(schema.breakLogs).values({
|
|
userId: log.userId,
|
|
breakType: log.breakType,
|
|
durationMinutes: log.durationMinutes,
|
|
}).returning();
|
|
return result;
|
|
}
|
|
|
|
async getBreakStats(userId: string): Promise<{ todayBreaks: number; totalMinutes: number; lastBreak: Date | null }> {
|
|
const today = new Date();
|
|
today.setHours(0, 0, 0, 0);
|
|
|
|
const todayLogs = await this.db.select()
|
|
.from(schema.breakLogs)
|
|
.where(and(
|
|
eq(schema.breakLogs.userId, userId),
|
|
gt(schema.breakLogs.createdAt, today)
|
|
));
|
|
|
|
const totalMinutes = todayLogs.reduce((sum, log) => sum + log.durationMinutes, 0);
|
|
const lastLog = await this.db.select()
|
|
.from(schema.breakLogs)
|
|
.where(eq(schema.breakLogs.userId, userId))
|
|
.orderBy(desc(schema.breakLogs.createdAt))
|
|
.limit(1);
|
|
|
|
return {
|
|
todayBreaks: todayLogs.length,
|
|
totalMinutes,
|
|
lastBreak: lastLog[0]?.createdAt || null,
|
|
};
|
|
}
|
|
|
|
async createEnergyLog(log: { userId: string; energyLevel: string; notes?: string }): Promise<EnergyLog> {
|
|
const [result] = await this.db.insert(schema.energyLogs).values({
|
|
userId: log.userId,
|
|
energyLevel: log.energyLevel,
|
|
notes: log.notes,
|
|
}).returning();
|
|
return result;
|
|
}
|
|
|
|
async getEnergyHistory(userId: string): Promise<EnergyLog[]> {
|
|
return await this.db.select()
|
|
.from(schema.energyLogs)
|
|
.where(eq(schema.energyLogs.userId, userId))
|
|
.orderBy(desc(schema.energyLogs.loggedAt))
|
|
.limit(30);
|
|
}
|
|
|
|
async getBodyDoublingSessions(userId: string): Promise<any[]> {
|
|
const sessions = await this.db.select({
|
|
id: schema.bodyDoublingSessions.id,
|
|
hostId: schema.bodyDoublingSessions.hostId,
|
|
title: schema.bodyDoublingSessions.title,
|
|
sessionType: schema.bodyDoublingSessions.sessionType,
|
|
startsAt: schema.bodyDoublingSessions.startsAt,
|
|
durationMinutes: schema.bodyDoublingSessions.durationMinutes,
|
|
maxParticipants: schema.bodyDoublingSessions.maxParticipants,
|
|
isPublic: schema.bodyDoublingSessions.isPublic,
|
|
status: schema.bodyDoublingSessions.status,
|
|
createdAt: schema.bodyDoublingSessions.createdAt,
|
|
hostUsername: schema.users.username,
|
|
})
|
|
.from(schema.bodyDoublingSessions)
|
|
.innerJoin(schema.users, eq(schema.bodyDoublingSessions.hostId, schema.users.id))
|
|
.where(or(
|
|
eq(schema.bodyDoublingSessions.isPublic, true),
|
|
eq(schema.bodyDoublingSessions.hostId, userId)
|
|
))
|
|
.orderBy(asc(schema.bodyDoublingSessions.startsAt));
|
|
|
|
// Get participant counts
|
|
const result = await Promise.all(sessions.map(async (session) => {
|
|
const participants = await this.db.select()
|
|
.from(schema.sessionParticipants)
|
|
.where(eq(schema.sessionParticipants.sessionId, session.id));
|
|
|
|
const isParticipant = participants.some(p => p.userId === userId);
|
|
|
|
return {
|
|
...session,
|
|
host: { username: session.hostUsername },
|
|
participantCount: participants.length,
|
|
isParticipant,
|
|
};
|
|
}));
|
|
|
|
return result;
|
|
}
|
|
|
|
async createBodyDoublingSession(sessionData: any): Promise<BodyDoublingSession> {
|
|
const [result] = await this.db.insert(schema.bodyDoublingSessions).values({
|
|
hostId: sessionData.hostId,
|
|
title: sessionData.title,
|
|
sessionType: sessionData.sessionType || 'focus',
|
|
startsAt: new Date(sessionData.startsAt),
|
|
durationMinutes: sessionData.durationMinutes || 50,
|
|
maxParticipants: sessionData.maxParticipants || 5,
|
|
isPublic: sessionData.isPublic ?? true,
|
|
}).returning();
|
|
return result;
|
|
}
|
|
|
|
async joinSession(sessionId: string, userId: string): Promise<void> {
|
|
// Check if already a participant
|
|
const existing = await this.db.select()
|
|
.from(schema.sessionParticipants)
|
|
.where(and(
|
|
eq(schema.sessionParticipants.sessionId, sessionId),
|
|
eq(schema.sessionParticipants.userId, userId)
|
|
));
|
|
|
|
if (existing.length === 0) {
|
|
await this.db.insert(schema.sessionParticipants).values({
|
|
sessionId,
|
|
userId,
|
|
});
|
|
}
|
|
}
|
|
|
|
async leaveSession(sessionId: string, userId: string): Promise<void> {
|
|
await this.db.update(schema.sessionParticipants)
|
|
.set({ leftAt: new Date() })
|
|
.where(and(
|
|
eq(schema.sessionParticipants.sessionId, sessionId),
|
|
eq(schema.sessionParticipants.userId, userId)
|
|
));
|
|
}
|
|
|
|
async createFocusSession(sessionData: any): Promise<FocusSession> {
|
|
const [result] = await this.db.insert(schema.focusSessions).values({
|
|
userId: sessionData.userId,
|
|
taskId: sessionData.taskId,
|
|
sessionType: sessionData.sessionType || 'pomodoro',
|
|
plannedMinutes: sessionData.plannedMinutes,
|
|
}).returning();
|
|
return result;
|
|
}
|
|
|
|
async completeFocusSession(sessionId: string, actualMinutes: number, wasCompleted: boolean): Promise<FocusSession | undefined> {
|
|
const [result] = await this.db.update(schema.focusSessions)
|
|
.set({
|
|
actualMinutes,
|
|
wasCompleted,
|
|
endedAt: new Date(),
|
|
})
|
|
.where(eq(schema.focusSessions.id, sessionId))
|
|
.returning();
|
|
return result;
|
|
}
|
|
|
|
async getTodaysChallenges(userId: string): Promise<DailyChallenge[]> {
|
|
const today = new Date().toISOString().split('T')[0];
|
|
return await this.db.select()
|
|
.from(schema.dailyChallenges)
|
|
.where(and(
|
|
eq(schema.dailyChallenges.userId, userId),
|
|
eq(schema.dailyChallenges.challengeDate, today)
|
|
));
|
|
}
|
|
|
|
async generateDailyChallenges(userId: string): Promise<DailyChallenge[]> {
|
|
const today = new Date().toISOString().split('T')[0];
|
|
|
|
const challengeTemplates = [
|
|
{ type: 'complete_tasks', title: 'Complete 3 tasks', target: 3, xp: 50 },
|
|
{ type: 'take_breaks', title: 'Take 2 breaks', target: 2, xp: 30 },
|
|
{ type: 'quick_wins', title: 'Complete 2 quick wins', target: 2, xp: 40 },
|
|
{ type: 'focus_time', title: 'Focus for 25 minutes', target: 25, xp: 35 },
|
|
{ type: 'use_timer', title: 'Use the visual timer', target: 1, xp: 20 },
|
|
];
|
|
|
|
// Pick 3 random challenges
|
|
const shuffled = challengeTemplates.sort(() => Math.random() - 0.5);
|
|
const selected = shuffled.slice(0, 3);
|
|
|
|
const challenges = await Promise.all(selected.map(async (template) => {
|
|
const [result] = await this.db.insert(schema.dailyChallenges).values({
|
|
userId,
|
|
challengeType: template.type,
|
|
title: template.title,
|
|
target: template.target,
|
|
xpReward: template.xp,
|
|
challengeDate: today,
|
|
}).returning();
|
|
return result;
|
|
}));
|
|
|
|
return challenges;
|
|
}
|
|
|
|
async updateChallengeProgress(challengeId: string, increment: number): Promise<DailyChallenge | undefined> {
|
|
const challenge = await this.db.select()
|
|
.from(schema.dailyChallenges)
|
|
.where(eq(schema.dailyChallenges.id, challengeId))
|
|
.limit(1);
|
|
|
|
if (!challenge[0]) return undefined;
|
|
|
|
const newProgress = Math.min(challenge[0].progress + increment, challenge[0].target);
|
|
const isCompleted = newProgress >= challenge[0].target;
|
|
|
|
const [result] = await this.db.update(schema.dailyChallenges)
|
|
.set({
|
|
progress: newProgress,
|
|
completedAt: isCompleted && !challenge[0].completedAt ? new Date() : challenge[0].completedAt,
|
|
})
|
|
.where(eq(schema.dailyChallenges.id, challengeId))
|
|
.returning();
|
|
|
|
return result;
|
|
}
|
|
}
|
|
|
|
// Export storage based on environment
|
|
// Export storage based on environment
|
|
// Default to database storage if DATABASE_URL is present, otherwise fallback to memory
|
|
export const storage = process.env.DATABASE_URL
|
|
? new DbStorage()
|
|
: new MemStorage();
|