tududi/backend/routes/tasks.js
2025-08-06 19:49:27 +03:00

1995 lines
68 KiB
JavaScript

const express = require('express');
const { Task, Tag, Project, TaskEvent, sequelize } = require('../models');
const { Op } = require('sequelize');
const RecurringTaskService = require('../services/recurringTaskService');
const TaskEventService = require('../services/taskEventService');
const { validateTagName } = require('../utils/validation');
const moment = require('moment-timezone');
const _ = require('lodash');
const router = express.Router();
// Helper function to serialize task with today move count
async function serializeTask(task) {
const taskJson = task.toJSON();
const todayMoveCount = await TaskEventService.getTaskTodayMoveCount(
task.id
);
// Include subtasks if they exist
const { Subtasks, ...taskWithoutSubtasks } = taskJson;
return {
...taskWithoutSubtasks,
uid: task.uid, // Explicitly include uid
tags: taskJson.Tags || [],
Project: taskJson.Project
? {
...taskJson.Project,
uid: taskJson.Project.uid, // Explicitly include Project uid
}
: null,
subtasks: Subtasks
? Subtasks.map((subtask) => ({
...subtask,
uid: subtask.uid, // Also include uid for subtasks
tags: subtask.Tags || [],
due_date: subtask.due_date
? subtask.due_date.toISOString().split('T')[0]
: null,
completed_at: subtask.completed_at
? subtask.completed_at.toISOString()
: null,
}))
: [],
due_date: task.due_date
? task.due_date.toISOString().split('T')[0]
: null,
completed_at: task.completed_at
? task.completed_at.toISOString()
: null,
today_move_count: todayMoveCount,
};
}
// Helper function to update task tags
async function updateTaskTags(task, tagsData, userId) {
if (!tagsData) return;
// Validate and filter tag names
const validTagNames = [];
const invalidTags = [];
for (const tag of tagsData) {
const validation = validateTagName(tag.name);
if (validation.valid) {
// Check for duplicates
if (!validTagNames.includes(validation.name)) {
validTagNames.push(validation.name);
}
} else {
invalidTags.push({ name: tag.name, error: validation.error });
}
}
// If there are invalid tags, throw an error
if (invalidTags.length > 0) {
throw new Error(
`Invalid tag names: ${invalidTags.map((t) => `"${t.name}" (${t.error})`).join(', ')}`
);
}
if (validTagNames.length === 0) {
await task.setTags([]);
return;
}
// Find existing tags
const existingTags = await Tag.findAll({
where: { user_id: userId, name: validTagNames },
});
// Create new tags
const existingTagNames = existingTags.map((tag) => tag.name);
const newTagNames = validTagNames.filter(
(name) => !existingTagNames.includes(name)
);
const createdTags = await Promise.all(
newTagNames.map((name) => Tag.create({ name, user_id: userId }))
);
// Set all tags to task
const allTags = [...existingTags, ...createdTags];
await task.setTags(allTags);
}
// Helper function to check if all subtasks are done and update parent task
async function checkAndUpdateParentTaskCompletion(parentTaskId, userId) {
try {
// Get all subtasks for the parent task
const subtasks = await Task.findAll({
where: {
parent_task_id: parentTaskId,
user_id: userId,
},
});
// Check if all subtasks are done
const allSubtasksDone =
subtasks.length > 0 &&
subtasks.every(
(subtask) =>
subtask.status === Task.STATUS.DONE ||
subtask.status === 'done'
);
if (allSubtasksDone) {
// Check if parent is already done to avoid unnecessary updates
const parentTask = await Task.findOne({
where: {
id: parentTaskId,
user_id: userId,
},
});
if (
parentTask &&
parentTask.status !== Task.STATUS.DONE &&
parentTask.status !== 'done'
) {
// Update parent task to done
await Task.update(
{
status: Task.STATUS.DONE,
completed_at: new Date(),
},
{
where: {
id: parentTaskId,
user_id: userId,
},
}
);
return true;
}
}
return false;
} catch (error) {
console.error('Error checking parent task completion:', error);
return false;
}
}
// Helper function to undone parent task when subtask gets undone
async function undoneParentTaskIfNeeded(parentTaskId, userId) {
try {
// Get parent task
const parentTask = await Task.findOne({
where: {
id: parentTaskId,
user_id: userId,
},
});
// If parent is done, undone it
if (
parentTask &&
(parentTask.status === Task.STATUS.DONE ||
parentTask.status === 'done')
) {
await Task.update(
{
status: Task.STATUS.NOT_STARTED,
completed_at: null,
},
{
where: {
id: parentTaskId,
user_id: userId,
},
}
);
return true;
}
return false;
} catch (error) {
console.error('Error undoing parent task:', error);
return false;
}
}
// Helper function to complete all subtasks when parent is done
async function completeAllSubtasks(parentTaskId, userId) {
try {
// Update all subtasks to be completed - this ensures completed_at is set for all
const result = await Task.update(
{
status: Task.STATUS.DONE,
completed_at: new Date(),
},
{
where: {
parent_task_id: parentTaskId,
user_id: userId,
},
}
);
return result[0] > 0; // Return true if any subtasks were actually updated
} catch (error) {
console.error('Error completing all subtasks:', error);
return false;
}
}
// Helper function to undone all subtasks when parent is undone
async function undoneAllSubtasks(parentTaskId, userId) {
try {
const result = await Task.update(
{
status: Task.STATUS.NOT_STARTED,
completed_at: null,
},
{
where: {
parent_task_id: parentTaskId,
user_id: userId,
status: {
[Op.in]: [Task.STATUS.DONE, 'done'],
},
},
}
);
return result[0] > 0; // Return true if any subtasks were actually updated
} catch (error) {
console.error('Error undoing all subtasks:', error);
return false;
}
}
// Filter tasks by parameters
async function filterTasksByParams(params, userId) {
let whereClause = {
user_id: userId,
parent_task_id: null, // Exclude subtasks from main task lists
};
let includeClause = [
{
model: Tag,
attributes: ['id', 'name', 'uid'],
through: { attributes: [] },
},
{
model: Project,
attributes: ['id', 'name', 'uid'],
required: false,
},
{
model: Task,
as: 'Subtasks',
include: [
{
model: Tag,
attributes: ['id', 'name', 'uid'],
through: { attributes: [] },
required: false,
},
],
required: false,
},
];
// Filter by type
switch (params.type) {
case 'today':
whereClause.status = {
[Op.notIn]: [
Task.STATUS.DONE,
Task.STATUS.ARCHIVED,
'done',
'archived',
],
}; // Exclude completed and archived tasks (both integer and string values)
break;
case 'upcoming':
whereClause.due_date = {
[Op.between]: [
new Date(),
new Date(Date.now() + 7 * 24 * 60 * 60 * 1000),
],
};
whereClause.status = { [Op.notIn]: [Task.STATUS.DONE, 'done'] };
break;
case 'next':
whereClause.due_date = null;
whereClause.project_id = null;
whereClause.status = { [Op.notIn]: [Task.STATUS.DONE, 'done'] };
break;
case 'inbox':
whereClause[Op.or] = [{ due_date: null }, { project_id: null }];
whereClause.status = { [Op.notIn]: [Task.STATUS.DONE, 'done'] };
break;
case 'someday':
whereClause.due_date = null;
whereClause.status = { [Op.notIn]: [Task.STATUS.DONE, 'done'] };
break;
case 'waiting':
whereClause.status = Task.STATUS.WAITING;
break;
default:
if (params.status === 'done') {
whereClause.status = { [Op.in]: [Task.STATUS.DONE, 'done'] };
} else if (!params.client_side_filtering) {
// Only exclude completed tasks if not doing client-side filtering
whereClause.status = { [Op.notIn]: [Task.STATUS.DONE, 'done'] };
}
// If client_side_filtering is true, don't add any status filter (include all)
}
// Filter by tag
if (params.tag) {
includeClause[0].where = { name: params.tag };
includeClause[0].required = true;
}
let orderClause = [['created_at', 'DESC']];
// Special ordering for inbox - newest items first
if (params.type === 'inbox') {
orderClause = [['created_at', 'DESC']];
}
// Apply ordering
if (params.order_by) {
const [orderColumn, orderDirection = 'asc'] =
params.order_by.split(':');
const allowedColumns = [
'created_at',
'updated_at',
'name',
'priority',
'status',
'due_date',
];
if (!allowedColumns.includes(orderColumn)) {
throw new Error('Invalid order column specified.');
}
if (orderColumn === 'due_date') {
orderClause = [
[
sequelize.literal(
'CASE WHEN Task.due_date IS NULL THEN 1 ELSE 0 END'
),
'ASC',
],
['due_date', orderDirection.toUpperCase()],
];
} else {
orderClause = [[orderColumn, orderDirection.toUpperCase()]];
}
}
return await Task.findAll({
where: whereClause,
include: includeClause,
order: orderClause,
distinct: true,
});
}
// Compute task metrics
async function computeTaskMetrics(userId, userTimezone = 'UTC') {
const totalOpenTasks = await Task.count({
where: { user_id: userId, status: { [Op.ne]: Task.STATUS.DONE } },
});
const oneMonthAgo = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000);
const tasksPendingOverMonth = await Task.count({
where: {
user_id: userId,
status: { [Op.ne]: Task.STATUS.DONE },
created_at: { [Op.lt]: oneMonthAgo },
},
});
const tasksInProgress = await Task.findAll({
where: {
user_id: userId,
status: { [Op.in]: [Task.STATUS.IN_PROGRESS, 'in_progress'] },
},
include: [
{
model: Tag,
attributes: ['id', 'name', 'uid'],
through: { attributes: [] },
required: false,
},
{
model: Project,
attributes: ['id', 'name', 'active', 'uid'],
required: false,
},
{
model: Task,
as: 'Subtasks',
include: [
{
model: Tag,
attributes: ['id', 'name', 'uid'],
through: { attributes: [] },
required: false,
},
],
required: false,
},
],
order: [['priority', 'DESC']],
});
// Get tasks in today plan
const todayPlanTasks = await Task.findAll({
where: {
user_id: userId,
today: true,
status: {
[Op.notIn]: [
Task.STATUS.DONE,
Task.STATUS.ARCHIVED,
'done',
'archived',
],
},
},
include: [
{
model: Tag,
attributes: ['id', 'name', 'uid'],
through: { attributes: [] },
required: false,
},
{
model: Project,
attributes: ['id', 'name', 'active', 'uid'],
required: false,
},
{
model: Task,
as: 'Subtasks',
include: [
{
model: Tag,
attributes: ['id', 'name', 'uid'],
through: { attributes: [] },
required: false,
},
],
required: false,
},
],
order: [
['priority', 'DESC'],
['created_at', 'ASC'],
],
});
const today = new Date();
today.setHours(23, 59, 59, 999);
const tasksDueToday = await Task.findAll({
where: {
user_id: userId,
status: {
[Op.notIn]: [
Task.STATUS.DONE,
Task.STATUS.ARCHIVED,
'done',
'archived',
],
},
[Op.or]: [
{ due_date: { [Op.lte]: today } },
sequelize.literal(`EXISTS (
SELECT 1 FROM projects
WHERE projects.id = Task.project_id
AND projects.due_date_at <= '${today.toISOString()}'
)`),
],
},
include: [
{
model: Tag,
attributes: ['id', 'name', 'uid'],
through: { attributes: [] },
required: false,
},
{
model: Project,
attributes: ['id', 'name', 'active', 'uid'],
required: false,
},
{
model: Task,
as: 'Subtasks',
include: [
{
model: Tag,
attributes: ['id', 'name', 'uid'],
through: { attributes: [] },
required: false,
},
],
required: false,
},
],
});
// Get suggested tasks only if user has a meaningful task base
let suggestedTasks = [];
// Only show suggested tasks if:
// 1. User has at least 3 total tasks, OR
// 2. User has at least 1 project with tasks
if (
totalOpenTasks >= 3 ||
tasksInProgress.length > 0 ||
tasksDueToday.length > 0
) {
const excludedTaskIds = [
...tasksInProgress.map((t) => t.id),
...tasksDueToday.map((t) => t.id),
];
// Get task IDs that have "someday" tag
const somedayTaskIds = await sequelize
.query(
`SELECT DISTINCT task_id FROM tasks_tags
JOIN tags ON tasks_tags.tag_id = tags.id
WHERE tags.name = 'someday' AND tags.user_id = ?`,
{
replacements: [userId],
type: sequelize.QueryTypes.SELECT,
}
)
.then((results) => results.map((r) => r.task_id));
// Get tasks without projects (excluding someday tagged tasks)
const nonProjectTasks = await Task.findAll({
where: {
user_id: userId,
status: {
[Op.in]: [Task.STATUS.NOT_STARTED, Task.STATUS.WAITING],
},
id: { [Op.notIn]: [...excludedTaskIds, ...somedayTaskIds] },
[Op.or]: [{ project_id: null }, { project_id: '' }],
},
include: [
{
model: Tag,
attributes: ['id', 'name', 'uid'],
through: { attributes: [] },
required: false,
},
{
model: Project,
attributes: ['id', 'name', 'active', 'uid'],
required: false,
},
{
model: Task,
as: 'Subtasks',
include: [
{
model: Tag,
attributes: ['id', 'name', 'uid'],
through: { attributes: [] },
required: false,
},
],
required: false,
},
],
order: [
['priority', 'DESC'],
['created_at', 'ASC'],
],
limit: 6,
});
// Get tasks with projects (excluding someday tagged tasks)
const projectTasks = await Task.findAll({
where: {
user_id: userId,
status: {
[Op.in]: [Task.STATUS.NOT_STARTED, Task.STATUS.WAITING],
},
id: { [Op.notIn]: [...excludedTaskIds, ...somedayTaskIds] },
project_id: { [Op.not]: null, [Op.ne]: '' },
},
include: [
{
model: Tag,
attributes: ['id', 'name', 'uid'],
through: { attributes: [] },
required: false,
},
{
model: Project,
attributes: ['id', 'name', 'active', 'uid'],
required: false,
},
{
model: Task,
as: 'Subtasks',
include: [
{
model: Tag,
attributes: ['id', 'name', 'uid'],
through: { attributes: [] },
required: false,
},
],
required: false,
},
],
order: [
['priority', 'DESC'],
['created_at', 'ASC'],
],
limit: 6,
});
// Check if we have enough suggestions (at least 6 total)
let combinedTasks = [...nonProjectTasks, ...projectTasks];
// If we don't have enough suggestions, include someday tasks as fallback
if (combinedTasks.length < 6) {
const usedTaskIds = [
...excludedTaskIds,
...combinedTasks.map((t) => t.id),
];
const somedayFallbackTasks = await Task.findAll({
where: {
user_id: userId,
status: {
[Op.in]: [Task.STATUS.NOT_STARTED, Task.STATUS.WAITING],
},
id: {
[Op.notIn]: usedTaskIds,
[Op.in]: somedayTaskIds,
},
},
include: [
{
model: Tag,
attributes: ['id', 'name', 'uid'],
through: { attributes: [] },
required: false,
},
{
model: Project,
attributes: ['id', 'name', 'active', 'uid'],
required: false,
},
{
model: Task,
as: 'Subtasks',
include: [
{
model: Tag,
attributes: ['id', 'name', 'uid'],
through: { attributes: [] },
required: false,
},
],
required: false,
},
],
order: [
['priority', 'DESC'],
['created_at', 'ASC'],
],
limit: 12 - combinedTasks.length,
});
combinedTasks = [...combinedTasks, ...somedayFallbackTasks];
}
suggestedTasks = combinedTasks;
}
// Get tasks completed today - use user's timezone
const todayInUserTz = moment.tz(userTimezone);
const todayStart = todayInUserTz.clone().startOf('day').utc().toDate();
const todayEnd = todayInUserTz.clone().endOf('day').utc().toDate();
const tasksCompletedToday = await Task.findAll({
where: {
user_id: userId,
status: Task.STATUS.DONE,
parent_task_id: null,
completed_at: {
[Op.between]: [todayStart, todayEnd],
},
},
include: [
{
model: Tag,
attributes: ['id', 'name', 'uid'],
through: { attributes: [] },
required: false,
},
{
model: Project,
attributes: ['id', 'name', 'active', 'uid'],
required: false,
},
{
model: Task,
as: 'Subtasks',
include: [
{
model: Tag,
attributes: ['id', 'name', 'uid'],
through: { attributes: [] },
required: false,
},
],
required: false,
},
],
order: [['completed_at', 'DESC']],
});
// Get weekly completion data (last 7 days) - use user's timezone
const weekStartInUserTz = moment.tz(userTimezone).subtract(6, 'days');
const weekStart = weekStartInUserTz.clone().startOf('day').utc().toDate();
const weekEnd = todayInUserTz.clone().endOf('day').utc().toDate();
// For SQLite, we'll fetch the raw data and process it in JavaScript
const weeklyCompletionsRaw = await Task.findAll({
where: {
user_id: userId,
status: Task.STATUS.DONE,
completed_at: {
[Op.between]: [weekStart, weekEnd],
},
},
attributes: ['completed_at'],
raw: true,
});
// Process the data in JavaScript to group by date in user's timezone
const dateCountMap = {};
weeklyCompletionsRaw.forEach((task) => {
// Parse the completed_at field more reliably - convert to Date first, then to moment
const completedDate = new Date(task.completed_at);
const dateInUserTz = moment(completedDate)
.tz(userTimezone)
.format('YYYY-MM-DD');
dateCountMap[dateInUserTz] = (dateCountMap[dateInUserTz] || 0) + 1;
});
// Convert to the format expected by the rest of the code
const weeklyCompletions = Object.entries(dateCountMap).map(
([date, count]) => ({
date,
count: count.toString(),
})
);
// Process weekly completion data to ensure all 7 days are represented
const weeklyData = [];
for (let i = 6; i >= 0; i--) {
const dateInUserTz = moment.tz(userTimezone).subtract(i, 'days');
const dateString = dateInUserTz.format('YYYY-MM-DD');
const found = weeklyCompletions.find(
(item) => item.date === dateString
);
const dayData = {
date: dateString,
count: found ? parseInt(found.count) : 0,
dayName: dateInUserTz.format('ddd'), // Short day name
};
weeklyData.push(dayData);
}
return {
total_open_tasks: totalOpenTasks,
tasks_pending_over_month: tasksPendingOverMonth,
tasks_in_progress_count: tasksInProgress.length,
tasks_in_progress: tasksInProgress,
tasks_due_today: tasksDueToday,
today_plan_tasks: todayPlanTasks,
suggested_tasks: suggestedTasks,
tasks_completed_today: tasksCompletedToday,
weekly_completions: weeklyData,
};
}
// GET /api/tasks
router.get('/tasks', async (req, res) => {
try {
const tasks = await filterTasksByParams(req.query, req.currentUser.id);
const metrics = await computeTaskMetrics(
req.currentUser.id,
req.currentUser.timezone
);
res.json({
tasks: await Promise.all(tasks.map((task) => serializeTask(task))),
metrics: {
total_open_tasks: metrics.total_open_tasks,
tasks_pending_over_month: metrics.tasks_pending_over_month,
tasks_in_progress_count: metrics.tasks_in_progress_count,
tasks_in_progress: await Promise.all(
metrics.tasks_in_progress.map((task) => serializeTask(task))
),
tasks_due_today: await Promise.all(
metrics.tasks_due_today.map((task) => serializeTask(task))
),
today_plan_tasks: await Promise.all(
metrics.today_plan_tasks.map((task) => serializeTask(task))
),
suggested_tasks: await Promise.all(
metrics.suggested_tasks.map((task) => serializeTask(task))
),
tasks_completed_today: await Promise.all(
metrics.tasks_completed_today.map(async (task) => {
const serialized = await serializeTask(task);
return {
...serialized,
completed_at: task.completed_at
? task.completed_at.toISOString()
: null,
};
})
),
weekly_completions: metrics.weekly_completions,
},
});
} catch (error) {
console.error('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' });
}
});
// GET /api/task?uid=...
router.get('/task', async (req, res) => {
try {
const { uid } = req.query;
if (_.isEmpty(uid)) {
return res
.status(400)
.json({ error: 'uid query parameter is required' });
}
const task = await Task.findOne({
where: { uid: uid, user_id: req.currentUser.id },
include: [
{
model: Tag,
attributes: ['id', 'name', 'uid'],
through: { attributes: [] },
},
{
model: Project,
attributes: ['id', 'name', 'uid'],
required: false,
},
],
});
if (!task) {
return res.status(404).json({ error: 'Task not found.' });
}
const serializedTask = await serializeTask(task);
res.json(serializedTask);
} catch (error) {
console.error('Error fetching task by UID:', error);
res.status(500).json({ error: 'Internal server error' });
}
});
// GET /api/task/:id
router.get('/task/:id', async (req, res) => {
try {
const task = await Task.findOne({
where: { id: req.params.id, user_id: req.currentUser.id },
include: [
{
model: Tag,
attributes: ['id', 'name', 'uid'],
through: { attributes: [] },
},
{
model: Project,
attributes: ['id', 'name', 'uid'],
required: false,
},
{
model: Task,
as: 'Subtasks',
include: [
{
model: Tag,
attributes: ['id', 'name', 'uid'],
through: { attributes: [] },
},
],
},
],
});
if (!task) {
return res.status(404).json({ error: 'Task not found.' });
}
const serializedTask = await serializeTask(task);
res.json(serializedTask);
} catch (error) {
console.error('Error fetching task:', error);
res.status(500).json({ error: 'Internal server error' });
}
});
// GET /api/task/:id/subtasks
router.get('/task/:id/subtasks', async (req, res) => {
try {
const subtasks = await Task.findAll({
where: {
parent_task_id: req.params.id,
user_id: req.currentUser.id,
},
include: [
{
model: Tag,
attributes: ['id', 'name', 'uid'],
through: { attributes: [] },
},
{
model: Project,
attributes: ['id', 'name', 'uid'],
required: false,
},
],
order: [['created_at', 'ASC']],
});
const serializedSubtasks = await Promise.all(
subtasks.map((subtask) => serializeTask(subtask))
);
res.json(serializedSubtasks);
} catch (error) {
console.error('Error fetching subtasks:', error);
res.status(500).json({ error: 'Internal server error' });
}
});
// POST /api/task
router.post('/task', async (req, res) => {
try {
const {
name,
priority,
due_date,
status,
note,
project_id,
parent_task_id,
tags,
Tags,
subtasks,
today,
recurrence_type,
recurrence_interval,
recurrence_end_date,
recurrence_weekday,
recurrence_month_day,
recurrence_week_of_month,
completion_based,
} = req.body;
// Handle both tags and Tags (Sequelize association format)
const tagsData = tags || Tags;
// Validate required fields
if (!name || name.trim() === '') {
return res.status(400).json({ error: 'Task name is required.' });
}
const taskAttributes = {
name: name.trim(),
priority:
priority !== undefined
? typeof priority === 'string'
? Task.getPriorityValue(priority)
: priority
: Task.PRIORITY.LOW,
due_date: due_date || null,
status:
status !== undefined
? typeof status === 'string'
? Task.getStatusValue(status)
: status
: Task.STATUS.NOT_STARTED,
note,
today: today !== undefined ? today : false,
user_id: req.currentUser.id,
recurrence_type: recurrence_type || 'none',
recurrence_interval: recurrence_interval || null,
recurrence_end_date: recurrence_end_date || null,
recurrence_weekday:
recurrence_weekday !== undefined ? recurrence_weekday : null,
recurrence_month_day:
recurrence_month_day !== undefined
? recurrence_month_day
: null,
recurrence_week_of_month:
recurrence_week_of_month !== undefined
? recurrence_week_of_month
: null,
completion_based: completion_based || false,
};
// Handle project assignment
if (project_id && project_id.toString().trim()) {
const project = await Project.findOne({
where: { id: project_id, user_id: req.currentUser.id },
});
if (!project) {
return res.status(400).json({ error: 'Invalid project.' });
}
taskAttributes.project_id = project_id;
}
// Handle parent task assignment
if (parent_task_id && parent_task_id.toString().trim()) {
const parentTask = await Task.findOne({
where: { id: parent_task_id, user_id: req.currentUser.id },
});
if (!parentTask) {
return res.status(400).json({ error: 'Invalid parent task.' });
}
taskAttributes.parent_task_id = parent_task_id;
}
const task = await Task.create(taskAttributes);
await updateTaskTags(task, tagsData, req.currentUser.id);
// Handle subtasks creation
if (subtasks && Array.isArray(subtasks)) {
const subtaskPromises = subtasks
.filter((subtask) => subtask.name && subtask.name.trim())
.map((subtask) =>
Task.create({
name: subtask.name.trim(),
parent_task_id: task.id,
user_id: req.currentUser.id,
priority: Task.PRIORITY.LOW,
status: Task.STATUS.NOT_STARTED,
today: false,
recurrence_type: 'none',
completion_based: false,
})
);
await Promise.all(subtaskPromises);
}
// Log task creation event
try {
await TaskEventService.logTaskCreated(
task.id,
req.currentUser.id,
{
name: task.name,
status: task.status,
priority: task.priority,
due_date: task.due_date,
project_id: task.project_id,
},
{ source: 'web' }
);
} catch (eventError) {
console.error('Error logging task creation event:', eventError);
// Don't fail the request if event logging fails
}
// Reload task with associations
const taskWithAssociations = await Task.findByPk(task.id, {
include: [
{
model: Tag,
attributes: ['id', 'name', 'uid'],
through: { attributes: [] },
},
{
model: Project,
attributes: ['id', 'name', 'uid'],
required: false,
},
],
});
const serializedTask = await serializeTask(taskWithAssociations);
res.status(201).json(serializedTask);
} catch (error) {
console.error('Error creating task:', error);
res.status(400).json({
error: 'There was a problem creating the task.',
details: error.errors
? error.errors.map((e) => e.message)
: [error.message],
});
}
});
// PATCH /api/task/:id
router.patch('/task/:id', async (req, res) => {
try {
const {
name,
priority,
status,
note,
due_date,
project_id,
parent_task_id,
tags,
Tags,
subtasks,
today,
recurrence_type,
recurrence_interval,
recurrence_end_date,
recurrence_weekday,
recurrence_month_day,
recurrence_week_of_month,
completion_based,
update_parent_recurrence,
} = req.body;
// Handle both tags and Tags (Sequelize association format)
const tagsData = tags || Tags;
const task = await Task.findOne({
where: { id: req.params.id, user_id: req.currentUser.id },
include: [
{
model: Tag,
attributes: ['id', 'name', 'uid'],
through: { attributes: [] },
},
],
});
if (!task) {
return res.status(404).json({ error: 'Task not found.' });
}
// Capture old values for event logging
const oldValues = {
name: task.name,
status: task.status,
priority: task.priority,
due_date: task.due_date,
project_id: task.project_id,
note: task.note,
recurrence_type: task.recurrence_type,
recurrence_interval: task.recurrence_interval,
recurrence_end_date: task.recurrence_end_date,
recurrence_weekday: task.recurrence_weekday,
recurrence_month_day: task.recurrence_month_day,
recurrence_week_of_month: task.recurrence_week_of_month,
completion_based: task.completion_based,
tags: task.Tags
? task.Tags.map((tag) => ({ id: tag.id, name: tag.name }))
: [],
};
// Handle updating parent recurrence settings if this is a child task
if (update_parent_recurrence && task.recurring_parent_id) {
const parentTask = await Task.findOne({
where: {
id: task.recurring_parent_id,
user_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_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 taskAttributes = {
name,
priority:
priority !== undefined
? typeof priority === 'string'
? Task.getPriorityValue(priority)
: priority
: undefined,
status:
status !== undefined
? typeof status === 'string'
? Task.getStatusValue(status)
: status
: Task.STATUS.NOT_STARTED,
note,
due_date: due_date || null,
today: today !== undefined ? today : task.today,
recurrence_type:
recurrence_type !== undefined
? recurrence_type
: task.recurrence_type,
recurrence_interval:
recurrence_interval !== undefined
? recurrence_interval
: task.recurrence_interval,
recurrence_end_date:
recurrence_end_date !== undefined
? recurrence_end_date
: task.recurrence_end_date,
recurrence_weekday:
recurrence_weekday !== undefined
? recurrence_weekday
: task.recurrence_weekday,
recurrence_month_day:
recurrence_month_day !== undefined
? recurrence_month_day
: task.recurrence_month_day,
recurrence_week_of_month:
recurrence_week_of_month !== undefined
? recurrence_week_of_month
: task.recurrence_week_of_month,
completion_based:
completion_based !== undefined
? completion_based
: task.completion_based,
};
// If task is being moved away from today and has in_progress status, change it to not_started
if (
today !== undefined &&
task.today === true &&
today === false &&
task.status === Task.STATUS.IN_PROGRESS
) {
taskAttributes.status = Task.STATUS.NOT_STARTED;
}
// Set completed_at when task is marked as done
if (status !== undefined) {
const newStatus =
typeof status === 'string'
? Task.getStatusValue(status)
: status;
const oldStatus =
typeof task.status === 'string'
? Task.getStatusValue(task.status)
: task.status;
if (
newStatus === Task.STATUS.DONE &&
oldStatus !== Task.STATUS.DONE
) {
// Task is being completed
taskAttributes.completed_at = new Date();
} else if (
newStatus !== Task.STATUS.DONE &&
oldStatus === Task.STATUS.DONE
) {
// Task is being uncompleted
taskAttributes.completed_at = null;
}
}
// Handle project assignment
if (project_id && project_id.toString().trim()) {
const project = await Project.findOne({
where: { id: project_id, user_id: req.currentUser.id },
});
if (!project) {
return res.status(400).json({ error: 'Invalid project.' });
}
taskAttributes.project_id = project_id;
} else {
taskAttributes.project_id = null;
}
// Handle parent task assignment
if (parent_task_id && parent_task_id.toString().trim()) {
const parentTask = await Task.findOne({
where: { id: parent_task_id, user_id: req.currentUser.id },
});
if (!parentTask) {
return res.status(400).json({ error: 'Invalid parent task.' });
}
taskAttributes.parent_task_id = parent_task_id;
} else if (parent_task_id === null || parent_task_id === '') {
taskAttributes.parent_task_id = null;
}
await task.update(taskAttributes);
await updateTaskTags(task, tagsData, req.currentUser.id);
// Handle subtasks updates
if (subtasks && Array.isArray(subtasks)) {
// Delete existing subtasks that are not in the new list
const existingSubtasks = await Task.findAll({
where: { parent_task_id: task.id, user_id: req.currentUser.id },
});
const subtasksToKeep = subtasks.filter((s) => s.id && !s.isNew);
const subtasksToDelete = existingSubtasks.filter(
(existing) =>
!subtasksToKeep.find((keep) => keep.id === existing.id)
);
// Delete removed subtasks
if (subtasksToDelete.length > 0) {
await Task.destroy({
where: {
id: subtasksToDelete.map((s) => s.id),
user_id: req.currentUser.id,
},
});
}
// Update edited subtasks and status changes
const subtasksToUpdate = subtasks.filter(
(s) =>
s.id &&
((s.isEdited && s.name && s.name.trim()) ||
s._statusChanged)
);
if (subtasksToUpdate.length > 0) {
const updatePromises = subtasksToUpdate.map((subtask) => {
const updateData = {};
if (
subtask.isEdited &&
subtask.name &&
subtask.name.trim()
) {
updateData.name = subtask.name.trim();
}
if (
subtask._statusChanged ||
subtask.status !== undefined
) {
updateData.status = subtask.status
? typeof subtask.status === 'string'
? Task.getStatusValue(subtask.status)
: subtask.status
: Task.STATUS.NOT_STARTED;
if (
updateData.status === Task.STATUS.DONE &&
!subtask.completed_at
) {
updateData.completed_at = new Date();
} else if (updateData.status !== Task.STATUS.DONE) {
updateData.completed_at = null;
}
}
if (subtask.priority !== undefined) {
updateData.priority = subtask.priority
? typeof subtask.priority === 'string'
? Task.getPriorityValue(subtask.priority)
: subtask.priority
: Task.PRIORITY.LOW;
}
return Task.update(updateData, {
where: {
id: subtask.id,
user_id: req.currentUser.id,
},
});
});
await Promise.all(updatePromises);
}
// Create new subtasks
const newSubtasks = subtasks.filter(
(s) => s.isNew && s.name && s.name.trim()
);
if (newSubtasks.length > 0) {
const subtaskPromises = newSubtasks.map((subtask) =>
Task.create({
name: subtask.name.trim(),
parent_task_id: task.id,
user_id: req.currentUser.id,
priority: subtask.priority
? typeof subtask.priority === 'string'
? Task.getPriorityValue(subtask.priority)
: subtask.priority
: Task.PRIORITY.LOW,
status: subtask.status
? typeof subtask.status === 'string'
? Task.getStatusValue(subtask.status)
: subtask.status
: Task.STATUS.NOT_STARTED,
completed_at:
subtask.status === 'done' ||
subtask.status === Task.STATUS.DONE
? subtask.completed_at
? new Date(subtask.completed_at)
: new Date()
: null,
today: subtask.today || false,
recurrence_type: 'none',
completion_based: false,
})
);
await Promise.all(subtaskPromises);
}
}
// Log task update events
try {
const changes = {};
// Check for changes in each field
if (name !== undefined && name !== oldValues.name) {
changes.name = { oldValue: oldValues.name, newValue: name };
}
if (status !== undefined && status !== oldValues.status) {
changes.status = {
oldValue: oldValues.status,
newValue: status,
};
}
if (priority !== undefined && priority !== oldValues.priority) {
changes.priority = {
oldValue: oldValues.priority,
newValue: priority,
};
}
if (due_date !== undefined) {
// Normalize dates for comparison (convert to YYYY-MM-DD format)
const oldDateStr = oldValues.due_date
? oldValues.due_date.toISOString().split('T')[0]
: null;
const newDateStr = due_date || null;
if (oldDateStr !== newDateStr) {
changes.due_date = {
oldValue: oldValues.due_date,
newValue: due_date,
};
}
}
if (
project_id !== undefined &&
project_id !== oldValues.project_id
) {
changes.project_id = {
oldValue: oldValues.project_id,
newValue: project_id,
};
}
if (note !== undefined && note !== oldValues.note) {
changes.note = { oldValue: oldValues.note, newValue: note };
}
// Check recurrence field changes
if (
recurrence_type !== undefined &&
recurrence_type !== oldValues.recurrence_type
) {
changes.recurrence_type = {
oldValue: oldValues.recurrence_type,
newValue: recurrence_type,
};
}
if (
recurrence_interval !== undefined &&
recurrence_interval !== oldValues.recurrence_interval
) {
changes.recurrence_interval = {
oldValue: oldValues.recurrence_interval,
newValue: recurrence_interval,
};
}
if (
recurrence_end_date !== undefined &&
recurrence_end_date !== oldValues.recurrence_end_date
) {
changes.recurrence_end_date = {
oldValue: oldValues.recurrence_end_date,
newValue: recurrence_end_date,
};
}
if (
recurrence_weekday !== undefined &&
recurrence_weekday !== oldValues.recurrence_weekday
) {
changes.recurrence_weekday = {
oldValue: oldValues.recurrence_weekday,
newValue: recurrence_weekday,
};
}
if (
recurrence_month_day !== undefined &&
recurrence_month_day !== oldValues.recurrence_month_day
) {
changes.recurrence_month_day = {
oldValue: oldValues.recurrence_month_day,
newValue: recurrence_month_day,
};
}
if (
recurrence_week_of_month !== undefined &&
recurrence_week_of_month !== oldValues.recurrence_week_of_month
) {
changes.recurrence_week_of_month = {
oldValue: oldValues.recurrence_week_of_month,
newValue: recurrence_week_of_month,
};
}
if (
completion_based !== undefined &&
completion_based !== oldValues.completion_based
) {
changes.completion_based = {
oldValue: oldValues.completion_based,
newValue: completion_based,
};
}
// Log all changes
if (Object.keys(changes).length > 0) {
await TaskEventService.logTaskUpdate(
task.id,
req.currentUser.id,
changes,
{ source: 'web' }
);
}
// Check for tag changes (this is more complex due to the array comparison)
if (tagsData) {
const newTags = tagsData.map((tag) => ({
id: tag.id,
name: tag.name,
}));
const oldTagNames = oldValues.tags
.map((tag) => tag.name)
.sort();
const newTagNames = newTags.map((tag) => tag.name).sort();
if (
JSON.stringify(oldTagNames) !== JSON.stringify(newTagNames)
) {
await TaskEventService.logEvent({
taskId: task.id,
userId: req.currentUser.id,
eventType: 'tags_changed',
fieldName: 'tags',
oldValue: oldValues.tags,
newValue: newTags,
metadata: { source: 'web', action: 'tags_update' },
});
}
}
} catch (eventError) {
console.error('Error logging task update events:', eventError);
// Don't fail the request if event logging fails
}
// Reload task with associations
const taskWithAssociations = await Task.findByPk(task.id, {
include: [
{
model: Tag,
attributes: ['id', 'name', 'uid'],
through: { attributes: [] },
},
{
model: Project,
attributes: ['id', 'name', 'uid'],
required: false,
},
],
});
// Use serializeTask to include subtasks data
const serializedTask = await serializeTask(taskWithAssociations);
res.json(serializedTask);
} catch (error) {
console.error('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],
});
}
});
// PATCH /api/task/:id/toggle_completion
router.patch('/task/:id/toggle_completion', async (req, res) => {
try {
const task = await Task.findOne({
where: { id: req.params.id, user_id: req.currentUser.id },
include: [
{
model: Tag,
attributes: ['id', 'name', 'uid'],
through: { attributes: [] },
},
{
model: Project,
attributes: ['id', 'name', 'uid'],
required: false,
},
{
model: Task,
as: 'Subtasks',
include: [
{
model: Tag,
attributes: ['id', 'name', 'uid'],
through: { attributes: [] },
},
],
},
],
});
if (!task) {
return res.status(404).json({ error: 'Task not found.' });
}
// Track if parent-child logic was executed
let parentChildLogicExecuted = false;
const newStatus =
task.status === Task.STATUS.DONE || task.status === 'done'
? task.note
? Task.STATUS.IN_PROGRESS
: Task.STATUS.NOT_STARTED
: Task.STATUS.DONE;
// Set completed_at when task is completed/uncompleted
const updateData = { status: newStatus };
if (newStatus === Task.STATUS.DONE) {
updateData.completed_at = new Date();
} else if (task.status === Task.STATUS.DONE || task.status === 'done') {
updateData.completed_at = null;
}
await task.update(updateData);
// Check if subtasks exist in database directly to debug association issue
const directSubtasksQuery = await Task.findAll({
where: {
parent_task_id: task.id,
user_id: req.currentUser.id,
},
attributes: ['id', 'name', 'status', 'parent_task_id'],
});
// If direct query finds subtasks but task.Subtasks is empty, there's an association issue
if (
directSubtasksQuery.length > 0 &&
(!task.Subtasks || task.Subtasks.length === 0)
) {
task.Subtasks = directSubtasksQuery;
}
if (task.parent_task_id) {
if (newStatus === Task.STATUS.DONE || newStatus === 'done') {
// When subtask is done, check if parent should be done
const parentUpdated = await checkAndUpdateParentTaskCompletion(
task.parent_task_id,
req.currentUser.id
);
if (parentUpdated) {
parentChildLogicExecuted = true;
}
} else {
// When subtask is undone, undone parent if it was done
const parentUpdated = await undoneParentTaskIfNeeded(
task.parent_task_id,
req.currentUser.id
);
if (parentUpdated) {
parentChildLogicExecuted = true;
}
}
} else if (task.Subtasks && task.Subtasks.length > 0) {
// This is a parent task with subtasks
if (newStatus === Task.STATUS.DONE) {
// When parent is done, complete all subtasks
const subtasksUpdated = await completeAllSubtasks(
task.id,
req.currentUser.id
);
if (subtasksUpdated) {
parentChildLogicExecuted = true;
}
} else {
// When parent is undone, undone all subtasks
const subtasksUpdated = await undoneAllSubtasks(
task.id,
req.currentUser.id
);
if (subtasksUpdated) {
parentChildLogicExecuted = true;
}
}
}
// Handle recurring task completion
let nextTask = null;
if (newStatus === Task.STATUS.DONE || newStatus === 'done') {
nextTask = await RecurringTaskService.handleTaskCompletion(task);
}
// Use serializeTask to include subtasks data
const response = await serializeTask(task);
// If parent-child logic was executed, we might need to reload data
// For now, let the frontend handle the refresh to avoid complex reloading logic
if (nextTask) {
response.next_task = {
...nextTask.toJSON(),
due_date: nextTask.due_date
? nextTask.due_date.toISOString().split('T')[0]
: null,
};
}
// Add flag to response to indicate if parent-child logic was executed
response.parent_child_logic_executed = parentChildLogicExecuted;
res.json(response);
} catch (error) {
console.error('Error in toggle completion endpoint:', error);
console.error('Error stack:', error.stack);
res.status(422).json({
error: 'Unable to update task',
details: error.message,
});
}
});
// DELETE /api/task/:id
router.delete('/task/:id', async (req, res) => {
try {
const task = await Task.findOne({
where: { id: req.params.id, user_id: req.currentUser.id },
});
if (!task) {
return res.status(404).json({ error: 'Task not found.' });
}
// Check for child tasks - prevent deletion of parent tasks with children
const childTasks = await Task.findAll({
where: { recurring_parent_id: req.params.id },
});
// If this is a recurring parent task with children, prevent deletion
if (childTasks.length > 0) {
return res
.status(400)
.json({ error: 'There was a problem deleting the task.' });
}
const taskEvents = await TaskEvent.findAll({
where: { task_id: req.params.id },
});
const tagAssociations = await sequelize.query(
'SELECT COUNT(*) as count FROM tasks_tags WHERE task_id = ?',
{ replacements: [req.params.id], type: sequelize.QueryTypes.SELECT }
);
// Check SQLite foreign key list for tasks table
const foreignKeys = await sequelize.query(
'PRAGMA foreign_key_list(tasks)',
{ type: sequelize.QueryTypes.SELECT }
);
// Find all tables that reference tasks
const allTables = await sequelize.query(
"SELECT name FROM sqlite_master WHERE type='table'",
{ type: sequelize.QueryTypes.SELECT }
);
for (const table of allTables) {
if (table.name !== 'tasks') {
try {
const fks = await sequelize.query(
`PRAGMA foreign_key_list(${table.name})`,
{ type: sequelize.QueryTypes.SELECT }
);
const taskRefs = fks.filter((fk) => fk.table === 'tasks');
if (taskRefs.length > 0) {
// Check if this table has any records referencing our task
for (const fk of taskRefs) {
const count = await sequelize.query(
`SELECT COUNT(*) as count FROM ${table.name} WHERE ${fk.from} = ?`,
{
replacements: [req.params.id],
type: sequelize.QueryTypes.SELECT,
}
);
}
}
} catch (error) {
// Skip tables that might not exist or have issues
}
}
}
// Temporarily disable foreign key constraints for this operation
await sequelize.query('PRAGMA foreign_keys = OFF');
try {
// Use force delete to bypass foreign key constraints
await TaskEvent.destroy({
where: { task_id: req.params.id },
force: true,
});
await sequelize.query('DELETE FROM tasks_tags WHERE task_id = ?', {
replacements: [req.params.id],
});
await Task.update(
{ recurring_parent_id: null },
{ where: { recurring_parent_id: req.params.id } }
);
// Delete the task itself
await task.destroy({ force: true });
} finally {
// Re-enable foreign key constraints
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.',
});
}
});
// POST /api/tasks/generate-recurring
router.post('/tasks/generate-recurring', async (req, res) => {
try {
const newTasks = await RecurringTaskService.generateRecurringTasks(
req.currentUser.id
);
res.json({
message: `Generated ${newTasks.length} recurring tasks`,
tasks: newTasks.map((task) => ({
...task.toJSON(),
due_date: task.due_date
? task.due_date.toISOString().split('T')[0]
: null,
})),
});
} catch (error) {
console.error('Error generating recurring tasks:', error);
res.status(500).json({ error: 'Failed to generate recurring tasks' });
}
});
// PATCH /api/task/:id/toggle-today
router.patch('/task/:id/toggle-today', async (req, res) => {
try {
const task = await Task.findOne({
where: { id: req.params.id, user_id: req.currentUser.id },
include: [
{
model: Tag,
attributes: ['id', 'name', 'uid'],
through: { attributes: [] },
},
{
model: Project,
attributes: ['id', 'name', 'uid'],
required: false,
},
],
});
if (!task) {
return res.status(404).json({ error: 'Task not found.' });
}
// Toggle the today flag
const newTodayValue = !task.today;
const updateData = { today: newTodayValue };
// If task is being moved away from today and has in_progress status, change it to not_started
if (
task.today === true &&
newTodayValue === false &&
task.status === Task.STATUS.IN_PROGRESS
) {
updateData.status = Task.STATUS.NOT_STARTED;
}
await task.update(updateData);
// Log the change
try {
await TaskEventService.logEvent({
taskId: task.id,
userId: req.currentUser.id,
eventType: 'today_changed',
fieldName: 'today',
oldValue: !newTodayValue,
newValue: newTodayValue,
metadata: { source: 'web', action: 'toggle_today' },
});
} catch (eventError) {
console.error('Error logging today toggle event:', eventError);
// Don't fail the request if event logging fails
}
// Use serializeTask helper to ensure consistent response format including tags
const serializedTask = await serializeTask(task);
res.json(serializedTask);
} catch (error) {
console.error('Error toggling task today flag:', error);
res.status(500).json({ error: 'Failed to update task today flag' });
}
});
module.exports = router;