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..045c3a1cc --- /dev/null +++ b/worklenz-backend/database/pg-migrations/1791364628703_fix_recurring_tasks_immutable_index.js @@ -0,0 +1,54 @@ +'use strict'; + +/** + * 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). + * + * 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 idx_tasks_schedule_end_date_unique exists + * 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} */ +exports.shorthands = undefined; + +/** @param {import('node-pg-migrate').MigrationBuilder} pgm */ +exports.up = async (pgm) => { + pgm.sql(` +-- ============================================================================ +-- Fix idx_tasks_schedule_end_date_unique +-- 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 AT TIME ZONE 'UTC')::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(` +-- ============================================================================ +-- 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\)/); + }); +}); 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 71372ffc5..9364ca42d 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 @@ -213,7 +213,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, }, { @@ -227,7 +227,7 @@ const useFilterData = (position: 'board' | 'list'): FilterSection[] => { })), selectedValues: filterData.kanbanPhases, multiSelect: true, - searchable: false, + searchable: true, icon: AppstoreOutlined, }, { @@ -308,7 +308,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, }, { @@ -322,7 +322,7 @@ const useFilterData = (position: 'board' | 'list'): FilterSection[] => { })), selectedValues: filterData.selectedPhases, multiSelect: true, - searchable: false, + searchable: true, icon: AppstoreOutlined, }, {