tududi/backend/modules/tasks/operations/subtasks.js
Chris e8c7eed226
fix: prevent subtasks from disappearing when updating parent task (#984)
This commit addresses a critical bug where subtasks would disappear when
updating the parent task (e.g., assigning tags). The issue had multiple
potential causes:

1. Backend vulnerability: The updateSubtasks() function would delete all
   subtasks if an empty array was sent, treating it as "delete everything
   not in this list"

2. Frontend state management: After reloading a task, subtasks weren't
   being preserved if the backend response didn't include them

3. Unclear error messages: "Invalid parent task" errors didn't provide
   enough context for debugging

Changes:
- Added defensive logging in updateSubtasks() to warn when all subtasks
  are being deleted with an empty array
- Enhanced validateParentTaskAccess() error messages to provide detailed
  diagnostics (task not found vs. permission issues vs. wrong user)
- Updated handleTagsUpdate() in TaskDetails to explicitly preserve
  subtasks when reloading task after tag updates

This fix is defensive in nature and adds better observability for
diagnosing similar issues in the future.

Fixes issue reported by user where subtasks disappeared after assigning
tags to parent task, and "Invalid parent task" errors occurred when
trying to update the orphaned subtasks.
2026-04-01 17:37:20 +03:00

183 lines
6.1 KiB
JavaScript

const { Task, Tag, Project } = require('../../../models');
const taskRepository = require('../repository');
const permissionsService = require('../../../services/permissionsService');
const { logError } = require('../../../services/logService');
const { serializeTask } = require('../core/serializers');
const { parsePriority, parseStatus } = require('../core/parsers');
async function getSubtasks(parentTaskId, userId, timezone) {
const parent = await taskRepository.findById(parentTaskId);
if (!parent) {
return { error: 'Not found', subtasks: [] };
}
const pAccess = await permissionsService.getAccess(
userId,
'task',
parent.uid
);
if (pAccess === 'none') {
return { error: 'Forbidden', subtasks: null };
}
const subtasks = await taskRepository.findAll(
{ parent_task_id: parentTaskId },
{
include: [
{
model: Tag,
attributes: ['id', 'name', 'uid'],
through: { attributes: [] },
},
{
model: Project,
attributes: ['id', 'name', 'uid'],
required: false,
},
],
order: [
['order', 'ASC'],
['created_at', 'ASC'],
], // Order by order field, fallback to created_at
}
);
const serializedSubtasks = await Promise.all(
subtasks.map((subtask) => serializeTask(subtask, timezone))
);
return { error: null, subtasks: serializedSubtasks };
}
async function createSubtasks(parentTaskId, subtasks, userId) {
if (!subtasks || !Array.isArray(subtasks)) return;
// Get the highest order value for existing subtasks
const existingSubtasks = await taskRepository.findAll(
{ parent_task_id: parentTaskId },
{ attributes: ['order'], order: [['order', 'DESC']], limit: 1 }
);
const maxOrder = existingSubtasks[0]?.order ?? 0;
const subtasksData = subtasks
.filter((subtask) => subtask.name && subtask.name.trim())
.map((subtask, index) => ({
name: subtask.name.trim(),
parent_task_id: parentTaskId,
user_id: userId,
priority: parsePriority(subtask.priority) || Task.PRIORITY.LOW,
status: parseStatus(subtask.status),
completed_at:
subtask.status === 'done' || subtask.status === Task.STATUS.DONE
? subtask.completed_at
? new Date(subtask.completed_at)
: new Date()
: null,
recurrence_type: 'none',
completion_based: false,
order: maxOrder + index + 1, // Assign sequential order values
}));
await taskRepository.createMany(subtasksData);
}
async function updateSubtasks(taskId, subtasks, userId) {
if (!subtasks || !Array.isArray(subtasks)) return;
const existingSubtasks = await taskRepository.findChildren(taskId, userId);
const subtasksToKeep = subtasks.filter((s) => s.id && !s.isNew);
const subtasksToDelete = existingSubtasks.filter(
(existing) => !subtasksToKeep.find((keep) => keep.id === existing.id)
);
if (subtasksToDelete.length > 0) {
if (
subtasksToDelete.length === existingSubtasks.length &&
subtasks.length === 0
) {
logError(
'WARNING: Attempting to delete all subtasks with empty array:',
{
taskId,
userId,
existingCount: existingSubtasks.length,
providedCount: subtasks.length,
subtasksToDelete: subtasksToDelete.map((s) => ({
id: s.id,
name: s.name,
parent_task_id: s.parent_task_id,
})),
}
);
}
await taskRepository.destroyMany({
where: {
id: subtasksToDelete.map((s) => s.id),
user_id: userId,
},
});
}
// Update order for all subtasks to reflect their position in the array
const allSubtasksToUpdate = subtasks.filter((s) => s.id);
const subtasksToUpdate = subtasks.filter(
(s) =>
s.id &&
((s.isEdited && s.name && s.name.trim()) || s._statusChanged)
);
if (subtasksToUpdate.length > 0 || allSubtasksToUpdate.length > 0) {
const updatePromises = allSubtasksToUpdate.map((subtask, index) => {
const updateData = {
order: index + 1, // Update order based on position in array
};
if (subtasksToUpdate.includes(subtask)) {
if (subtask.isEdited && subtask.name && subtask.name.trim()) {
updateData.name = subtask.name.trim();
}
if (subtask._statusChanged || subtask.status !== undefined) {
updateData.status = parseStatus(subtask.status);
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 =
parsePriority(subtask.priority) || Task.PRIORITY.LOW;
}
}
return taskRepository.bulkUpdate(updateData, {
where: { id: subtask.id, user_id: userId },
});
});
await Promise.all(updatePromises);
}
const newSubtasks = subtasks.filter(
(s) => s.isNew && s.name && s.name.trim()
);
if (newSubtasks.length > 0) {
await createSubtasks(taskId, newSubtasks, userId);
}
}
module.exports = {
getSubtasks,
createSubtasks,
updateSubtasks,
};