diff --git a/backend/migrations/20251117013905-add-order-to-tasks.js b/backend/migrations/20251117013905-add-order-to-tasks.js new file mode 100644 index 0000000..1b77794 --- /dev/null +++ b/backend/migrations/20251117013905-add-order-to-tasks.js @@ -0,0 +1,68 @@ +'use strict'; + +const { + safeAddColumns, + safeAddIndex, + safeRemoveColumn, +} = require('../utils/migration-utils'); + +module.exports = { + async up(queryInterface, Sequelize) { + // Add order column to tasks table for subtask ordering + await safeAddColumns(queryInterface, 'tasks', [ + { + name: 'order', + definition: { + type: Sequelize.INTEGER, + allowNull: true, + defaultValue: null, + comment: 'Order position for subtasks within a parent task', + }, + }, + ]); + + // Add index on parent_task_id and order for efficient subtask queries + await safeAddIndex( + queryInterface, + 'tasks', + ['parent_task_id', 'order'], + { + name: 'tasks_parent_task_id_order', + } + ); + + // Populate order field for existing subtasks based on created_at + await queryInterface.sequelize.query(` + UPDATE tasks + SET "order" = subquery.row_num + FROM ( + SELECT id, + ROW_NUMBER() OVER ( + PARTITION BY parent_task_id + ORDER BY created_at ASC + ) as row_num + FROM tasks + WHERE parent_task_id IS NOT NULL + ) AS subquery + WHERE tasks.id = subquery.id + `); + }, + + async down(queryInterface) { + // Remove the index + const indexes = await queryInterface.showIndex('tasks'); + const indexExists = indexes.some( + (index) => index.name === 'tasks_parent_task_id_order' + ); + + if (indexExists) { + await queryInterface.removeIndex( + 'tasks', + 'tasks_parent_task_id_order' + ); + } + + // Remove the order column using safe utility + await safeRemoveColumn(queryInterface, 'tasks', 'order'); + }, +}; diff --git a/backend/models/task.js b/backend/models/task.js index a40681d..4f28edf 100644 --- a/backend/models/task.js +++ b/backend/models/task.js @@ -139,6 +139,11 @@ module.exports = (sequelize) => { key: 'id', }, }, + order: { + type: DataTypes.INTEGER, + allowNull: true, + comment: 'Order position for subtasks within a parent task', + }, completed_at: { type: DataTypes.DATE, allowNull: true, @@ -162,6 +167,10 @@ module.exports = (sequelize) => { { fields: ['parent_task_id'], }, + { + name: 'tasks_parent_task_id_order', + fields: ['parent_task_id', 'order'], + }, ], } ); diff --git a/backend/routes/tasks/operations/subtasks.js b/backend/routes/tasks/operations/subtasks.js index e43db07..b52afe6 100644 --- a/backend/routes/tasks/operations/subtasks.js +++ b/backend/routes/tasks/operations/subtasks.js @@ -35,7 +35,10 @@ async function getSubtasks(parentTaskId, userId, timezone) { required: false, }, ], - order: [['created_at', 'ASC']], + order: [ + ['order', 'ASC'], + ['created_at', 'ASC'], + ], // Order by order field, fallback to created_at } ); @@ -49,9 +52,16 @@ async function getSubtasks(parentTaskId, userId, timezone) { 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) => ({ + .map((subtask, index) => ({ name: subtask.name.trim(), parent_task_id: parentTaskId, user_id: userId, @@ -66,6 +76,7 @@ async function createSubtasks(parentTaskId, subtasks, userId) { today: subtask.today || false, recurrence_type: 'none', completion_based: false, + order: maxOrder + index + 1, // Assign sequential order values })); await taskRepository.createMany(subtasksData); @@ -90,36 +101,43 @@ async function updateSubtasks(taskId, subtasks, 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) { - const updatePromises = subtasksToUpdate.map((subtask) => { - const updateData = {}; + 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 (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 (subtasksToUpdate.includes(subtask)) { + if (subtask.isEdited && subtask.name && subtask.name.trim()) { + updateData.name = subtask.name.trim(); } - } - if (subtask.priority !== undefined) { - updateData.priority = - parsePriority(subtask.priority) || Task.PRIORITY.LOW; + 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, { diff --git a/backend/routes/tasks/utils/constants.js b/backend/routes/tasks/utils/constants.js index 0b9a987..37ee585 100644 --- a/backend/routes/tasks/utils/constants.js +++ b/backend/routes/tasks/utils/constants.js @@ -26,6 +26,11 @@ const TASK_INCLUDES_WITH_SUBTASKS = [ through: { attributes: [] }, }, ], + separate: true, // Required for order to work with associations + order: [ + ['order', 'ASC'], + ['created_at', 'ASC'], + ], }, ];