tududi/backend/modules/tasks/routes.js

889 lines
29 KiB
JavaScript

const express = require('express');
const router = express.Router();
// Import sub-routers for task-related routes
const attachmentsRouter = require('./attachments');
const eventsRouter = require('./events');
const {
Task,
TaskEvent,
RecurringCompletion,
sequelize,
} = require('../../models');
const taskRepository = require('./repository');
const {
resetQueryCounter,
getQueryStats,
enableQueryLogging,
} = require('../../middleware/queryLogger');
const {
calculateNextDueDate,
calculateVirtualOccurrences,
shouldGenerateNextTask,
} = require('./recurringTaskService');
const { logError } = require('../../services/logService');
const { logEvent } = require('./taskEventService');
const { serializeTask, serializeTasks } = require('./core/serializers');
const { updateTaskTags } = require('./operations/tags');
const { filterTasksByParams } = require('./queries/query-builders');
const {
getSafeTimezone,
getTodayBoundsInUTC,
} = require('../../utils/timezone-utils');
const { isValidUid } = require('../../utils/slug-utils');
const {
validateProjectAccess,
validateParentTaskAccess,
validateDeferUntilAndDueDate,
} = require('./utils/validation');
const {
buildTaskAttributes,
buildUpdateAttributes,
} = require('./core/builders');
const { createSubtasks, updateSubtasks } = require('./operations/subtasks');
const { handleCompletionStatus } = require('./operations/completion');
const { captureOldValues, logTaskChanges } = require('./utils/logging');
const {
handleParentChildOnStatusChange,
} = require('./operations/parent-child');
const {
TASK_INCLUDES,
TASK_INCLUDES_WITH_SUBTASKS,
} = require('./utils/constants');
const {
handleRecurringTasks,
buildGroupedTasks,
serializeGroupedTasks,
addDashboardLists,
addPerformanceHeaders,
} = require('./operations/list');
const {
handleRecurrenceUpdate,
calculateNextIterations,
} = require('./operations/recurring');
const { getTaskMetrics } = require('./queries/metrics-computation');
const { getSubtasks } = require('./operations/subtasks');
const {
requireTaskReadAccess,
requireTaskWriteAccess,
} = require('./middleware/access');
if (process.env.NODE_ENV === 'development') {
enableQueryLogging();
}
function expandRecurringTasks(tasks, maxDays = 7, statusFilter = null) {
const expandedTasks = [];
const now = new Date();
now.setHours(0, 0, 0, 0);
tasks.forEach((task) => {
const isRecurring =
task.recurrence_type &&
task.recurrence_type !== 'none' &&
!task.recurring_parent_id;
if (!isRecurring) {
expandedTasks.push(task);
return;
}
console.log('[DEBUG] Processing recurring task:', {
id: task.id,
name: task.name,
recurrence_type: task.recurrence_type,
due_date: task.due_date,
status: task.status,
completed_at: task.completed_at,
has_due_date: !!task.due_date,
statusFilter: statusFilter,
});
if (
(statusFilter === 'completed' || statusFilter === 'done') &&
(task.status === 2 || task.status === 'done')
) {
console.log(
'[DEBUG] Task is completed and filter is completed, showing actual task'
);
expandedTasks.push(task);
return;
}
let startFrom = task.due_date ? new Date(task.due_date) : now;
if (task.status === 2 || task.status === 'done') {
const baseDate =
task.completion_based && task.completed_at
? new Date(task.completed_at)
: new Date(task.due_date || now);
const nextDate = calculateNextDueDate(task, baseDate);
startFrom = nextDate || now;
console.log(
'[DEBUG] Task is completed, starting from next occurrence:',
startFrom
);
} else if (startFrom < now) {
let nextDate = startFrom;
let iterations = 0;
const MAX_ITERATIONS = 100;
while (nextDate && nextDate < now && iterations < MAX_ITERATIONS) {
nextDate = calculateNextDueDate(task, nextDate);
iterations++;
}
startFrom = nextDate || now;
}
console.log('[DEBUG] Starting from date:', startFrom);
const occurrences = calculateVirtualOccurrences(
task,
maxDays,
startFrom
);
console.log('[DEBUG] Generated occurrences:', occurrences.length);
occurrences.forEach((occurrence, index) => {
const virtualTask = {
...(task.toJSON ? task.toJSON() : task),
due_date: occurrence.due_date,
is_virtual_occurrence: true,
occurrence_index: index,
virtual_id: `${task.id}_occurrence_${index}`,
};
expandedTasks.push(virtualTask);
});
});
return expandedTasks;
}
router.get('/tasks', async (req, res) => {
const startTime = Date.now();
resetQueryCounter();
try {
const {
type,
groupBy,
maxDays,
order_by,
include_lists,
limit: limitParam,
offset: offsetParam,
} = req.query;
const { id: userId, timezone, language } = req.currentUser;
await handleRecurringTasks(userId, type);
let tasks = await filterTasksByParams(req.query, userId, timezone);
if (type === 'upcoming' && groupBy === 'day') {
console.log('[DEBUG] Expanding recurring tasks for /upcoming');
console.log('[DEBUG] Total tasks before expansion:', tasks.length);
console.log(
'[DEBUG] Recurring tasks:',
tasks
.filter(
(t) => t.recurrence_type && t.recurrence_type !== 'none'
)
.map((t) => ({
id: t.id,
name: t.name,
recurrence_type: t.recurrence_type,
due_date: t.due_date,
recurring_parent_id: t.recurring_parent_id,
}))
);
const days = maxDays ? parseInt(maxDays, 10) : 7;
tasks = expandRecurringTasks(tasks, days, req.query.status);
console.log('[DEBUG] Total tasks after expansion:', tasks.length);
}
if (type === 'today') {
const safeTimezone = getSafeTimezone(timezone);
const todayBounds = getTodayBoundsInUTC(safeTimezone);
const instancesForToday = tasks.filter(
(t) =>
t.recurring_parent_id &&
t.due_date &&
new Date(t.due_date) >= todayBounds.start &&
new Date(t.due_date) <= todayBounds.end
);
const parentIdsWithTodayInstances = new Set(
instancesForToday.map((t) => t.recurring_parent_id)
);
tasks = tasks.filter(
(t) =>
!t.recurrence_type ||
t.recurrence_type === 'none' ||
t.recurring_parent_id !== null ||
!parentIdsWithTodayInstances.has(t.id)
);
}
const hasPagination =
limitParam !== undefined || offsetParam !== undefined;
const totalCount = tasks.length;
let paginatedTasks = tasks;
if (hasPagination) {
const limit = parseInt(limitParam, 10) || 20;
const offset = parseInt(offsetParam, 10) || 0;
paginatedTasks = tasks.slice(offset, offset + limit);
}
const groupedTasks = await buildGroupedTasks(
paginatedTasks,
type,
groupBy,
maxDays,
order_by,
timezone,
language || 'en'
);
const serializationOptions =
type === 'today' ? { preserveOriginalName: true } : {};
const response = {
tasks: await serializeTasks(
paginatedTasks,
timezone,
serializationOptions
),
};
const serializedGrouped = await serializeGroupedTasks(
groupedTasks,
timezone
);
if (serializedGrouped) {
response.groupedTasks = serializedGrouped;
}
await addDashboardLists(
response,
userId,
timezone,
type,
include_lists,
serializationOptions
);
if (hasPagination) {
const limit = parseInt(limitParam, 10) || 20;
const offset = parseInt(offsetParam, 10) || 0;
response.pagination = {
total: totalCount,
limit: limit,
offset: offset,
hasMore: offset + paginatedTasks.length < totalCount,
};
}
addPerformanceHeaders(res, startTime, getQueryStats());
res.json(response);
} catch (error) {
logError('Error fetching tasks:', error);
if (error.message === 'Invalid order column specified.') {
return res.status(400).json({ error: error.message });
}
res.status(500).json({ error: 'Internal server error' });
}
});
router.get('/tasks/metrics', async (req, res) => {
try {
const response = await getTaskMetrics(
req.currentUser.id,
req.currentUser.timezone,
req.query.type
);
res.json(response);
} catch (error) {
logError('Error fetching task metrics:', error);
res.status(500).json({ error: 'Internal server error' });
}
});
router.post('/task', async (req, res) => {
try {
const { name, project_id, parent_task_id, tags, Tags, subtasks } =
req.body;
const tagsData = tags || Tags;
if (!name || name.trim() === '') {
return res.status(400).json({ error: 'Task name is required.' });
}
const timezone = getSafeTimezone(req.currentUser.timezone);
const taskAttributes = buildTaskAttributes(
req.body,
req.currentUser.id,
timezone
);
try {
validateDeferUntilAndDueDate(
taskAttributes.defer_until,
taskAttributes.due_date
);
} catch (error) {
return res.status(400).json({ error: error.message });
}
try {
const validProjectId = await validateProjectAccess(
project_id,
req.currentUser.id
);
if (validProjectId) taskAttributes.project_id = validProjectId;
} catch (error) {
return res
.status(error.message === 'Forbidden' ? 403 : 400)
.json({ error: error.message });
}
try {
const validParentId = await validateParentTaskAccess(
parent_task_id,
req.currentUser.id
);
if (validParentId) taskAttributes.parent_task_id = validParentId;
} catch (error) {
return res.status(400).json({ error: error.message });
}
const task = await taskRepository.create(taskAttributes);
await updateTaskTags(task, tagsData, req.currentUser.id);
await createSubtasks(task.id, subtasks, req.currentUser.id);
const taskWithAssociations = await taskRepository.findById(task.id, {
include: TASK_INCLUDES,
});
if (!taskWithAssociations) {
logError('Failed to reload created task:', task.id);
const fallbackTask = {
...task.toJSON(),
tags: [],
Project: null,
subtasks: [],
today_move_count: 0,
due_date: task.due_date
? task.due_date instanceof Date
? task.due_date.toISOString().split('T')[0]
: new Date(task.due_date).toISOString().split('T')[0]
: null,
completed_at: task.completed_at
? task.completed_at instanceof Date
? task.completed_at.toISOString()
: new Date(task.completed_at).toISOString()
: null,
};
return res.status(201).json(fallbackTask);
}
const serializedTask = await serializeTask(
taskWithAssociations,
req.currentUser.timezone,
{ skipDisplayNameTransform: true }
);
res.set({
'Cache-Control': 'no-cache, no-store, must-revalidate',
Pragma: 'no-cache',
Expires: '0',
});
res.status(201).json(serializedTask);
} catch (error) {
logError('Error creating task:', error);
logError('Error stack:', error.stack);
logError('Error name:', error.name);
res.status(400).json({
error: 'There was a problem creating the task.',
details: error.errors
? error.errors.map((e) => e.message)
: [error.message],
});
}
});
router.get('/task/:uid', requireTaskReadAccess, async (req, res) => {
try {
const task = await taskRepository.findByUid(req.params.uid, {
include: TASK_INCLUDES_WITH_SUBTASKS,
});
if (!task) {
return res.status(404).json({ error: 'Task not found.' });
}
const serializedTask = await serializeTask(
task,
req.currentUser.timezone,
{ skipDisplayNameTransform: true }
);
res.json(serializedTask);
} catch (error) {
logError('Error fetching task:', error);
res.status(500).json({ error: 'Internal server error' });
}
});
router.patch('/task/:uid', requireTaskWriteAccess, async (req, res) => {
try {
const {
status,
project_id,
parent_task_id,
tags,
Tags,
subtasks,
recurrence_type,
recurrence_interval,
recurrence_end_date,
recurrence_weekday,
recurrence_weekdays,
recurrence_month_day,
recurrence_week_of_month,
completion_based,
update_parent_recurrence,
} = req.body;
const tagsData = tags || Tags;
const task = await taskRepository.findByUid(req.params.uid, {
include: TASK_INCLUDES_WITH_SUBTASKS,
});
if (!task) {
return res.status(404).json({ error: 'Task not found.' });
}
const oldValues = captureOldValues(task);
const oldStatus = task.status;
if (update_parent_recurrence && task.recurring_parent_id) {
const parentTask = await taskRepository.findByIdAndUser(
task.recurring_parent_id,
req.currentUser.id
);
if (parentTask) {
await parentTask.update({
recurrence_type:
recurrence_type !== undefined
? recurrence_type
: parentTask.recurrence_type,
recurrence_interval:
recurrence_interval !== undefined
? recurrence_interval
: parentTask.recurrence_interval,
recurrence_end_date:
recurrence_end_date !== undefined
? recurrence_end_date
: parentTask.recurrence_end_date,
recurrence_weekday:
recurrence_weekday !== undefined
? recurrence_weekday
: parentTask.recurrence_weekday,
recurrence_weekdays:
recurrence_weekdays !== undefined
? recurrence_weekdays
: parentTask.recurrence_weekdays,
recurrence_month_day:
recurrence_month_day !== undefined
? recurrence_month_day
: parentTask.recurrence_month_day,
recurrence_week_of_month:
recurrence_week_of_month !== undefined
? recurrence_week_of_month
: parentTask.recurrence_week_of_month,
completion_based:
completion_based !== undefined
? completion_based
: parentTask.completion_based,
});
}
}
const timezone = getSafeTimezone(req.currentUser.timezone);
const taskAttributes = buildUpdateAttributes(req.body, task, timezone);
try {
const finalDeferUntil =
taskAttributes.defer_until !== undefined
? taskAttributes.defer_until
: task.defer_until;
const finalDueDate =
taskAttributes.due_date !== undefined
? taskAttributes.due_date
: task.due_date;
validateDeferUntilAndDueDate(finalDeferUntil, finalDueDate);
} catch (error) {
return res.status(400).json({ error: error.message });
}
await handleCompletionStatus(taskAttributes, status, task);
if (project_id !== undefined) {
try {
const validProjectId = await validateProjectAccess(
project_id,
req.currentUser.id
);
taskAttributes.project_id = validProjectId;
} catch (error) {
return res
.status(error.message === 'Forbidden' ? 403 : 400)
.json({ error: error.message });
}
}
if (parent_task_id !== undefined) {
if (parent_task_id && parent_task_id.toString().trim()) {
try {
const validParentId = await validateParentTaskAccess(
parent_task_id,
req.currentUser.id
);
taskAttributes.parent_task_id = validParentId;
} catch (error) {
return res.status(400).json({ error: error.message });
}
} else {
taskAttributes.parent_task_id = null;
}
}
const recurrenceFields = [
'recurrence_type',
'recurrence_interval',
'recurrence_end_date',
'recurrence_weekday',
'recurrence_weekdays',
'recurrence_month_day',
'recurrence_week_of_month',
'completion_based',
];
const recurrenceChanged = await handleRecurrenceUpdate(
task,
recurrenceFields,
req.body
);
const resolveFinalValue = (field) =>
taskAttributes[field] !== undefined
? taskAttributes[field]
: task[field];
const finalRecurrenceType = resolveFinalValue('recurrence_type');
const finalCompletionBased = resolveFinalValue('completion_based');
const finalDueDateBeforeAdvance =
taskAttributes.due_date !== undefined
? taskAttributes.due_date
: task.due_date;
let recurringCompletionPayload = null;
let recurrenceAdvanceInfo = null;
if (
status !== undefined &&
(taskAttributes.status === Task.STATUS.DONE ||
taskAttributes.status === 'done') &&
finalRecurrenceType &&
finalRecurrenceType !== 'none' &&
!task.recurring_parent_id
) {
const completedAt = new Date();
const hasOriginalDueDate =
finalDueDateBeforeAdvance !== undefined &&
finalDueDateBeforeAdvance !== null &&
finalDueDateBeforeAdvance !== '';
const originalDueDate = hasOriginalDueDate
? new Date(finalDueDateBeforeAdvance)
: new Date(completedAt);
const recurrenceContext = {
...(typeof task.get === 'function'
? task.get({ plain: true })
: task),
recurrence_type: finalRecurrenceType,
recurrence_interval: resolveFinalValue('recurrence_interval'),
recurrence_end_date: resolveFinalValue('recurrence_end_date'),
recurrence_weekday: resolveFinalValue('recurrence_weekday'),
recurrence_weekdays: resolveFinalValue('recurrence_weekdays'),
recurrence_month_day: resolveFinalValue('recurrence_month_day'),
recurrence_week_of_month: resolveFinalValue(
'recurrence_week_of_month'
),
completion_based: finalCompletionBased,
due_date: originalDueDate,
};
const baseDate = finalCompletionBased
? completedAt
: new Date(originalDueDate);
const nextDueDate = calculateNextDueDate(
recurrenceContext,
baseDate
);
recurringCompletionPayload = {
task_id: task.id,
completed_at: completedAt,
original_due_date: new Date(originalDueDate),
skipped: false,
};
recurrenceAdvanceInfo = {
originalDueDate: new Date(originalDueDate),
completedAt,
nextDueDate,
};
if (
nextDueDate &&
shouldGenerateNextTask(recurrenceContext, nextDueDate)
) {
taskAttributes.status = Task.STATUS.NOT_STARTED;
taskAttributes.completed_at = null;
taskAttributes.due_date = nextDueDate;
}
}
await task.update(taskAttributes);
if (status !== undefined) {
await handleParentChildOnStatusChange(
task,
oldStatus,
taskAttributes.status,
req.currentUser.id
);
}
if (recurringCompletionPayload) {
await RecurringCompletion.create(recurringCompletionPayload);
try {
await logEvent({
taskId: task.id,
userId: req.currentUser.id,
eventType: 'recurring_occurrence_completed',
fieldName: 'recurrence',
oldValue: recurrenceAdvanceInfo
? recurrenceAdvanceInfo.originalDueDate
: null,
newValue: recurrenceAdvanceInfo
? recurrenceAdvanceInfo.nextDueDate
: null,
metadata: {
action: 'recurring_occurrence_completed',
original_due_date:
recurrenceAdvanceInfo?.originalDueDate?.toISOString?.() ??
recurrenceAdvanceInfo?.originalDueDate,
next_due_date:
recurrenceAdvanceInfo?.nextDueDate?.toISOString?.() ??
null,
completion_based: finalCompletionBased,
},
});
} catch (eventError) {
logError(
'Error logging recurring occurrence completion event:',
eventError
);
}
}
await updateTaskTags(task, tagsData, req.currentUser.id);
await updateSubtasks(task.id, subtasks, req.currentUser.id);
await logTaskChanges(
task,
oldValues,
req.body,
tagsData,
req.currentUser.id
);
const taskWithAssociations = await taskRepository.findById(task.id, {
include: TASK_INCLUDES,
});
const serializedTask = await serializeTask(
taskWithAssociations,
req.currentUser.timezone,
{ skipDisplayNameTransform: true }
);
res.json(serializedTask);
} catch (error) {
logError('Error updating task:', error);
res.status(400).json({
error: 'There was a problem updating the task.',
details: error.errors
? error.errors.map((e) => e.message)
: [error.message],
});
}
});
router.delete('/task/:uid', requireTaskWriteAccess, async (req, res) => {
try {
const task = await taskRepository.findByUid(req.params.uid);
if (!task) {
return res.status(404).json({ error: 'Task not found.' });
}
const taskId = task.id;
const childTasks = await taskRepository.findRecurringChildren(taskId);
if (childTasks.length > 0) {
const now = new Date();
const futureInstances = childTasks.filter((child) => {
if (!child.due_date) return true;
return new Date(child.due_date) > now;
});
const pastInstances = childTasks.filter((child) => {
if (!child.due_date) return false;
return new Date(child.due_date) <= now;
});
for (const futureInstance of futureInstances) {
await futureInstance.destroy();
}
for (const pastInstance of pastInstances) {
await pastInstance.update({
recurring_parent_id: null,
recurrence_type: 'none',
recurrence_interval: null,
recurrence_end_date: null,
recurrence_weekday: null,
recurrence_month_day: null,
recurrence_week_of_month: null,
completion_based: false,
});
}
}
await sequelize.query('PRAGMA foreign_keys = OFF');
try {
await TaskEvent.destroy({
where: { task_id: taskId },
force: true,
});
await sequelize.query('DELETE FROM tasks_tags WHERE task_id = ?', {
replacements: [taskId],
});
await taskRepository.clearRecurringParent(taskId);
await task.destroy({ force: true });
} finally {
await sequelize.query('PRAGMA foreign_keys = ON');
}
res.json({ message: 'Task successfully deleted' });
} catch (error) {
res.status(400).json({
error: 'There was a problem deleting the task.',
});
}
});
router.get('/task/:uid/subtasks', async (req, res) => {
try {
if (!isValidUid(req.params.uid)) {
return res.status(400).json({ error: 'Invalid UID' });
}
const task = await taskRepository.findByUid(req.params.uid);
if (!task) {
return res.json([]);
}
const result = await getSubtasks(
task.id,
req.currentUser.id,
req.currentUser.timezone
);
if (result.error === 'Forbidden') {
return res.status(403).json({ error: 'Forbidden' });
}
if (result.error === 'Not found') {
return res.json([]);
}
res.json(result.subtasks);
} catch (error) {
logError('Error fetching subtasks:', error);
res.status(500).json({ error: 'Internal server error' });
}
});
router.get('/task/:uid/next-iterations', async (req, res) => {
try {
if (!isValidUid(req.params.uid)) {
return res.status(400).json({ error: 'Invalid UID' });
}
const task = await taskRepository.findByUid(req.params.uid);
if (!task) {
return res.status(404).json({ error: 'Task not found' });
}
// Verify user owns this task
if (task.user_id !== req.currentUser.id) {
return res.status(403).json({ error: 'Access denied' });
}
if (!task.recurrence_type || task.recurrence_type === 'none') {
return res.json({ iterations: [] });
}
const iterations = await calculateNextIterations(
task,
req.query.startFromDate,
req.currentUser.timezone
);
res.json({ iterations });
} catch (error) {
logError('Error getting next iterations:', error);
res.status(500).json({ error: 'Failed to get next iterations' });
}
});
// Mount sub-routers for task-related routes
router.use(attachmentsRouter);
router.use(eventsRouter);
module.exports = router;