diff --git a/backend/migrations/20251204000002-migrate-recurring-instances.js b/backend/migrations/20251204000002-migrate-recurring-instances.js index a83ea29..5a78cb6 100644 --- a/backend/migrations/20251204000002-migrate-recurring-instances.js +++ b/backend/migrations/20251204000002-migrate-recurring-instances.js @@ -7,65 +7,137 @@ const { module.exports = { async up(queryInterface, Sequelize) { + // Check if migration has already been applied by looking for tasks with recurring_parent_id + const instanceCount = await queryInterface.sequelize.query( + `SELECT COUNT(*) as count FROM tasks WHERE recurring_parent_id IS NOT NULL`, + { type: Sequelize.QueryTypes.SELECT } + ); + + // If no instances exist, migration has likely already run, skip gracefully + if (instanceCount[0].count === 0) { + console.log( + 'No recurring instances found, migration may have already run' + ); + await safeRemoveColumn( + queryInterface, + 'tasks', + 'last_generated_date' + ); + return; + } + const templates = await queryInterface.sequelize.query( `SELECT * FROM tasks WHERE recurrence_type != 'none' AND recurring_parent_id IS NULL`, { type: Sequelize.QueryTypes.SELECT } ); - for (const template of templates) { - const completedInstances = await queryInterface.sequelize.query( - `SELECT * FROM tasks - WHERE recurring_parent_id = :templateId - AND status IN (2, 3) - AND completed_at IS NOT NULL - ORDER BY completed_at ASC`, - { - replacements: { templateId: template.id }, - type: Sequelize.QueryTypes.SELECT, - } - ); + // Disable foreign key constraints for SQLite during deletion + const dialect = queryInterface.sequelize.getDialect(); + if (dialect === 'sqlite') { + await queryInterface.sequelize.query('PRAGMA foreign_keys = OFF;'); + } - for (const instance of completedInstances) { - await queryInterface.bulkInsert('recurring_completions', [ + try { + for (const template of templates) { + const completedInstances = await queryInterface.sequelize.query( + `SELECT * FROM tasks + WHERE recurring_parent_id = :templateId + AND status IN (2, 3) + AND completed_at IS NOT NULL + ORDER BY completed_at ASC`, { - task_id: template.id, - completed_at: instance.completed_at, - original_due_date: instance.due_date, - skipped: false, - created_at: instance.completed_at, - }, - ]); - } - - const nextInstance = await queryInterface.sequelize.query( - `SELECT * FROM tasks - WHERE recurring_parent_id = :templateId - AND status NOT IN (2, 3) - AND due_date >= date('now') - ORDER BY due_date ASC - LIMIT 1`, - { - replacements: { templateId: template.id }, - type: Sequelize.QueryTypes.SELECT, - } - ); - - if (nextInstance.length > 0) { - await queryInterface.sequelize.query( - `UPDATE tasks SET due_date = :nextDue WHERE id = :templateId`, - { - replacements: { - nextDue: nextInstance[0].due_date, - templateId: template.id, - }, + replacements: { templateId: template.id }, + type: Sequelize.QueryTypes.SELECT, } ); - } - await queryInterface.sequelize.query( - `DELETE FROM tasks WHERE recurring_parent_id = :templateId`, - { replacements: { templateId: template.id } } - ); + for (const instance of completedInstances) { + // Check if this completion record already exists to avoid duplicates + const existing = await queryInterface.sequelize.query( + `SELECT COUNT(*) as count FROM recurring_completions + WHERE task_id = :taskId + AND completed_at = :completedAt + AND original_due_date = :originalDueDate`, + { + replacements: { + taskId: template.id, + completedAt: instance.completed_at, + originalDueDate: instance.due_date, + }, + type: Sequelize.QueryTypes.SELECT, + } + ); + + // Only insert if it doesn't already exist + if (existing[0].count === 0) { + await queryInterface.bulkInsert( + 'recurring_completions', + [ + { + task_id: template.id, + completed_at: instance.completed_at, + original_due_date: instance.due_date, + skipped: false, + created_at: instance.completed_at, + }, + ] + ); + } + } + + const nextInstance = await queryInterface.sequelize.query( + `SELECT * FROM tasks + WHERE recurring_parent_id = :templateId + AND status NOT IN (2, 3) + AND due_date >= date('now') + ORDER BY due_date ASC + LIMIT 1`, + { + replacements: { templateId: template.id }, + type: Sequelize.QueryTypes.SELECT, + } + ); + + if (nextInstance.length > 0) { + await queryInterface.sequelize.query( + `UPDATE tasks SET due_date = :nextDue WHERE id = :templateId`, + { + replacements: { + nextDue: nextInstance[0].due_date, + templateId: template.id, + }, + } + ); + } + + // Delete related records first to avoid foreign key constraints + await queryInterface.sequelize.query( + `DELETE FROM tasks_tags WHERE task_id IN (SELECT id FROM tasks WHERE recurring_parent_id = :templateId)`, + { replacements: { templateId: template.id } } + ); + + await queryInterface.sequelize.query( + `DELETE FROM task_events WHERE task_id IN (SELECT id FROM tasks WHERE recurring_parent_id = :templateId)`, + { replacements: { templateId: template.id } } + ); + + await queryInterface.sequelize.query( + `DELETE FROM task_attachments WHERE task_id IN (SELECT id FROM tasks WHERE recurring_parent_id = :templateId)`, + { replacements: { templateId: template.id } } + ); + + await queryInterface.sequelize.query( + `DELETE FROM tasks WHERE recurring_parent_id = :templateId`, + { replacements: { templateId: template.id } } + ); + } + } finally { + // Re-enable foreign key constraints + if (dialect === 'sqlite') { + await queryInterface.sequelize.query( + 'PRAGMA foreign_keys = ON;' + ); + } } await safeRemoveColumn(queryInterface, 'tasks', 'last_generated_date');