import { Router, Request, Response } from 'express'; import { asyncHandler } from '../utils/asyncHandler'; import { db } from '../db'; import { tasks, subtasks, categories } from '../db/schema'; import { eq, and, desc, asc, gte, lte, inArray, sql } from 'drizzle-orm'; import { authMiddleware } from '../utils/auth'; import { AppError } from '../middleware/errorHandler'; import { taskCreateSchema, taskUpdateSchema, taskQuerySchema, canCompleteTask } from '../utils/validation'; import { z } from 'zod'; const router = Router(); router.use(authMiddleware); function applyTaskFilters(query: any, userId: string, filters: any) { const conditions = [eq(tasks.userId, userId)]; if (filters.categoryId) { conditions.push(eq(tasks.categoryId, filters.categoryId)); } if (filters.completed !== undefined) { conditions.push(eq(tasks.completed, filters.completed)); } if (filters.dueBefore) { conditions.push(lte(tasks.dueDate, filters.dueBefore)); } if (filters.dueAfter) { conditions.push(gte(tasks.dueDate, filters.dueAfter)); } if (filters.priority) { conditions.push(eq(tasks.priority, filters.priority)); } return query.where(and(...conditions)); } function applyTaskSorting(query: any, sortBy: string = 'dueDate', sortOrder: string = 'asc') { const orderFn = sortOrder === 'desc' ? desc : asc; const columnMap: Record = { dueDate: tasks.dueDate, priority: tasks.priority, createdAt: tasks.createdAt, title: tasks.title, }; return query.orderBy(orderFn(columnMap[sortBy] || tasks.dueDate)); } router.get('/', asyncHandler(async (req: Request, res: Response) => { const filters = taskQuerySchema.parse(req.query); const userId = req.user!.userId; let query: any = db.select().from(tasks); query = applyTaskFilters(query, userId, filters); query = applyTaskSorting(query, filters.sortBy, filters.sortOrder); const limit = filters.limit ?? 50; const offset = filters.offset ?? 0; query = query.limit(limit).offset(offset); const results = await query; // Fetch subtasks for each task const taskIds = results.map((t: { id: string }) => t.id); let taskSubtasks: any[] = []; if (taskIds.length > 0) { taskSubtasks = await db .select() .from(subtasks) .where(and(eq(subtasks.userId, userId), inArray(subtasks.taskId, taskIds))) .orderBy(asc(subtasks.order)); } const subtasksByTask = taskSubtasks.reduce((acc, st) => { if (!acc[st.taskId]) acc[st.taskId] = []; acc[st.taskId].push(st); return acc; }, {} as Record); const tasksWithSubtasks = results.map((task: any) => ({ ...task, subtasks: subtasksByTask[task.id] || [], })); // Get total count const countQuery: any = db.select({ count: sql`count(*)` }).from(tasks); const countResult = await applyTaskFilters(countQuery, userId, filters); const total = Number(countResult[0]?.count ?? 0); res.json({ tasks: tasksWithSubtasks, total, limit, offset }); })); router.get('/:id', asyncHandler(async (req: Request, res: Response) => { const userId = req.user!.userId; const task = await db .select() .from(tasks) .where(and(eq(tasks.id, req.params.id), eq(tasks.userId, userId))) .limit(1); if (task.length === 0) { throw new AppError('NOT_FOUND', 'Task not found', 404); } const taskSubtasks = await db .select() .from(subtasks) .where(and(eq(subtasks.taskId, req.params.id), eq(subtasks.userId, userId))) .orderBy(asc(subtasks.order)); res.json({ ...task[0], subtasks: taskSubtasks }); })); router.post('/', asyncHandler(async (req: Request, res: Response) => { const data = taskCreateSchema.parse(req.body); const userId = req.user!.userId; const now = Date.now(); // Verify category exists and belongs to user (optional - tasks may be uncategorized) if (data.categoryId) { const cat = await db .select() .from(categories) .where(and(eq(categories.id, data.categoryId), eq(categories.userId, userId))) .limit(1); if (cat.length === 0) { throw new AppError('NOT_FOUND', 'Category not found', 404); } } const taskId = `task_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`; const newTask = { id: taskId, userId, categoryId: data.categoryId, tags: data.tags ?? '', title: data.title, description: data.description ?? '', priority: data.priority ?? 'none', completed: false, dueDate: data.dueDate ?? 0, dueTime: data.dueTime ?? '', endTime: data.endTime ?? '', allDay: data.allDay ?? false, assigneeId: data.assigneeId ?? null, reminder: data.reminder ?? 'none', reminders: data.reminders ?? '', createdAt: now, updatedAt: now, }; await db.insert(tasks).values(newTask); // Create subtasks if provided if (data.subtasks && data.subtasks.length > 0) { const subtaskValues = data.subtasks.map((st, index) => ({ id: `sub_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}${index}`, userId, taskId, title: st.title, categoryId: (st as any).categoryId ?? null, tags: (st as any).tags ?? '', completed: false, order: index, createdAt: now, updatedAt: now, })); await db.insert(subtasks).values(subtaskValues); } const createdSubtasks = await db .select() .from(subtasks) .where(and(eq(subtasks.taskId, taskId), eq(subtasks.userId, userId))) .orderBy(asc(subtasks.order)); res.status(201).json({ ...newTask, subtasks: createdSubtasks }); })); router.patch('/:id', asyncHandler(async (req: Request, res: Response) => { const data = taskUpdateSchema.parse(req.body); const userId = req.user!.userId; const existing = await db .select() .from(tasks) .where(and(eq(tasks.id, req.params.id), eq(tasks.userId, userId))) .limit(1); if (existing.length === 0) { throw new AppError('NOT_FOUND', 'Task not found', 404); } // Verify category if provided if (data.categoryId) { const cat = await db .select() .from(categories) .where(and(eq(categories.id, data.categoryId), eq(categories.userId, userId))) .limit(1); if (cat.length === 0) { throw new AppError('NOT_FOUND', 'Category not found', 404); } } // Prevent completing tasks with future due dates if (data.completed === true) { const effectiveDueDate = data.dueDate ?? existing[0].dueDate; if (!canCompleteTask(effectiveDueDate)) { throw new AppError('VALIDATION_ERROR', 'Cannot mark a task as completed if its due date is in the future', 400); } } const now = Date.now(); const updated = await db .update(tasks) .set({ ...data, updatedAt: now }) .where(and(eq(tasks.id, req.params.id), eq(tasks.userId, userId))) .returning(); const taskSubtasks = await db .select() .from(subtasks) .where(and(eq(subtasks.taskId, req.params.id), eq(subtasks.userId, userId))) .orderBy(asc(subtasks.order)); res.json({ ...updated[0], subtasks: taskSubtasks }); })); router.delete('/:id', asyncHandler(async (req: Request, res: Response) => { const userId = req.user!.userId; const existing = await db .select() .from(tasks) .where(and(eq(tasks.id, req.params.id), eq(tasks.userId, userId))) .limit(1); if (existing.length === 0) { throw new AppError('NOT_FOUND', 'Task not found', 404); } await db .delete(tasks) .where(and(eq(tasks.id, req.params.id), eq(tasks.userId, userId))); res.status(204).send(); })); router.post('/batch', asyncHandler(async (req: Request, res: Response) => { const { operations } = z.object({ operations: z.array( z.union([ z.object({ type: z.literal('create'), data: taskCreateSchema }), z.object({ type: z.literal('update'), id: z.string(), data: taskUpdateSchema }), z.object({ type: z.literal('delete'), id: z.string() }), ]) ), }).parse(req.body); const userId = req.user!.userId; const results: any[] = []; for (const op of operations) { try { if (op.type === 'create') { if (op.data.categoryId) { const cat = await db .select() .from(categories) .where(and(eq(categories.id, op.data.categoryId), eq(categories.userId, userId))) .limit(1); if (cat.length === 0) throw new AppError('NOT_FOUND', 'Category not found', 404); } const taskId = `task_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`; const now = Date.now(); await db.insert(tasks).values({ id: taskId, userId, ...op.data, completed: false, createdAt: now, updatedAt: now, }); results.push({ id: taskId, success: true }); } else if (op.type === 'update') { if (op.data.completed === true) { let effectiveDueDate: number; if (op.data.dueDate !== undefined) { effectiveDueDate = op.data.dueDate; } else { const existingTask = await db .select({ dueDate: tasks.dueDate }) .from(tasks) .where(and(eq(tasks.id, op.id), eq(tasks.userId, userId))) .limit(1); if (existingTask.length === 0) throw new AppError('NOT_FOUND', 'Task not found', 404); effectiveDueDate = existingTask[0].dueDate; } if (!canCompleteTask(effectiveDueDate)) { throw new AppError('VALIDATION_ERROR', 'Cannot mark a task as completed if its due date is in the future', 400); } } await db .update(tasks) .set({ ...op.data, updatedAt: Date.now() }) .where(and(eq(tasks.id, op.id), eq(tasks.userId, userId))); results.push({ id: op.id, success: true }); } else if (op.type === 'delete') { await db .delete(tasks) .where(and(eq(tasks.id, op.id), eq(tasks.userId, userId))); results.push({ id: op.id, success: true }); } } catch (error) { results.push({ id: 'id' in op ? op.id : 'unknown', success: false, error: error instanceof Error ? error.message : 'Unknown error' }); } } res.json({ results }); })); export default router;