feat: add social features, leaderboard, auth enhancements, and admin fixes
continuous-integration/drone/push Build is passing

- Implement Social Features: Shared Tasks, Global Access, Privacy Settings (Leaderboard/Searchable).
- Add Leaderboard Page and API.
- Enhance Auth: Support Email/Username login, explicit duplicate registration errors.
- Fix: Admin login password hash regression.
- Refactor: Move to wouter for routing, add Admin Dashboard and User Management.
- Add Setup Wizard.
- Update UI with Sidebar and Gamification elements.
This commit is contained in:
2025-12-10 14:04:26 +01:00
parent d5b045158a
commit ccfb674318
52 changed files with 9206 additions and 1505 deletions
+471 -24
View File
@@ -1,43 +1,103 @@
import { type User, type InsertUser, type Label, type InsertLabel, type Task, type InsertTask } from "../shared/schema.js";
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 { randomUUID } from "crypto";
import session from "express-session";
import createMemoryStore from "memorystore";
import connectPg from "connect-pg-simple";
import { pool } from "./db";
// modify the interface with any CRUD methods
// you might need
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>;
createUser(user: InsertUser): Promise<User>;
getUserByEmail(email: string): Promise<User | undefined>;
createUser(user: InsertUser & { role?: string; isActive?: boolean }): Promise<User>;
updateUser(id: string, updates: Partial<User>): Promise<User>;
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
getUserTaskAccess(viewerId: string): Promise<UserTaskAccess[]>; // Access Viewer has to Owners
// System Settings (Admin)
getSystemSettings(key: string): Promise<string | undefined>;
setSystemSettings(key: string, value: string): Promise<void>;
hasAdminUser(): Promise<boolean>;
// Labels
getAllLabels(): 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
getAllTasks(): Promise<Task[]>;
getTasksForUser(userId: string): Promise<Task[]>; // Replaces getAllTasks
getTask(id: string): Promise<Task | undefined>;
createTask(task: InsertTask): Promise<Task>;
createTask(task: InsertTask & { userId?: string }): Promise<Task>;
updateTask(id: string, updates: Partial<InsertTask>): Promise<Task | undefined>;
deleteTask(id: string): Promise<boolean>;
// Gamification
logXpEvent(event: InsertXpEvent): Promise<XpEvent>;
getGoals(): Promise<Goal[]>;
createGoal(goal: InsertGoal): Promise<Goal>;
// Rewards
getAllRewards(): Promise<Reward[]>;
getUserRewards(userId: string): Promise<UserReward[]>;
createReward(reward: InsertReward): Promise<Reward>;
createUserReward(userReward: InsertUserReward): Promise<UserReward>;
}
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 userTaskAccess: Map<string, UserTaskAccess>;
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.userTaskAccess = 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 = [
@@ -46,9 +106,10 @@ export class MemStorage implements IStorage {
{ id: '2e8c3aa0-278f-4220-b2c5-aa109b4279b7', name: "Urgent", color: "#EF4444" },
{ id: 'c5eccac9-7919-4eb1-bb50-badacad6b1c1', name: "Study", color: "#8B5CF6" },
];
for (const label of defaultLabels) {
this.labels.set(label.id, label);
if (!this.labels.has(label.id)) {
this.labels.set(label.id, label);
}
}
}
@@ -62,13 +123,101 @@ export class MemStorage implements IStorage {
);
}
async createUser(insertUser: InsertUser): Promise<User> {
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 createUser(insertUser: InsertUser & { role?: string; isActive?: boolean }): Promise<User> {
const id = randomUUID();
const user: User = { ...insertUser, id };
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,
};
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;
}
// 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 getUserTaskAccess(viewerId: string): Promise<UserTaskAccess[]> {
return Array.from(this.userTaskAccess.values()).filter(uta => uta.viewerId === viewerId);
}
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());
@@ -88,7 +237,7 @@ export class MemStorage implements IStorage {
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;
@@ -99,17 +248,43 @@ export class MemStorage implements IStorage {
}
// Tasks
async getAllTasks(): Promise<Task[]> {
return Array.from(this.tasks.values());
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));
}
// Merge and Dedupe
const combined = [...myTasks, ...sharedToMe, ...globalSharedTasks];
const unique = Array.from(new Map(combined.map(t => [t.id, t])).values());
return unique;
}
async getTask(id: string): Promise<Task | undefined> {
return this.tasks.get(id);
}
async createTask(insertTask: InsertTask): Promise<Task> {
async createTask(insertTask: InsertTask & { userId?: string }): Promise<Task> {
const id = randomUUID();
const task: Task = {
const task: Task = {
id,
title: insertTask.title,
description: insertTask.description || null,
@@ -121,6 +296,10 @@ export class MemStorage implements IStorage {
projectId: insertTask.projectId || null,
notes: insertTask.notes || null,
labelId: insertTask.labelId || null,
energyLevel: insertTask.energyLevel || "medium",
estimatedDuration: insertTask.estimatedDuration || null,
dependencies: insertTask.dependencies || null,
userId: insertTask.userId || null // Set ownership
};
this.tasks.set(id, task);
return task;
@@ -129,7 +308,7 @@ export class MemStorage implements IStorage {
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;
@@ -138,6 +317,98 @@ export class MemStorage implements IStorage {
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()
};
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;
}
// 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
};
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;
}
}
import { getDatabase } from './db.js';
@@ -146,6 +417,14 @@ import * as schema from '../shared/schema.js';
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));
@@ -157,11 +436,53 @@ export class DbStorage implements IStorage {
return result[0];
}
async createUser(insertUser: InsertUser): Promise<User> {
const result = await this.db.insert(schema.users).values(insertUser).returning();
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 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,
}).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 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;
}
// ... (rest of DbStorage labels, tasks, etc. implementation - unchanged mostly)
async getAllLabels(): Promise<Label[]> {
return await this.db.select().from(schema.labels);
}
@@ -190,8 +511,38 @@ export class DbStorage implements IStorage {
return result.length > 0;
}
async getAllTasks(): Promise<Task[]> {
return await this.db.select().from(schema.tasks);
async getTasksForUser(userId: string): Promise<Task[]> {
// Complex query:
// (tasks.userId = current)
// OR (id IN (select taskId from sharedTasks where sharedWith = current))
// OR (userId IN (select ownerId from userTaskAccess where viewerId = current))
// For simplicity in this generated code, we can do parallel queries or use `or`.
// Drizzle's `or` and `inArray` can be used.
// 1. My tasks
const result = await this.db.select().from(schema.tasks).where(eq(schema.tasks.userId, userId));
// 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}`);
}
// Dedupe
const combined = [...result, ...sharedTasks, ...globalTasks];
const unique = Array.from(new Map(combined.map(t => [t.id, t])).values());
return unique;
}
async getTask(id: string): Promise<Task | undefined> {
@@ -199,7 +550,7 @@ export class DbStorage implements IStorage {
return result[0];
}
async createTask(insertTask: InsertTask): Promise<Task> {
async createTask(insertTask: InsertTask & { userId?: string }): Promise<Task> {
const result = await this.db.insert(schema.tasks).values(insertTask).returning();
return result[0];
}
@@ -217,6 +568,102 @@ export class DbStorage implements IStorage {
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();
// Also update user XP
if (event.userId) { // In real app ensure ID
await this.updateUserXP(event.userId, event.amount);
}
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];
}
// Rewards
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];
}
// 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`);
}
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 + '%'}`);
}
async shareTask(sharedTask: InsertSharedTask): Promise<SharedTask> {
return this.createSharedTask(sharedTask);
}
async createSharedTask(sharedTask: InsertSharedTask): Promise<SharedTask> {
const result = await this.db.insert(schema.sharedTasks).values(sharedTask).returning();
return result[0];
}
async shareAllTasks(access: InsertUserTaskAccess): Promise<UserTaskAccess> {
return this.createUserTaskAccess(access);
}
async createUserTaskAccess(access: InsertUserTaskAccess): Promise<UserTaskAccess> {
// Upsert or simple insert. Let's assume one Record per pair
const result = await this.db.insert(schema.userTaskAccess).values(access).returning();
return result[0];
}
async getSharedTasks(userId: string): Promise<SharedTask[]> {
return await this.db.select()
.from(schema.sharedTasks)
.where(eq(schema.sharedTasks.sharedWithUserId, userId));
}
async getUserTaskAccess(viewerId: string): Promise<UserTaskAccess[]> {
return await this.db.select()
.from(schema.userTaskAccess)
.where(eq(schema.userTaskAccess.viewerId, viewerId));
}
}
// Export storage based on environment