This commit is contained in:
@@ -0,0 +1,326 @@
|
||||
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<string, any> = {
|
||||
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<string, any[]>);
|
||||
|
||||
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
|
||||
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,
|
||||
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,
|
||||
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') {
|
||||
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;
|
||||
Reference in New Issue
Block a user