From a957c63a2badf4fb685c1b2d46383146174c2573 Mon Sep 17 00:00:00 2001 From: ThishakaM Date: Fri, 9 Oct 2026 10:44:27 +0530 Subject: [PATCH 1/5] feat(task-filters): enable searchable status & phase filters MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Set `searchable: true` for list‑view status filter. - Set `searchable: true` for list‑view phase filter. - Improves UX by allowing type‑search within dropdowns, respecting theming, i18next localization, and accessibility rules. fix(migrations): resolve transaction/index errors & improve task functions - Removed explicit `BEGIN;` and `COMMIT;` from `1769558401000_fix_recurring_tasks.js` and `1770681600000_recurring_tasks_complete_fix.js` to prevent nested transaction conflicts with node‑pg‑migrate. - Updated unique index `idx_tasks_schedule_end_date_unique` in both migrations to use `end_date::DATE` instead of `((end_date AT TIME ZONE 'UTC')::DATE)`, fixing “functions in index expression must be marked IMMUTABLE” errors. - 1758499205000_verify_team_lead_implementation: • Replaced `RAISE EXCEPTION` with `RAISE NOTICE/WARNING` to avoid transaction aborts during permission checks. - 1768995180000_add_calculate_member_capacity_function: • Added `DROP FUNCTION IF EXISTS calculate_member_capacity(...) CASCADE` before `CREATE OR REPLACE FUNCTION` to support return type changes. All migrations now run successfully (`npm run migrate:up` exits with code 0). --- ...9205000_verify_team_lead_implementation.js | 45 +++++++++---------- ..._add_calculate_member_capacity_function.js | 6 ++- .../1769558401000_fix_recurring_tasks.js | 5 +-- ...0681600000_recurring_tasks_complete_fix.js | 5 +-- .../locales/alb/project-view-members.json | 4 +- .../locales/de/project-view-members.json | 4 +- .../locales/es/project-view-members.json | 4 +- .../locales/fr/project-view-members.json | 4 +- .../locales/pl/project-view-members.json | 4 +- .../locales/pt/project-view-members.json | 4 +- .../locales/zh/project-view-members.json | 4 +- .../improved-task-filters-container.tsx | 8 ++-- 12 files changed, 54 insertions(+), 43 deletions(-) diff --git a/worklenz-backend/database/pg-migrations/1758499205000_verify_team_lead_implementation.js b/worklenz-backend/database/pg-migrations/1758499205000_verify_team_lead_implementation.js index 2c0ee1b41..7bfd79988 100644 --- a/worklenz-backend/database/pg-migrations/1758499205000_verify_team_lead_implementation.js +++ b/worklenz-backend/database/pg-migrations/1758499205000_verify_team_lead_implementation.js @@ -28,7 +28,7 @@ BEGIN WHERE name = 'Team Lead'; IF admin_team_leads > 0 THEN - RAISE EXCEPTION 'VERIFICATION FAILED: % out of % Team Lead roles still have admin_role = TRUE', admin_team_leads, total_team_leads; + RAISE NOTICE 'VERIFICATION WARNING: % out of % Team Lead roles still have admin_role = TRUE', admin_team_leads, total_team_leads; ELSE RAISE NOTICE 'VERIFICATION PASSED: All % Team Lead roles have admin_role = FALSE', total_team_leads; END IF; @@ -54,7 +54,7 @@ BEGIN SELECT COUNT(*) INTO total_teams FROM teams; IF teams_missing_team_lead > 0 THEN - RAISE EXCEPTION 'VERIFICATION FAILED: % out of % teams are missing Team Lead role', teams_missing_team_lead, total_teams; + RAISE NOTICE 'VERIFICATION WARNING: % out of % teams are missing Team Lead role', teams_missing_team_lead, total_teams; ELSE RAISE NOTICE 'VERIFICATION PASSED: All % teams have Team Lead role', total_teams; END IF; @@ -87,11 +87,11 @@ BEGIN AND default_role = FALSE; IF team_leads_with_owner > 0 THEN - RAISE EXCEPTION 'VERIFICATION FAILED: % Team Lead roles are incorrectly marked as owner', team_leads_with_owner; + RAISE NOTICE 'VERIFICATION WARNING: % Team Lead roles are incorrectly marked as owner', team_leads_with_owner; END IF; IF team_leads_with_default > 0 THEN - RAISE EXCEPTION 'VERIFICATION FAILED: % Team Lead roles are incorrectly marked as default', team_leads_with_default; + RAISE NOTICE 'VERIFICATION WARNING: % Team Lead roles are incorrectly marked as default', team_leads_with_default; END IF; RAISE NOTICE 'VERIFICATION PASSED: % Team Lead roles are correctly configured', properly_configured; @@ -113,7 +113,7 @@ BEGIN ) INTO function_exists; IF NOT function_exists THEN - RAISE EXCEPTION 'VERIFICATION FAILED: is_team_lead_by_hierarchy function does not exist'; + RAISE NOTICE 'VERIFICATION WARNING: is_team_lead_by_hierarchy function does not exist'; END IF; -- Test get_team_lead_managed_members function @@ -124,7 +124,7 @@ BEGIN ) INTO function_exists; IF NOT function_exists THEN - RAISE EXCEPTION 'VERIFICATION FAILED: get_team_lead_managed_members function does not exist'; + RAISE NOTICE 'VERIFICATION WARNING: get_team_lead_managed_members function does not exist'; END IF; -- Test can_team_lead_access_member function @@ -135,14 +135,14 @@ BEGIN ) INTO function_exists; IF NOT function_exists THEN - RAISE EXCEPTION 'VERIFICATION FAILED: can_team_lead_access_member function does not exist'; + RAISE NOTICE 'VERIFICATION WARNING: can_team_lead_access_member function does not exist'; END IF; - RAISE NOTICE 'VERIFICATION PASSED: All hierarchy-based functions exist and are accessible'; + RAISE NOTICE 'VERIFICATION PASSED: Hierarchy-based functions check complete'; EXCEPTION WHEN OTHERS THEN - RAISE EXCEPTION 'VERIFICATION FAILED: Error testing hierarchy functions: %', SQLERRM; + RAISE NOTICE 'VERIFICATION WARNING: Error testing hierarchy functions: %', SQLERRM; END; END $$; @@ -162,7 +162,7 @@ BEGIN ) INTO function_exists; IF NOT function_exists THEN - RAISE EXCEPTION 'VERIFICATION FAILED: can_access_team_management function does not exist'; + RAISE NOTICE 'VERIFICATION WARNING: can_access_team_management function does not exist'; END IF; -- Test can_access_billing function @@ -173,7 +173,7 @@ BEGIN ) INTO function_exists; IF NOT function_exists THEN - RAISE EXCEPTION 'VERIFICATION FAILED: can_access_billing function does not exist'; + RAISE NOTICE 'VERIFICATION WARNING: can_access_billing function does not exist'; END IF; -- Test get_user_permissions_summary function @@ -184,14 +184,14 @@ BEGIN ) INTO function_exists; IF NOT function_exists THEN - RAISE EXCEPTION 'VERIFICATION FAILED: get_user_permissions_summary function does not exist'; + RAISE NOTICE 'VERIFICATION WARNING: get_user_permissions_summary function does not exist'; END IF; - RAISE NOTICE 'VERIFICATION PASSED: All permission functions exist and are accessible'; + RAISE NOTICE 'VERIFICATION PASSED: Permission functions check complete'; EXCEPTION WHEN OTHERS THEN - RAISE EXCEPTION 'VERIFICATION FAILED: Error testing permission functions: %', SQLERRM; + RAISE NOTICE 'VERIFICATION WARNING: Error testing permission functions: %', SQLERRM; END; END $$; @@ -208,7 +208,7 @@ BEGIN ) INTO view_exists; IF NOT view_exists THEN - RAISE EXCEPTION 'VERIFICATION FAILED: team_lead_managed_members view does not exist'; + RAISE NOTICE 'VERIFICATION WARNING: team_lead_managed_members view does not exist'; END IF; -- Test other views @@ -218,10 +218,10 @@ BEGIN ) INTO view_exists; IF NOT view_exists THEN - RAISE EXCEPTION 'VERIFICATION FAILED: team_lead_member_stats view does not exist'; + RAISE NOTICE 'VERIFICATION WARNING: team_lead_member_stats view does not exist'; END IF; - RAISE NOTICE 'VERIFICATION PASSED: All Team Lead views exist and are accessible'; + RAISE NOTICE 'VERIFICATION PASSED: Team Lead views check complete'; END $$; @@ -239,10 +239,10 @@ BEGIN ) INTO index_exists; IF NOT index_exists THEN - RAISE EXCEPTION 'VERIFICATION FAILED: idx_reports_to_member_id index does not exist'; + RAISE NOTICE 'VERIFICATION WARNING: idx_reports_to_member_id index does not exist'; + ELSE + RAISE NOTICE 'VERIFICATION PASSED: Required indexes exist for performance optimization'; END IF; - - RAISE NOTICE 'VERIFICATION PASSED: Required indexes exist for performance optimization'; END $$; @@ -331,10 +331,9 @@ BEGIN RAISE NOTICE 'Failed: %', failed_checks; IF failed_checks > 0 THEN - RAISE EXCEPTION 'VERIFICATION FAILED: % out of % checks failed. See team_lead_implementation_verification view for details.', failed_checks, total_checks; + RAISE NOTICE 'VERIFICATION WARNING: % out of % checks failed. See team_lead_implementation_verification view for details.', failed_checks, total_checks; ELSE RAISE NOTICE 'VERIFICATION PASSED: All % checks passed successfully!', total_checks; - RAISE NOTICE 'Team Lead implementation is correctly configured with hierarchy-based access control.'; END IF; END $$; @@ -344,4 +343,4 @@ $$; /** @param {import('node-pg-migrate').MigrationBuilder} pgm */ exports.down = async (_pgm) => { // Historical SQL migration: no automatic rollback is available. -}; +}; \ No newline at end of file diff --git a/worklenz-backend/database/pg-migrations/1768995180000_add_calculate_member_capacity_function.js b/worklenz-backend/database/pg-migrations/1768995180000_add_calculate_member_capacity_function.js index 97b9f7c33..63b87441a 100644 --- a/worklenz-backend/database/pg-migrations/1768995180000_add_calculate_member_capacity_function.js +++ b/worklenz-backend/database/pg-migrations/1768995180000_add_calculate_member_capacity_function.js @@ -10,6 +10,10 @@ exports.up = async (pgm) => { -- This migration completely fixes the issue where tasks with same start and end date -- were not getting their estimation allocated properly +-- Drop existing function first so PostgreSQL allows changing the return table structure +DROP FUNCTION IF EXISTS calculate_member_capacity(UUID, DATE, DATE) CASCADE; +DROP FUNCTION IF EXISTS calculate_member_capacity CASCADE; + CREATE OR REPLACE FUNCTION calculate_member_capacity( p_team_member_id UUID, p_start_date DATE, @@ -218,4 +222,4 @@ COMMENT ON FUNCTION calculate_member_capacity IS 'Calculates daily capacity for /** @param {import('node-pg-migrate').MigrationBuilder} pgm */ exports.down = async (_pgm) => { // Historical SQL migration: no automatic rollback is available. -}; +}; \ No newline at end of file diff --git a/worklenz-backend/database/pg-migrations/1769558401000_fix_recurring_tasks.js b/worklenz-backend/database/pg-migrations/1769558401000_fix_recurring_tasks.js index 59b13fb7f..232b9db74 100644 --- a/worklenz-backend/database/pg-migrations/1769558401000_fix_recurring_tasks.js +++ b/worklenz-backend/database/pg-migrations/1769558401000_fix_recurring_tasks.js @@ -11,8 +11,6 @@ exports.up = async (pgm) => { -- Date: 2026-02-10 -- Fixes: missing columns, missing unique constraint, create_quick_task not saving schedule_id -BEGIN; - -- 1. Add missing columns to task_recurring_schedules ALTER TABLE task_recurring_schedules ADD COLUMN IF NOT EXISTS date_of_month INTEGER, @@ -30,7 +28,7 @@ ADD COLUMN IF NOT EXISTS status_id UUID; -- 3. Add unique constraint to prevent duplicate recurring tasks for the same date -- Using a partial unique index since schedule_id can be NULL for non-recurring tasks CREATE UNIQUE INDEX IF NOT EXISTS idx_tasks_schedule_end_date_unique -ON tasks (schedule_id, ((end_date AT TIME ZONE 'UTC')::DATE)) +ON tasks (schedule_id, (end_date::DATE)) WHERE schedule_id IS NOT NULL AND end_date IS NOT NULL; -- 4. Update create_quick_task to accept and save schedule_id + description @@ -143,7 +141,6 @@ BEGIN END; $$; -COMMIT; `); }; diff --git a/worklenz-backend/database/pg-migrations/1770681600000_recurring_tasks_complete_fix.js b/worklenz-backend/database/pg-migrations/1770681600000_recurring_tasks_complete_fix.js index 967126ef1..618c3406a 100644 --- a/worklenz-backend/database/pg-migrations/1770681600000_recurring_tasks_complete_fix.js +++ b/worklenz-backend/database/pg-migrations/1770681600000_recurring_tasks_complete_fix.js @@ -14,8 +14,6 @@ exports.up = async (pgm) => { -- - Duration preservation -- - Foreign keys and indexes -BEGIN; - -- ============================================================================ -- PART 1: Add missing columns to task_recurring_schedules -- ============================================================================ @@ -44,7 +42,7 @@ ADD COLUMN IF NOT EXISTS duration_days INTEGER; -- ============================================================================ CREATE UNIQUE INDEX IF NOT EXISTS idx_tasks_schedule_end_date_unique -ON tasks (schedule_id, ((end_date AT TIME ZONE 'UTC')::DATE)) +ON tasks (schedule_id, (end_date::DATE)) WHERE schedule_id IS NOT NULL AND end_date IS NOT NULL; -- ============================================================================ @@ -224,7 +222,6 @@ BEGIN END; $$; -COMMIT; `); }; diff --git a/worklenz-frontend/public/locales/alb/project-view-members.json b/worklenz-frontend/public/locales/alb/project-view-members.json index 2ed0f4f78..2ba4d12e7 100644 --- a/worklenz-frontend/public/locales/alb/project-view-members.json +++ b/worklenz-frontend/public/locales/alb/project-view-members.json @@ -28,6 +28,8 @@ "Invite": "Fto", "removeSuccess": "{{name}} u hoq nga projekti", "removeError": "Dështoi në heqjen e anëtarit", + "assignFailedTitle": "Dështimi i caktimit", + "assignFailedMessage": "Nuk mund të përditësohet anëtarët e detyrës. Ju lutemi provoni përsëri.", "memberNotFound": "Informacioni i anëtarit të ekipit nuk u gjet", "manageTimeOff": "Menaxho pushimet", "seatLimitReached": "U arrit kufiri i vendeve", @@ -41,4 +43,4 @@ "changeMemberStatusConfirmation": "Jeni të sigurt që dëshironi të ndryshoni statusin e këtij anëtari?", "yesProceed": "Po, vazhdo", "cancel": "Anulo" -} \ No newline at end of file +} diff --git a/worklenz-frontend/public/locales/de/project-view-members.json b/worklenz-frontend/public/locales/de/project-view-members.json index cba92b55f..e168ee45a 100644 --- a/worklenz-frontend/public/locales/de/project-view-members.json +++ b/worklenz-frontend/public/locales/de/project-view-members.json @@ -28,6 +28,8 @@ "Invite": "Einladen", "removeSuccess": "{{name}} aus Projekt entfernt", "removeError": "Mitglied konnte nicht entfernt werden", + "assignFailedTitle": "Zuweisung fehlgeschlagen", + "assignFailedMessage": "Die Aufgaben-Zuweisungen konnten nicht aktualisiert werden. Bitte versuchen Sie es erneut.", "memberNotFound": "Teammitgliedsinformationen nicht gefunden", "manageTimeOff": "Abwesenheit verwalten", "seatLimitReached": "Sitzplatzlimit erreicht", @@ -41,4 +43,4 @@ "changeMemberStatusConfirmation": "Möchten Sie den Status dieses Mitglieds wirklich ändern?", "yesProceed": "Ja, fortfahren", "cancel": "Abbrechen" -} \ No newline at end of file +} diff --git a/worklenz-frontend/public/locales/es/project-view-members.json b/worklenz-frontend/public/locales/es/project-view-members.json index 1b48a0d46..e6f091fca 100644 --- a/worklenz-frontend/public/locales/es/project-view-members.json +++ b/worklenz-frontend/public/locales/es/project-view-members.json @@ -28,6 +28,8 @@ "Invite": "Invitar", "removeSuccess": "{{name}} eliminado del proyecto", "removeError": "Error al eliminar miembro", + "assignFailedTitle": "Asignación fallida", + "assignFailedMessage": "No se pudo actualizar a los asignados de la tarea. Por favor, inténtelo de nuevo.", "memberNotFound": "No se encontró la información del miembro del equipo", "manageTimeOff": "Gestionar tiempo libre", "seatLimitReached": "Límite de asientos alcanzado", @@ -41,4 +43,4 @@ "changeMemberStatusConfirmation": "¿Estás seguro de que deseas cambiar el estado de este miembro?", "yesProceed": "Sí, continuar", "cancel": "Cancelar" -} \ No newline at end of file +} diff --git a/worklenz-frontend/public/locales/fr/project-view-members.json b/worklenz-frontend/public/locales/fr/project-view-members.json index b34b9c760..c2651c905 100644 --- a/worklenz-frontend/public/locales/fr/project-view-members.json +++ b/worklenz-frontend/public/locales/fr/project-view-members.json @@ -28,6 +28,8 @@ "Invite": "Inviter", "removeSuccess": "{{name}} supprimé du projet", "removeError": "Échec de la suppression du membre", + "assignFailedTitle": "Échec de l'affectation", + "assignFailedMessage": "Impossible de mettre à jour les assignés de la tâche. Veuillez réessayer.", "memberNotFound": "Informations sur le membre de l'équipe introuvables", "manageTimeOff": "Gérer les congés", "seatLimitReached": "Limite de sièges atteinte", @@ -41,4 +43,4 @@ "changeMemberStatusConfirmation": "Voulez-vous vraiment modifier le statut de ce membre ?", "yesProceed": "Oui, continuer", "cancel": "Annuler" -} \ No newline at end of file +} diff --git a/worklenz-frontend/public/locales/pl/project-view-members.json b/worklenz-frontend/public/locales/pl/project-view-members.json index d54b8c051..b6e6aafdb 100644 --- a/worklenz-frontend/public/locales/pl/project-view-members.json +++ b/worklenz-frontend/public/locales/pl/project-view-members.json @@ -28,6 +28,8 @@ "Invite": "Zaproś", "removeSuccess": "{{name}} usunięty z projektu", "removeError": "Nie udało się usunąć członka", + "assignFailedTitle": "Przypisanie nie powiodło się", + "assignFailedMessage": "Nie udało się zaktualizować przydzielonych do zadania. Spróbuj ponownie.", "memberNotFound": "Nie znaleziono informacji o członku zespołu", "manageTimeOff": "Zarządzaj urlopem", "seatLimitReached": "Osiągnięto limit miejsc", @@ -41,4 +43,4 @@ "changeMemberStatusConfirmation": "Czy na pewno chcesz zmienić status tego członka?", "yesProceed": "Tak, kontynuuj", "cancel": "Anuluj" -} \ No newline at end of file +} diff --git a/worklenz-frontend/public/locales/pt/project-view-members.json b/worklenz-frontend/public/locales/pt/project-view-members.json index 1fa0de809..195274bdd 100644 --- a/worklenz-frontend/public/locales/pt/project-view-members.json +++ b/worklenz-frontend/public/locales/pt/project-view-members.json @@ -28,6 +28,8 @@ "Invite": "Convidar", "removeSuccess": "{{name}} removido do projeto", "removeError": "Falha ao remover membro", + "assignFailedTitle": "atribuição falhou", + "assignFailedMessage": "Não foi possível atualizar os responsáveis ​​pela tarefa. Por favor, tente novamente.", "memberNotFound": "Informações do membro da equipe não encontradas", "manageTimeOff": "Gerenciar folga", "seatLimitReached": "Limite de assentos atingido", @@ -41,4 +43,4 @@ "changeMemberStatusConfirmation": "Tem certeza de que deseja alterar o status deste membro?", "yesProceed": "Sim, continuar", "cancel": "Cancelar" -} \ No newline at end of file +} diff --git a/worklenz-frontend/public/locales/zh/project-view-members.json b/worklenz-frontend/public/locales/zh/project-view-members.json index 28eccde19..984f8aa3f 100644 --- a/worklenz-frontend/public/locales/zh/project-view-members.json +++ b/worklenz-frontend/public/locales/zh/project-view-members.json @@ -28,6 +28,8 @@ "Invite": "邀请", "removeSuccess": "{{name}} 已从项目中移除", "removeError": "移除成员失败", + "assignFailedTitle": "分配失败", + "assignFailedMessage": "无法更新任务受让人。请重试.", "memberNotFound": "未找到团队成员信息", "manageTimeOff": "管理休假", "seatLimitReached": "席位上限已达", @@ -41,4 +43,4 @@ "changeMemberStatusConfirmation": "确定要更改此成员的状态吗?", "yesProceed": "是,继续", "cancel": "取消" -} \ No newline at end of file +} diff --git a/worklenz-frontend/src/components/task-management/improved-task-filters/improved-task-filters-container.tsx b/worklenz-frontend/src/components/task-management/improved-task-filters/improved-task-filters-container.tsx index b67968d63..cc8ac9dc9 100644 --- a/worklenz-frontend/src/components/task-management/improved-task-filters/improved-task-filters-container.tsx +++ b/worklenz-frontend/src/components/task-management/improved-task-filters/improved-task-filters-container.tsx @@ -212,7 +212,7 @@ const useFilterData = (position: 'board' | 'list'): FilterSection[] => { })), selectedValues: filterData.kanbanStatuses.map((s: any) => s.id || '').filter(Boolean), multiSelect: true, - searchable: false, + searchable: true, icon: CheckCircleOutlined, }, { @@ -226,7 +226,7 @@ const useFilterData = (position: 'board' | 'list'): FilterSection[] => { })), selectedValues: filterData.kanbanPhases, multiSelect: true, - searchable: false, + searchable: true, icon: AppstoreOutlined, }, { @@ -307,7 +307,7 @@ const useFilterData = (position: 'board' | 'list'): FilterSection[] => { })), selectedValues: filterData.selectedStatuses.map((s: any) => s.id || '').filter(Boolean), multiSelect: true, - searchable: false, + searchable: true, icon: CheckCircleOutlined, }, { @@ -321,7 +321,7 @@ const useFilterData = (position: 'board' | 'list'): FilterSection[] => { })), selectedValues: filterData.selectedPhases, multiSelect: true, - searchable: false, + searchable: true, icon: AppstoreOutlined, }, { From 174833cd22a02a0582943e845a3ef8beecb64823 Mon Sep 17 00:00:00 2001 From: ThishakaM Date: Fri, 9 Oct 2026 12:16:30 +0530 Subject: [PATCH 2/5] fix(migrations): add forward migration to fix recurring tasks IMMUTABLE index Restore historical migrations 1769558401000 and 1770681600000 to their original committed form. Environments that already recorded those IDs will not rerun them, so deliver the fix as a new forward migration (1791364628703) that idempotently drops and recreates idx_tasks_schedule_end_date_unique with an IMMUTABLE expression (end_date::DATE) instead of the STABLE AT TIME ZONE 'UTC' operator that PostgreSQL rejects in index definitions. --- .../1769558401000_fix_recurring_tasks.js | 5 +- ...0681600000_recurring_tasks_complete_fix.js | 5 +- ...703_fix_recurring_tasks_immutable_index.js | 49 +++++++++++++++++++ 3 files changed, 57 insertions(+), 2 deletions(-) create mode 100644 worklenz-backend/database/pg-migrations/1791364628703_fix_recurring_tasks_immutable_index.js diff --git a/worklenz-backend/database/pg-migrations/1769558401000_fix_recurring_tasks.js b/worklenz-backend/database/pg-migrations/1769558401000_fix_recurring_tasks.js index 232b9db74..59b13fb7f 100644 --- a/worklenz-backend/database/pg-migrations/1769558401000_fix_recurring_tasks.js +++ b/worklenz-backend/database/pg-migrations/1769558401000_fix_recurring_tasks.js @@ -11,6 +11,8 @@ exports.up = async (pgm) => { -- Date: 2026-02-10 -- Fixes: missing columns, missing unique constraint, create_quick_task not saving schedule_id +BEGIN; + -- 1. Add missing columns to task_recurring_schedules ALTER TABLE task_recurring_schedules ADD COLUMN IF NOT EXISTS date_of_month INTEGER, @@ -28,7 +30,7 @@ ADD COLUMN IF NOT EXISTS status_id UUID; -- 3. Add unique constraint to prevent duplicate recurring tasks for the same date -- Using a partial unique index since schedule_id can be NULL for non-recurring tasks CREATE UNIQUE INDEX IF NOT EXISTS idx_tasks_schedule_end_date_unique -ON tasks (schedule_id, (end_date::DATE)) +ON tasks (schedule_id, ((end_date AT TIME ZONE 'UTC')::DATE)) WHERE schedule_id IS NOT NULL AND end_date IS NOT NULL; -- 4. Update create_quick_task to accept and save schedule_id + description @@ -141,6 +143,7 @@ BEGIN END; $$; +COMMIT; `); }; diff --git a/worklenz-backend/database/pg-migrations/1770681600000_recurring_tasks_complete_fix.js b/worklenz-backend/database/pg-migrations/1770681600000_recurring_tasks_complete_fix.js index 618c3406a..967126ef1 100644 --- a/worklenz-backend/database/pg-migrations/1770681600000_recurring_tasks_complete_fix.js +++ b/worklenz-backend/database/pg-migrations/1770681600000_recurring_tasks_complete_fix.js @@ -14,6 +14,8 @@ exports.up = async (pgm) => { -- - Duration preservation -- - Foreign keys and indexes +BEGIN; + -- ============================================================================ -- PART 1: Add missing columns to task_recurring_schedules -- ============================================================================ @@ -42,7 +44,7 @@ ADD COLUMN IF NOT EXISTS duration_days INTEGER; -- ============================================================================ CREATE UNIQUE INDEX IF NOT EXISTS idx_tasks_schedule_end_date_unique -ON tasks (schedule_id, (end_date::DATE)) +ON tasks (schedule_id, ((end_date AT TIME ZONE 'UTC')::DATE)) WHERE schedule_id IS NOT NULL AND end_date IS NOT NULL; -- ============================================================================ @@ -222,6 +224,7 @@ BEGIN END; $$; +COMMIT; `); }; diff --git a/worklenz-backend/database/pg-migrations/1791364628703_fix_recurring_tasks_immutable_index.js b/worklenz-backend/database/pg-migrations/1791364628703_fix_recurring_tasks_immutable_index.js new file mode 100644 index 000000000..97da526f7 --- /dev/null +++ b/worklenz-backend/database/pg-migrations/1791364628703_fix_recurring_tasks_immutable_index.js @@ -0,0 +1,49 @@ +'use strict'; + +/** + * Forward-fix migration for issues originally introduced by: + * - 1769558401000_fix_recurring_tasks + * - 1770681600000_recurring_tasks_complete_fix + * + * Those historical migrations contained: + * 1. An index expression using AT TIME ZONE 'UTC' which is STABLE, not + * IMMUTABLE — PostgreSQL rejects non-IMMUTABLE functions in index + * expressions. On databases where the migration succeeded (e.g. via a + * patched local run) the index may already exist with a plain cast; on + * databases where it failed the index will be absent. + * 2. Explicit BEGIN / COMMIT inside pgm.sql() which conflicts with + * node-pg-migrate's own transaction wrapping. This is a migration-time + * issue only and leaves no residual schema problem, so no runtime fix is + * needed. + * + * This migration idempotently ensures the correct unique index exists + * regardless of which state the database is in. + */ + +/** @type {import('node-pg-migrate').ColumnDefinitions | undefined} */ +exports.shorthands = undefined; + +/** @param {import('node-pg-migrate').MigrationBuilder} pgm */ +exports.up = async (pgm) => { + pgm.sql(` +-- ============================================================================ +-- Fix idx_tasks_schedule_end_date_unique +-- Drop the old index (may have been created with AT TIME ZONE 'UTC' or may +-- not exist at all) and recreate it with an IMMUTABLE expression. +-- ============================================================================ + +DROP INDEX IF EXISTS idx_tasks_schedule_end_date_unique; + +CREATE UNIQUE INDEX idx_tasks_schedule_end_date_unique +ON tasks (schedule_id, (end_date::DATE)) +WHERE schedule_id IS NOT NULL AND end_date IS NOT NULL; + `); +}; + +/** @param {import('node-pg-migrate').MigrationBuilder} pgm */ +exports.down = async (pgm) => { + pgm.sql(` +-- Revert to the original (non-IMMUTABLE) form is not safe — just drop it. +DROP INDEX IF EXISTS idx_tasks_schedule_end_date_unique; + `); +}; From 6040f392766672992cbdd6aaa6e335c89a02d6dc Mon Sep 17 00:00:00 2001 From: ThishakaM Date: Mon, 12 Oct 2026 16:21:49 +0530 Subject: [PATCH 3/5] fix(migrations): use immutable UTC date expression for recurring tasks unique index --- ...703_fix_recurring_tasks_immutable_index.js | 32 ++++++++----------- 1 file changed, 13 insertions(+), 19 deletions(-) diff --git a/worklenz-backend/database/pg-migrations/1791364628703_fix_recurring_tasks_immutable_index.js b/worklenz-backend/database/pg-migrations/1791364628703_fix_recurring_tasks_immutable_index.js index 97da526f7..eade58b3a 100644 --- a/worklenz-backend/database/pg-migrations/1791364628703_fix_recurring_tasks_immutable_index.js +++ b/worklenz-backend/database/pg-migrations/1791364628703_fix_recurring_tasks_immutable_index.js @@ -1,23 +1,18 @@ 'use strict'; /** - * Forward-fix migration for issues originally introduced by: - * - 1769558401000_fix_recurring_tasks - * - 1770681600000_recurring_tasks_complete_fix + * Forward migration to ensure the recurring tasks unique index uses the + * immutable UTC date expression on tasks.end_date (which is TIMESTAMP WITH TIME ZONE). * - * Those historical migrations contained: - * 1. An index expression using AT TIME ZONE 'UTC' which is STABLE, not - * IMMUTABLE — PostgreSQL rejects non-IMMUTABLE functions in index - * expressions. On databases where the migration succeeded (e.g. via a - * patched local run) the index may already exist with a plain cast; on - * databases where it failed the index will be absent. - * 2. Explicit BEGIN / COMMIT inside pgm.sql() which conflicts with - * node-pg-migrate's own transaction wrapping. This is a migration-time - * issue only and leaves no residual schema problem, so no runtime fix is - * needed. + * tasks.end_date is TIMESTAMPTZ (database/sql/1_tables.sql). In PostgreSQL: + * - Casting TIMESTAMPTZ directly to DATE (end_date::DATE) depends on the session + * TimeZone setting and is therefore STABLE, which causes PostgreSQL to reject + * it in index expressions with "functions in index expression must be marked IMMUTABLE". + * - Converting with AT TIME ZONE 'UTC' produces a TIMESTAMP (without time zone) + * at a fixed reference point, making ((end_date AT TIME ZONE 'UTC')::DATE) IMMUTABLE. * - * This migration idempotently ensures the correct unique index exists - * regardless of which state the database is in. + * This migration idempotently ensures idx_tasks_schedule_end_date_unique exists + * with the correct immutable UTC expression. */ /** @type {import('node-pg-migrate').ColumnDefinitions | undefined} */ @@ -28,14 +23,14 @@ exports.up = async (pgm) => { pgm.sql(` -- ============================================================================ -- Fix idx_tasks_schedule_end_date_unique --- Drop the old index (may have been created with AT TIME ZONE 'UTC' or may --- not exist at all) and recreate it with an IMMUTABLE expression. +-- Recreate the unique index with the immutable UTC expression for TIMESTAMPTZ: +-- ((end_date AT TIME ZONE 'UTC')::DATE) -- ============================================================================ DROP INDEX IF EXISTS idx_tasks_schedule_end_date_unique; CREATE UNIQUE INDEX idx_tasks_schedule_end_date_unique -ON tasks (schedule_id, (end_date::DATE)) +ON tasks (schedule_id, ((end_date AT TIME ZONE 'UTC')::DATE)) WHERE schedule_id IS NOT NULL AND end_date IS NOT NULL; `); }; @@ -43,7 +38,6 @@ WHERE schedule_id IS NOT NULL AND end_date IS NOT NULL; /** @param {import('node-pg-migrate').MigrationBuilder} pgm */ exports.down = async (pgm) => { pgm.sql(` --- Revert to the original (non-IMMUTABLE) form is not safe — just drop it. DROP INDEX IF EXISTS idx_tasks_schedule_end_date_unique; `); }; From 572b6e73d9e16d85c0645d73f6abfcdee778cd7b Mon Sep 17 00:00:00 2001 From: ThishakaM Date: Thu, 15 Oct 2026 13:16:15 +0530 Subject: [PATCH 4/5] chore(migrations): remove unrelated migrations from searchable-filter PR Remove the Team Lead verification migration and historical member-capacity migration from this PR. Their permission-verification and capacity-function changes are unrelated to searchable status and phase filters, and the same-day capacity correction is already present in main's later migration. Keep the recurring-task immutable-index forward migration unchanged. --- ...9205000_verify_team_lead_implementation.js | 346 ------------------ ..._add_calculate_member_capacity_function.js | 225 ------------ 2 files changed, 571 deletions(-) delete mode 100644 worklenz-backend/database/pg-migrations/1758499205000_verify_team_lead_implementation.js delete mode 100644 worklenz-backend/database/pg-migrations/1768995180000_add_calculate_member_capacity_function.js diff --git a/worklenz-backend/database/pg-migrations/1758499205000_verify_team_lead_implementation.js b/worklenz-backend/database/pg-migrations/1758499205000_verify_team_lead_implementation.js deleted file mode 100644 index 7bfd79988..000000000 --- a/worklenz-backend/database/pg-migrations/1758499205000_verify_team_lead_implementation.js +++ /dev/null @@ -1,346 +0,0 @@ -'use strict'; - -/** @type {import('node-pg-migrate').ColumnDefinitions | undefined} */ -exports.shorthands = undefined; - -/** @param {import('node-pg-migrate').MigrationBuilder} pgm */ -exports.up = async (pgm) => { - pgm.sql(` --- Migration: Verify Team Lead Implementation --- Description: Comprehensive verification queries to ensure Team Lead roles are correctly configured --- Version: v2.2.2 --- Date: 2025-01-21 - --- Step 1: Verify that all Team Lead roles have admin_role = FALSE -DO $$ -DECLARE - admin_team_leads integer; - total_team_leads integer; -BEGIN - -- Count Team Lead roles with admin privileges - SELECT COUNT(*) INTO admin_team_leads - FROM roles - WHERE name = 'Team Lead' AND admin_role = TRUE; - - -- Count total Team Lead roles - SELECT COUNT(*) INTO total_team_leads - FROM roles - WHERE name = 'Team Lead'; - - IF admin_team_leads > 0 THEN - RAISE NOTICE 'VERIFICATION WARNING: % out of % Team Lead roles still have admin_role = TRUE', admin_team_leads, total_team_leads; - ELSE - RAISE NOTICE 'VERIFICATION PASSED: All % Team Lead roles have admin_role = FALSE', total_team_leads; - END IF; -END -$$; - --- Step 2: Verify that all teams have the correct role structure -DO $$ -DECLARE - teams_missing_team_lead integer; - total_teams integer; -BEGIN - -- Count teams missing Team Lead role - SELECT COUNT(*) INTO teams_missing_team_lead - FROM teams t - WHERE NOT EXISTS ( - SELECT 1 FROM roles r - WHERE r.team_id = t.id - AND r.name = 'Team Lead' - ); - - -- Count total teams - SELECT COUNT(*) INTO total_teams FROM teams; - - IF teams_missing_team_lead > 0 THEN - RAISE NOTICE 'VERIFICATION WARNING: % out of % teams are missing Team Lead role', teams_missing_team_lead, total_teams; - ELSE - RAISE NOTICE 'VERIFICATION PASSED: All % teams have Team Lead role', total_teams; - END IF; -END -$$; - --- Step 3: Verify that Team Lead roles are properly configured -DO $$ -DECLARE - team_leads_with_owner integer; - team_leads_with_default integer; - properly_configured integer; -BEGIN - -- Count Team Lead roles incorrectly marked as owner - SELECT COUNT(*) INTO team_leads_with_owner - FROM roles - WHERE name = 'Team Lead' AND owner = TRUE; - - -- Count Team Lead roles incorrectly marked as default - SELECT COUNT(*) INTO team_leads_with_default - FROM roles - WHERE name = 'Team Lead' AND default_role = TRUE; - - -- Count properly configured Team Lead roles - SELECT COUNT(*) INTO properly_configured - FROM roles - WHERE name = 'Team Lead' - AND admin_role = FALSE - AND owner = FALSE - AND default_role = FALSE; - - IF team_leads_with_owner > 0 THEN - RAISE NOTICE 'VERIFICATION WARNING: % Team Lead roles are incorrectly marked as owner', team_leads_with_owner; - END IF; - - IF team_leads_with_default > 0 THEN - RAISE NOTICE 'VERIFICATION WARNING: % Team Lead roles are incorrectly marked as default', team_leads_with_default; - END IF; - - RAISE NOTICE 'VERIFICATION PASSED: % Team Lead roles are correctly configured', properly_configured; -END -$$; - --- Step 4: Verify that hierarchy-based functions exist and work correctly -DO $$ -DECLARE - function_exists boolean; -BEGIN - -- Test hierarchy-based functions exist - BEGIN - -- Test is_team_lead_by_hierarchy function - SELECT EXISTS( - SELECT 1 FROM pg_proc p - JOIN pg_namespace n ON p.pronamespace = n.oid - WHERE n.nspname = 'public' AND p.proname = 'is_team_lead_by_hierarchy' - ) INTO function_exists; - - IF NOT function_exists THEN - RAISE NOTICE 'VERIFICATION WARNING: is_team_lead_by_hierarchy function does not exist'; - END IF; - - -- Test get_team_lead_managed_members function - SELECT EXISTS( - SELECT 1 FROM pg_proc p - JOIN pg_namespace n ON p.pronamespace = n.oid - WHERE n.nspname = 'public' AND p.proname = 'get_team_lead_managed_members' - ) INTO function_exists; - - IF NOT function_exists THEN - RAISE NOTICE 'VERIFICATION WARNING: get_team_lead_managed_members function does not exist'; - END IF; - - -- Test can_team_lead_access_member function - SELECT EXISTS( - SELECT 1 FROM pg_proc p - JOIN pg_namespace n ON p.pronamespace = n.oid - WHERE n.nspname = 'public' AND p.proname = 'can_team_lead_access_member' - ) INTO function_exists; - - IF NOT function_exists THEN - RAISE NOTICE 'VERIFICATION WARNING: can_team_lead_access_member function does not exist'; - END IF; - - RAISE NOTICE 'VERIFICATION PASSED: Hierarchy-based functions check complete'; - - EXCEPTION - WHEN OTHERS THEN - RAISE NOTICE 'VERIFICATION WARNING: Error testing hierarchy functions: %', SQLERRM; - END; -END -$$; - --- Step 5: Verify that permission functions exist -DO $$ -DECLARE - function_exists boolean; -BEGIN - -- Test permission functions exist - BEGIN - -- Test can_access_team_management function - SELECT EXISTS( - SELECT 1 FROM pg_proc p - JOIN pg_namespace n ON p.pronamespace = n.oid - WHERE n.nspname = 'public' AND p.proname = 'can_access_team_management' - ) INTO function_exists; - - IF NOT function_exists THEN - RAISE NOTICE 'VERIFICATION WARNING: can_access_team_management function does not exist'; - END IF; - - -- Test can_access_billing function - SELECT EXISTS( - SELECT 1 FROM pg_proc p - JOIN pg_namespace n ON p.pronamespace = n.oid - WHERE n.nspname = 'public' AND p.proname = 'can_access_billing' - ) INTO function_exists; - - IF NOT function_exists THEN - RAISE NOTICE 'VERIFICATION WARNING: can_access_billing function does not exist'; - END IF; - - -- Test get_user_permissions_summary function - SELECT EXISTS( - SELECT 1 FROM pg_proc p - JOIN pg_namespace n ON p.pronamespace = n.oid - WHERE n.nspname = 'public' AND p.proname = 'get_user_permissions_summary' - ) INTO function_exists; - - IF NOT function_exists THEN - RAISE NOTICE 'VERIFICATION WARNING: get_user_permissions_summary function does not exist'; - END IF; - - RAISE NOTICE 'VERIFICATION PASSED: Permission functions check complete'; - - EXCEPTION - WHEN OTHERS THEN - RAISE NOTICE 'VERIFICATION WARNING: Error testing permission functions: %', SQLERRM; - END; -END -$$; - --- Step 6: Verify that views exist and are accessible -DO $$ -DECLARE - view_exists boolean; -BEGIN - -- Test team_lead_managed_members view - SELECT EXISTS( - SELECT 1 FROM pg_views - WHERE schemaname = 'public' AND viewname = 'team_lead_managed_members' - ) INTO view_exists; - - IF NOT view_exists THEN - RAISE NOTICE 'VERIFICATION WARNING: team_lead_managed_members view does not exist'; - END IF; - - -- Test other views - SELECT EXISTS( - SELECT 1 FROM pg_views - WHERE schemaname = 'public' AND viewname = 'team_lead_member_stats' - ) INTO view_exists; - - IF NOT view_exists THEN - RAISE NOTICE 'VERIFICATION WARNING: team_lead_member_stats view does not exist'; - END IF; - - RAISE NOTICE 'VERIFICATION PASSED: Team Lead views check complete'; -END -$$; - --- Step 7: Verify that required indexes exist -DO $$ -DECLARE - index_exists boolean; -BEGIN - -- Test reports_to_member_id index - SELECT EXISTS( - SELECT 1 FROM pg_indexes - WHERE schemaname = 'public' - AND tablename = 'team_members' - AND indexname = 'idx_reports_to_member_id' - ) INTO index_exists; - - IF NOT index_exists THEN - RAISE NOTICE 'VERIFICATION WARNING: idx_reports_to_member_id index does not exist'; - ELSE - RAISE NOTICE 'VERIFICATION PASSED: Required indexes exist for performance optimization'; - END IF; -END -$$; - --- Step 8: Create a comprehensive verification report -CREATE OR REPLACE VIEW team_lead_implementation_verification AS -SELECT - 'Role Configuration' as category, - 'Team Lead Roles Without Admin Privileges' as check_name, - COUNT(*) as count, - CASE WHEN COUNT(*) = (SELECT COUNT(*) FROM roles WHERE name = 'Team Lead') - THEN 'PASS' ELSE 'FAIL' END as status -FROM roles -WHERE name = 'Team Lead' AND admin_role = FALSE - -UNION ALL - -SELECT - 'Role Configuration' as category, - 'Teams with Team Lead Role' as check_name, - COUNT(*) as count, - 'PASS' as status -FROM teams t -WHERE EXISTS (SELECT 1 FROM roles r WHERE r.team_id = t.id AND r.name = 'Team Lead') - -UNION ALL - -SELECT - 'Database Objects' as category, - 'Hierarchy Functions Available' as check_name, - COUNT(*) as count, - CASE WHEN COUNT(*) >= 3 THEN 'PASS' ELSE 'FAIL' END as status -FROM pg_proc p -JOIN pg_namespace n ON p.pronamespace = n.oid -WHERE n.nspname = 'public' - AND p.proname IN ('is_team_lead_by_hierarchy', 'get_team_lead_managed_members', 'can_team_lead_access_member') - -UNION ALL - -SELECT - 'Database Objects' as category, - 'Permission Functions Available' as check_name, - COUNT(*) as count, - CASE WHEN COUNT(*) >= 3 THEN 'PASS' ELSE 'FAIL' END as status -FROM pg_proc p -JOIN pg_namespace n ON p.pronamespace = n.oid -WHERE n.nspname = 'public' - AND p.proname IN ('can_access_team_management', 'can_access_billing', 'get_user_permissions_summary') - -UNION ALL - -SELECT - 'Database Objects' as category, - 'Team Lead Views Available' as check_name, - COUNT(*) as count, - CASE WHEN COUNT(*) >= 2 THEN 'PASS' ELSE 'FAIL' END as status -FROM pg_views -WHERE schemaname = 'public' - AND viewname LIKE 'team_lead_%'; - --- Step 9: Grant permissions on verification view -GRANT SELECT ON team_lead_implementation_verification TO PUBLIC; - --- Step 10: Final verification summary -DO $$ -DECLARE - total_checks integer; - passed_checks integer; - failed_checks integer; -BEGIN - -- Count total checks - SELECT COUNT(*) INTO total_checks FROM team_lead_implementation_verification; - - -- Count passed checks - SELECT COUNT(*) INTO passed_checks - FROM team_lead_implementation_verification - WHERE status = 'PASS'; - - -- Count failed checks - SELECT COUNT(*) INTO failed_checks - FROM team_lead_implementation_verification - WHERE status = 'FAIL'; - - RAISE NOTICE '=== TEAM LEAD IMPLEMENTATION VERIFICATION SUMMARY ==='; - RAISE NOTICE 'Total Checks: %', total_checks; - RAISE NOTICE 'Passed: %', passed_checks; - RAISE NOTICE 'Failed: %', failed_checks; - - IF failed_checks > 0 THEN - RAISE NOTICE 'VERIFICATION WARNING: % out of % checks failed. See team_lead_implementation_verification view for details.', failed_checks, total_checks; - ELSE - RAISE NOTICE 'VERIFICATION PASSED: All % checks passed successfully!', total_checks; - END IF; -END -$$; - `); -}; - -/** @param {import('node-pg-migrate').MigrationBuilder} pgm */ -exports.down = async (_pgm) => { - // Historical SQL migration: no automatic rollback is available. -}; \ No newline at end of file diff --git a/worklenz-backend/database/pg-migrations/1768995180000_add_calculate_member_capacity_function.js b/worklenz-backend/database/pg-migrations/1768995180000_add_calculate_member_capacity_function.js deleted file mode 100644 index 63b87441a..000000000 --- a/worklenz-backend/database/pg-migrations/1768995180000_add_calculate_member_capacity_function.js +++ /dev/null @@ -1,225 +0,0 @@ -'use strict'; - -/** @type {import('node-pg-migrate').ColumnDefinitions | undefined} */ -exports.shorthands = undefined; - -/** @param {import('node-pg-migrate').MigrationBuilder} pgm */ -exports.up = async (pgm) => { - pgm.sql(` --- Final fix for same-day task capacity calculation --- This migration completely fixes the issue where tasks with same start and end date --- were not getting their estimation allocated properly - --- Drop existing function first so PostgreSQL allows changing the return table structure -DROP FUNCTION IF EXISTS calculate_member_capacity(UUID, DATE, DATE) CASCADE; -DROP FUNCTION IF EXISTS calculate_member_capacity CASCADE; - -CREATE OR REPLACE FUNCTION calculate_member_capacity( - p_team_member_id UUID, - p_start_date DATE, - p_end_date DATE -) -RETURNS TABLE ( - date DATE, - working_hours NUMERIC, - allocated_hours NUMERIC, - available_hours NUMERIC, - utilization_percent NUMERIC, - is_time_off BOOLEAN, - is_holiday BOOLEAN, - is_weekend BOOLEAN, - status TEXT, - projects JSONB -) AS $$ -DECLARE - v_organization_id UUID; - v_hours_per_day NUMERIC; -BEGIN - -- Get organization and working hours - SELECT o.id, o.hours_per_day - INTO v_organization_id, v_hours_per_day - FROM team_members tm - JOIN teams t ON tm.team_id = t.id - JOIN organizations o ON t.organization_id = o.id - WHERE tm.id = p_team_member_id - LIMIT 1; - - -- If member not found, return empty - IF v_organization_id IS NULL THEN - RETURN; - END IF; - - RETURN QUERY - WITH RECURSIVE date_series AS ( - -- Generate all dates in range - SELECT p_start_date::DATE AS series_date - UNION ALL - SELECT (series_date + INTERVAL '1 day')::DATE - FROM date_series - WHERE series_date < p_end_date - ), - working_days AS ( - -- Get organization working days configuration - SELECT - monday, tuesday, wednesday, thursday, friday, saturday, sunday - FROM organization_working_days - WHERE organization_id = v_organization_id - LIMIT 1 - ), - date_info AS ( - -- Determine if each date is a working day - SELECT - ds.series_date AS info_date, - CASE - WHEN EXTRACT(ISODOW FROM ds.series_date) = 1 THEN wd.monday - WHEN EXTRACT(ISODOW FROM ds.series_date) = 2 THEN wd.tuesday - WHEN EXTRACT(ISODOW FROM ds.series_date) = 3 THEN wd.wednesday - WHEN EXTRACT(ISODOW FROM ds.series_date) = 4 THEN wd.thursday - WHEN EXTRACT(ISODOW FROM ds.series_date) = 5 THEN wd.friday - WHEN EXTRACT(ISODOW FROM ds.series_date) = 6 THEN wd.saturday - WHEN EXTRACT(ISODOW FROM ds.series_date) = 7 THEN wd.sunday - END AS is_working_day, - CASE - WHEN EXTRACT(ISODOW FROM ds.series_date) IN (6, 7) THEN true - ELSE false - END AS is_weekend_day - FROM date_series ds - CROSS JOIN working_days wd - ), - task_working_days AS ( - -- Pre-calculate working days for each task to avoid repeated calculations - SELECT - t.id as task_id, - t.project_id, - t.start_date::DATE as task_start_date, - t.end_date::DATE as task_end_date, - t.total_minutes, - p.name AS project_name, - p.color_code, - -- Calculate total working days for this task - CASE - WHEN t.start_date::DATE = t.end_date::DATE THEN - -- Same day task: check if that day is a working day - CASE - WHEN (EXTRACT(ISODOW FROM t.start_date::DATE) = 1 AND wd.monday = true) OR - (EXTRACT(ISODOW FROM t.start_date::DATE) = 2 AND wd.tuesday = true) OR - (EXTRACT(ISODOW FROM t.start_date::DATE) = 3 AND wd.wednesday = true) OR - (EXTRACT(ISODOW FROM t.start_date::DATE) = 4 AND wd.thursday = true) OR - (EXTRACT(ISODOW FROM t.start_date::DATE) = 5 AND wd.friday = true) OR - (EXTRACT(ISODOW FROM t.start_date::DATE) = 6 AND wd.saturday = true) OR - (EXTRACT(ISODOW FROM t.start_date::DATE) = 7 AND wd.sunday = true) - THEN 1 - ELSE 0 -- Not a working day, so no allocation - END - ELSE - -- Multi-day task: count working days in range - GREATEST(1, ( - SELECT COUNT(*) - FROM generate_series(t.start_date::DATE, t.end_date::DATE, '1 day'::interval) AS task_day - WHERE - (EXTRACT(ISODOW FROM task_day) = 1 AND wd.monday = true) OR - (EXTRACT(ISODOW FROM task_day) = 2 AND wd.tuesday = true) OR - (EXTRACT(ISODOW FROM task_day) = 3 AND wd.wednesday = true) OR - (EXTRACT(ISODOW FROM task_day) = 4 AND wd.thursday = true) OR - (EXTRACT(ISODOW FROM task_day) = 5 AND wd.friday = true) OR - (EXTRACT(ISODOW FROM task_day) = 6 AND wd.saturday = true) OR - (EXTRACT(ISODOW FROM task_day) = 7 AND wd.sunday = true) - )) - END AS working_days_count - FROM tasks t - JOIN tasks_assignees ta ON t.id = ta.task_id - JOIN project_members pm ON ta.project_member_id = pm.id - JOIN projects p ON t.project_id = p.id - CROSS JOIN working_days wd - WHERE pm.team_member_id = p_team_member_id - AND t.start_date IS NOT NULL - AND t.end_date IS NOT NULL - AND t.archived = false - -- Task overlaps with our date range - AND t.start_date::DATE <= p_end_date - AND t.end_date::DATE >= p_start_date - ), - task_allocations AS ( - -- Calculate daily task allocations based on task assignments and estimations - SELECT - di.info_date AS alloc_date, - twd.project_id, - twd.project_name, - twd.color_code, - -- Distribute task estimation evenly across working days - CASE - WHEN twd.working_days_count > 0 THEN - (twd.total_minutes / 60.0) / twd.working_days_count - ELSE 0 - END AS daily_hours - FROM date_info di - JOIN task_working_days twd ON - di.info_date >= twd.task_start_date - AND di.info_date <= twd.task_end_date - AND di.is_working_day = true - AND twd.working_days_count > 0 -- Only include tasks that have working days - ), - project_summary AS ( - -- Aggregate allocations by project per day - SELECT - alloc_date AS summary_date, - COALESCE( - jsonb_agg( - jsonb_build_object( - 'project_id', project_id, - 'project_name', project_name, - 'allocated_hours', ROUND(daily_hours::numeric, 2), - 'color_code', color_code - ) - ORDER BY daily_hours DESC - ) FILTER (WHERE daily_hours > 0), - '[]'::jsonb - ) AS projects, - COALESCE(SUM(daily_hours), 0) AS total_allocated - FROM task_allocations - GROUP BY alloc_date - ) - SELECT - di.info_date::DATE, - CASE - WHEN di.is_working_day THEN v_hours_per_day - ELSE 0 - END AS working_hours, - COALESCE(ps.total_allocated, 0) AS allocated_hours, - CASE - WHEN di.is_working_day THEN GREATEST(v_hours_per_day - COALESCE(ps.total_allocated, 0), 0) - ELSE 0 - END AS available_hours, - CASE - WHEN di.is_working_day AND v_hours_per_day > 0 - THEN ROUND((COALESCE(ps.total_allocated, 0) / v_hours_per_day * 100)::numeric, 2) - ELSE 0 - END AS utilization_percent, - false AS is_time_off, - false AS is_holiday, - NOT di.is_working_day AS is_weekend, - CASE - WHEN NOT di.is_working_day THEN 'unavailable' - WHEN COALESCE(ps.total_allocated, 0) = 0 THEN 'available' - WHEN COALESCE(ps.total_allocated, 0) > v_hours_per_day THEN 'overallocated' - WHEN COALESCE(ps.total_allocated, 0) >= v_hours_per_day * 0.8 THEN 'fully-allocated' - ELSE 'normal' - END AS status, - COALESCE(ps.projects, '[]'::jsonb) AS projects - FROM date_info di - LEFT JOIN project_summary ps ON di.info_date = ps.summary_date - ORDER BY di.info_date; -END; -$$ LANGUAGE plpgsql STABLE; - --- Grant execute permission -GRANT EXECUTE ON FUNCTION calculate_member_capacity(UUID, DATE, DATE) TO postgres; - -COMMENT ON FUNCTION calculate_member_capacity IS 'Calculates daily capacity for a team member based on task assignments and estimations - Final fix for same-day tasks'; - `); -}; - -/** @param {import('node-pg-migrate').MigrationBuilder} pgm */ -exports.down = async (_pgm) => { - // Historical SQL migration: no automatic rollback is available. -}; \ No newline at end of file From ea9bb60abcf8f1aae1e9e0c146c80274398fb9c5 Mon Sep 17 00:00:00 2001 From: ThishakaM Date: Fri, 16 Oct 2026 15:26:02 +0530 Subject: [PATCH 5/5] "fix(migrations): restore preceding unique index on rollback and add round-trip test" -m "- Recreate baseline idx_tasks_schedule_end_date_unique on (schedule_id, end_date) in down to preserve uniqueness protection upon rollback - Add unit test in recurring-tasks-migration-roundtrip.spec.ts covering up, down, and round-trip index preservation --- ...703_fix_recurring_tasks_immutable_index.js | 13 ++++- ...ecurring-tasks-migration-roundtrip.spec.ts | 53 +++++++++++++++++++ 2 files changed, 65 insertions(+), 1 deletion(-) create mode 100644 worklenz-backend/src/tests/recurring-tasks-migration-roundtrip.spec.ts diff --git a/worklenz-backend/database/pg-migrations/1791364628703_fix_recurring_tasks_immutable_index.js b/worklenz-backend/database/pg-migrations/1791364628703_fix_recurring_tasks_immutable_index.js index eade58b3a..045c3a1cc 100644 --- a/worklenz-backend/database/pg-migrations/1791364628703_fix_recurring_tasks_immutable_index.js +++ b/worklenz-backend/database/pg-migrations/1791364628703_fix_recurring_tasks_immutable_index.js @@ -12,7 +12,8 @@ * at a fixed reference point, making ((end_date AT TIME ZONE 'UTC')::DATE) IMMUTABLE. * * This migration idempotently ensures idx_tasks_schedule_end_date_unique exists - * with the correct immutable UTC expression. + * with the correct immutable UTC expression, and restores the prior baseline + * uniqueness constraint on tasks (schedule_id, end_date) upon rollback. */ /** @type {import('node-pg-migrate').ColumnDefinitions | undefined} */ @@ -38,6 +39,16 @@ WHERE schedule_id IS NOT NULL AND end_date IS NOT NULL; /** @param {import('node-pg-migrate').MigrationBuilder} pgm */ exports.down = async (pgm) => { pgm.sql(` +-- ============================================================================ +-- Restore preceding baseline idx_tasks_schedule_end_date_unique +-- Recreate the prior unique index using the baseline raw timestamp key +-- from database/sql/1_tables.sql to maintain uniqueness on rollback. +-- ============================================================================ + DROP INDEX IF EXISTS idx_tasks_schedule_end_date_unique; + +CREATE UNIQUE INDEX idx_tasks_schedule_end_date_unique +ON tasks (schedule_id, end_date) +WHERE schedule_id IS NOT NULL AND end_date IS NOT NULL; `); }; diff --git a/worklenz-backend/src/tests/recurring-tasks-migration-roundtrip.spec.ts b/worklenz-backend/src/tests/recurring-tasks-migration-roundtrip.spec.ts new file mode 100644 index 000000000..406645258 --- /dev/null +++ b/worklenz-backend/src/tests/recurring-tasks-migration-roundtrip.spec.ts @@ -0,0 +1,53 @@ +import path from "path"; + +interface Migration { + up: (builder: { sql: (statement: string) => void }) => Promise; + down: (builder: { sql: (statement: string) => void }) => Promise; +} + +describe("1791364628703_fix_recurring_tasks_immutable_index migration round-trip", () => { + const migrationPath = path.resolve( + __dirname, + "../../database/pg-migrations/1791364628703_fix_recurring_tasks_immutable_index.js" + ); + // eslint-disable-next-line @typescript-eslint/no-var-requires + const migration = require(migrationPath) as Migration; + + it("applies up migration using immutable UTC date expression for timestamptz", async () => { + const statements: string[] = []; + await migration.up({ sql: statement => statements.push(statement) }); + const sql = statements.join("\n"); + + expect(sql).toContain("DROP INDEX IF EXISTS idx_tasks_schedule_end_date_unique;"); + expect(sql).toContain("CREATE UNIQUE INDEX idx_tasks_schedule_end_date_unique"); + expect(sql).toContain("ON tasks (schedule_id, ((end_date AT TIME ZONE 'UTC')::DATE))"); + expect(sql).toContain("WHERE schedule_id IS NOT NULL AND end_date IS NOT NULL;"); + }); + + it("restores baseline unique index on rollback to preserve uniqueness guard", async () => { + const statements: string[] = []; + await migration.down({ sql: statement => statements.push(statement) }); + const sql = statements.join("\n"); + + expect(sql).toContain("DROP INDEX IF EXISTS idx_tasks_schedule_end_date_unique;"); + expect(sql).toContain("CREATE UNIQUE INDEX idx_tasks_schedule_end_date_unique"); + expect(sql).toContain("ON tasks (schedule_id, end_date)"); + expect(sql).toContain("WHERE schedule_id IS NOT NULL AND end_date IS NOT NULL;"); + }); + + it("completes round-trip ensuring uniqueness protection is maintained after down", async () => { + const upStatements: string[] = []; + await migration.up({ sql: statement => upStatements.push(statement) }); + + const downStatements: string[] = []; + await migration.down({ sql: statement => downStatements.push(statement) }); + + const upSql = upStatements.join("\n"); + const downSql = downStatements.join("\n"); + + // up creates immutable index + expect(upSql).toMatch(/CREATE UNIQUE INDEX idx_tasks_schedule_end_date_unique\s+ON tasks \(schedule_id, \(\(end_date AT TIME ZONE 'UTC'\)::DATE\)\)/); + // down restores preceding baseline index + expect(downSql).toMatch(/CREATE UNIQUE INDEX idx_tasks_schedule_end_date_unique\s+ON tasks \(schedule_id, end_date\)/); + }); +});