diff --git a/docker-compose.yml b/docker-compose.yml index 4f3808796..70bd2aa94 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,6 +1,6 @@ services: frontend: - image: chamikajaycey/worklenz-frontend:3.1.0-beta + image: chamikajaycey/worklenz-frontend:latest container_name: worklenz_frontend ports: - "5000:5000" @@ -13,7 +13,7 @@ services: - worklenz backend: - image: chamikajaycey/worklenz-backend:3.1.0-beta + image: chamikajaycey/worklenz-backend:latest container_name: worklenz_backend ports: - "3000:3000" diff --git a/scripts/test-e2e.js b/scripts/test-e2e.js new file mode 100644 index 000000000..fc0ab6d93 --- /dev/null +++ b/scripts/test-e2e.js @@ -0,0 +1,259 @@ +const path = require('path'); +const { chromium } = require(path.resolve(__dirname, '../worklenz-frontend/node_modules/playwright')); + +(async () => { + const browser = await chromium.launch({ headless: true }); + const context = await browser.newContext(); + + // Pre-seed cookie consent so banner does not block UI interactions + await context.addInitScript(() => { + try { + localStorage.setItem('worklenz_cookie_consent', JSON.stringify({ analytics: true, timestamp: Date.now() })); + } catch (e) {} + }); + + const page = await context.newPage(); + + page.on('console', msg => { + if (msg.type() === 'error') console.log(`[Browser console.error] ${msg.text()}`); + }); + page.on('pageerror', err => console.log(`[Browser pageerror] ${err.message}`)); + + const testId = Date.now(); + const testUser = { + name: `Smoke Tester`, + email: `smoke.${testId}@example.test`, + password: 'Password123!', + orgName: `Smoke Org ${testId}`, + projectName: `Alpha Proj ${testId}`, + taskName: `First Task ${testId}`, + createdProjectName: `Beta Proj ${testId}`, + createdTaskName: `Second Task ${testId}`, + }; + + console.log('=== STEP 1: SIGN UP ==='); + console.log(`Navigating to http://localhost:5000/auth/signup...`); + await page.goto('http://localhost:5000/auth/signup', { waitUntil: 'domcontentloaded' }); + + const nameInput = page.locator('input#signup_name'); + await nameInput.waitFor({ state: 'visible', timeout: 15000 }); + console.log('Filling signup form with', testUser.email); + await nameInput.fill(testUser.name); + await page.locator('input#signup_email').fill(testUser.email); + await page.locator('input[type="password"]').fill(testUser.password); + + console.log('Submitting signup...'); + await page.locator('button[type="submit"]').click(); + + // Wait for setup navigation + console.log('Waiting for setup redirect...'); + await page.waitForURL(url => url.pathname.includes('/worklenz/setup') || url.pathname.includes('/auth/authenticating'), { timeout: 20000 }); + console.log('Redirected to:', page.url()); + + if (page.url().includes('/auth/authenticating')) { + await page.waitForURL(url => url.pathname.includes('/worklenz/setup'), { timeout: 20000 }); + console.log('Setup page loaded:', page.url()); + } + + // Handle Account Setup Wizard + console.log('=== STEP 1.1: ACCOUNT SETUP WIZARD ==='); + await page.waitForTimeout(2000); + + // Step 0: Organization + console.log('Handling Step 0: Organization...'); + const orgInput = page.locator('.organization-step input').first(); + if (await orgInput.waitFor({ state: 'visible', timeout: 5000 }).then(() => true).catch(() => false)) { + await orgInput.fill(testUser.orgName); + } + const continueBtn = page.getByRole('button', { name: /^Continue$/i }); + await continueBtn.click({ force: true }); + await page.waitForTimeout(1500); + + // Step 1: Survey Step + console.log('Handling Step 1: Survey...'); + const orgTypeBtn = page.locator('button', { hasText: /Startup|Small/i }).first(); + if (await orgTypeBtn.waitFor({ state: 'visible', timeout: 5000 }).then(() => true).catch(() => false)) { + console.log('Selecting Organization Type...'); + await orgTypeBtn.click({ force: true }); + await page.waitForTimeout(500); + + console.log('Selecting User Role...'); + const userRoleBtn = page.locator('button', { hasText: /Founder|Developer|Project Manager/i }).first(); + await userRoleBtn.click({ force: true }); + await page.waitForTimeout(500); + + // Continue to survey sub-step 1 + await continueBtn.click({ force: true }); + await page.waitForTimeout(1000); + + // Survey Sub-step 1: Main use cases + console.log('Selecting Use Cases...'); + const useCaseBtn = page.locator('button').filter({ hasText: /Project|Task|Team/i }).first(); + if (await useCaseBtn.waitFor({ state: 'visible', timeout: 5000 }).then(() => true).catch(() => false)) { + await useCaseBtn.click({ force: true }); + await page.waitForTimeout(500); + await continueBtn.click({ force: true }); + await page.waitForTimeout(1000); + } + + // Survey Sub-step 2: How heard about + console.log('Selecting How heard about...'); + const howHeardBtn = page.locator('button').filter({ hasText: /Google|Search|Friend|Social|Other/i }).first(); + if (await howHeardBtn.waitFor({ state: 'visible', timeout: 5000 }).then(() => true).catch(() => false)) { + await howHeardBtn.click({ force: true }); + await page.waitForTimeout(500); + await continueBtn.click({ force: true }); + await page.waitForTimeout(1500); + } + } + + // Step 2: Project Step + console.log('Handling Step 2: Project...'); + const suggestionBtn = page.locator('.project-suggestion-button').first(); + if (await suggestionBtn.waitFor({ state: 'visible', timeout: 5000 }).then(() => true).catch(() => false)) { + console.log('Clicking project suggestion...'); + await suggestionBtn.click({ force: true }); + await page.waitForTimeout(500); + } else { + const projInput = page.locator('input').last(); + if (await projInput.waitFor({ state: 'visible', timeout: 3000 }).then(() => true).catch(() => false)) { + await projInput.fill(testUser.projectName); + } + } + await continueBtn.click({ force: true }); + await page.waitForTimeout(1500); + + // Step 3: Tasks Step + console.log('Handling Step 3: Tasks...'); + const taskInput = page.locator('input.task-input, .tasks-step input').first(); + if (await taskInput.waitFor({ state: 'visible', timeout: 5000 }).then(() => true).catch(() => false)) { + await taskInput.fill(testUser.taskName); + await page.waitForTimeout(500); + } + await continueBtn.click({ force: true }); + await page.waitForTimeout(1500); + + // Step 4: Members Step (Skip) + console.log('Handling Step 4: Members (Skip)...'); + const skipBtn = page.locator('button', { hasText: /Skip/i }).first(); + if (await skipBtn.waitFor({ state: 'visible', timeout: 5000 }).then(() => true).catch(() => false)) { + await skipBtn.click({ force: true }); + } else { + await continueBtn.click({ force: true }); + } + + // Wait for redirect to main application + console.log('Waiting for redirection to main application...'); + await page.waitForURL(url => !url.pathname.includes('/setup') && url.pathname.includes('/worklenz/'), { timeout: 30000 }); + console.log('SUCCESSFULLY COMPLETED SIGNUP & ONBOARDING! Current URL:', page.url()); + await page.waitForTimeout(3000); + const initialButtons = await page.locator('button').allTextContents(); + console.log('Buttons on onboarding destination project page:', initialButtons); + + console.log('\n=== STEP 2: LOGIN WITH CREATED USER IN A FRESH CONTEXT ==='); + const context2 = await browser.newContext(); + const page2 = await context2.newPage(); + page2.on('console', async msg => { + try { + const args = await Promise.all(msg.args().map(a => a.jsonValue().catch(() => a.toString()))); + console.log(`[Browser console.${msg.type()}]`, ...args); + } catch (e) { + console.log(`[Browser console.${msg.type()}]`, msg.text()); + } + }); + page2.on('pageerror', err => console.log('[Browser pageerror]', err.message)); + page2.on('response', async res => { + if (res.status() >= 400) { + let body = ''; + try { body = await res.text(); } catch (e) {} + console.log(`[HTTP ${res.status()}] ${res.url()}:`, body.slice(0, 200)); + } + }); + + await page2.goto('http://localhost:5000/auth/login', { waitUntil: 'domcontentloaded' }); + await page2.evaluate(() => { + localStorage.setItem('worklenz_cookie_consent', JSON.stringify({ + necessary: true, + analytics: false, + marketing: false, + version: '1.0', + })); + }); + await page2.reload({ waitUntil: 'domcontentloaded' }); + + const loginEmail = page2.locator('input#login_email'); + await loginEmail.waitFor({ state: 'visible', timeout: 15000 }); + console.log('Logging in as', testUser.email); + await loginEmail.fill(testUser.email); + await page2.locator('input#login_password').fill(testUser.password); + await page2.locator('button[type="submit"]').click(); + + await page2.waitForURL(url => url.pathname.includes('/worklenz/'), { timeout: 20000 }); + console.log('SUCCESSFULLY LOGGED IN! Current URL:', page2.url()); + await page2.waitForTimeout(3000); + + console.log('\n=== STEP 3: CREATE PROJECT ==='); + await page2.goto('http://localhost:5000/worklenz/projects', { waitUntil: 'domcontentloaded' }); + await page2.waitForTimeout(2000); + + const createProjBtn = page2.getByRole('button', { name: /create project/i }).first(); + await createProjBtn.waitFor({ state: 'visible', timeout: 10000 }); + await createProjBtn.click(); + await page2.waitForTimeout(1000); + + console.log('Entering project name:', testUser.createdProjectName); + const projNameInput = page2.locator('input[placeholder="Enter project name"]').first(); + await projNameInput.waitFor({ state: 'visible', timeout: 10000 }); + await projNameInput.fill(testUser.createdProjectName); + + const submitCreateProj = page2.locator('.ant-drawer button').filter({ hasText: /^Create$/i }).first(); + await submitCreateProj.click(); + console.log('Clicked Create Project. Waiting for project view navigation...'); + await page2.waitForURL(url => url.pathname.includes('/worklenz/projects/'), { timeout: 20000 }); + console.log('SUCCESSFULLY CREATED PROJECT & NAVIGATED TO PROJECT VIEW! URL:', page2.url()); + await page2.waitForTimeout(3000); + + console.log('\n=== STEP 4: CREATE TASK ==='); + console.log('Current URL on project page:', page2.url()); + await page2.waitForTimeout(3000); + + // Use "+ Add Task" button to create an inline task + const addTaskBtn = page2.getByRole('button', { name: /add task/i }).first(); + if (await addTaskBtn.waitFor({ state: 'visible', timeout: 5000 }).then(() => true).catch(() => false)) { + console.log('Found Add Task button. Clicking...'); + await addTaskBtn.click(); + await page2.waitForTimeout(500); + + const taskInput = page2.locator('input[placeholder*="Type your task"]').first(); + await taskInput.waitFor({ state: 'visible', timeout: 5000 }); + console.log('Entering task name:', testUser.createdTaskName); + await taskInput.fill(testUser.createdTaskName); + await taskInput.press('Enter'); + await page2.waitForTimeout(3000); + } else { + // Fallback: click "Create task" button + console.log('Looking for Create task button...'); + const createTaskBtn = page2.getByRole('button', { name: /create task/i }).first(); + await createTaskBtn.waitFor({ state: 'visible', timeout: 5000 }); + await createTaskBtn.click(); + await page2.waitForTimeout(2000); + } + + await page2.getByText(testUser.createdTaskName, { exact: true }).waitFor({ state: 'visible', timeout: 10000 }); + console.log('SUCCESSFULLY CREATED AND VERIFIED TASK:', testUser.createdTaskName); + + console.log('\n======================================================'); + console.log('✅ ALL VERIFICATIONS COMPLETED SUCCESSFULLY:'); + console.log(' 1. Sign Up (New user registered and authenticated)'); + console.log(' 2. Login (Credentials authenticated successfully)'); + console.log(' 3. Create Project (New blank project created)'); + console.log(' 4. Create Task (New task created in project)'); + console.log('======================================================'); + + process.exitCode = 0; +})().catch(err => { + console.error('\n❌ VERIFICATION FAILED:', err); + process.exitCode = 1; +}).finally(async () => { + await browser.close(); +}); diff --git a/worklenz-backend/database/migrations/20260427000002-add-due-time-column-to-task-list.sql b/worklenz-backend/database/migrations/20260427000002-add-due-time-column-to-task-list.sql index f68ebc27f..392224218 100644 --- a/worklenz-backend/database/migrations/20260427000002-add-due-time-column-to-task-list.sql +++ b/worklenz-backend/database/migrations/20260427000002-add-due-time-column-to-task-list.sql @@ -14,14 +14,13 @@ BEGIN; -- Insert DUE_TIME column for each project that doesn't already have it -- Default: pinned = false (hidden by default, users can enable it) -- Index: 13 (after DUE_DATE which is typically index 12) -INSERT INTO project_task_list_cols (name, key, index, pinned, project_id, custom_column) +INSERT INTO project_task_list_cols (name, key, index, pinned, project_id) SELECT 'Due Time' AS name, 'DUE_TIME'::WL_TASK_LIST_COL_KEY AS key, 13 AS index, false AS pinned, - p.id AS project_id, - false AS custom_column + p.id AS project_id FROM projects p WHERE NOT EXISTS ( SELECT 1 diff --git a/worklenz-backend/database/pg-migrations/20260821000001000_create_import_tasks_tables.js b/worklenz-backend/database/pg-migrations/20260821000001000_create_import_tasks_tables.js new file mode 100644 index 000000000..3f7e8763c --- /dev/null +++ b/worklenz-backend/database/pg-migrations/20260821000001000_create_import_tasks_tables.js @@ -0,0 +1,131 @@ +'use strict'; +// Converted from database/migrations/import-tasks/20260101000000-create-imports.sql + +/** @type {import('node-pg-migrate').ColumnDefinitions | undefined} */ +exports.shorthands = undefined; + +/** @param {import('node-pg-migrate').MigrationBuilder} pgm */ +exports.up = async (pgm) => { + pgm.sql(` +-- Imports base tables for external/csv imports +-- Direct and CSV flows share these tables + +CREATE TABLE IF NOT EXISTS import_jobs ( + id UUID DEFAULT uuid_generate_v4(), + provider TEXT NOT NULL, + flow_type TEXT NOT NULL CHECK (flow_type IN ('direct','csv')), + status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending','ready','running','success','failed')), + current_step INT NOT NULL DEFAULT 0, + target_project_id UUID NULL, + target_space_type TEXT NULL, + target_template TEXT NULL, + source_reference JSONB NULL, + created_by UUID NOT NULL, + stats JSONB DEFAULT '{}'::jsonb, + error_message TEXT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +DO $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'import_jobs_pkey') THEN + ALTER TABLE import_jobs ADD CONSTRAINT import_jobs_pkey PRIMARY KEY (id); + END IF; +END $$; + +CREATE TABLE IF NOT EXISTS import_hierarchy_mappings ( + id BIGSERIAL PRIMARY KEY, + job_id UUID NOT NULL REFERENCES import_jobs(id) ON DELETE CASCADE, + source_level TEXT NOT NULL, + target_level TEXT NOT NULL, + position INT NOT NULL DEFAULT 0, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS import_field_mappings ( + id BIGSERIAL PRIMARY KEY, + job_id UUID NOT NULL REFERENCES import_jobs(id) ON DELETE CASCADE, + source_field TEXT NOT NULL, + target_field TEXT NOT NULL, + required BOOLEAN NOT NULL DEFAULT FALSE, + include BOOLEAN NOT NULL DEFAULT TRUE, + meta JSONB DEFAULT '{}'::jsonb, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS import_value_mappings ( + id BIGSERIAL PRIMARY KEY, + job_id UUID NOT NULL REFERENCES import_jobs(id) ON DELETE CASCADE, + source_value TEXT NOT NULL, + target_worktype TEXT NOT NULL, + include BOOLEAN NOT NULL DEFAULT TRUE, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS import_user_mappings ( + id BIGSERIAL PRIMARY KEY, + job_id UUID NOT NULL REFERENCES import_jobs(id) ON DELETE CASCADE, + source_user_id TEXT NULL, + source_email TEXT NULL, + target_user_id UUID NULL, + resolution TEXT NOT NULL DEFAULT 'unresolved', + include BOOLEAN NOT NULL DEFAULT TRUE, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS import_attachment_plans ( + id BIGSERIAL PRIMARY KEY, + job_id UUID NOT NULL REFERENCES import_jobs(id) ON DELETE CASCADE, + source_url TEXT NOT NULL, + filename TEXT NULL, + content_type TEXT NULL, + size_bytes BIGINT NULL, + status TEXT NOT NULL DEFAULT 'planned', + storage_key TEXT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS import_stage_tasks ( + id BIGSERIAL PRIMARY KEY, + job_id UUID NOT NULL REFERENCES import_jobs(id) ON DELETE CASCADE, + source_task_id TEXT NULL, + parent_source_task_id TEXT NULL, + title TEXT NOT NULL, + description TEXT NULL, + status TEXT NULL, + due_at TIMESTAMPTZ NULL, + start_at TIMESTAMPTZ NULL, + worktype TEXT NULL, + assignee_source_id TEXT NULL, + attachments_planned BOOLEAN NOT NULL DEFAULT FALSE, + raw JSONB NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS import_logs ( + id BIGSERIAL PRIMARY KEY, + job_id UUID NOT NULL REFERENCES import_jobs(id) ON DELETE CASCADE, + level TEXT NOT NULL DEFAULT 'info', + message TEXT NOT NULL, + context JSONB DEFAULT '{}'::jsonb, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_import_jobs_status ON import_jobs(status); +CREATE INDEX IF NOT EXISTS idx_import_jobs_provider ON import_jobs(provider); +CREATE INDEX IF NOT EXISTS idx_import_hierarchy_job ON import_hierarchy_mappings(job_id); +CREATE INDEX IF NOT EXISTS idx_import_field_job ON import_field_mappings(job_id); +CREATE INDEX IF NOT EXISTS idx_import_value_job ON import_value_mappings(job_id); +CREATE INDEX IF NOT EXISTS idx_import_user_job ON import_user_mappings(job_id); +CREATE INDEX IF NOT EXISTS idx_import_attachment_job ON import_attachment_plans(job_id); +CREATE INDEX IF NOT EXISTS idx_import_stage_task_job ON import_stage_tasks(job_id); +CREATE INDEX IF NOT EXISTS idx_import_logs_job ON import_logs(job_id); + + `); +}; + +/** @param {import('node-pg-migrate').MigrationBuilder} pgm */ +exports.down = async (_pgm) => { + // Review manually before running migrate:down. +}; diff --git a/worklenz-backend/database/pg-migrations/20260821000002000_add_position_import_hierarchy.js b/worklenz-backend/database/pg-migrations/20260821000002000_add_position_import_hierarchy.js new file mode 100644 index 000000000..59417fe4f --- /dev/null +++ b/worklenz-backend/database/pg-migrations/20260821000002000_add_position_import_hierarchy.js @@ -0,0 +1,20 @@ +'use strict'; +// Converted from database/migrations/import-tasks/20260106000001-add-position-import-hierarchy.sql + +/** @type {import('node-pg-migrate').ColumnDefinitions | undefined} */ +exports.shorthands = undefined; + +/** @param {import('node-pg-migrate').MigrationBuilder} pgm */ +exports.up = async (pgm) => { + pgm.sql(` +-- Ensure import_hierarchy_mappings has position column +ALTER TABLE IF EXISTS import_hierarchy_mappings + ADD COLUMN IF NOT EXISTS position INT NOT NULL DEFAULT 0; + + `); +}; + +/** @param {import('node-pg-migrate').MigrationBuilder} pgm */ +exports.down = async (_pgm) => { + // Review manually before running migrate:down. +}; diff --git a/worklenz-backend/database/pg-migrations/20260821000003000_set_import_jobs_id_default_uuid.js b/worklenz-backend/database/pg-migrations/20260821000003000_set_import_jobs_id_default_uuid.js new file mode 100644 index 000000000..90c541ee1 --- /dev/null +++ b/worklenz-backend/database/pg-migrations/20260821000003000_set_import_jobs_id_default_uuid.js @@ -0,0 +1,21 @@ +'use strict'; +// Converted from database/migrations/import-tasks/20260318000000-set-import-jobs-id-default-uuid-generate-v4.sql + +/** @type {import('node-pg-migrate').ColumnDefinitions | undefined} */ +exports.shorthands = undefined; + +/** @param {import('node-pg-migrate').MigrationBuilder} pgm */ +exports.up = async (pgm) => { + pgm.sql(` +CREATE EXTENSION IF NOT EXISTS "uuid-ossp"; + +ALTER TABLE import_jobs + ALTER COLUMN id SET DEFAULT uuid_generate_v4(); + + `); +}; + +/** @param {import('node-pg-migrate').MigrationBuilder} pgm */ +exports.down = async (_pgm) => { + // Review manually before running migrate:down. +}; diff --git a/worklenz-backend/database/pg-migrations/20260821000004000_add_auto_assign_task_creator.js b/worklenz-backend/database/pg-migrations/20260821000004000_add_auto_assign_task_creator.js new file mode 100644 index 000000000..7123020b4 --- /dev/null +++ b/worklenz-backend/database/pg-migrations/20260821000004000_add_auto_assign_task_creator.js @@ -0,0 +1,358 @@ +'use strict'; +// Converted from database/migrations/release-v2.5/20260203000000-add-auto-assign-task-creator.sql + +/** @type {import('node-pg-migrate').ColumnDefinitions | undefined} */ +exports.shorthands = undefined; + +/** @param {import('node-pg-migrate').MigrationBuilder} pgm */ +exports.up = async (pgm) => { + pgm.sql(` +-- Migration: Add auto_assign_task_creator column to projects table +-- This feature allows projects to automatically assign the task creator to newly created tasks + +-- Add the column to projects table +ALTER TABLE projects +ADD COLUMN IF NOT EXISTS auto_assign_task_creator BOOLEAN DEFAULT FALSE; + +-- Add comment to explain the column +COMMENT ON COLUMN projects.auto_assign_task_creator IS 'When enabled, automatically assigns the person who creates a task to that task'; + +-- Update create_project function to include auto_assign_task_creator +CREATE OR REPLACE FUNCTION create_project(_body json) RETURNS json + LANGUAGE plpgsql +AS +$$ +DECLARE + _user_id UUID; + _team_id UUID; + _client_id UUID; + _project_id UUID; + _client_name TEXT; + _project_name TEXT; + _team_member_id UUID; +BEGIN + _client_name = TRIM((_body ->> 'client_name')::TEXT); + _project_name = TRIM((_body ->> 'name')::TEXT); + _user_id = (_body ->> 'user_id')::UUID; + _team_id = (_body ->> 'team_id')::UUID; + + SELECT id FROM clients WHERE LOWER(name) = LOWER(_client_name) AND team_id = _team_id INTO _client_id; + SELECT id FROM team_members WHERE team_id = _team_id AND user_id = _user_id INTO _team_member_id; + + IF EXISTS(SELECT name FROM projects WHERE LOWER(name) = LOWER(_project_name) AND team_id = _team_id) + THEN + RAISE 'PROJECT_EXISTS_ERROR:%', _project_name; + END IF; + + IF is_null_or_empty(_client_id) IS TRUE AND is_null_or_empty(_client_name) IS FALSE + THEN + INSERT INTO clients (name, team_id) VALUES (_client_name, _team_id) RETURNING id INTO _client_id; + END IF; + + INSERT INTO projects (name, key, notes, color_code, team_id, client_id, owner_id, status_id, health_id, start_date, + end_date, folder_id, category_id, estimated_working_days, estimated_man_days, hours_per_day, + use_manual_progress, use_weighted_progress, use_time_progress, auto_assign_task_creator) + VALUES (_project_name, (_body ->> 'key')::TEXT, (_body ->> 'notes')::TEXT, (_body ->> 'color_code')::TEXT, _team_id, + _client_id, _user_id, (_body ->> 'status_id')::UUID, (_body ->> 'health_id')::UUID, + (_body ->> 'start_date')::TIMESTAMPTZ, (_body ->> 'end_date')::TIMESTAMPTZ, + (_body ->> 'folder_id')::UUID, (_body ->> 'category_id')::UUID, + (_body ->> 'working_days')::INTEGER, (_body ->> 'man_days')::INTEGER, (_body ->> 'hours_per_day')::INTEGER, + COALESCE((_body ->> 'use_manual_progress')::BOOLEAN, FALSE), + COALESCE((_body ->> 'use_weighted_progress')::BOOLEAN, FALSE), + COALESCE((_body ->> 'use_time_progress')::BOOLEAN, FALSE), + COALESCE((_body ->> 'auto_assign_task_creator')::BOOLEAN, FALSE)) + RETURNING id INTO _project_id; + + INSERT INTO project_logs (team_id, project_id, description) + VALUES (_team_id, _project_id, + REPLACE((_body ->> 'project_created_log')::TEXT, '@user', + (SELECT name FROM users WHERE id = _user_id))); + + INSERT INTO project_members (team_member_id, project_access_level_id, project_id, role_id) + VALUES (_team_member_id, (SELECT id FROM project_access_levels WHERE key = 'ADMIN'), + _project_id, + (SELECT id FROM roles WHERE team_id = _team_id AND default_role IS TRUE)); + + INSERT INTO task_statuses (name, project_id, team_id, category_id, sort_order) + VALUES ('To Do', _project_id, _team_id, (SELECT id FROM sys_task_status_categories WHERE is_todo IS TRUE), 0); + INSERT INTO task_statuses (name, project_id, team_id, category_id, sort_order) + VALUES ('Doing', _project_id, _team_id, (SELECT id FROM sys_task_status_categories WHERE is_doing IS TRUE), 1); + INSERT INTO task_statuses (name, project_id, team_id, category_id, sort_order) + VALUES ('Done', _project_id, _team_id, (SELECT id FROM sys_task_status_categories WHERE is_done IS TRUE), 2); + + PERFORM insert_task_list_columns(_project_id); + + RETURN JSON_BUILD_OBJECT('id', _project_id, 'name', (_body ->> 'name')::TEXT); +END; +$$; + +-- Update update_project function to include auto_assign_task_creator +CREATE OR REPLACE FUNCTION update_project(_body json) RETURNS json + LANGUAGE plpgsql +AS +$$ +DECLARE + _user_id UUID; + _team_id UUID; + _client_id UUID; + _project_id UUID; + _project_manager_team_member_id UUID; + _client_name TEXT; + _project_name TEXT; +BEGIN + _client_name = TRIM((_body ->> 'client_name')::TEXT); + _project_name = TRIM((_body ->> 'name')::TEXT); + _user_id = (_body ->> 'user_id')::UUID; + _team_id = (_body ->> 'team_id')::UUID; + _project_manager_team_member_id = (_body ->> 'team_member_id')::UUID; + + SELECT id FROM clients WHERE LOWER(name) = LOWER(_client_name) AND team_id = _team_id INTO _client_id; + + IF is_null_or_empty(_client_id) IS TRUE AND is_null_or_empty(_client_name) IS FALSE + THEN + INSERT INTO clients (name, team_id) VALUES (_client_name, _team_id) RETURNING id INTO _client_id; + END IF; + + IF EXISTS( + SELECT name FROM projects WHERE LOWER(name) = LOWER(_project_name) + AND team_id = _team_id AND id != (_body ->> 'id')::UUID + ) + THEN + RAISE 'PROJECT_EXISTS_ERROR:%', _project_name; + END IF; + + UPDATE projects + SET name = _project_name, + notes = (_body ->> 'notes')::TEXT, + color_code = (_body ->> 'color_code')::TEXT, + status_id = (_body ->> 'status_id')::UUID, + health_id = (_body ->> 'health_id')::UUID, + key = (_body ->> 'key')::TEXT, + start_date = (_body ->> 'start_date')::TIMESTAMPTZ, + end_date = (_body ->> 'end_date')::TIMESTAMPTZ, + client_id = _client_id, + folder_id = (_body ->> 'folder_id')::UUID, + category_id = (_body ->> 'category_id')::UUID, + updated_at = CURRENT_TIMESTAMP, + estimated_working_days = (_body ->> 'working_days')::INTEGER, + estimated_man_days = (_body ->> 'man_days')::INTEGER, + hours_per_day = (_body ->> 'hours_per_day')::INTEGER, + use_manual_progress = COALESCE((_body ->> 'use_manual_progress')::BOOLEAN, FALSE), + use_weighted_progress = COALESCE((_body ->> 'use_weighted_progress')::BOOLEAN, FALSE), + use_time_progress = COALESCE((_body ->> 'use_time_progress')::BOOLEAN, FALSE), + auto_assign_task_creator = COALESCE((_body ->> 'auto_assign_task_creator')::BOOLEAN, FALSE) + WHERE id = (_body ->> 'id')::UUID + AND team_id = _team_id + RETURNING id INTO _project_id; + + UPDATE project_members SET project_access_level_id = (SELECT id FROM project_access_levels WHERE key = 'MEMBER') WHERE project_id = _project_id; + + IF NOT (_project_manager_team_member_id IS NULL) + THEN + PERFORM update_project_manager(_project_manager_team_member_id, _project_id::UUID); + END IF; + + RETURN JSON_BUILD_OBJECT( + 'id', _project_id, + 'name', (_body ->> 'name')::TEXT, + 'project_manager_id', _project_manager_team_member_id::UUID + ); +END; +$$; + +-- Update create_quick_task function to auto-assign creator if enabled +CREATE OR REPLACE FUNCTION create_quick_task(_body json) RETURNS json + LANGUAGE plpgsql +AS +$$ +DECLARE + _task_id UUID; + _parent_task UUID; + _status_id UUID; + _priority_id UUID; + _start_date TIMESTAMP; + _end_date TIMESTAMP; + _auto_assign_task_creator BOOLEAN; + _reporter_id UUID; + _project_id UUID; + _team_id UUID; + _team_member_id UUID; + _is_admin BOOLEAN; +BEGIN + _parent_task = (_body ->> 'parent_task_id')::UUID; + _reporter_id = (_body ->> 'reporter_id')::UUID; + _project_id = (_body ->> 'project_id')::UUID; + + _status_id = COALESCE( + (_body ->> 'status_id')::UUID, + (SELECT id + FROM task_statuses + WHERE project_id = _project_id + AND category_id IN (SELECT id FROM sys_task_status_categories WHERE is_todo IS TRUE) + LIMIT 1) + ); + _priority_id = COALESCE((_body ->> 'priority_id')::UUID, (SELECT id FROM task_priorities WHERE value = 1)); + _start_date = (_body ->> 'start_date')::TIMESTAMP; + _end_date = (_body ->> 'end_date')::TIMESTAMP; + + INSERT INTO tasks (name, priority_id, project_id, reporter_id, status_id, parent_task_id, sort_order, roadmap_sort_order, start_date, end_date) + VALUES (TRIM((_body ->> 'name')::TEXT), + _priority_id, + _project_id, + _reporter_id, + _status_id, + _parent_task, + COALESCE((SELECT MAX(COALESCE(sort_order, roadmap_sort_order, 0)) + 1 FROM tasks WHERE project_id = _project_id), 0), + COALESCE((SELECT MAX(COALESCE(roadmap_sort_order, sort_order, 0)) + 1 FROM tasks WHERE project_id = _project_id), 0), + _start_date, + _end_date) + RETURNING id INTO _task_id; + + PERFORM handle_on_task_phase_change(_task_id, (_body ->> 'phase_id')::UUID); + + -- Check if auto-assign is enabled for this project + SELECT auto_assign_task_creator, team_id INTO _auto_assign_task_creator, _team_id + FROM projects + WHERE id = _project_id; + + -- If auto-assign is enabled, assign the task creator + IF _auto_assign_task_creator IS TRUE THEN + -- Get the team_member_id and check if their role is admin or owner + SELECT tm.id, (r.admin_role OR r.owner) INTO _team_member_id, _is_admin + FROM team_members tm + INNER JOIN roles r ON tm.role_id = r.id + WHERE tm.user_id = _reporter_id + AND tm.team_id = _team_id; + + IF _team_member_id IS NOT NULL THEN + -- Check if user is already a project member + IF NOT EXISTS ( + SELECT 1 FROM project_members + WHERE project_id = _project_id + AND team_member_id = _team_member_id + ) THEN + -- Only auto-add and assign if user is admin or owner + -- create_task_assignee will automatically add them to project_members + IF _is_admin IS TRUE THEN + PERFORM create_task_assignee(_team_member_id, _project_id, _task_id, _reporter_id); + END IF; + ELSE + -- User is already a project member, assign them to the task + PERFORM create_task_assignee(_team_member_id, _project_id, _task_id, _reporter_id); + END IF; + END IF; + END IF; + + RETURN get_single_task(_task_id); +END; +$$; + +-- Update create_task function to auto-assign creator if enabled +CREATE OR REPLACE FUNCTION create_task(_body json) RETURNS json + LANGUAGE plpgsql +AS +$$ +DECLARE + _assignee TEXT; + _attachment_id TEXT; + _assignee_id UUID; + _task_id UUID; + _label JSON; + _auto_assign_task_creator BOOLEAN; + _reporter_id UUID; + _project_id UUID; + _team_id UUID; + _team_member_id UUID; + _is_admin BOOLEAN; + _already_assigned BOOLEAN := FALSE; +BEGIN + _reporter_id = (_body ->> 'reporter_id')::UUID; + _project_id = (_body ->> 'project_id')::UUID; + _team_id = (_body ->> 'team_id')::UUID; + + INSERT INTO tasks (name, done, priority_id, project_id, reporter_id, start_date, end_date, total_minutes, + description, parent_task_id, status_id, sort_order) + VALUES (TRIM((_body ->> 'name')::TEXT), (FALSE), + COALESCE((_body ->> 'priority_id')::UUID, (SELECT id FROM task_priorities WHERE value = 1)), + _project_id, + _reporter_id, + (_body ->> 'start')::TIMESTAMPTZ, + (_body ->> 'end')::TIMESTAMPTZ, + (_body ->> 'total_minutes')::NUMERIC, + (_body ->> 'description')::TEXT, + (_body ->> 'parent_task_id')::UUID, + (_body ->> 'status_id')::UUID, + COALESCE((SELECT MAX(sort_order) + 1 FROM tasks WHERE project_id = _project_id), 0)) + RETURNING id INTO _task_id; + + -- Insert task assignees from the request + FOR _assignee IN SELECT * FROM JSON_ARRAY_ELEMENTS((_body ->> 'assignees')::JSON) + LOOP + _assignee_id = TRIM('"' FROM _assignee)::UUID; + PERFORM create_task_assignee(_assignee_id, _project_id, _task_id, _reporter_id); + + -- Check if the reporter is already in the assignees list + IF _assignee_id IN ( + SELECT id FROM team_members WHERE user_id = _reporter_id + ) THEN + _already_assigned := TRUE; + END IF; + END LOOP; + + FOR _attachment_id IN SELECT * FROM JSON_ARRAY_ELEMENTS((_body ->> 'attachments')::JSON) + LOOP + UPDATE task_attachments SET task_id = _task_id WHERE id = TRIM('"' FROM _attachment_id)::UUID; + END LOOP; + + FOR _label IN SELECT * FROM JSON_ARRAY_ELEMENTS((_body ->> 'labels')::JSON) + LOOP + PERFORM assign_or_create_label(_team_id, _task_id, (_label ->> 'name')::TEXT, + (_label ->> 'color')::TEXT); + END LOOP; + + -- Check if auto-assign is enabled for this project and creator is not already assigned + IF _already_assigned IS FALSE THEN + SELECT auto_assign_task_creator INTO _auto_assign_task_creator + FROM projects + WHERE id = _project_id; + + -- If auto-assign is enabled, assign the task creator + IF _auto_assign_task_creator IS TRUE THEN + -- Get the team_member_id and check if their role is admin or owner + SELECT tm.id, (r.admin_role OR r.owner) INTO _team_member_id, _is_admin + FROM team_members tm + INNER JOIN roles r ON tm.role_id = r.id + WHERE tm.user_id = _reporter_id + AND tm.team_id = _team_id; + + IF _team_member_id IS NOT NULL THEN + -- Check if user is already a project member + IF NOT EXISTS ( + SELECT 1 FROM project_members + WHERE project_id = _project_id + AND team_member_id = _team_member_id + ) THEN + -- Only auto-add and assign if user is admin or owner + -- create_task_assignee will automatically add them to project_members + IF _is_admin IS TRUE THEN + PERFORM create_task_assignee(_team_member_id, _project_id, _task_id, _reporter_id); + END IF; + ELSE + -- User is already a project member, assign them to the task + PERFORM create_task_assignee(_team_member_id, _project_id, _task_id, _reporter_id); + END IF; + END IF; + END IF; + END IF; + + RETURN get_task_form_view_model(_reporter_id, _team_id, _task_id, _project_id); +END; +$$; + + `); +}; + +/** @param {import('node-pg-migrate').MigrationBuilder} pgm */ +exports.down = async (_pgm) => { + // Review manually before running migrate:down. +}; diff --git a/worklenz-backend/database/pg-migrations/20260821000005000_add_recurring_mode_selection.js b/worklenz-backend/database/pg-migrations/20260821000005000_add_recurring_mode_selection.js new file mode 100644 index 000000000..1ab0e0d12 --- /dev/null +++ b/worklenz-backend/database/pg-migrations/20260821000005000_add_recurring_mode_selection.js @@ -0,0 +1,41 @@ +'use strict'; +// Converted from database/migrations/release-v2.5/20260212000000-add-recurring-mode-selection.sql + +/** @type {import('node-pg-migrate').ColumnDefinitions | undefined} */ +exports.shorthands = undefined; + +/** @param {import('node-pg-migrate').MigrationBuilder} pgm */ +exports.up = async (pgm) => { + pgm.sql(` +-- Migration: Add recurring mode selection feature +-- Date: 2026-02-12 +-- Description: Adds ability to choose between creating new tasks or changing task status for recurring tasks + +-- Add recurring_mode enum type +DO $$ BEGIN + CREATE TYPE recurring_mode AS ENUM ('create_task', 'change_status'); +EXCEPTION + WHEN duplicate_object THEN null; +END $$; + +-- Add new columns to task_recurring_schedules table +ALTER TABLE task_recurring_schedules + ADD COLUMN IF NOT EXISTS recurring_mode recurring_mode DEFAULT 'create_task' NOT NULL, + ADD COLUMN IF NOT EXISTS target_status_id UUID REFERENCES task_statuses(id) ON DELETE SET NULL; + +-- Add index for better query performance +CREATE INDEX IF NOT EXISTS idx_task_recurring_schedules_recurring_mode + ON task_recurring_schedules (recurring_mode) + WHERE recurring_mode = 'change_status'; + +-- Add comment for documentation +COMMENT ON COLUMN task_recurring_schedules.recurring_mode IS 'Determines behavior: create_task (creates new task copy) or change_status (updates existing task status)'; +COMMENT ON COLUMN task_recurring_schedules.target_status_id IS 'Target status to set when recurring_mode is change_status. Defaults to Todo category status if null.'; + + `); +}; + +/** @param {import('node-pg-migrate').MigrationBuilder} pgm */ +exports.down = async (_pgm) => { + // Review manually before running migrate:down. +}; diff --git a/worklenz-backend/database/pg-migrations/20260821000006000_add_project_members_to_account_setup.js b/worklenz-backend/database/pg-migrations/20260821000006000_add_project_members_to_account_setup.js new file mode 100644 index 000000000..e2a82c4ff --- /dev/null +++ b/worklenz-backend/database/pg-migrations/20260821000006000_add_project_members_to_account_setup.js @@ -0,0 +1,131 @@ +'use strict'; +// Converted from database/migrations/release-v2.5/20260224000000-add-project-members-to-account-setup.sql + +/** @type {import('node-pg-migrate').ColumnDefinitions | undefined} */ +exports.shorthands = undefined; + +/** @param {import('node-pg-migrate').MigrationBuilder} pgm */ +exports.up = async (pgm) => { + pgm.sql(` +-- Migration: Add invited team members as project members during account setup +-- Date: 2026-02-24 +-- Description: Modifies complete_account_setup function to automatically add invited team members +-- to the newly created project, not just to the team. This ensures invited members +-- have immediate access to the project created during account setup. + +CREATE OR REPLACE FUNCTION complete_account_setup(_user_id uuid, _team_id uuid, _body json) RETURNS json + LANGUAGE plpgsql +AS +$$ +DECLARE + _project_id UUID; + _default_status_id UUID; + _task_id UUID; + _task TEXT; + _members JSON; + _sort_order INT; + _team_member_id UUID; + _project_member_id UUID; + _member JSON; + _invited_team_member_id UUID; + _project_member_result JSON; +BEGIN + + -- Update team name + UPDATE teams SET name = TRIM((_body ->> 'team_name')::TEXT) WHERE id = _team_id AND user_id = _user_id; + + -- Create the project + INSERT INTO projects (name, team_id, owner_id, color_code, status_id, key) + VALUES ((_body ->> 'project_name')::TEXT, _team_id, _user_id, '#3b7ad4', + (SELECT id FROM sys_project_statuses WHERE is_default IS TRUE), (_body ->> 'key')::TEXT) + RETURNING id INTO _project_id; + + -- Insert task's statuses + INSERT INTO task_statuses (name, project_id, team_id, category_id) + VALUES ('To do', _project_id, _team_id, (SELECT id FROM sys_task_status_categories WHERE is_todo IS TRUE)) + RETURNING id INTO _default_status_id; + + INSERT INTO task_statuses (name, project_id, team_id, category_id) + VALUES ('Doing', _project_id, _team_id, (SELECT id FROM sys_task_status_categories WHERE is_doing IS TRUE)); + + INSERT INTO task_statuses (name, project_id, team_id, category_id) + VALUES ('Done', _project_id, _team_id, (SELECT id FROM sys_task_status_categories WHERE is_done IS TRUE)); + + SELECT id FROM team_members WHERE user_id = _user_id AND team_id = _team_id INTO _team_member_id; + + INSERT INTO project_members (team_member_id, project_access_level_id, project_id, role_id) + VALUES (_team_member_id, (SELECT id FROM project_access_levels WHERE key = 'PROJECT_MANAGER'), + _project_id, + (SELECT id FROM roles WHERE team_id = _team_id AND default_role IS TRUE)) + RETURNING id INTO _project_member_id; + + -- Insert tasks + _sort_order = 1; + FOR _task IN SELECT * FROM JSON_ARRAY_ELEMENTS((_body ->> 'tasks')::JSON) + LOOP + INSERT INTO tasks (name, priority_id, project_id, reporter_id, status_id, sort_order) + VALUES (TRIM('"' FROM _task)::TEXT, (SELECT id FROM task_priorities WHERE value = 1), _project_id, _user_id, + _default_status_id, _sort_order) + RETURNING id INTO _task_id; + _sort_order = _sort_order + 1; + + INSERT INTO tasks_assignees (task_id, project_member_id, team_member_id, assigned_by) + VALUES (_task_id, _project_member_id, _team_member_id, _user_id); + END LOOP; + + -- Insert team members if available + IF is_null_or_empty((_body ->> 'team_members')) IS FALSE + THEN + SELECT create_team_member(JSON_BUILD_OBJECT('team_id', _team_id, 'emails', (_body ->> 'team_members'))) + INTO _members; + + -- NEW: Add each invited team member to the project as well + -- This ensures they have access to the project, not just the team + IF _members IS NOT NULL + THEN + FOR _member IN SELECT * FROM JSON_ARRAY_ELEMENTS(_members) + LOOP + _invited_team_member_id = (_member ->> 'team_member_id')::UUID; + + -- Only add to project if team_member_id exists (member was successfully created) + IF _invited_team_member_id IS NOT NULL + THEN + -- Add the invited team member to the project with MEMBER access level + SELECT create_project_member(JSON_BUILD_OBJECT( + 'team_member_id', _invited_team_member_id, + 'team_id', _team_id, + 'project_id', _project_id, + 'user_id', _user_id, + 'access_level', 'MEMBER' + )) INTO _project_member_result; + END IF; + END LOOP; + END IF; + END IF; + + -- insert default columns for task list + PERFORM insert_task_list_columns(_project_id); + + UPDATE users SET setup_completed = TRUE WHERE id = _user_id; + + -- Update organization name + UPDATE organizations SET organization_name = TRIM((_body ->> 'team_name')::TEXT) WHERE user_id = _user_id; + + --insert user data + INSERT INTO users_data (user_id, organization_name, contact_number, contact_number_secondary, trial_in_progress, + trial_expire_date, subscription_status) + VALUES (_user_id, TRIM((_body ->> 'team_name')::TEXT), NULL, NULL, TRUE, CURRENT_DATE + INTERVAL '14 days', + 'trialing') + ON CONFLICT (user_id) DO UPDATE SET organization_name = TRIM((_body ->> 'team_name')::TEXT); + + RETURN JSON_BUILD_OBJECT('id', _project_id, 'members', _members); +END; +$$; + + `); +}; + +/** @param {import('node-pg-migrate').MigrationBuilder} pgm */ +exports.down = async (_pgm) => { + // Review manually before running migrate:down. +}; diff --git a/worklenz-backend/database/pg-migrations/20260821000007000_add_business_plan_overrides.js b/worklenz-backend/database/pg-migrations/20260821000007000_add_business_plan_overrides.js new file mode 100644 index 000000000..b509f0d06 --- /dev/null +++ b/worklenz-backend/database/pg-migrations/20260821000007000_add_business_plan_overrides.js @@ -0,0 +1,41 @@ +'use strict'; +// Converted from database/migrations/release-v2.5/20260316000000-add-business-plan-overrides.sql + +/** @type {import('node-pg-migrate').ColumnDefinitions | undefined} */ +exports.shorthands = undefined; + +/** @param {import('node-pg-migrate').MigrationBuilder} pgm */ +exports.up = async (pgm) => { + pgm.sql(` +-- Migration: Add business plan override flags +-- Description: Adds manual override flags for business plan feature access and team member limits +-- Date: 2026-03-16 + +-- Add override columns to organizations table +ALTER TABLE organizations +ADD COLUMN IF NOT EXISTS business_plan_override BOOLEAN DEFAULT FALSE NOT NULL, +ADD COLUMN IF NOT EXISTS team_member_limit_override BOOLEAN DEFAULT FALSE NOT NULL; + +-- Add comments for documentation +COMMENT ON COLUMN organizations.business_plan_override IS +'Manual override to grant business plan feature access (client portal, Slack, finance, etc.) regardless of subscription status. Set manually by admins.'; + +COMMENT ON COLUMN organizations.team_member_limit_override IS +'Manual override to bypass all team member limits. When enabled, organization can add unlimited team members regardless of subscription plan.'; + +-- Create indexes for performance (partial indexes for TRUE values only) +CREATE INDEX IF NOT EXISTS idx_organizations_business_override +ON organizations(business_plan_override) +WHERE business_plan_override = TRUE; + +CREATE INDEX IF NOT EXISTS idx_organizations_member_limit_override +ON organizations(team_member_limit_override) +WHERE team_member_limit_override = TRUE; + + `); +}; + +/** @param {import('node-pg-migrate').MigrationBuilder} pgm */ +exports.down = async (_pgm) => { + // Review manually before running migrate:down. +}; diff --git a/worklenz-backend/database/pg-migrations/20260821000008000_add_calculate_member_capacity_function.js b/worklenz-backend/database/pg-migrations/20260821000008000_add_calculate_member_capacity_function.js new file mode 100644 index 000000000..51ff58695 --- /dev/null +++ b/worklenz-backend/database/pg-migrations/20260821000008000_add_calculate_member_capacity_function.js @@ -0,0 +1,230 @@ +'use strict'; +// Converted from database/migrations/release-v2.5/add_calculate_member_capacity_function.sql + +/** @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 + +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 (defaults: Mon-Fri enabled, Sat-Sun disabled) + SELECT + COALESCE(owd.monday, TRUE) AS monday, + COALESCE(owd.tuesday, TRUE) AS tuesday, + COALESCE(owd.wednesday, TRUE) AS wednesday, + COALESCE(owd.thursday, TRUE) AS thursday, + COALESCE(owd.friday, TRUE) AS friday, + COALESCE(owd.saturday, FALSE) AS saturday, + COALESCE(owd.sunday, FALSE) AS sunday + FROM (SELECT 1) _ + LEFT JOIN organization_working_days owd ON owd.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 + ), + daily_project_allocations AS ( + SELECT alloc_date, project_id, project_name, color_code, SUM(daily_hours) AS daily_hours + FROM task_allocations + GROUP BY alloc_date, project_id, project_name, color_code + ), + project_summary AS ( + -- Build one project entry per day after combining task allocations. + 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 daily_project_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; + +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) => { + // Review manually before running migrate:down. +}; diff --git a/worklenz-backend/database/pg-migrations/20260821000009000_update_register_db_functions_owd.js b/worklenz-backend/database/pg-migrations/20260821000009000_update_register_db_functions_owd.js new file mode 100644 index 000000000..51d59edc9 --- /dev/null +++ b/worklenz-backend/database/pg-migrations/20260821000009000_update_register_db_functions_owd.js @@ -0,0 +1,406 @@ +'use strict'; +// Converted from database/migrations/release-v2.5/update_register_db_functions_OWD.sql + +/** @type {import('node-pg-migrate').ColumnDefinitions | undefined} */ +exports.shorthands = undefined; + +/** @param {import('node-pg-migrate').MigrationBuilder} pgm */ +exports.up = async (pgm) => { + pgm.sql(` +-- Register user +CREATE OR REPLACE FUNCTION register_user(_body json) returns json + language plpgsql +as +$$ +DECLARE + _user_id UUID; + _organization_id UUID; + _team_id UUID; + _role_id UUID; + _trimmed_email TEXT; + _trimmed_name TEXT; + _trimmed_team_name TEXT; + _business_plan_id UUID; + _trial_id UUID; +BEGIN + + _trimmed_email = LOWER(TRIM((_body ->> 'email'))); + _trimmed_name = TRIM((_body ->> 'name')); + _trimmed_team_name = TRIM((_body ->> 'team_name')); + + -- check user exists (case-insensitive) + IF EXISTS(SELECT email FROM users WHERE LOWER(email) = _trimmed_email) + THEN + RAISE 'EMAIL_EXISTS_ERROR:%', (_body ->> 'email'); + END IF; + + -- insert user + INSERT INTO users (name, email, password, timezone_id) + VALUES (_trimmed_name, _trimmed_email, (_body ->> 'password'), + COALESCE((SELECT id FROM timezones WHERE name = (_body ->> 'timezone')), + (SELECT id FROM timezones WHERE name = 'UTC'))) + RETURNING id INTO _user_id; + + --insert organization data + INSERT INTO organizations (user_id, organization_name, contact_number, contact_number_secondary, trial_in_progress, + trial_expire_date, subscription_status, license_type_id) + VALUES (_user_id, COALESCE(TRIM((_body ->> 'team_name')::TEXT), _trimmed_name), NULL, NULL, TRUE, CURRENT_DATE + INTERVAL '9999 days', + 'trialing', (SELECT id FROM sys_license_types WHERE key = 'TRIAL')) + RETURNING id INTO _organization_id; + + -- Insert default organization working days if not exists + IF NOT EXISTS(SELECT 1 FROM organization_working_days WHERE organization_id = _organization_id) THEN + INSERT INTO organization_working_days (organization_id, monday, tuesday, wednesday, thursday, friday, saturday, sunday) + VALUES (_organization_id, TRUE, TRUE, TRUE, TRUE, TRUE, TRUE, TRUE); + END IF; + + + -- insert team + INSERT INTO teams (name, user_id, organization_id) + VALUES (_trimmed_team_name, _user_id, _organization_id) + RETURNING id INTO _team_id; + + IF (is_null_or_empty((_body ->> 'invited_team_id'))) + THEN + UPDATE users SET active_team = _team_id WHERE id = _user_id; + ELSE + IF NOT EXISTS(SELECT id + FROM email_invitations + WHERE team_id = (_body ->> 'invited_team_id')::UUID + AND LOWER(email) = _trimmed_email) + THEN + RAISE 'ERROR_INVALID_JOINING_EMAIL'; + END IF; + UPDATE users SET active_team = (_body ->> 'invited_team_id')::UUID WHERE id = _user_id; + END IF; + + -- insert default roles + INSERT INTO roles (name, team_id, default_role) VALUES ('Member', _team_id, TRUE); + INSERT INTO roles (name, team_id, admin_role) VALUES ('Admin', _team_id, TRUE); + INSERT INTO roles (name, team_id, admin_role) VALUES ('Team Lead', _team_id, TRUE); + INSERT INTO roles (name, team_id, owner) VALUES ('Owner', _team_id, TRUE) RETURNING id INTO _role_id; + + -- insert team member + INSERT INTO team_members (user_id, team_id, role_id) + VALUES (_user_id, _team_id, _role_id); + + -- update team member table with user id + IF (_body ->> 'team_member_id') IS NOT NULL + THEN + UPDATE team_members SET user_id = (_user_id)::UUID WHERE id = (_body ->> 'team_member_id')::UUID; + DELETE + FROM email_invitations + WHERE LOWER(email) = _trimmed_email + AND team_member_id = (_body ->> 'team_member_id')::UUID; + END IF; + + -- AUTO-START BUSINESS PLAN TRIAL FOR NEW SIGNUPS + -- Get the Business plan tier ID + SELECT id INTO _business_plan_id + FROM licensing_plan_tiers + WHERE tier_name = 'BUSINESS_LARGE' + AND trial_enabled = TRUE; + + -- Start the business plan trial automatically if plan exists and trial is enabled + IF _business_plan_id IS NOT NULL THEN + BEGIN + -- Call the start_plan_trial function to activate the trial + SELECT start_plan_trial(_user_id, _organization_id, _business_plan_id) INTO _trial_id; + + -- Log successful trial activation (optional, for debugging) + RAISE NOTICE 'Business plan trial automatically started for user % with trial_id %', _user_id, _trial_id; + EXCEPTION + WHEN OTHERS THEN + -- Log error but don't fail the registration + RAISE WARNING 'Failed to auto-start business trial for user %: %', _user_id, SQLERRM; + END; + END IF; + + RETURN JSON_BUILD_OBJECT( + 'id', _user_id, + 'name', _trimmed_name, + 'email', _trimmed_email, + 'team_id', _team_id, + 'trial_id', _trial_id + ); +END; +$$; + +comment on function register_user(json) is 'Registers a new user and automatically starts a 14-day Business plan trial'; + +-- Google Register user +CREATE OR REPLACE FUNCTION register_google_user(_body json) returns json + language plpgsql +as +$$ +DECLARE + _user_id UUID; + _organization_id UUID; + _team_id UUID; + _role_id UUID; + _name TEXT; + _email TEXT; + _google_id TEXT; + _business_plan_id UUID; + _trial_id UUID; +BEGIN + _name = (_body ->> 'displayName')::TEXT; + _email = LOWER(TRIM((_body ->> 'email')::TEXT)); + _google_id = (_body ->> 'id'); + + INSERT INTO users (name, email, google_id, timezone_id) + VALUES (_name, _email, _google_id, COALESCE((SELECT id FROM timezones WHERE name = (_body ->> 'timezone')), + (SELECT id FROM timezones WHERE name = 'UTC'))) + RETURNING id INTO _user_id; + + --insert organization data + INSERT INTO organizations (user_id, organization_name, contact_number, contact_number_secondary, trial_in_progress, + trial_expire_date, subscription_status, license_type_id) + VALUES (_user_id, COALESCE(TRIM((_body ->> 'team_name')::TEXT), _name), NULL, NULL, TRUE, CURRENT_DATE + INTERVAL '9999 days', + 'trialing', (SELECT id FROM sys_license_types WHERE key = 'TRIAL')) + RETURNING id INTO _organization_id; + + -- Insert default organization working days if not exists + IF NOT EXISTS(SELECT 1 FROM organization_working_days WHERE organization_id = _organization_id) THEN + INSERT INTO organization_working_days (organization_id, monday, tuesday, wednesday, thursday, friday, saturday, sunday) + VALUES (_organization_id, TRUE, TRUE, TRUE, TRUE, TRUE, TRUE, TRUE); + END IF; + + INSERT INTO teams (name, user_id, organization_id) + VALUES (COALESCE(TRIM((_body ->> 'team_name')::TEXT), _name), _user_id, _organization_id) + RETURNING id INTO _team_id; + + -- insert default roles + INSERT INTO roles (name, team_id, default_role) VALUES ('Member', _team_id, TRUE); + INSERT INTO roles (name, team_id, admin_role) VALUES ('Admin', _team_id, TRUE); + INSERT INTO roles (name, team_id, admin_role) VALUES ('Team Lead', _team_id, TRUE); + INSERT INTO roles (name, team_id, owner) VALUES ('Owner', _team_id, TRUE) RETURNING id INTO _role_id; + + INSERT INTO team_members (user_id, team_id, role_id) + VALUES (_user_id, _team_id, _role_id); + + IF (is_null_or_empty(_body ->> 'team') OR is_null_or_empty(_body ->> 'member_id')) + THEN + UPDATE users SET active_team = _team_id WHERE id = _user_id; + ELSE + -- Verify team member + IF EXISTS(SELECT id + FROM team_members + WHERE id = (_body ->> 'member_id')::UUID + AND team_id = (_body ->> 'team')::UUID) + THEN + UPDATE team_members + SET user_id = _user_id + WHERE id = (_body ->> 'member_id')::UUID + AND team_id = (_body ->> 'team')::UUID; + + DELETE + FROM email_invitations + WHERE team_id = (_body ->> 'team')::UUID + AND team_member_id = (_body ->> 'member_id')::UUID; + + UPDATE users SET active_team = (_body ->> 'team')::UUID WHERE id = _user_id; + END IF; + END IF; + + -- AUTO-START BUSINESS PLAN TRIAL FOR NEW GOOGLE SIGNUPS + -- Get the Business plan tier ID + SELECT id INTO _business_plan_id + FROM licensing_plan_tiers + WHERE tier_name = 'BUSINESS_LARGE' + AND trial_enabled = TRUE; + + -- Start the business plan trial automatically if plan exists and trial is enabled + IF _business_plan_id IS NOT NULL THEN + BEGIN + -- Call the start_plan_trial function to activate the trial + SELECT start_plan_trial(_user_id, _organization_id, _business_plan_id) INTO _trial_id; + + -- Log successful trial activation (optional, for debugging) + RAISE NOTICE 'Business plan trial automatically started for Google user % with trial_id %', _user_id, _trial_id; + EXCEPTION + WHEN OTHERS THEN + -- Log error but don't fail the registration + RAISE WARNING 'Failed to auto-start business trial for Google user %: %', _user_id, SQLERRM; + END; + END IF; + + RETURN JSON_BUILD_OBJECT( + 'id', _user_id, + 'email', _email, + 'google_id', _google_id, + 'trial_id', _trial_id + ); +END +$$; + +comment on function register_google_user(json) is 'Registers a new Google OAuth user and automatically starts a 14-day Business plan trial'; + +-- Apple Register user +CREATE OR REPLACE FUNCTION register_apple_user(_body json) returns json + language plpgsql +as +$$ +DECLARE + _user_id UUID; + _organization_id UUID; + _team_id UUID; + _role_id UUID; + _name TEXT; + _email TEXT; + _apple_id TEXT; + _business_plan_id UUID; + _trial_id UUID; +BEGIN + -- Extract data from JSON body + _name = COALESCE((_body ->> 'displayName')::TEXT, 'Apple User'); + _email = (_body ->> 'email')::TEXT; + _apple_id = (_body ->> 'id')::TEXT; + + -- Validate required fields + IF _apple_id IS NULL THEN + RAISE EXCEPTION 'Apple ID (sub) is required for user registration'; + END IF; + + -- Insert new user with Apple ID + INSERT INTO users (name, email, apple_id, timezone_id) + VALUES ( + _name, + _email, + _apple_id, + COALESCE( + (SELECT id FROM timezones WHERE name = (_body ->> 'timezone')), + (SELECT id FROM timezones WHERE name = 'UTC') + ) + ) + RETURNING id INTO _user_id; + + -- Insert organization data + INSERT INTO organizations ( + user_id, + organization_name, + contact_number, + contact_number_secondary, + trial_in_progress, + trial_expire_date, + subscription_status, + license_type_id + ) + VALUES ( + _user_id, + COALESCE(TRIM((_body ->> 'team_name')::TEXT), _name), + NULL, + NULL, + TRUE, + CURRENT_DATE + INTERVAL '9999 days', + 'trialing', + (SELECT id FROM sys_license_types WHERE key = 'TRIAL') + ) + RETURNING id INTO _organization_id; + + -- Insert default organization working days if not exists + IF NOT EXISTS(SELECT 1 FROM organization_working_days WHERE organization_id = _organization_id) THEN + INSERT INTO organization_working_days (organization_id, monday, tuesday, wednesday, thursday, friday, saturday, sunday) + VALUES (_organization_id, TRUE, TRUE, TRUE, TRUE, TRUE, TRUE, TRUE); + END IF; + + -- Insert default team + INSERT INTO teams (name, user_id, organization_id) + VALUES (COALESCE(TRIM((_body ->> 'team_name')::TEXT), _name), _user_id, _organization_id) + RETURNING id INTO _team_id; + + -- Insert default roles + INSERT INTO roles (name, team_id, default_role) + VALUES ('Member', _team_id, TRUE); + + INSERT INTO roles (name, team_id, admin_role) + VALUES ('Admin', _team_id, TRUE); + + INSERT INTO roles (name, team_id, admin_role) + VALUES ('Team Lead', _team_id, TRUE); + + INSERT INTO roles (name, team_id, owner) + VALUES ('Owner', _team_id, TRUE) + RETURNING id INTO _role_id; + + -- Add user to team with owner role + INSERT INTO team_members (user_id, team_id, role_id) + VALUES (_user_id, _team_id, _role_id); + + -- Handle team invitations (if applicable) + IF (is_null_or_empty(_body ->> 'team') OR is_null_or_empty(_body ->> 'member_id')) + THEN + -- Set active team for new user + UPDATE users SET active_team = _team_id WHERE id = _user_id; + ELSE + -- Verify team member invitation exists + IF EXISTS( + SELECT id + FROM team_members + WHERE id = (_body ->> 'member_id')::UUID + AND team_id = (_body ->> 'team')::UUID + ) + THEN + -- Link user to existing team member record + UPDATE team_members + SET user_id = _user_id + WHERE id = (_body ->> 'member_id')::UUID + AND team_id = (_body ->> 'team')::UUID; + + -- Remove email invitation + DELETE FROM email_invitations + WHERE team_id = (_body ->> 'team')::UUID + AND team_member_id = (_body ->> 'member_id')::UUID; + + -- Set active team to invited team + UPDATE users SET active_team = (_body ->> 'team')::UUID WHERE id = _user_id; + END IF; + END IF; + + -- AUTO-START BUSINESS PLAN TRIAL FOR NEW APPLE SIGNUPS + -- Get the Business plan tier ID + SELECT id INTO _business_plan_id + FROM licensing_plan_tiers + WHERE tier_name = 'BUSINESS_LARGE' + AND trial_enabled = TRUE; + + -- Start the business plan trial automatically if plan exists and trial is enabled + IF _business_plan_id IS NOT NULL THEN + BEGIN + -- Call the start_plan_trial function to activate the trial + SELECT start_plan_trial(_user_id, _organization_id, _business_plan_id) INTO _trial_id; + + -- Log successful trial activation (optional, for debugging) + RAISE NOTICE 'Business plan trial automatically started for Apple user % with trial_id %', _user_id, _trial_id; + EXCEPTION + WHEN OTHERS THEN + -- Log error but don't fail the registration + RAISE WARNING 'Failed to auto-start business trial for Apple user %: %', _user_id, SQLERRM; + END; + END IF; + + -- Return user data as JSON + RETURN JSON_BUILD_OBJECT( + 'id', _user_id, + 'email', _email, + 'apple_id', _apple_id, + 'name', _name, + 'active_team', (SELECT active_team FROM users WHERE id = _user_id), + 'trial_id', _trial_id + ); +END +$$; + +comment on function register_apple_user(json) is 'Registers a new Apple Sign-In OAuth user and automatically starts a 14-day Business plan trial'; + + + + + + `); +}; + +/** @param {import('node-pg-migrate').MigrationBuilder} pgm */ +exports.down = async (_pgm) => { + // Review manually before running migrate:down. +}; diff --git a/worklenz-backend/database/pg-migrations/20260821000010000_update_request_sequences_per_service.js b/worklenz-backend/database/pg-migrations/20260821000010000_update_request_sequences_per_service.js new file mode 100644 index 000000000..05b3e3570 --- /dev/null +++ b/worklenz-backend/database/pg-migrations/20260821000010000_update_request_sequences_per_service.js @@ -0,0 +1,63 @@ +'use strict'; +// Converted from database/migrations/20260105000008-update-request-sequences-per-service.sql + +/** @type {import('node-pg-migrate').ColumnDefinitions | undefined} */ +exports.shorthands = undefined; + +/** @param {import('node-pg-migrate').MigrationBuilder} pgm */ +exports.up = async (pgm) => { + pgm.sql(` +-- Migration: Update Request Sequences to be Per Service +-- Description: Changes sequence tracking from per-organization to per-service +-- Date: 2026-01-05 +-- Issue: Request numbers should be unique per service, not per organization + +-- Drop the old sequence table +DROP TABLE IF EXISTS client_portal_request_sequences; + +-- Create new sequence tracking table per service +CREATE TABLE IF NOT EXISTS client_portal_request_sequences ( + service_id UUID PRIMARY KEY REFERENCES client_portal_services(id) ON DELETE CASCADE, + last_request_number INTEGER NOT NULL DEFAULT 0, + created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP +); + +-- Add index for performance +CREATE INDEX IF NOT EXISTS idx_client_portal_request_sequences_service_id +ON client_portal_request_sequences(service_id); + +-- Initialize sequences for existing services that have requests +INSERT INTO client_portal_request_sequences (service_id, last_request_number, created_at, updated_at) +SELECT + service_id, + COALESCE( + MAX( + CASE + WHEN req_no ~ '[0-9]+$' + THEN CAST(SUBSTRING(req_no FROM '[0-9]+$') AS INTEGER) + ELSE 0 + END + ), + 0 + ) as last_request_number, + NOW(), + NOW() +FROM client_portal_requests +GROUP BY service_id +ON CONFLICT (service_id) DO UPDATE +SET + last_request_number = GREATEST( + client_portal_request_sequences.last_request_number, + EXCLUDED.last_request_number + ), + updated_at = NOW(); + + + `); +}; + +/** @param {import('node-pg-migrate').MigrationBuilder} pgm */ +exports.down = async (_pgm) => { + // Review manually before running migrate:down. +}; diff --git a/worklenz-backend/database/pg-migrations/20260821000011000_recurring_tasks_complete_fix.js b/worklenz-backend/database/pg-migrations/20260821000011000_recurring_tasks_complete_fix.js new file mode 100644 index 000000000..89f48abcb --- /dev/null +++ b/worklenz-backend/database/pg-migrations/20260821000011000_recurring_tasks_complete_fix.js @@ -0,0 +1,245 @@ +'use strict'; +// Converted from database/migrations/20260210000000-recurring-tasks-complete-fix.sql + +/** @type {import('node-pg-migrate').ColumnDefinitions | undefined} */ +exports.shorthands = undefined; + +/** @param {import('node-pg-migrate').MigrationBuilder} pgm */ +exports.up = async (pgm) => { + pgm.sql(` +-- Migration: Complete recurring tasks system fix +-- Date: 2026-02-10 +-- Includes: +-- - Missing columns and constraints +-- - Timezone support +-- - Duration preservation +-- - Foreign keys and indexes + +-- ============================================================================ +-- PART 1: Add missing columns to task_recurring_schedules +-- ============================================================================ + +ALTER TABLE task_recurring_schedules +ADD COLUMN IF NOT EXISTS date_of_month INTEGER, +ADD COLUMN IF NOT EXISTS last_checked_at TIMESTAMP WITH TIME ZONE, +ADD COLUMN IF NOT EXISTS last_created_task_end_date DATE, +ADD COLUMN IF NOT EXISTS max_occurrences INTEGER, +ADD COLUMN IF NOT EXISTS occurrence_count INTEGER DEFAULT 0, +ADD COLUMN IF NOT EXISTS is_active BOOLEAN DEFAULT TRUE, +ADD COLUMN IF NOT EXISTS timezone_id UUID, +ADD COLUMN IF NOT EXISTS created_by UUID; + +-- ============================================================================ +-- PART 2: Add reporter_id, status_id, and duration_days to task_recurring_templates +-- ============================================================================ + +ALTER TABLE task_recurring_templates +ADD COLUMN IF NOT EXISTS reporter_id UUID, +ADD COLUMN IF NOT EXISTS status_id UUID, +ADD COLUMN IF NOT EXISTS duration_days INTEGER; + +-- ============================================================================ +-- PART 3: Add unique constraint to prevent duplicate recurring tasks +-- ============================================================================ + +CREATE UNIQUE INDEX IF NOT EXISTS idx_tasks_schedule_end_date_unique +ON tasks (schedule_id, end_date) +WHERE schedule_id IS NOT NULL AND end_date IS NOT NULL; + +-- ============================================================================ +-- PART 4: Add foreign key constraints +-- ============================================================================ + +DO $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'task_recurring_schedules_timezone_id_fk') THEN + ALTER TABLE task_recurring_schedules ADD CONSTRAINT task_recurring_schedules_timezone_id_fk FOREIGN KEY (timezone_id) REFERENCES timezones(id) + ON DELETE SET NULL; + END IF; +END $$; + +DO $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'task_recurring_schedules_created_by_fk') THEN + ALTER TABLE task_recurring_schedules ADD CONSTRAINT task_recurring_schedules_created_by_fk FOREIGN KEY (created_by) REFERENCES users(id) + ON DELETE SET NULL; + END IF; +END $$; + +-- ============================================================================ +-- PART 5: Add indexes for performance +-- ============================================================================ + +CREATE INDEX IF NOT EXISTS idx_task_recurring_schedules_timezone_id +ON task_recurring_schedules(timezone_id) +WHERE timezone_id IS NOT NULL; + +CREATE INDEX IF NOT EXISTS idx_task_recurring_schedules_active_schedules +ON task_recurring_schedules(is_active, end_date, occurrence_count, max_occurrences) +WHERE is_active IS NOT FALSE; + +-- ============================================================================ +-- PART 6: Backfill duration_days for existing templates +-- ============================================================================ + +UPDATE task_recurring_templates trt +SET duration_days = ( + SELECT + CASE + WHEN t.start_date IS NOT NULL AND t.end_date IS NOT NULL + THEN (t.end_date::DATE - t.start_date::DATE) + ELSE NULL + END + FROM tasks t + WHERE t.id = trt.task_id +) +WHERE trt.duration_days IS NULL; + +-- ============================================================================ +-- PART 7: Update create_quick_task to accept schedule_id, description, and start_date +-- ============================================================================ + +CREATE OR REPLACE FUNCTION create_quick_task(_body json) RETURNS json + LANGUAGE plpgsql +AS +$$ +DECLARE + _task_id UUID; + _parent_task UUID; + _status_id UUID; + _priority_id UUID; + _start_date TIMESTAMP; + _end_date TIMESTAMP; + _schedule_id UUID; + _description TEXT; +BEGIN + + _parent_task = (_body ->> 'parent_task_id')::UUID; + _status_id = COALESCE( + (_body ->> 'status_id')::UUID, + (SELECT id + FROM task_statuses + WHERE project_id = (_body ->> 'project_id')::UUID + AND category_id IN (SELECT id FROM sys_task_status_categories WHERE is_todo IS TRUE) + LIMIT 1) + ); + _priority_id = COALESCE((_body ->> 'priority_id')::UUID, (SELECT id FROM task_priorities WHERE value = 1)); + _start_date = (_body ->> 'start_date')::TIMESTAMP; + _end_date = (_body ->> 'end_date')::TIMESTAMP; + _schedule_id = (_body ->> 'schedule_id')::UUID; + _description = (_body ->> 'description')::TEXT; + + INSERT INTO tasks (name, priority_id, project_id, reporter_id, status_id, parent_task_id, sort_order, roadmap_sort_order, start_date, end_date, schedule_id, description) + VALUES (TRIM((_body ->> 'name')::TEXT), + _priority_id, + (_body ->> 'project_id')::UUID, + (_body ->> 'reporter_id')::UUID, + _status_id, _parent_task, + COALESCE((SELECT MAX(COALESCE(sort_order, roadmap_sort_order, 0)) + 1 FROM tasks WHERE project_id = (_body ->> 'project_id')::UUID), 0), + COALESCE((SELECT MAX(COALESCE(roadmap_sort_order, sort_order, 0)) + 1 FROM tasks WHERE project_id = (_body ->> 'project_id')::UUID), 0), + _start_date, + _end_date, + _schedule_id, + _description) + RETURNING id INTO _task_id; + + PERFORM handle_on_task_phase_change(_task_id, (_body ->> 'phase_id')::UUID); + + RETURN get_single_task(_task_id); +END; +$$; + +-- ============================================================================ +-- PART 8: Update create_recurring_task_template to capture all fields +-- ============================================================================ + +CREATE OR REPLACE FUNCTION create_recurring_task_template(p_task_id uuid, p_schedule_id uuid) RETURNS uuid + LANGUAGE plpgsql +AS +$$ +DECLARE + v_new_id UUID; +BEGIN + INSERT INTO task_recurring_templates ( + id, + task_id, + schedule_id, + name, + description, + end_date, + priority_id, + project_id, + reporter_id, + status_id, + assignees, + labels, + duration_days + ) + SELECT + uuid_generate_v4(), + t.id AS task_id, + p_schedule_id, + t.name, + t.description, + t.end_date, + t.priority_id, + t.project_id, + t.reporter_id, + t.status_id, + COALESCE( + (SELECT JSONB_AGG(JSONB_BUILD_OBJECT( + 'project_member_id', tas.project_member_id, + 'team_member_id', tas.team_member_id, + 'assigned_by', tas.assigned_by + )) + FROM tasks_assignees tas + WHERE tas.task_id = t.id), + '[]'::JSONB + ) AS assignees, + COALESCE( + (SELECT JSONB_AGG(JSONB_BUILD_OBJECT('label_id', tla.label_id)) + FROM task_labels tla + WHERE tla.task_id = t.id), + '[]'::JSONB + ) AS labels, + CASE + WHEN t.start_date IS NOT NULL AND t.end_date IS NOT NULL + THEN (t.end_date::DATE - t.start_date::DATE) + ELSE NULL + END AS duration_days + FROM tasks t + WHERE t.id = p_task_id + RETURNING id INTO v_new_id; + + RETURN v_new_id; +END; +$$; + +-- Existing templates created before assigned_by was captured would otherwise +-- create recurring tasks without any assignees. +UPDATE task_recurring_templates trt +SET assignees = COALESCE( + ( + SELECT JSONB_AGG(JSONB_BUILD_OBJECT( + 'project_member_id', tas.project_member_id, + 'team_member_id', tas.team_member_id, + 'assigned_by', tas.assigned_by + )) + FROM tasks_assignees tas + WHERE tas.task_id = trt.task_id + ), + '[]'::JSONB +) +WHERE EXISTS ( + SELECT 1 + FROM JSONB_ARRAY_ELEMENTS(COALESCE(trt.assignees::JSONB, '[]'::JSONB)) assignee + WHERE NOT (assignee ? 'assigned_by') +); + + `); +}; + +/** @param {import('node-pg-migrate').MigrationBuilder} pgm */ +exports.down = async (_pgm) => { + // Review manually before running migrate:down. +}; diff --git a/worklenz-backend/database/pg-migrations/20260821000012000_fix_notification_email_loop.js b/worklenz-backend/database/pg-migrations/20260821000012000_fix_notification_email_loop.js new file mode 100644 index 000000000..24d6e30c6 --- /dev/null +++ b/worklenz-backend/database/pg-migrations/20260821000012000_fix_notification_email_loop.js @@ -0,0 +1,88 @@ +'use strict'; +// Converted from database/migrations/20260213000000-fix-notification-email-loop.sql + +/** @type {import('node-pg-migrate').ColumnDefinitions | undefined} */ +exports.shorthands = undefined; + +/** @param {import('node-pg-migrate').MigrationBuilder} pgm */ +exports.up = async (pgm) => { + pgm.sql(` +-- Fix notification email loop bug +-- This migration updates the get_task_updates() function to include update_id +-- and removes the problematic batch update that caused email loops + +CREATE OR REPLACE FUNCTION get_task_updates() RETURNS json + LANGUAGE plpgsql +AS +$$ +DECLARE + _result JSON; +BEGIN + SELECT COALESCE(ARRAY_TO_JSON(ARRAY_AGG(ROW_TO_JSON(rec))), '[]'::JSON) + INTO _result + FROM (SELECT name, + email, + (SELECT id + FROM team_members + WHERE team_id = users.active_team + AND user_id = users.id) AS team_member_id, + (SELECT COALESCE(ARRAY_TO_JSON(ARRAY_AGG(ROW_TO_JSON(r))), '[]'::JSON) AS teams + FROM (SELECT id, + name, + (SELECT team_member_id + FROM team_member_info_view + WHERE team_id = teams.id + AND user_id = users.id) AS team_member_id, + (SELECT COALESCE(ARRAY_TO_JSON(ARRAY_AGG(ROW_TO_JSON(r))), '[]'::JSON) AS projects + FROM (SELECT id, + name, + (SELECT COALESCE(ARRAY_TO_JSON(ARRAY_AGG(ROW_TO_JSON(r))), '[]'::JSON) AS tasks + FROM (SELECT t.id, + t.name AS name, + task_updates.id AS update_id, + (SELECT name FROM users WHERE id = task_updates.reporter_id) AS updater_name, + (SELECT STRING_AGG(DISTINCT + (SELECT name + FROM team_member_info_view + WHERE team_member_id = tasks_assignees.team_member_id), + ', ') + FROM tasks_assignees + WHERE task_id = task_updates.task_id) AS members + FROM task_updates + INNER JOIN tasks t ON task_updates.task_id = t.id + WHERE task_updates.user_id = users.id + AND task_updates.project_id = projects.id + AND task_updates.type = 'ASSIGN' + AND is_sent IS FALSE + ORDER BY task_updates.created_at) r) + FROM projects + WHERE team_id = teams.id + AND EXISTS(SELECT 1 + FROM task_updates + WHERE project_id = projects.id + AND type = 'ASSIGN' + AND is_sent IS FALSE)) r) + FROM teams + WHERE EXISTS(SELECT 1 FROM team_members WHERE team_id = teams.id AND user_id = users.id) + AND (SELECT email_notifications_enabled + FROM notification_settings + WHERE team_id = teams.id + AND user_id = users.id) IS TRUE) r) + FROM users + WHERE EXISTS(SELECT 1 FROM task_updates WHERE user_id = users.id) + AND users.is_deleted IS NOT TRUE) rec; + + -- Individual task_updates will be deleted after successful email send + -- No batch update needed here + + RETURN _result; +END +$$; + + `); +}; + +/** @param {import('node-pg-migrate').MigrationBuilder} pgm */ +exports.down = async (_pgm) => { + // Review manually before running migrate:down. +}; diff --git a/worklenz-backend/database/pg-migrations/20260821000013000_cleanup_stuck_notifications.js b/worklenz-backend/database/pg-migrations/20260821000013000_cleanup_stuck_notifications.js new file mode 100644 index 000000000..6a6dd58bb --- /dev/null +++ b/worklenz-backend/database/pg-migrations/20260821000013000_cleanup_stuck_notifications.js @@ -0,0 +1,24 @@ +'use strict'; +// Converted from database/migrations/20260213000001-cleanup-stuck-notifications.sql + +/** @type {import('node-pg-migrate').ColumnDefinitions | undefined} */ +exports.shorthands = undefined; + +/** @param {import('node-pg-migrate').MigrationBuilder} pgm */ +exports.up = async (pgm) => { + pgm.sql(` +-- Cleanup script for stuck task_updates that were caught in the email loop +-- This will reset notifications that failed to send properly + +-- Add an index to improve performance of the cron job +CREATE INDEX IF NOT EXISTS idx_task_updates_is_sent_created_at +ON task_updates(is_sent, created_at) +WHERE is_sent = FALSE; + + `); +}; + +/** @param {import('node-pg-migrate').MigrationBuilder} pgm */ +exports.down = async (_pgm) => { + // Review manually before running migrate:down. +}; diff --git a/worklenz-backend/database/pg-migrations/20260821000014000_add_retry_mechanism.js b/worklenz-backend/database/pg-migrations/20260821000014000_add_retry_mechanism.js new file mode 100644 index 000000000..bd9ff9109 --- /dev/null +++ b/worklenz-backend/database/pg-migrations/20260821000014000_add_retry_mechanism.js @@ -0,0 +1,158 @@ +'use strict'; +// Converted from database/migrations/20260213000002-add-retry-mechanism.sql + +/** @type {import('node-pg-migrate').ColumnDefinitions | undefined} */ +exports.shorthands = undefined; + +/** @param {import('node-pg-migrate').MigrationBuilder} pgm */ +exports.up = async (pgm) => { + pgm.sql(` +-- Add retry mechanism to prevent infinite email loops +-- This adds an attempts counter and max retry limit + +-- 1. Add attempts column to track retry count +ALTER TABLE task_updates +ADD COLUMN IF NOT EXISTS attempts INTEGER DEFAULT 0; + +-- 2. Create a table for permanently failed notifications (for debugging/review) +CREATE TABLE IF NOT EXISTS failed_task_notifications ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + task_update_id UUID UNIQUE, + user_id UUID, + task_id UUID, + project_id UUID, + type VARCHAR(50), + email VARCHAR(255), + attempts INTEGER, + last_error TEXT, + failed_at TIMESTAMP DEFAULT NOW(), + created_at TIMESTAMP, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, + FOREIGN KEY (task_id) REFERENCES tasks(id) ON DELETE CASCADE, + FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE +); + +-- 3. Add index for faster lookups +CREATE INDEX IF NOT EXISTS idx_failed_task_notifications_user_id +ON failed_task_notifications(user_id); + +CREATE INDEX IF NOT EXISTS idx_failed_task_notifications_failed_at +ON failed_task_notifications(failed_at); + +-- 4. Update the get_task_updates function to only return notifications with attempts < 3 +CREATE OR REPLACE FUNCTION get_task_updates() RETURNS json + LANGUAGE plpgsql +AS +$$ +DECLARE + _result JSON; + _max_attempts INTEGER := 3; -- Maximum retry attempts +BEGIN + SELECT COALESCE(ARRAY_TO_JSON(ARRAY_AGG(ROW_TO_JSON(rec))), '[]'::JSON) + INTO _result + FROM (SELECT name, + email, + (SELECT id + FROM team_members + WHERE team_id = users.active_team + AND user_id = users.id) AS team_member_id, + (SELECT COALESCE(ARRAY_TO_JSON(ARRAY_AGG(ROW_TO_JSON(r))), '[]'::JSON) AS teams + FROM (SELECT id, + name, + (SELECT team_member_id + FROM team_member_info_view + WHERE team_id = teams.id + AND user_id = users.id) AS team_member_id, + (SELECT COALESCE(ARRAY_TO_JSON(ARRAY_AGG(ROW_TO_JSON(r))), '[]'::JSON) AS projects + FROM (SELECT id, + name, + (SELECT COALESCE(ARRAY_TO_JSON(ARRAY_AGG(ROW_TO_JSON(r))), '[]'::JSON) AS tasks + FROM (SELECT t.id, + t.name AS name, + task_updates.id AS update_id, + task_updates.attempts AS attempts, + (SELECT name FROM users WHERE id = task_updates.reporter_id) AS updater_name, + (SELECT STRING_AGG(DISTINCT + (SELECT name + FROM team_member_info_view + WHERE team_member_id = tasks_assignees.team_member_id), + ', ') + FROM tasks_assignees + WHERE task_id = task_updates.task_id) AS members + FROM task_updates + INNER JOIN tasks t ON task_updates.task_id = t.id + WHERE task_updates.user_id = users.id + AND task_updates.project_id = projects.id + AND task_updates.type = 'ASSIGN' + AND is_sent IS FALSE + AND task_updates.attempts < _max_attempts + ORDER BY task_updates.created_at) r) + FROM projects + WHERE team_id = teams.id + AND EXISTS(SELECT 1 + FROM task_updates + WHERE project_id = projects.id + AND type = 'ASSIGN' + AND is_sent IS FALSE + AND attempts < _max_attempts)) r) + FROM teams + WHERE EXISTS(SELECT 1 FROM team_members WHERE team_id = teams.id AND user_id = users.id) + AND (SELECT email_notifications_enabled + FROM notification_settings + WHERE team_id = teams.id + AND user_id = users.id) IS TRUE) r) + FROM users + WHERE EXISTS(SELECT 1 FROM task_updates WHERE user_id = users.id) + AND users.is_deleted IS NOT TRUE) rec; + + -- Individual task_updates will be deleted after successful email send + -- No batch update needed here + + RETURN _result; +END +$$; + +-- 5. Clean up notifications that exceeded max attempts (move to failed table) +INSERT INTO failed_task_notifications ( + task_update_id, + user_id, + task_id, + project_id, + type, + email, + attempts, + last_error, + created_at +) +SELECT + tu.id, + tu.user_id, + tu.task_id, + tu.project_id, + tu.type, + u.email, + tu.attempts, + 'Max retry attempts exceeded', + tu.created_at +FROM task_updates tu +JOIN users u ON tu.user_id = u.id +WHERE tu.attempts >= 3 + AND NOT EXISTS ( + SELECT 1 FROM failed_task_notifications + WHERE task_update_id = tu.id + ); + +-- 6. Delete task_updates that exceeded max attempts +DELETE FROM task_updates +WHERE attempts >= 3; + +COMMENT ON TABLE failed_task_notifications IS 'Stores notifications that failed to send after max retry attempts'; +COMMENT ON COLUMN task_updates.attempts IS 'Number of times we attempted to send this notification (max 3)'; + + `); +}; + +/** @param {import('node-pg-migrate').MigrationBuilder} pgm */ +exports.down = async (_pgm) => { + // Review manually before running migrate:down. +}; diff --git a/worklenz-backend/database/pg-migrations/20260821000015000_clear_pending_notifications.js b/worklenz-backend/database/pg-migrations/20260821000015000_clear_pending_notifications.js new file mode 100644 index 000000000..326cf3e0a --- /dev/null +++ b/worklenz-backend/database/pg-migrations/20260821000015000_clear_pending_notifications.js @@ -0,0 +1,21 @@ +'use strict'; +// Converted from database/migrations/20260213000003-clear-pending-notifications.sql + +/** @type {import('node-pg-migrate').ColumnDefinitions | undefined} */ +exports.shorthands = undefined; + +/** @param {import('node-pg-migrate').MigrationBuilder} pgm */ +exports.up = async (pgm) => { + pgm.sql(` +-- This legacy deployment cleanup intentionally does nothing when converted to +-- node-pg-migrate. A converted migration has a new tracking name and may run +-- against a live database whose pending notifications must be preserved. +SELECT 1; + + `); +}; + +/** @param {import('node-pg-migrate').MigrationBuilder} pgm */ +exports.down = async (_pgm) => { + // Review manually before running migrate:down. +}; diff --git a/worklenz-backend/database/pg-migrations/20260821000016000_fix_notification_email_edge_cases.js b/worklenz-backend/database/pg-migrations/20260821000016000_fix_notification_email_edge_cases.js new file mode 100644 index 000000000..6c44a56b0 --- /dev/null +++ b/worklenz-backend/database/pg-migrations/20260821000016000_fix_notification_email_edge_cases.js @@ -0,0 +1,148 @@ +'use strict'; +// Converted from database/migrations/20260213000004-fix-notification-email-edge-cases.sql + +/** @type {import('node-pg-migrate').ColumnDefinitions | undefined} */ +exports.shorthands = undefined; + +/** @param {import('node-pg-migrate').MigrationBuilder} pgm */ +exports.up = async (pgm) => { + pgm.sql(` +-- Fix remaining email notification edge cases: +-- 1. Keep base/migrated schemas aligned for task update retry attempts. +-- 2. Ensure project digests respect team email notification settings. +-- 3. Make SES delivery webhooks update multi-recipient email log rows. + +ALTER TABLE task_updates +ADD COLUMN IF NOT EXISTS attempts INTEGER DEFAULT 0; + +CREATE TABLE IF NOT EXISTS failed_task_notifications ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + task_update_id UUID UNIQUE, + user_id UUID, + task_id UUID, + project_id UUID, + type VARCHAR(50), + email VARCHAR(255), + attempts INTEGER, + last_error TEXT, + failed_at TIMESTAMP DEFAULT NOW(), + created_at TIMESTAMP, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, + FOREIGN KEY (task_id) REFERENCES tasks(id) ON DELETE CASCADE, + FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE +); + +CREATE INDEX IF NOT EXISTS idx_failed_task_notifications_user_id +ON failed_task_notifications(user_id); + +CREATE INDEX IF NOT EXISTS idx_failed_task_notifications_failed_at +ON failed_task_notifications(failed_at); + +CREATE OR REPLACE FUNCTION get_project_daily_digest() RETURNS json + LANGUAGE plpgsql +AS +$$ +DECLARE + _result JSON; +BEGIN + + SELECT COALESCE(JSON_AGG(rec), '[]'::JSON) + INTO _result + FROM (SELECT id, + name, + (SELECT name FROM teams WHERE id = projects.team_id) AS team_name, + + (SELECT COALESCE(JSON_AGG(rec), '[]'::JSON) + FROM (SELECT id, + name, + (SELECT STRING_AGG(DISTINCT + (SELECT name + FROM team_member_info_view + WHERE team_member_id = tasks_assignees.team_member_id), + ', ') + FROM tasks_assignees + WHERE task_id = tasks.id) AS members + FROM tasks + WHERE project_id = projects.id + AND TO_CHAR(tasks.completed_at, 'yyyy-mm-dd') = + TO_CHAR(CURRENT_DATE, 'yyyy-mm-dd')) rec) AS today_completed, + + (SELECT COALESCE(JSON_AGG(rec), '[]'::JSON) + FROM (SELECT id, + name, + (SELECT STRING_AGG(DISTINCT + (SELECT name + FROM team_member_info_view + WHERE team_member_id = tasks_assignees.team_member_id), + ', ') + FROM tasks_assignees + WHERE task_id = tasks.id) AS members + FROM tasks + WHERE project_id = projects.id + AND TO_CHAR(tasks.created_at, 'yyyy-mm-dd') = + TO_CHAR(CURRENT_DATE, 'yyyy-mm-dd')) rec) AS today_new, + + (SELECT COALESCE(JSON_AGG(rec), '[]'::JSON) + FROM (SELECT id, + name, + (SELECT STRING_AGG(DISTINCT + (SELECT name + FROM team_member_info_view + WHERE team_member_id = tasks_assignees.team_member_id), + ', ') + FROM tasks_assignees + WHERE task_id = tasks.id) AS members + FROM tasks + WHERE project_id = projects.id + AND TO_CHAR(tasks.end_date, 'yyyy-mm-dd') = + TO_CHAR(CURRENT_DATE + INTERVAL '1 day', 'yyyy-mm-dd')) rec) AS due_tomorrow, + + (SELECT COALESCE(JSON_AGG(rec), '[]'::JSON) + FROM (SELECT u.name, u.email + FROM project_subscribers ps + INNER JOIN users u ON ps.user_id = u.id + INNER JOIN notification_settings ns ON ns.user_id = u.id + WHERE ps.project_id = projects.id + AND ns.team_id = projects.team_id + AND ns.email_notifications_enabled IS TRUE + AND u.is_deleted IS NOT TRUE) rec) AS subscribers + + FROM projects + WHERE EXISTS(SELECT 1 FROM project_subscribers WHERE project_id = projects.id) + ORDER BY team_id, name) rec; + + RETURN _result; +END +$$; + +CREATE OR REPLACE FUNCTION update_email_log_status() +RETURNS TRIGGER AS $$ +BEGIN + UPDATE email_logs + SET status = CASE + WHEN NEW.event_type = 'send' THEN 'sent'::email_status_type + WHEN NEW.event_type = 'delivery' THEN 'delivered'::email_status_type + WHEN NEW.event_type = 'bounce' THEN 'bounced'::email_status_type + WHEN NEW.event_type = 'complaint' THEN 'complaint'::email_status_type + WHEN NEW.event_type = 'reject' THEN 'failed'::email_status_type + ELSE status + END, + delivered_at = CASE + WHEN NEW.event_type = 'delivery' THEN NEW.timestamp + ELSE delivered_at + END, + updated_at = CURRENT_TIMESTAMP + WHERE (message_id = NEW.message_id OR message_id LIKE NEW.message_id || '-%') + AND LOWER(recipient_email) = LOWER(NEW.recipient_email); + + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + + `); +}; + +/** @param {import('node-pg-migrate').MigrationBuilder} pgm */ +exports.down = async (_pgm) => { + // Review manually before running migrate:down. +}; diff --git a/worklenz-backend/database/pg-migrations/20260821000017000_fix_quick_task_sort_order.js b/worklenz-backend/database/pg-migrations/20260821000017000_fix_quick_task_sort_order.js new file mode 100644 index 000000000..f17aa96b8 --- /dev/null +++ b/worklenz-backend/database/pg-migrations/20260821000017000_fix_quick_task_sort_order.js @@ -0,0 +1,182 @@ +'use strict'; +// Converted from database/migrations/20260217000002-fix-quick-task-sort-order.sql + +/** @type {import('node-pg-migrate').ColumnDefinitions | undefined} */ +exports.shorthands = undefined; + +/** @param {import('node-pg-migrate').MigrationBuilder} pgm */ +exports.up = async (pgm) => { + pgm.sql(` +-- Migration: Fix quick task sort order in phase/status/priority/member grouped views +-- Date: 2026-02-17 +-- Problem: create_quick_task() only writes sort_order and roadmap_sort_order. +-- status_sort_order, priority_sort_order, phase_sort_order, and member_sort_order +-- default to 0, so all tasks share the same rank when grouped — ordering is arbitrary +-- on reload. +-- Solution (Part A): Replace create_quick_task() with a version that: +-- 1. Calculates a single _next_sort_order using GREATEST() across all six sort columns. +-- 2. Writes that value to all six columns on INSERT. +-- 3. Carries forward schedule_id + description support (from 20260210000000-fix-recurring-tasks). +-- 4. Carries forward auto-assign task creator (from release-v2.5/20260203000000-add-auto-assign-task-creator). +-- Solution (Part B): Data migration — back-fill existing tasks whose group sort orders are 0 +-- but whose base sort_order is already set. + +-- ─── Part A: Replace create_quick_task() ──────────────────────────────────── + +CREATE OR REPLACE FUNCTION create_quick_task(_body json) RETURNS json + LANGUAGE plpgsql +AS +$$ +DECLARE + _task_id UUID; + _parent_task UUID; + _status_id UUID; + _priority_id UUID; + _start_date TIMESTAMP; + _end_date TIMESTAMP; + _schedule_id UUID; + _description TEXT; + _next_sort_order INTEGER; + _auto_assign_task_creator BOOLEAN; + _reporter_id UUID; + _project_id UUID; + _team_id UUID; + _team_member_id UUID; + _is_admin BOOLEAN; +BEGIN + _reporter_id = (_body ->> 'reporter_id')::UUID; + _project_id = (_body ->> 'project_id')::UUID; + _parent_task = (_body ->> 'parent_task_id')::UUID; + _schedule_id = (_body ->> 'schedule_id')::UUID; + _description = (_body ->> 'description')::TEXT; + + _status_id = COALESCE( + (_body ->> 'status_id')::UUID, + (SELECT id + FROM task_statuses + WHERE project_id = _project_id + AND category_id IN (SELECT id FROM sys_task_status_categories WHERE is_todo IS TRUE) + LIMIT 1) + ); + _priority_id = COALESCE((_body ->> 'priority_id')::UUID, (SELECT id FROM task_priorities WHERE value = 1)); + _start_date = (_body ->> 'start_date')::TIMESTAMP; + _end_date = (_body ->> 'end_date')::TIMESTAMP; + + -- Calculate the next sort order value once and apply it to every sort column. + -- Using GREATEST() across all six columns guarantees the new task lands at the + -- bottom regardless of which column had the highest current value. + SELECT COALESCE(MAX(GREATEST( + COALESCE(sort_order, 0), + COALESCE(roadmap_sort_order, 0), + COALESCE(status_sort_order, 0), + COALESCE(priority_sort_order, 0), + COALESCE(phase_sort_order, 0), + COALESCE(member_sort_order, 0) + )) + 1, 0) + INTO _next_sort_order + FROM tasks + WHERE project_id = _project_id; + + INSERT INTO tasks ( + name, + priority_id, + project_id, + reporter_id, + status_id, + parent_task_id, + sort_order, + roadmap_sort_order, + status_sort_order, + priority_sort_order, + phase_sort_order, + member_sort_order, + start_date, + end_date, + schedule_id, + description + ) + VALUES ( + TRIM((_body ->> 'name')::TEXT), + _priority_id, + _project_id, + _reporter_id, + _status_id, + _parent_task, + _next_sort_order, + _next_sort_order, + _next_sort_order, + _next_sort_order, + _next_sort_order, + _next_sort_order, + _start_date, + _end_date, + _schedule_id, + _description + ) + RETURNING id INTO _task_id; + + PERFORM handle_on_task_phase_change(_task_id, (_body ->> 'phase_id')::UUID); + + -- Check if auto-assign is enabled for this project + SELECT auto_assign_task_creator, team_id + INTO _auto_assign_task_creator, _team_id + FROM projects + WHERE id = _project_id; + + -- If auto-assign is enabled, assign the task creator + IF _auto_assign_task_creator IS TRUE THEN + -- Get the team_member_id and check if their role is admin or owner + SELECT tm.id, (r.admin_role OR r.owner) + INTO _team_member_id, _is_admin + FROM team_members tm + INNER JOIN roles r ON tm.role_id = r.id + WHERE tm.user_id = _reporter_id + AND tm.team_id = _team_id; + + IF _team_member_id IS NOT NULL THEN + -- Check if user is already a project member + IF NOT EXISTS ( + SELECT 1 FROM project_members + WHERE project_id = _project_id + AND team_member_id = _team_member_id + ) THEN + -- Only auto-add and assign if user is admin or owner + IF _is_admin IS TRUE THEN + PERFORM create_task_assignee(_team_member_id, _project_id, _task_id, _reporter_id); + END IF; + ELSE + -- User is already a project member, assign them to the task + PERFORM create_task_assignee(_team_member_id, _project_id, _task_id, _reporter_id); + END IF; + END IF; + END IF; + + RETURN get_single_task(_task_id); +END; +$$; + +-- ─── Part B: Back-fill existing tasks ─────────────────────────────────────── +-- Condition: sort_order > 0 (task was explicitly ordered) AND all group-specific +-- sort orders are still 0 (they were never set — not legitimately the first task). +-- Safe: does NOT touch tasks where any group sort order is already non-zero. + +UPDATE tasks +SET + status_sort_order = sort_order, + priority_sort_order = sort_order, + phase_sort_order = sort_order, + member_sort_order = sort_order +WHERE + sort_order > 0 + AND status_sort_order = 0 + AND priority_sort_order = 0 + AND phase_sort_order = 0 + AND member_sort_order = 0; + + `); +}; + +/** @param {import('node-pg-migrate').MigrationBuilder} pgm */ +exports.down = async (_pgm) => { + // Review manually before running migrate:down. +}; diff --git a/worklenz-backend/database/pg-migrations/20260821000018000_fix_template_import_sort_order.js b/worklenz-backend/database/pg-migrations/20260821000018000_fix_template_import_sort_order.js new file mode 100644 index 000000000..91894351a --- /dev/null +++ b/worklenz-backend/database/pg-migrations/20260821000018000_fix_template_import_sort_order.js @@ -0,0 +1,124 @@ +'use strict'; +// Converted from database/migrations/20260217000003-fix-template-import-sort-order.sql + +/** @type {import('node-pg-migrate').ColumnDefinitions | undefined} */ +exports.shorthands = undefined; + +/** @param {import('node-pg-migrate').MigrationBuilder} pgm */ +exports.up = async (pgm) => { + pgm.sql(` +-- Fix import_tasks_from_template() to write all 6 sort columns +CREATE OR REPLACE FUNCTION import_tasks_from_template(_project_id uuid, _user_id uuid, _tasks json) RETURNS json + LANGUAGE plpgsql +AS +$$ +DECLARE + _task JSON; + _max_sort INT; + _task_id_new UUID; +BEGIN + + SELECT COALESCE((SELECT MAX(sort_order) FROM tasks WHERE project_id = _project_id), 0) INTO _max_sort; + + -- insert tasks for task templates + FOR _task IN SELECT * FROM JSON_ARRAY_ELEMENTS(_tasks) + LOOP + _max_sort = _max_sort + 1; + INSERT INTO tasks (name, priority_id, project_id, reporter_id, status_id, + sort_order, roadmap_sort_order, + status_sort_order, priority_sort_order, phase_sort_order, member_sort_order, + total_minutes) + VALUES (TRIM((_task ->> 'name')::TEXT), + (SELECT id FROM task_priorities WHERE value = 1), + _project_id, + _user_id, + + -- This should be came from client side later + (SELECT id + FROM task_statuses + WHERE project_id = _project_id::UUID + AND category_id IN (SELECT id FROM sys_task_status_categories WHERE is_todo IS TRUE) + LIMIT 1), + _max_sort, _max_sort, + _max_sort, _max_sort, _max_sort, _max_sort, + (_task ->> 'total_minutes')::NUMERIC) RETURNING id INTO _task_id_new; + + INSERT INTO task_activity_logs (task_id, team_id, attribute_type, user_id, log_type, old_value, new_value, project_id) + VALUES ( + _task_id_new, + (SELECT team_id FROM projects WHERE id = _project_id), + 'status', + _user_id, + 'update', + NULL, + (SELECT id FROM task_statuses WHERE project_id = _project_id::UUID AND category_id IN (SELECT id FROM sys_task_status_categories WHERE is_todo IS TRUE)LIMIT 1), + _project_id + ); + + END LOOP; + + RETURN JSON_BUILD_OBJECT('id', _project_id); +END; +$$; + +-- Fix create_quick_pt_task() to write all sort columns for cpt_tasks +CREATE OR REPLACE FUNCTION create_quick_pt_task(_body json) RETURNS json + LANGUAGE plpgsql +AS +$$ +DECLARE + _task_id UUID; + _parent_task UUID; + _status_id UUID; + _priority_id UUID; + _next_sort INTEGER; +BEGIN + + _parent_task = (_body ->> 'parent_task_id')::UUID; + _status_id = COALESCE( + (_body ->> 'status_id')::UUID, + (SELECT id + FROM cpt_task_statuses + WHERE template_id = (_body ->> 'template_id')::UUID + AND category_id IN (SELECT id FROM sys_task_status_categories WHERE is_todo IS TRUE) + LIMIT 1) + ); + _priority_id = COALESCE((_body ->> 'priority_id')::UUID, (SELECT id FROM task_priorities WHERE value = 1)); + + -- Calculate next sort order across all sort columns + SELECT COALESCE(MAX(GREATEST( + COALESCE(sort_order, 0), + COALESCE(status_sort_order, 0), + COALESCE(priority_sort_order, 0), + COALESCE(phase_sort_order, 0) + )) + 1, 0) + INTO _next_sort + FROM cpt_tasks + WHERE template_id = (_body ->> 'template_id')::UUID; + + INSERT INTO cpt_tasks(name, priority_id, template_id, status_id, parent_task_id, + sort_order, status_sort_order, priority_sort_order, phase_sort_order, + task_no) + VALUES (TRIM((_body ->> 'name')::TEXT), + _priority_id, + (_body ->> 'template_id')::UUID, + + -- This should be came from client side later + _status_id, _parent_task, + _next_sort, _next_sort, _next_sort, _next_sort, + ((SELECT COUNT(*) FROM cpt_tasks WHERE template_id = (_body ->> 'template_id')::UUID) + 1)) + RETURNING id INTO _task_id; + + PERFORM handle_on_pt_task_phase_change(_task_id, (_body ->> 'phase_id')::UUID); + + RETURN get_single_pt_task(_task_id); +END; +$$; + + `); +}; + +/** @param {import('node-pg-migrate').MigrationBuilder} pgm */ +exports.down = async (_pgm) => { + // Review manually before running migrate:down. +}; diff --git a/worklenz-backend/database/pg-migrations/20260821000019000_add_grouped_reporting_indexes.js b/worklenz-backend/database/pg-migrations/20260821000019000_add_grouped_reporting_indexes.js new file mode 100644 index 000000000..ef344a338 --- /dev/null +++ b/worklenz-backend/database/pg-migrations/20260821000019000_add_grouped_reporting_indexes.js @@ -0,0 +1,53 @@ +'use strict'; +// Converted from database/migrations/20260220000002-add-grouped-reporting-indexes.sql + +/** @type {import('node-pg-migrate').ColumnDefinitions | undefined} */ +exports.shorthands = undefined; + +/** @param {import('node-pg-migrate').MigrationBuilder} pgm */ +exports.up = async (pgm) => { + pgm.sql(` +-- Migration: Add indexes for grouped project reporting queries +-- Improves performance when grouping by category, status, health, and filtering by teams +-- Created: 2026-02-20 + +-- Index for category grouping (most common grouping) +-- Helps optimize GROUP BY category_id queries +CREATE INDEX IF NOT EXISTS idx_projects_category_team +ON projects (category_id, team_id) +WHERE category_id IS NOT NULL; + +-- Index for status grouping +-- Helps optimize GROUP BY status_id queries +CREATE INDEX IF NOT EXISTS idx_projects_status_team +ON projects (status_id, team_id) +WHERE status_id IS NOT NULL; + +-- Index for health grouping +-- Helps optimize GROUP BY health_id queries +CREATE INDEX IF NOT EXISTS idx_projects_health_team +ON projects (health_id, team_id) +WHERE health_id IS NOT NULL; + +-- Composite index for active tasks (used in CTE aggregation) +-- Improves performance of project_tasks CTE in getGrouped query +CREATE INDEX IF NOT EXISTS idx_tasks_project_archived +ON tasks (project_id, archived) +WHERE archived = FALSE; + +-- Index for task status filtering (speeds up is_completed/is_doing/is_todo checks) +-- Helps aggregate task counts by status type +CREATE INDEX IF NOT EXISTS idx_tasks_status_project +ON tasks (status_id, project_id) +WHERE archived = FALSE; + +-- Note: Using CONCURRENTLY to avoid locking tables during index creation +-- This allows the migration to run on production without downtime + + `); +}; + +/** @param {import('node-pg-migrate').MigrationBuilder} pgm */ +exports.down = async (_pgm) => { + // Review manually before running migrate:down. +}; diff --git a/worklenz-backend/database/pg-migrations/20260821000020000_fix_task_activity_logs_cascade_delete.js b/worklenz-backend/database/pg-migrations/20260821000020000_fix_task_activity_logs_cascade_delete.js new file mode 100644 index 000000000..677d63fe6 --- /dev/null +++ b/worklenz-backend/database/pg-migrations/20260821000020000_fix_task_activity_logs_cascade_delete.js @@ -0,0 +1,57 @@ +'use strict'; +// Converted from database/migrations/20260222000000-fix-task-activity-logs-cascade-delete.sql + +/** @type {import('node-pg-migrate').ColumnDefinitions | undefined} */ +exports.shorthands = undefined; + +/** @param {import('node-pg-migrate').MigrationBuilder} pgm */ +exports.up = async (pgm) => { + pgm.sql(` +-- Migration: Fix task_activity_logs CASCADE delete to preserve user activity history +-- Date: 2026-02-22 +-- +-- Problem: When a task is deleted, all activity logs for that task are CASCADE deleted, +-- causing the user's "Last Activity" timestamp to revert to older activities instead of +-- reflecting the most recent deletion action. +-- +-- Solution: Change the foreign key constraint from ON DELETE CASCADE to ON DELETE SET NULL, +-- preserving activity logs even after task deletion. + +-- First, make sure task_id column allows NULL values +ALTER TABLE task_activity_logs + ALTER COLUMN task_id DROP NOT NULL; + +-- Drop the existing foreign key constraint +ALTER TABLE task_activity_logs + DROP CONSTRAINT IF EXISTS task_activity_logs_tasks_id_fk; + +-- Recreate the constraint with ON DELETE SET NULL instead of CASCADE +DO $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'task_activity_logs_tasks_id_fk') THEN + ALTER TABLE task_activity_logs ADD CONSTRAINT task_activity_logs_tasks_id_fk FOREIGN KEY (task_id) REFERENCES tasks(id) + ON DELETE SET NULL; + END IF; +END $$; + +-- Add a comment explaining the behavior +COMMENT ON CONSTRAINT task_activity_logs_tasks_id_fk ON task_activity_logs IS + 'Foreign key to tasks table. ON DELETE SET NULL preserves activity logs for deleted tasks, maintaining accurate user activity history.'; + +-- Add an index on task_id for performance (NULL values will be included) +CREATE INDEX IF NOT EXISTS idx_task_activity_logs_task_id + ON task_activity_logs(task_id) + WHERE task_id IS NOT NULL; + +-- Create an index for NULL task_ids (deleted tasks) to help with analytics +CREATE INDEX IF NOT EXISTS idx_task_activity_logs_deleted_task + ON task_activity_logs(created_at DESC) + WHERE task_id IS NULL; + + `); +}; + +/** @param {import('node-pg-migrate').MigrationBuilder} pgm */ +exports.down = async (_pgm) => { + // Review manually before running migrate:down. +}; diff --git a/worklenz-backend/database/pg-migrations/20260821000021000_fix_bulk_delete_activity_logs.js b/worklenz-backend/database/pg-migrations/20260821000021000_fix_bulk_delete_activity_logs.js new file mode 100644 index 000000000..329f317e9 --- /dev/null +++ b/worklenz-backend/database/pg-migrations/20260821000021000_fix_bulk_delete_activity_logs.js @@ -0,0 +1,66 @@ +'use strict'; +// Converted from database/migrations/20260222000001-fix-bulk-delete-activity-logs.sql + +/** @type {import('node-pg-migrate').ColumnDefinitions | undefined} */ +exports.shorthands = undefined; + +/** @param {import('node-pg-migrate').MigrationBuilder} pgm */ +exports.up = async (pgm) => { + pgm.sql(` +-- Migration: Fix bulk_delete_tasks to create activity logs before deletion +-- Date: 2026-02-22 +-- Description: Updates the bulk_delete_tasks stored procedure to insert activity logs +-- before deleting tasks, so Last Activity updates correctly + +-- Drop the existing function +DROP FUNCTION IF EXISTS bulk_delete_tasks(json); + +-- Recreate the function with activity log support +CREATE OR REPLACE FUNCTION bulk_delete_tasks(_body json) RETURNS json + LANGUAGE plpgsql +AS +$$ +DECLARE + _task JSON; + _output JSON; + _task_id UUID; + _user_id UUID; + _project_id UUID; + _team_id UUID; +BEGIN + -- Extract user_id from body + _user_id := (_body ->> 'user_id')::UUID; + + -- Loop through each task to delete + FOR _task IN SELECT * FROM JSON_ARRAY_ELEMENTS((_body ->> 'tasks')::JSON) + LOOP + _task_id := (_task ->> 'id')::UUID; + + -- Fetch task details before deletion + SELECT t.project_id, p.team_id + INTO _project_id, _team_id + FROM tasks t + JOIN projects p ON p.id = t.project_id + WHERE t.id = _task_id; + + -- Insert activity log BEFORE deletion + IF _project_id IS NOT NULL AND _user_id IS NOT NULL THEN + INSERT INTO task_activity_logs (task_id, team_id, project_id, user_id, log_type, attribute_type, created_at) + VALUES (_task_id, _team_id, _project_id, _user_id, 'delete', 'task', NOW()); + END IF; + + -- Delete the task (CASCADE will set task_id to NULL in activity log due to previous migration) + DELETE FROM tasks WHERE id = _task_id; + END LOOP; + + RETURN _output; +END; +$$; + + `); +}; + +/** @param {import('node-pg-migrate').MigrationBuilder} pgm */ +exports.down = async (_pgm) => { + // Review manually before running migrate:down. +}; diff --git a/worklenz-backend/database/pg-migrations/20260821000022000_invalidate_bcrypt_reset_tokens.js b/worklenz-backend/database/pg-migrations/20260821000022000_invalidate_bcrypt_reset_tokens.js new file mode 100644 index 000000000..1c343ef5c --- /dev/null +++ b/worklenz-backend/database/pg-migrations/20260821000022000_invalidate_bcrypt_reset_tokens.js @@ -0,0 +1,29 @@ +'use strict'; +// Converted from database/migrations/20260309000000-invalidate-bcrypt-reset-tokens.sql + +/** @type {import('node-pg-migrate').ColumnDefinitions | undefined} */ +exports.shorthands = undefined; + +/** @param {import('node-pg-migrate').MigrationBuilder} pgm */ +exports.up = async (pgm) => { + pgm.sql(` +-- Migration: Invalidate legacy bcrypt-based password reset tokens +-- Date: 2026-03-09 +-- Description: The password reset system now uses random hex tokens with SHA-256 hashing. +-- Old tokens stored as bcrypt hashes (starting with '$2b$') are incompatible +-- with the new lookup mechanism and must be marked as used. + +-- Mark all existing bcrypt-style tokens as used so they cannot be replayed. +-- New tokens are 64-char hex strings; old ones start with '$2b$'. +UPDATE password_reset_tokens +SET is_used = TRUE, used_at = NOW() +WHERE is_used = FALSE + AND token_hash LIKE '$2b$%'; + + `); +}; + +/** @param {import('node-pg-migrate').MigrationBuilder} pgm */ +exports.down = async (_pgm) => { + // Review manually before running migrate:down. +}; diff --git a/worklenz-backend/database/pg-migrations/20260821000023000_fix_create_task_auto_assign_task_creator.js b/worklenz-backend/database/pg-migrations/20260821000023000_fix_create_task_auto_assign_task_creator.js new file mode 100644 index 000000000..dccc42da5 --- /dev/null +++ b/worklenz-backend/database/pg-migrations/20260821000023000_fix_create_task_auto_assign_task_creator.js @@ -0,0 +1,114 @@ +'use strict'; +// Converted from database/migrations/20260317000000-fix-create-task-auto-assign-task-creator.sql + +/** @type {import('node-pg-migrate').ColumnDefinitions | undefined} */ +exports.shorthands = undefined; + +/** @param {import('node-pg-migrate').MigrationBuilder} pgm */ +exports.up = async (pgm) => { + pgm.sql(` +-- Migration: Restore task creator auto-assignment in create_task() +-- Problem: create_task() lost the auto-assign branch even though the project setting +-- and create_quick_task() still support it. Standard task creation therefore +-- skips assigning the creator when auto_assign_task_creator is enabled. +-- Created: 2026-03-17 + +CREATE OR REPLACE FUNCTION create_task(_body json) RETURNS json + LANGUAGE plpgsql +AS +$$ +DECLARE + _assignee TEXT; + _attachment_id TEXT; + _assignee_id UUID; + _task_id UUID; + _label JSON; + _auto_assign_task_creator BOOLEAN; + _reporter_id UUID; + _project_id UUID; + _team_id UUID; + _team_member_id UUID; + _is_admin BOOLEAN; + _already_assigned BOOLEAN := FALSE; +BEGIN + _reporter_id = (_body ->> 'reporter_id')::UUID; + _project_id = (_body ->> 'project_id')::UUID; + _team_id = (_body ->> 'team_id')::UUID; + + INSERT INTO tasks (name, done, priority_id, project_id, reporter_id, start_date, end_date, total_minutes, + description, parent_task_id, status_id, sort_order) + VALUES (TRIM((_body ->> 'name')::TEXT), (FALSE), + COALESCE((_body ->> 'priority_id')::UUID, (SELECT id FROM task_priorities WHERE value = 1)), + _project_id, + _reporter_id, + (_body ->> 'start')::TIMESTAMPTZ, + (_body ->> 'end')::TIMESTAMPTZ, + (_body ->> 'total_minutes')::NUMERIC, + (_body ->> 'description')::TEXT, + (_body ->> 'parent_task_id')::UUID, + (_body ->> 'status_id')::UUID, + COALESCE((SELECT MAX(sort_order) + 1 FROM tasks WHERE project_id = _project_id), 0)) + RETURNING id INTO _task_id; + + FOR _assignee IN SELECT * FROM JSON_ARRAY_ELEMENTS((_body ->> 'assignees')::JSON) + LOOP + _assignee_id = TRIM('"' FROM _assignee)::UUID; + PERFORM create_task_assignee(_assignee_id, _project_id, _task_id, _reporter_id); + + IF _assignee_id IN ( + SELECT id FROM team_members WHERE user_id = _reporter_id + ) THEN + _already_assigned := TRUE; + END IF; + END LOOP; + + FOR _attachment_id IN SELECT * FROM JSON_ARRAY_ELEMENTS((_body ->> 'attachments')::JSON) + LOOP + UPDATE task_attachments SET task_id = _task_id WHERE id = TRIM('"' FROM _attachment_id)::UUID; + END LOOP; + + FOR _label IN SELECT * FROM JSON_ARRAY_ELEMENTS((_body ->> 'labels')::JSON) + LOOP + PERFORM assign_or_create_label(_team_id, _task_id, (_label ->> 'name')::TEXT, (_label ->> 'color')::TEXT); + END LOOP; + + IF _already_assigned IS FALSE THEN + SELECT auto_assign_task_creator INTO _auto_assign_task_creator + FROM projects + WHERE id = _project_id; + + IF _auto_assign_task_creator IS TRUE THEN + SELECT tm.id, (r.admin_role OR r.owner) INTO _team_member_id, _is_admin + FROM team_members tm + INNER JOIN roles r ON tm.role_id = r.id + WHERE tm.user_id = _reporter_id + AND tm.team_id = _team_id; + + IF _team_member_id IS NOT NULL THEN + IF NOT EXISTS ( + SELECT 1 + FROM project_members + WHERE project_id = _project_id + AND team_member_id = _team_member_id + ) THEN + IF _is_admin IS TRUE THEN + PERFORM create_task_assignee(_team_member_id, _project_id, _task_id, _reporter_id); + END IF; + ELSE + PERFORM create_task_assignee(_team_member_id, _project_id, _task_id, _reporter_id); + END IF; + END IF; + END IF; + END IF; + + RETURN get_task_form_view_model(_reporter_id, _team_id, _task_id, _project_id); +END; +$$; + + `); +}; + +/** @param {import('node-pg-migrate').MigrationBuilder} pgm */ +exports.down = async (_pgm) => { + // Review manually before running migrate:down. +}; diff --git a/worklenz-backend/database/pg-migrations/20260821000024000_fix_bulk_archive_subtask_selection.js b/worklenz-backend/database/pg-migrations/20260821000024000_fix_bulk_archive_subtask_selection.js new file mode 100644 index 000000000..8ace12179 --- /dev/null +++ b/worklenz-backend/database/pg-migrations/20260821000024000_fix_bulk_archive_subtask_selection.js @@ -0,0 +1,48 @@ +'use strict'; +// Converted from database/migrations/20260319000001-fix-bulk-archive-subtask-selection.sql + +/** @type {import('node-pg-migrate').ColumnDefinitions | undefined} */ +exports.shorthands = undefined; + +/** @param {import('node-pg-migrate').MigrationBuilder} pgm */ +exports.up = async (pgm) => { + pgm.sql(` +-- Migration: Fix bulk_archive_tasks to support direct subtask selection +-- Date: 2026-03-19 +-- Description: +-- - Archives/unarchives selected task IDs directly (including subtasks) +-- - Preserves cascade to descendants when a selected ID is a parent task +-- - Uses set-based updates to avoid duplicate row updates + +CREATE OR REPLACE FUNCTION bulk_archive_tasks(_body json) RETURNS json + LANGUAGE plpgsql +AS +$$ +DECLARE + _archive_value BOOLEAN = ((_body ->> 'type')::TEXT = 'archive'); + _output JSON; +BEGIN + WITH selected_ids AS (SELECT DISTINCT (elem.value ->> 'id')::UUID AS id + FROM JSON_ARRAY_ELEMENTS((_body ->> 'tasks')::JSON) AS elem(value)), + parent_ids AS (SELECT id FROM tasks WHERE id IN (SELECT id FROM selected_ids) AND parent_task_id IS NULL), + selected_and_descendants AS (SELECT id + FROM selected_ids + UNION + SELECT t.id + FROM tasks t + WHERE t.parent_task_id IN (SELECT id FROM parent_ids)) + UPDATE tasks + SET archived = _archive_value + WHERE id IN (SELECT id FROM selected_and_descendants); + + RETURN _output; +END; +$$; + + `); +}; + +/** @param {import('node-pg-migrate').MigrationBuilder} pgm */ +exports.down = async (_pgm) => { + // Review manually before running migrate:down. +}; diff --git a/worklenz-backend/database/pg-migrations/20260821000025000_make_bulk_archive_recursive.js b/worklenz-backend/database/pg-migrations/20260821000025000_make_bulk_archive_recursive.js new file mode 100644 index 000000000..8b52b7d7d --- /dev/null +++ b/worklenz-backend/database/pg-migrations/20260821000025000_make_bulk_archive_recursive.js @@ -0,0 +1,52 @@ +'use strict'; +// Converted from database/migrations/20260320000001-make-bulk-archive-recursive.sql + +/** @type {import('node-pg-migrate').ColumnDefinitions | undefined} */ +exports.shorthands = undefined; + +/** @param {import('node-pg-migrate').MigrationBuilder} pgm */ +exports.up = async (pgm) => { + pgm.sql(` +-- Migration: Make bulk_archive_tasks recursive for nested subtasks +-- Date: 2026-03-20 +-- Description: +-- - Archives/unarchives selected IDs directly (including subtasks) +-- - Cascades archive/unarchive to all descendants at any nesting depth + +CREATE OR REPLACE FUNCTION bulk_archive_tasks(_body json) RETURNS json + LANGUAGE plpgsql +AS +$$ +DECLARE + _archive_value BOOLEAN = ((_body ->> 'type')::TEXT = 'archive'); + _output JSON; +BEGIN + WITH RECURSIVE selected_ids AS ( + SELECT DISTINCT (elem.value ->> 'id')::UUID AS id + FROM JSON_ARRAY_ELEMENTS((_body ->> 'tasks')::JSON) AS elem(value) + ), + selected_and_descendants AS ( + -- Base set: explicitly selected tasks (supports direct subtask selection) + SELECT id + FROM selected_ids + UNION + -- Recursive set: include all descendants at any nesting level + SELECT t.id + FROM tasks t + INNER JOIN selected_and_descendants sd ON t.parent_task_id = sd.id + ) + UPDATE tasks + SET archived = _archive_value + WHERE id IN (SELECT id FROM selected_and_descendants); + + RETURN _output; +END; +$$; + + `); +}; + +/** @param {import('node-pg-migrate').MigrationBuilder} pgm */ +exports.down = async (_pgm) => { + // Review manually before running migrate:down. +}; diff --git a/worklenz-backend/database/pg-migrations/20260821000026000_add_critical_task_priority.js b/worklenz-backend/database/pg-migrations/20260821000026000_add_critical_task_priority.js new file mode 100644 index 000000000..33745ec2f --- /dev/null +++ b/worklenz-backend/database/pg-migrations/20260821000026000_add_critical_task_priority.js @@ -0,0 +1,24 @@ +'use strict'; +// Converted from database/migrations/20260403000000-add-critical-task-priority.sql + +/** @type {import('node-pg-migrate').ColumnDefinitions | undefined} */ +exports.shorthands = undefined; + +/** @param {import('node-pg-migrate').MigrationBuilder} pgm */ +exports.up = async (pgm) => { + pgm.sql(` +INSERT INTO task_priorities (name, value, color_code, color_code_dark) +SELECT 'Critical', 3, '#8B1A1A', '#B22222' +WHERE NOT EXISTS ( + SELECT 1 + FROM task_priorities + WHERE LOWER(name) = 'critical' OR value = 3 +); + + `); +}; + +/** @param {import('node-pg-migrate').MigrationBuilder} pgm */ +exports.down = async (_pgm) => { + // Review manually before running migrate:down. +}; diff --git a/worklenz-backend/database/pg-migrations/20260821000027000_add_due_time_to_tasks.js b/worklenz-backend/database/pg-migrations/20260821000027000_add_due_time_to_tasks.js new file mode 100644 index 000000000..f1691765f --- /dev/null +++ b/worklenz-backend/database/pg-migrations/20260821000027000_add_due_time_to_tasks.js @@ -0,0 +1,21 @@ +'use strict'; +// Converted from database/migrations/20260424000001-add-due-time-to-tasks.sql + +/** @type {import('node-pg-migrate').ColumnDefinitions | undefined} */ +exports.shorthands = undefined; + +/** @param {import('node-pg-migrate').MigrationBuilder} pgm */ +exports.up = async (pgm) => { + pgm.sql(` +-- Migration: Add due_time column to tasks table +-- This stores the due time separately from end_date (date-only field) + +ALTER TABLE tasks ADD COLUMN IF NOT EXISTS due_time TIME; + + `); +}; + +/** @param {import('node-pg-migrate').MigrationBuilder} pgm */ +exports.down = async (_pgm) => { + // Review manually before running migrate:down. +}; diff --git a/worklenz-backend/database/pg-migrations/20260821000028000_add_due_time_to_task_form_view_model.js b/worklenz-backend/database/pg-migrations/20260821000028000_add_due_time_to_task_form_view_model.js new file mode 100644 index 000000000..5e68b43a1 --- /dev/null +++ b/worklenz-backend/database/pg-migrations/20260821000028000_add_due_time_to_task_form_view_model.js @@ -0,0 +1,234 @@ +'use strict'; +// Converted from database/migrations/20260427000001-add-due-time-to-task-form-view-model.sql + +/** @type {import('node-pg-migrate').ColumnDefinitions | undefined} */ +exports.shorthands = undefined; + +/** @param {import('node-pg-migrate').MigrationBuilder} pgm */ +exports.up = async (pgm) => { + pgm.sql(` +-- Migration: Add due_time to get_task_form_view_model function +-- This ensures due_time is fetched when opening the task drawer + +create or replace function get_task_form_view_model(_user_id uuid, _team_id uuid, _task_id uuid, _project_id uuid) returns json + language plpgsql +as +$$ +DECLARE + _task JSON; + _priorities JSON; + _projects JSON; + _statuses JSON; + _team_members JSON; + _assignees JSON; + _phases JSON; + _custom_columns JSON; + _custom_column_values JSON; +BEGIN + + SELECT COALESCE(ROW_TO_JSON(rec), '{}'::JSON) + INTO _task + FROM (SELECT id, + name, + description, + start_date, + end_date, + due_time, + done, + total_minutes, + priority_id, + project_id, + created_at, + updated_at, + status_id, + parent_task_id, + sort_order, + (SELECT phase_id FROM task_phase WHERE task_id = tasks.id) AS phase_id, + CONCAT((SELECT key FROM projects WHERE id = tasks.project_id), '-', task_no) AS task_key, + (SELECT start_time + FROM task_timers + WHERE task_id = tasks.id + AND user_id = _user_id) AS timer_start_time, + parent_task_id IS NOT NULL AS is_sub_task, + (SELECT COUNT('*') + FROM tasks + WHERE parent_task_id = tasks.id + AND archived IS FALSE) AS sub_tasks_count, + (SELECT COUNT(*) + FROM tasks_with_status_view tt + WHERE (tt.parent_task_id = tasks.id OR tt.task_id = tasks.id) + AND tt.is_done IS TRUE) AS completed_count, + (SELECT COUNT(*) FROM task_attachments WHERE task_id = tasks.id) AS attachments_count, + (SELECT COALESCE(ARRAY_TO_JSON(ARRAY_AGG(ROW_TO_JSON(r))), '[]'::JSON) + FROM (SELECT task_labels.label_id AS id, + (SELECT name FROM team_labels WHERE id = task_labels.label_id), + (SELECT color_code FROM team_labels WHERE id = task_labels.label_id) + FROM task_labels + WHERE task_id = tasks.id + ORDER BY name) r) AS labels, + (SELECT color_code + FROM sys_task_status_categories + WHERE id = (SELECT category_id FROM task_statuses WHERE id = tasks.status_id)) AS status_color, + (SELECT color_code_dark + FROM sys_task_status_categories + WHERE id = (SELECT category_id FROM task_statuses WHERE id = tasks.status_id)) AS status_color_dark, + (SELECT COUNT(*) FROM tasks WHERE parent_task_id = _task_id) AS sub_tasks_count, + (SELECT name FROM users WHERE id = tasks.reporter_id) AS reporter, + (SELECT get_task_assignees(tasks.id)) AS assignees, + (SELECT id FROM team_members WHERE user_id = _user_id AND team_id = _team_id) AS team_member_id, + billable, + schedule_id + FROM tasks + WHERE id = _task_id) rec; + + SELECT COALESCE(ARRAY_TO_JSON(ARRAY_AGG(ROW_TO_JSON(rec))), '[]'::JSON) + INTO _priorities + FROM (SELECT id, name FROM task_priorities ORDER BY value) rec; + + SELECT COALESCE(ARRAY_TO_JSON(ARRAY_AGG(ROW_TO_JSON(rec))), '[]'::JSON) + INTO _phases + FROM (SELECT id, name FROM project_phases WHERE project_id = _project_id ORDER BY name) rec; + + SELECT COALESCE(ARRAY_TO_JSON(ARRAY_AGG(ROW_TO_JSON(rec))), '[]'::JSON) + INTO _projects + FROM (SELECT id, name + FROM projects + WHERE team_id = _team_id + AND (CASE + WHEN (is_owner(_user_id, _team_id) OR is_admin(_user_id, _team_id) IS TRUE) THEN TRUE + ELSE is_member_of_project(projects.id, _user_id, _team_id) END) + ORDER BY name) rec; + + SELECT COALESCE(ARRAY_TO_JSON(ARRAY_AGG(ROW_TO_JSON(rec))), '[]'::JSON) + INTO _statuses + FROM (SELECT id, name FROM task_statuses WHERE project_id = _project_id) rec; + + SELECT COALESCE(ARRAY_TO_JSON(ARRAY_AGG(ROW_TO_JSON(rec))), '[]'::JSON) + INTO _team_members + FROM (SELECT team_members.id, + (SELECT name FROM team_member_info_view WHERE team_member_info_view.team_member_id = team_members.id), + (SELECT email FROM team_member_info_view WHERE team_member_info_view.team_member_id = team_members.id), + (SELECT avatar_url + FROM team_member_info_view + WHERE team_member_info_view.team_member_id = team_members.id) + FROM team_members + LEFT JOIN users u ON team_members.user_id = u.id + WHERE team_id = _team_id AND team_members.active IS TRUE) rec; + + SELECT get_task_assignees(_task_id) INTO _assignees; + + SELECT COALESCE( + JSON_AGG( + JSON_BUILD_OBJECT( + 'key', rec.key, + 'id', rec.id, + 'name', rec.name, + 'width', rec.width, + 'pinned', rec.is_visible, + 'custom_column', TRUE, + 'custom_column_obj', JSON_BUILD_OBJECT( + 'fieldType', rec.field_type, + 'fieldTitle', rec.field_title, + 'numberType', rec.number_type, + 'decimals', rec.decimals, + 'label', rec.label, + 'labelPosition', rec.label_position, + 'previewValue', rec.preview_value, + 'expression', rec.expression, + 'firstNumericColumnKey', rec.first_numeric_column_key, + 'secondNumericColumnKey', rec.second_numeric_column_key, + 'selectionsList', COALESCE(rec.selections_list, '[]'::JSON), + 'labelsList', COALESCE(rec.labels_list, '[]'::JSON) + ) + ) + ORDER BY rec.created_at + ), + '[]'::JSON + ) + INTO _custom_columns + FROM ( + SELECT cc.id, + cc.key, + cc.name, + cc.width, + cc.is_visible, + cc.created_at, + cc.field_type, + cf.field_title, + cf.number_type, + cf.decimals, + cf.label, + cf.label_position, + cf.preview_value, + cf.expression, + cf.first_numeric_column_key, + cf.second_numeric_column_key, + (SELECT JSON_AGG( + JSON_BUILD_OBJECT( + 'selection_id', so.selection_id, + 'selection_name', so.selection_name, + 'selection_color', so.selection_color + ) + ORDER BY so.selection_order + ) + FROM cc_selection_options so + WHERE so.column_id = cc.id) AS selections_list, + (SELECT JSON_AGG( + JSON_BUILD_OBJECT( + 'label_id', lo.label_id, + 'label_name', lo.label_name, + 'label_color', lo.label_color + ) + ORDER BY lo.label_order + ) + FROM cc_label_options lo + WHERE lo.column_id = cc.id) AS labels_list + FROM cc_custom_columns cc + LEFT JOIN cc_column_configurations cf ON cf.column_id = cc.id + WHERE cc.project_id = _project_id + AND cc.is_visible IS TRUE + ) rec; + + SELECT COALESCE( + JSON_OBJECT_AGG(rec.key, rec.value), + '{}'::JSON + ) + INTO _custom_column_values + FROM ( + SELECT cc.key, + CASE + WHEN ccv.text_value IS NOT NULL THEN TO_JSON(ccv.text_value) + WHEN ccv.number_value IS NOT NULL THEN TO_JSON(ccv.number_value) + WHEN ccv.boolean_value IS NOT NULL THEN TO_JSON(ccv.boolean_value) + WHEN ccv.date_value IS NOT NULL THEN TO_JSON(ccv.date_value) + WHEN ccv.json_value IS NOT NULL THEN ccv.json_value::JSON + ELSE NULL::JSON + END AS value + FROM cc_column_values ccv + INNER JOIN cc_custom_columns cc ON ccv.column_id = cc.id + WHERE ccv.task_id = _task_id + AND cc.project_id = _project_id + AND cc.is_visible IS TRUE + ) rec + WHERE rec.value IS NOT NULL; + + RETURN JSON_BUILD_OBJECT( + 'task', (_task::JSONB || JSONB_BUILD_OBJECT('custom_column_values', COALESCE(_custom_column_values, '{}'::JSON)::JSONB))::JSON, + 'priorities', _priorities, + 'projects', _projects, + 'statuses', _statuses, + 'team_members', _team_members, + 'assignees', _assignees, + 'phases', _phases, + 'custom_columns', _custom_columns + ); +END; +$$; + + `); +}; + +/** @param {import('node-pg-migrate').MigrationBuilder} pgm */ +exports.down = async (_pgm) => { + // Review manually before running migrate:down. +}; diff --git a/worklenz-backend/database/pg-migrations/20260821000028500_add_due_time_task_list_enum.js b/worklenz-backend/database/pg-migrations/20260821000028500_add_due_time_task_list_enum.js new file mode 100644 index 000000000..2d4d4b6b7 --- /dev/null +++ b/worklenz-backend/database/pg-migrations/20260821000028500_add_due_time_task_list_enum.js @@ -0,0 +1,15 @@ +'use strict'; + +/** {import('node-pg-migrate').MigrationBuilder} pgm */ +exports.up = async (pgm) => { + // PostgreSQL makes a newly added enum value usable only after commit. + pgm.noTransaction(); + pgm.sql(` +ALTER TYPE WL_TASK_LIST_COL_KEY ADD VALUE IF NOT EXISTS 'DUE_TIME' AFTER 'DUE_DATE'; + `); +}; + +/** {import('node-pg-migrate').MigrationBuilder} pgm */ +exports.down = async (_pgm) => { + // Enum values cannot be removed safely. +}; diff --git a/worklenz-backend/database/pg-migrations/20260821000029000_add_due_time_column_to_task_list.js b/worklenz-backend/database/pg-migrations/20260821000029000_add_due_time_column_to_task_list.js new file mode 100644 index 000000000..64622e6ba --- /dev/null +++ b/worklenz-backend/database/pg-migrations/20260821000029000_add_due_time_column_to_task_list.js @@ -0,0 +1,79 @@ +'use strict'; +// Converted from database/migrations/20260427000002-add-due-time-column-to-task-list.sql + +/** @type {import('node-pg-migrate').ColumnDefinitions | undefined} */ +exports.shorthands = undefined; + +/** @param {import('node-pg-migrate').MigrationBuilder} pgm */ +exports.up = async (pgm) => { + pgm.sql(` +-- Migration: Add DUE_TIME to task list columns +-- This allows users to show/hide the due time column and saves their preference +-- Note: We add it after DUE_DATE to keep related fields together +-- Insert DUE_TIME column for each project that doesn't already have it +-- Default: pinned = false (hidden by default, users can enable it) +-- Index: 13 (after DUE_DATE which is typically index 12) +INSERT INTO project_task_list_cols (name, key, index, pinned, project_id) +SELECT + 'Due Time' AS name, + 'DUE_TIME'::WL_TASK_LIST_COL_KEY AS key, + 13 AS index, + false AS pinned, + p.id AS project_id +FROM projects p +WHERE NOT EXISTS ( + SELECT 1 + FROM project_task_list_cols ptlc + WHERE ptlc.project_id = p.id + AND ptlc.key = 'DUE_TIME' +); +CREATE OR REPLACE FUNCTION insert_task_list_columns(_project_id uuid) RETURNS void + LANGUAGE plpgsql +AS +$$ +DECLARE +BEGIN + INSERT INTO project_task_list_cols (project_id, name, key, index, pinned) + VALUES (_project_id, 'Key', 'KEY', 0, FALSE); + INSERT INTO project_task_list_cols (project_id, name, key, index, pinned) + VALUES (_project_id, 'Description', 'DESCRIPTION', 2, FALSE); + INSERT INTO project_task_list_cols (project_id, name, key, index, pinned) + VALUES (_project_id, 'Progress', 'PROGRESS', 3, TRUE); + INSERT INTO project_task_list_cols (project_id, name, key, index, pinned) + VALUES (_project_id, 'Status', 'STATUS', 4, TRUE); + INSERT INTO project_task_list_cols (project_id, name, key, index, pinned) + VALUES (_project_id, 'Members', 'ASSIGNEES', 5, TRUE); + INSERT INTO project_task_list_cols (project_id, name, key, index, pinned) + VALUES (_project_id, 'Labels', 'LABELS', 6, TRUE); + INSERT INTO project_task_list_cols (project_id, name, key, index, pinned) + VALUES (_project_id, 'Phase', 'PHASE', 7, TRUE); + INSERT INTO project_task_list_cols (project_id, name, key, index, pinned) + VALUES (_project_id, 'Priority', 'PRIORITY', 8, TRUE); + INSERT INTO project_task_list_cols (project_id, name, key, index, pinned) + VALUES (_project_id, 'Time Tracking', 'TIME_TRACKING', 9, TRUE); + INSERT INTO project_task_list_cols (project_id, name, key, index, pinned) + VALUES (_project_id, 'Estimation', 'ESTIMATION', 10, FALSE); + INSERT INTO project_task_list_cols (project_id, name, key, index, pinned) + VALUES (_project_id, 'Start Date', 'START_DATE', 11, FALSE); + INSERT INTO project_task_list_cols (project_id, name, key, index, pinned) + VALUES (_project_id, 'Due Date', 'DUE_DATE', 12, TRUE); + INSERT INTO project_task_list_cols (project_id, name, key, index, pinned) + VALUES (_project_id, 'Due Time', 'DUE_TIME', 13, FALSE); + INSERT INTO project_task_list_cols (project_id, name, key, index, pinned) + VALUES (_project_id, 'Completed Date', 'COMPLETED_DATE', 14, FALSE); + INSERT INTO project_task_list_cols (project_id, name, key, index, pinned) + VALUES (_project_id, 'Created Date', 'CREATED_DATE', 15, FALSE); + INSERT INTO project_task_list_cols (project_id, name, key, index, pinned) + VALUES (_project_id, 'Last Updated', 'LAST_UPDATED', 16, FALSE); + INSERT INTO project_task_list_cols (project_id, name, key, index, pinned) + VALUES (_project_id, 'Reporter', 'REPORTER', 17, FALSE); +END +$$; + + `); +}; + +/** @param {import('node-pg-migrate').MigrationBuilder} pgm */ +exports.down = async (_pgm) => { + // Review manually before running migrate:down. +}; diff --git a/worklenz-backend/database/pg-migrations/20260821000030000_add_multiple_reaction_types.js b/worklenz-backend/database/pg-migrations/20260821000030000_add_multiple_reaction_types.js new file mode 100644 index 000000000..bc3e4bc62 --- /dev/null +++ b/worklenz-backend/database/pg-migrations/20260821000030000_add_multiple_reaction_types.js @@ -0,0 +1,46 @@ +'use strict'; +// Converted from database/migrations/20260430000000-add-multiple-reaction-types.sql + +/** @type {import('node-pg-migrate').ColumnDefinitions | undefined} */ +exports.shorthands = undefined; + +/** @param {import('node-pg-migrate').MigrationBuilder} pgm */ +exports.up = async (pgm) => { + pgm.sql(` +-- Migration: Add multiple reaction types for task comments +-- Date: 2026-04-30 +-- Description: Extends REACTION_TYPES enum to support emoji reactions beyond just 'like' + +-- Add new reaction types to the enum +ALTER TYPE REACTION_TYPES ADD VALUE IF NOT EXISTS 'love'; +ALTER TYPE REACTION_TYPES ADD VALUE IF NOT EXISTS 'celebrate'; +ALTER TYPE REACTION_TYPES ADD VALUE IF NOT EXISTS 'support'; +ALTER TYPE REACTION_TYPES ADD VALUE IF NOT EXISTS 'insightful'; +ALTER TYPE REACTION_TYPES ADD VALUE IF NOT EXISTS 'curious'; + +-- Deduplicate existing reactions so unique constraint creation succeeds +DELETE FROM task_comment_reactions a USING task_comment_reactions b +WHERE a.ctid < b.ctid + AND a.comment_id = b.comment_id + AND a.team_member_id = b.team_member_id; + +-- Add a unique constraint to prevent duplicate reactions from the same user +-- A user can only have one reaction type per comment +DO $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'task_comment_reactions_unique_user_comment') THEN + ALTER TABLE task_comment_reactions ADD CONSTRAINT task_comment_reactions_unique_user_comment UNIQUE (comment_id, team_member_id); + END IF; +END $$; + +-- Create an index for faster reaction queries +CREATE INDEX IF NOT EXISTS idx_task_comment_reactions_comment_type + ON task_comment_reactions (comment_id, reaction_type); + + `); +}; + +/** @param {import('node-pg-migrate').MigrationBuilder} pgm */ +exports.down = async (_pgm) => { + // Review manually before running migrate:down. +}; diff --git a/worklenz-backend/database/pg-migrations/20260821000031000_add_priority_to_projects.js b/worklenz-backend/database/pg-migrations/20260821000031000_add_priority_to_projects.js new file mode 100644 index 000000000..4928716a9 --- /dev/null +++ b/worklenz-backend/database/pg-migrations/20260821000031000_add_priority_to_projects.js @@ -0,0 +1,37 @@ +'use strict'; +// Converted from database/migrations/20260507000000-add-priority-to-projects.sql + +/** @type {import('node-pg-migrate').ColumnDefinitions | undefined} */ +exports.shorthands = undefined; + +/** @param {import('node-pg-migrate').MigrationBuilder} pgm */ +exports.up = async (pgm) => { + pgm.sql(` +-- Migration: Add priority column to projects table +-- Date: 2026-05-07 + +ALTER TABLE projects + ADD COLUMN IF NOT EXISTS priority_id UUID REFERENCES sys_project_priorities (id) ON DELETE SET NULL; + +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint + WHERE conrelid = 'projects'::regclass AND conname = 'projects_priority_id_fk' + ) THEN + ALTER TABLE projects DROP CONSTRAINT IF EXISTS projects_priority_id_fkey; + ALTER TABLE projects + ADD CONSTRAINT projects_priority_id_fk + FOREIGN KEY (priority_id) REFERENCES sys_project_priorities (id) ON DELETE SET NULL; + END IF; +END $$; + +COMMENT ON COLUMN projects.priority_id IS 'Optional project-level priority (references sys_project_priorities)'; + + `); +}; + +/** @param {import('node-pg-migrate').MigrationBuilder} pgm */ +exports.down = async (_pgm) => { + // Review manually before running migrate:down. +}; diff --git a/worklenz-backend/database/pg-migrations/20260821000032000_optimize_reporting_projects_grouped.js b/worklenz-backend/database/pg-migrations/20260821000032000_optimize_reporting_projects_grouped.js new file mode 100644 index 000000000..e81a26fdb --- /dev/null +++ b/worklenz-backend/database/pg-migrations/20260821000032000_optimize_reporting_projects_grouped.js @@ -0,0 +1,57 @@ +'use strict'; +// Converted from database/migrations/20260507000000-optimize-reporting-projects-grouped.sql + +/** @type {import('node-pg-migrate').ColumnDefinitions | undefined} */ +exports.shorthands = undefined; + +/** @param {import('node-pg-migrate').MigrationBuilder} pgm */ +exports.up = async (pgm) => { + pgm.sql(` +-- Migration: Optimize reporting projects grouped query performance +-- Date: 2026-05-07 +-- Description: Add indexes to improve performance of getGrouped endpoint + +-- Index for task status category lookups (replaces function calls) +-- This allows direct JOIN instead of calling is_completed/is_doing/is_todo functions +CREATE INDEX IF NOT EXISTS idx_task_statuses_category_project +ON task_statuses(category_id, project_id); + +-- Composite index for tasks aggregation in project_tasks CTE +-- Covers: project_id, archived, status_id for efficient filtering and grouping +CREATE INDEX IF NOT EXISTS idx_tasks_project_archived_status +ON tasks(project_id, archived, status_id) +WHERE archived IS FALSE; + +-- Index for project filtering with common lookup columns +-- Covers most WHERE clause conditions in the grouped query +CREATE INDEX IF NOT EXISTS idx_projects_team_status_health_category +ON projects(team_id, status_id, health_id, category_id); + +-- Index for project manager filtering +-- Optimizes the projectManagersClause subquery +CREATE INDEX IF NOT EXISTS idx_project_members_access_level_team_member +ON project_members(project_access_level_id, team_member_id, project_id); + +-- Index for team member user lookup (used in manager filtering) +CREATE INDEX IF NOT EXISTS idx_team_members_user_team +ON team_members(user_id, team_id, id); + +-- Index for archived projects filtering +CREATE INDEX IF NOT EXISTS idx_archived_projects_project_user +ON archived_projects(project_id, user_id); + +-- Index for project name search (used in searchQuery) +CREATE INDEX IF NOT EXISTS idx_projects_name_lower +ON projects(LOWER(name)); + +-- Covering index for teams lookup in JSON aggregation +CREATE INDEX IF NOT EXISTS idx_teams_id_name +ON teams(id, name); + + `); +}; + +/** @param {import('node-pg-migrate').MigrationBuilder} pgm */ +exports.down = async (_pgm) => { + // Review manually before running migrate:down. +}; diff --git a/worklenz-backend/database/pg-migrations/20260821000033000_update_project_functions_with_priority.js b/worklenz-backend/database/pg-migrations/20260821000033000_update_project_functions_with_priority.js new file mode 100644 index 000000000..5af5d1dcf --- /dev/null +++ b/worklenz-backend/database/pg-migrations/20260821000033000_update_project_functions_with_priority.js @@ -0,0 +1,171 @@ +'use strict'; +// Converted from database/migrations/20260507000001-update-project-functions-with-priority.sql + +/** @type {import('node-pg-migrate').ColumnDefinitions | undefined} */ +exports.shorthands = undefined; + +/** @param {import('node-pg-migrate').MigrationBuilder} pgm */ +exports.up = async (pgm) => { + pgm.sql(` +-- Migration: Persist project priority through create/update project functions +-- Date: 2026-05-07 + +CREATE OR REPLACE FUNCTION create_project(_body json) RETURNS json + LANGUAGE plpgsql +AS +$$ +DECLARE + _user_id UUID; + _team_id UUID; + _client_id UUID; + _project_id UUID; + _client_name TEXT; + _project_name TEXT; + _team_member_id UUID; +BEGIN + _client_name = TRIM((_body ->> 'client_name')::TEXT); + _project_name = TRIM((_body ->> 'name')::TEXT); + + _user_id = (_body ->> 'user_id')::UUID; + _team_id = (_body ->> 'team_id')::UUID; + + SELECT id FROM clients WHERE LOWER(name) = LOWER(_client_name) AND team_id = _team_id INTO _client_id; + SELECT id FROM team_members WHERE team_id = _team_id AND user_id = _user_id INTO _team_member_id; + + IF EXISTS(SELECT name + FROM projects + WHERE LOWER(name) = LOWER(_project_name) + AND team_id = _team_id) + THEN + RAISE 'PROJECT_EXISTS_ERROR:%', _project_name; + END IF; + + IF is_null_or_empty(_client_id) IS TRUE AND is_null_or_empty(_client_name) IS FALSE + THEN + INSERT INTO clients (name, team_id) VALUES (_client_name, _team_id) RETURNING id INTO _client_id; + END IF; + + INSERT INTO projects (name, key, notes, color_code, team_id, client_id, owner_id, status_id, health_id, priority_id, start_date, + end_date, + folder_id, category_id, estimated_working_days, estimated_man_days, hours_per_day, + use_manual_progress, use_weighted_progress, use_time_progress, auto_assign_task_creator) + VALUES (_project_name, (_body ->> 'key')::TEXT, (_body ->> 'notes')::TEXT, (_body ->> 'color_code')::TEXT, _team_id, + _client_id, + _user_id, (_body ->> 'status_id')::UUID, (_body ->> 'health_id')::UUID, (_body ->> 'priority_id')::UUID, + (_body ->> 'start_date')::TIMESTAMPTZ, + (_body ->> 'end_date')::TIMESTAMPTZ, (_body ->> 'folder_id')::UUID, (_body ->> 'category_id')::UUID, + (_body ->> 'working_days')::INTEGER, (_body ->> 'man_days')::INTEGER, (_body ->> 'hours_per_day')::INTEGER, + COALESCE((_body ->> 'use_manual_progress')::BOOLEAN, FALSE), + COALESCE((_body ->> 'use_weighted_progress')::BOOLEAN, FALSE), + COALESCE((_body ->> 'use_time_progress')::BOOLEAN, FALSE), + COALESCE((_body ->> 'auto_assign_task_creator')::BOOLEAN, FALSE)) + RETURNING id INTO _project_id; + + INSERT INTO project_logs (team_id, project_id, description) + VALUES (_team_id, _project_id, + REPLACE((_body ->> 'project_created_log')::TEXT, '@user', + (SELECT name FROM users WHERE id = _user_id))); + + INSERT INTO project_members (team_member_id, project_access_level_id, project_id, role_id) + VALUES (_team_member_id, (SELECT id FROM project_access_levels WHERE key = 'ADMIN'), + _project_id, + (SELECT id FROM roles WHERE team_id = _team_id AND default_role IS TRUE)); + + INSERT INTO task_statuses (name, project_id, team_id, category_id, sort_order) + VALUES ('To Do', _project_id, _team_id, (SELECT id FROM sys_task_status_categories WHERE is_todo IS TRUE), 0); + INSERT INTO task_statuses (name, project_id, team_id, category_id, sort_order) + VALUES ('Doing', _project_id, _team_id, (SELECT id FROM sys_task_status_categories WHERE is_doing IS TRUE), 1); + INSERT INTO task_statuses (name, project_id, team_id, category_id, sort_order) + VALUES ('Done', _project_id, _team_id, (SELECT id FROM sys_task_status_categories WHERE is_done IS TRUE), 2); + + PERFORM insert_task_list_columns(_project_id); + + RETURN JSON_BUILD_OBJECT( + 'id', _project_id, + 'name', (_body ->> 'name')::TEXT + ); +END; +$$; + +CREATE OR REPLACE FUNCTION update_project(_body json) RETURNS json + LANGUAGE plpgsql +AS +$$ +DECLARE + _user_id UUID; + _team_id UUID; + _client_id UUID; + _project_id UUID; + _project_manager_team_member_id UUID; + _client_name TEXT; + _project_name TEXT; +BEGIN + _client_name = TRIM((_body ->> 'client_name')::TEXT); + _project_name = TRIM((_body ->> 'name')::TEXT); + + _user_id = (_body ->> 'user_id')::UUID; + _team_id = (_body ->> 'team_id')::UUID; + _project_manager_team_member_id = (_body ->> 'team_member_id')::UUID; + + SELECT id FROM clients WHERE LOWER(name) = LOWER(_client_name) AND team_id = _team_id INTO _client_id; + + IF is_null_or_empty(_client_id) IS TRUE AND is_null_or_empty(_client_name) IS FALSE + THEN + INSERT INTO clients (name, team_id) VALUES (_client_name, _team_id) RETURNING id INTO _client_id; + END IF; + + IF EXISTS( + SELECT name FROM projects WHERE LOWER(name) = LOWER(_project_name) + AND team_id = _team_id AND id != (_body ->> 'id')::UUID + ) + THEN + RAISE 'PROJECT_EXISTS_ERROR:%', _project_name; + END IF; + + UPDATE projects + SET name = _project_name, + notes = (_body ->> 'notes')::TEXT, + color_code = (_body ->> 'color_code')::TEXT, + status_id = (_body ->> 'status_id')::UUID, + health_id = (_body ->> 'health_id')::UUID, + priority_id = (_body ->> 'priority_id')::UUID, + key = (_body ->> 'key')::TEXT, + start_date = (_body ->> 'start_date')::TIMESTAMPTZ, + end_date = (_body ->> 'end_date')::TIMESTAMPTZ, + client_id = _client_id, + folder_id = (_body ->> 'folder_id')::UUID, + category_id = (_body ->> 'category_id')::UUID, + updated_at = CURRENT_TIMESTAMP, + estimated_working_days = (_body ->> 'working_days')::INTEGER, + estimated_man_days = (_body ->> 'man_days')::INTEGER, + hours_per_day = (_body ->> 'hours_per_day')::INTEGER, + use_manual_progress = COALESCE((_body ->> 'use_manual_progress')::BOOLEAN, FALSE), + use_weighted_progress = COALESCE((_body ->> 'use_weighted_progress')::BOOLEAN, FALSE), + use_time_progress = COALESCE((_body ->> 'use_time_progress')::BOOLEAN, FALSE), + auto_assign_task_creator = COALESCE((_body ->> 'auto_assign_task_creator')::BOOLEAN, FALSE) + WHERE id = (_body ->> 'id')::UUID + AND team_id = _team_id + RETURNING id INTO _project_id; + + UPDATE project_members SET project_access_level_id = (SELECT id FROM project_access_levels WHERE key = 'MEMBER') WHERE project_id = _project_id; + + IF NOT (_project_manager_team_member_id IS NULL) + THEN + PERFORM update_project_manager(_project_manager_team_member_id, _project_id::UUID); + END IF; + + RETURN JSON_BUILD_OBJECT( + 'id', _project_id, + 'name', (_body ->> 'name')::TEXT, + 'project_manager_id', _project_manager_team_member_id::UUID + ); +END; +$$; + + `); +}; + +/** @param {import('node-pg-migrate').MigrationBuilder} pgm */ +exports.down = async (_pgm) => { + // Review manually before running migrate:down. +}; diff --git a/worklenz-backend/database/pg-migrations/20260821000034000_add_restrict_task_creation.js b/worklenz-backend/database/pg-migrations/20260821000034000_add_restrict_task_creation.js new file mode 100644 index 000000000..56f48b8bc --- /dev/null +++ b/worklenz-backend/database/pg-migrations/20260821000034000_add_restrict_task_creation.js @@ -0,0 +1,259 @@ +'use strict'; +// Converted from database/migrations/release-v2.6/20260515000000-add-restrict-task-creation.sql + +/** @type {import('node-pg-migrate').ColumnDefinitions | undefined} */ +exports.shorthands = undefined; + +/** @param {import('node-pg-migrate').MigrationBuilder} pgm */ +exports.up = async (pgm) => { + pgm.sql(` +-- Migration: Add restrict_task_creation feature +-- Description: Adds toggles to restrict task creation/assignment to Admins and Team Leads only. +-- Configurable at both project level and organization level. +-- This is a Business Plan feature. +-- Date: 2026-05-15 + +-- 1. Add project-level toggle +ALTER TABLE projects + ADD COLUMN IF NOT EXISTS restrict_task_creation BOOLEAN DEFAULT FALSE; + +COMMENT ON COLUMN projects.restrict_task_creation IS + 'When TRUE (Business Plan), only project members with Admin or Team Lead role can create and assign tasks.'; + +-- 2. Add organization-level toggle +ALTER TABLE organizations + ADD COLUMN IF NOT EXISTS restrict_task_creation BOOLEAN DEFAULT FALSE; + +COMMENT ON COLUMN organizations.restrict_task_creation IS + 'When TRUE (Business Plan), restricts task creation/assignment to Admins and Team Leads across all projects in the organization.'; + +-- 3. Create a helper function to check if task creation is restricted for a given user in a project. +-- Returns TRUE when the restriction is active AND the user is NOT an Admin/Team Lead. +CREATE OR REPLACE FUNCTION is_task_creation_restricted(_user_id UUID, _project_id UUID) +RETURNS BOOLEAN +LANGUAGE plpgsql +STABLE +AS $$ +DECLARE + _team_id UUID; + _project_restricted BOOLEAN := FALSE; + _org_restricted BOOLEAN := FALSE; + _effective_restricted BOOLEAN := FALSE; + _is_admin_or_lead BOOLEAN := FALSE; +BEGIN + -- Get the team that owns this project + SELECT team_id INTO _team_id FROM projects WHERE id = _project_id; + IF _team_id IS NULL THEN + RETURN FALSE; + END IF; + + -- Read project-level flag + SELECT COALESCE(restrict_task_creation, FALSE) + INTO _project_restricted + FROM projects + WHERE id = _project_id; + + -- Read org-level flag (via the team owner's organization) + SELECT COALESCE(o.restrict_task_creation, FALSE) + INTO _org_restricted + FROM organizations o + JOIN teams t ON t.user_id = o.user_id + WHERE t.id = _team_id + LIMIT 1; + + -- Project-level overrides org-level when it is explicitly set (non-default). + -- If project flag is TRUE → restricted. + -- If project flag is FALSE (explicitly disabled) → not restricted regardless of org. + -- If project flag is NULL/default → fall back to org flag. + -- Since we store FALSE as default, we treat project flag as authoritative when the + -- project has been explicitly saved (i.e. the column exists). We use org flag only + -- when the project flag is FALSE AND the org flag is TRUE. + -- Effective rule: project_restricted OR (NOT project_restricted AND org_restricted) + -- simplifies to: project_restricted OR org_restricted + -- But per spec "project-level overrides org-level", meaning if project is explicitly + -- FALSE it should NOT be overridden by org TRUE. We therefore need a tri-state. + -- We model this as: if project flag is TRUE → restricted; else use org flag. + IF _project_restricted IS TRUE THEN + _effective_restricted := TRUE; + ELSE + _effective_restricted := _org_restricted; + END IF; + + IF NOT _effective_restricted THEN + RETURN FALSE; + END IF; + + -- Check if the user is an Admin/Owner (team-level) or has admin_role (Team Lead) + SELECT (r.admin_role OR r.owner) + INTO _is_admin_or_lead + FROM team_members tm + JOIN roles r ON tm.role_id = r.id + WHERE tm.user_id = _user_id + AND tm.team_id = _team_id + LIMIT 1; + + -- If user is admin/owner/team-lead → NOT restricted + IF COALESCE(_is_admin_or_lead, FALSE) IS TRUE THEN + RETURN FALSE; + END IF; + + -- Restriction is active and user is a plain Member + RETURN TRUE; +END; +$$; + +-- 4. Update create_project to include both priority_id (sys_project_priorities) and restrict_task_creation +CREATE OR REPLACE FUNCTION create_project(_body json) RETURNS json + LANGUAGE plpgsql +AS +$$ +DECLARE + _user_id UUID; + _team_id UUID; + _client_id UUID; + _project_id UUID; + _client_name TEXT; + _project_name TEXT; + _team_member_id UUID; +BEGIN + _client_name = TRIM((_body ->> 'client_name')::TEXT); + _project_name = TRIM((_body ->> 'name')::TEXT); + _user_id = (_body ->> 'user_id')::UUID; + _team_id = (_body ->> 'team_id')::UUID; + + SELECT id FROM clients WHERE LOWER(name) = LOWER(_client_name) AND team_id = _team_id INTO _client_id; + SELECT id FROM team_members WHERE team_id = _team_id AND user_id = _user_id INTO _team_member_id; + + IF EXISTS(SELECT name FROM projects WHERE LOWER(name) = LOWER(_project_name) AND team_id = _team_id) + THEN + RAISE 'PROJECT_EXISTS_ERROR:%', _project_name; + END IF; + + IF is_null_or_empty(_client_id) IS TRUE AND is_null_or_empty(_client_name) IS FALSE + THEN + INSERT INTO clients (name, team_id) VALUES (_client_name, _team_id) RETURNING id INTO _client_id; + END IF; + + INSERT INTO projects (name, key, notes, color_code, team_id, client_id, owner_id, status_id, health_id, priority_id, start_date, + end_date, folder_id, category_id, estimated_working_days, estimated_man_days, hours_per_day, + use_manual_progress, use_weighted_progress, use_time_progress, auto_assign_task_creator, + restrict_task_creation) + VALUES (_project_name, (_body ->> 'key')::TEXT, (_body ->> 'notes')::TEXT, (_body ->> 'color_code')::TEXT, _team_id, + _client_id, _user_id, (_body ->> 'status_id')::UUID, (_body ->> 'health_id')::UUID, + COALESCE((_body ->> 'priority_id')::UUID, (SELECT id FROM sys_project_priorities WHERE name = 'Medium' LIMIT 1)), + (_body ->> 'start_date')::TIMESTAMPTZ, (_body ->> 'end_date')::TIMESTAMPTZ, + (_body ->> 'folder_id')::UUID, (_body ->> 'category_id')::UUID, + (_body ->> 'working_days')::INTEGER, (_body ->> 'man_days')::INTEGER, (_body ->> 'hours_per_day')::INTEGER, + COALESCE((_body ->> 'use_manual_progress')::BOOLEAN, FALSE), + COALESCE((_body ->> 'use_weighted_progress')::BOOLEAN, FALSE), + COALESCE((_body ->> 'use_time_progress')::BOOLEAN, FALSE), + COALESCE((_body ->> 'auto_assign_task_creator')::BOOLEAN, FALSE), + COALESCE((_body ->> 'restrict_task_creation')::BOOLEAN, FALSE)) + RETURNING id INTO _project_id; + + INSERT INTO project_logs (team_id, project_id, description) + VALUES (_team_id, _project_id, + REPLACE((_body ->> 'project_created_log')::TEXT, '@user', + (SELECT name FROM users WHERE id = _user_id))); + + INSERT INTO project_members (team_member_id, project_access_level_id, project_id, role_id) + VALUES (_team_member_id, (SELECT id FROM project_access_levels WHERE key = 'ADMIN'), + _project_id, + (SELECT id FROM roles WHERE team_id = _team_id AND default_role IS TRUE)); + + INSERT INTO task_statuses (name, project_id, team_id, category_id, sort_order) + VALUES ('To Do', _project_id, _team_id, (SELECT id FROM sys_task_status_categories WHERE is_todo IS TRUE), 0); + INSERT INTO task_statuses (name, project_id, team_id, category_id, sort_order) + VALUES ('Doing', _project_id, _team_id, (SELECT id FROM sys_task_status_categories WHERE is_doing IS TRUE), 1); + INSERT INTO task_statuses (name, project_id, team_id, category_id, sort_order) + VALUES ('Done', _project_id, _team_id, (SELECT id FROM sys_task_status_categories WHERE is_done IS TRUE), 2); + + PERFORM insert_task_list_columns(_project_id); + + RETURN JSON_BUILD_OBJECT('id', _project_id, 'name', (_body ->> 'name')::TEXT); +END; +$$; + +-- 5. Update update_project to include both priority_id (sys_project_priorities) and restrict_task_creation +CREATE OR REPLACE FUNCTION update_project(_body json) RETURNS json + LANGUAGE plpgsql +AS +$$ +DECLARE + _user_id UUID; + _team_id UUID; + _client_id UUID; + _project_id UUID; + _project_manager_team_member_id UUID; + _client_name TEXT; + _project_name TEXT; +BEGIN + _client_name = TRIM((_body ->> 'client_name')::TEXT); + _project_name = TRIM((_body ->> 'name')::TEXT); + _user_id = (_body ->> 'user_id')::UUID; + _team_id = (_body ->> 'team_id')::UUID; + _project_manager_team_member_id = (_body ->> 'team_member_id')::UUID; + + SELECT id FROM clients WHERE LOWER(name) = LOWER(_client_name) AND team_id = _team_id INTO _client_id; + + IF is_null_or_empty(_client_id) IS TRUE AND is_null_or_empty(_client_name) IS FALSE + THEN + INSERT INTO clients (name, team_id) VALUES (_client_name, _team_id) RETURNING id INTO _client_id; + END IF; + + IF EXISTS( + SELECT name FROM projects WHERE LOWER(name) = LOWER(_project_name) + AND team_id = _team_id AND id != (_body ->> 'id')::UUID + ) + THEN + RAISE 'PROJECT_EXISTS_ERROR:%', _project_name; + END IF; + + UPDATE projects + SET name = _project_name, + notes = (_body ->> 'notes')::TEXT, + color_code = (_body ->> 'color_code')::TEXT, + status_id = (_body ->> 'status_id')::UUID, + health_id = (_body ->> 'health_id')::UUID, + priority_id = COALESCE((_body ->> 'priority_id')::UUID, (SELECT id FROM sys_project_priorities WHERE name = 'Medium' LIMIT 1)), + key = (_body ->> 'key')::TEXT, + start_date = (_body ->> 'start_date')::TIMESTAMPTZ, + end_date = (_body ->> 'end_date')::TIMESTAMPTZ, + client_id = _client_id, + folder_id = (_body ->> 'folder_id')::UUID, + category_id = (_body ->> 'category_id')::UUID, + updated_at = CURRENT_TIMESTAMP, + estimated_working_days = (_body ->> 'working_days')::INTEGER, + estimated_man_days = (_body ->> 'man_days')::INTEGER, + hours_per_day = (_body ->> 'hours_per_day')::INTEGER, + use_manual_progress = COALESCE((_body ->> 'use_manual_progress')::BOOLEAN, FALSE), + use_weighted_progress = COALESCE((_body ->> 'use_weighted_progress')::BOOLEAN, FALSE), + use_time_progress = COALESCE((_body ->> 'use_time_progress')::BOOLEAN, FALSE), + auto_assign_task_creator = COALESCE((_body ->> 'auto_assign_task_creator')::BOOLEAN, FALSE), + restrict_task_creation = COALESCE((_body ->> 'restrict_task_creation')::BOOLEAN, FALSE) + WHERE id = (_body ->> 'id')::UUID + AND team_id = _team_id + RETURNING id INTO _project_id; + + UPDATE project_members SET project_access_level_id = (SELECT id FROM project_access_levels WHERE key = 'MEMBER') WHERE project_id = _project_id; + + IF NOT (_project_manager_team_member_id IS NULL) + THEN + PERFORM update_project_manager(_project_manager_team_member_id, _project_id::UUID); + END IF; + + RETURN JSON_BUILD_OBJECT( + 'id', _project_id, + 'name', (_body ->> 'name')::TEXT, + 'project_manager_id', _project_manager_team_member_id::UUID + ); +END; +$$; + + `); +}; + +/** @param {import('node-pg-migrate').MigrationBuilder} pgm */ +exports.down = async (_pgm) => { + // Review manually before running migrate:down. +}; diff --git a/worklenz-backend/database/pg-migrations/20260821000035000_task_template_subtask_support.js b/worklenz-backend/database/pg-migrations/20260821000035000_task_template_subtask_support.js new file mode 100644 index 000000000..843ba438c --- /dev/null +++ b/worklenz-backend/database/pg-migrations/20260821000035000_task_template_subtask_support.js @@ -0,0 +1,311 @@ +'use strict'; +// Converted from database/migrations/20260520000001-task-template-subtask-support.sql + +/** @type {import('node-pg-migrate').ColumnDefinitions | undefined} */ +exports.shorthands = undefined; + +/** @param {import('node-pg-migrate').MigrationBuilder} pgm */ +exports.up = async (pgm) => { + pgm.sql(` +-- ============================================================ +-- Migration: Task Template Subtask Support +-- Adds parent_task_name column to task_templates_tasks so that +-- subtask hierarchy can be preserved in templates. +-- A NULL parent_task_name means the row is a top-level task. +-- A non-NULL parent_task_name links the row to its parent by name. +-- ============================================================ + +-- 1. Add parent_task_name column to task_templates_tasks +ALTER TABLE task_templates_tasks + ADD COLUMN IF NOT EXISTS parent_task_name TEXT DEFAULT NULL; + +-- 2. Replace create_task_template to support nested sub_tasks in the JSON payload. +-- Each task object may carry a "sub_tasks" JSON array. +-- Sub-tasks are stored with parent_task_name = parent task name. +CREATE OR REPLACE FUNCTION create_task_template(_name text, _team_id uuid, _tasks json) + RETURNS json + LANGUAGE plpgsql +AS +$$ +DECLARE + _template_id UUID; + _task JSON; + _subtask JSON; + _parent_name TEXT; +BEGIN + -- Prevent duplicate template names within the same team + IF EXISTS ( + SELECT 1 + FROM task_templates + WHERE LOWER(name) = LOWER(_name) + AND team_id = _team_id + ) THEN + RAISE 'TASK_TEMPLATE_EXISTS_ERROR:%', _name; + END IF; + + INSERT INTO task_templates (name, team_id) + VALUES (_name, _team_id) + RETURNING id INTO _template_id; + + -- Insert parent tasks and their subtasks + FOR _task IN SELECT * FROM JSON_ARRAY_ELEMENTS(_tasks) + LOOP + _parent_name := TRIM((_task ->> 'name')::TEXT); + + -- Insert the parent task row (parent_task_name = NULL) + INSERT INTO task_templates_tasks (template_id, name, total_minutes, parent_task_name) + VALUES ( + _template_id, + _parent_name, + COALESCE( + (SELECT total_minutes FROM tasks WHERE id = (_task ->> 'id')::UUID), + (_task ->> 'total_minutes')::NUMERIC, + 0 + ), + NULL + ); + + -- Insert subtasks if present + IF (_task -> 'sub_tasks') IS NOT NULL AND + JSON_ARRAY_LENGTH(_task -> 'sub_tasks') > 0 THEN + FOR _subtask IN SELECT * FROM JSON_ARRAY_ELEMENTS(_task -> 'sub_tasks') + LOOP + INSERT INTO task_templates_tasks (template_id, name, total_minutes, parent_task_name) + VALUES ( + _template_id, + TRIM((_subtask ->> 'name')::TEXT), + COALESCE( + (SELECT total_minutes FROM tasks WHERE id = (_subtask ->> 'id')::UUID), + (_subtask ->> 'total_minutes')::NUMERIC, + 0 + ), + _parent_name + ); + END LOOP; + END IF; + END LOOP; + + RETURN JSON_BUILD_OBJECT( + 'id', _template_id, + 'template_name', _name + ); +END +$$; + +-- 3. Replace update_task_template to support nested sub_tasks in the JSON payload. +CREATE OR REPLACE FUNCTION update_task_template(_id uuid, _name text, _tasks json, _team_id uuid) + RETURNS json + LANGUAGE plpgsql +AS +$$ +DECLARE + _task JSON; + _subtask JSON; + _parent_name TEXT; +BEGIN + -- Prevent duplicate template names within the same team (excluding self) + IF EXISTS ( + SELECT 1 + FROM task_templates + WHERE LOWER(name) = LOWER(_name) + AND team_id = _team_id + AND id != _id + ) THEN + RAISE 'TASK_TEMPLATE_EXISTS_ERROR:%', _name; + END IF; + + UPDATE task_templates + SET name = _name, + updated_at = NOW() + WHERE id = _id; + + -- Remove all existing tasks for this template before re-inserting + DELETE FROM task_templates_tasks WHERE template_id = _id; + + -- Re-insert parent tasks and their subtasks + FOR _task IN SELECT * FROM JSON_ARRAY_ELEMENTS(_tasks) + LOOP + _parent_name := TRIM((_task ->> 'name')::TEXT); + + INSERT INTO task_templates_tasks (template_id, name, total_minutes, parent_task_name) + VALUES ( + _id, + _parent_name, + COALESCE((_task ->> 'total_minutes')::NUMERIC, 0), + NULL + ); + + IF (_task -> 'sub_tasks') IS NOT NULL AND + JSON_ARRAY_LENGTH(_task -> 'sub_tasks') > 0 THEN + FOR _subtask IN SELECT * FROM JSON_ARRAY_ELEMENTS(_task -> 'sub_tasks') + LOOP + INSERT INTO task_templates_tasks (template_id, name, total_minutes, parent_task_name) + VALUES ( + _id, + TRIM((_subtask ->> 'name')::TEXT), + COALESCE((_subtask ->> 'total_minutes')::NUMERIC, 0), + _parent_name + ); + END LOOP; + END IF; + END LOOP; + + RETURN JSON_BUILD_OBJECT( + 'id', _id, + 'template_name', _name + ); +END +$$; + +-- 4. Replace import_tasks_from_template to restore subtask hierarchy on import. +-- Two-pass approach: +-- Pass 1 – insert all parent tasks (parent_task_name IS NULL), capture name→new_id mapping. +-- Pass 2 – insert subtasks using the mapping to set parent_task_id. +CREATE OR REPLACE FUNCTION import_tasks_from_template(_project_id uuid, _user_id uuid, _tasks json) + RETURNS json + LANGUAGE plpgsql +AS +$$ +DECLARE + _task JSON; + _max_sort INT; + _task_id_new UUID; + _default_status_id UUID; + _default_priority_id UUID; + -- Temporary table to map template task name → newly created task UUID + _parent_map JSONB := '{}'::JSONB; + _parent_id UUID; +BEGIN + SELECT COALESCE((SELECT MAX(sort_order) FROM tasks WHERE project_id = _project_id), 0) + INTO _max_sort; + + -- Cache the default status and priority to avoid repeated sub-selects + SELECT id + INTO _default_status_id + FROM task_statuses + WHERE project_id = _project_id + AND category_id IN ( + SELECT id FROM sys_task_status_categories WHERE is_todo IS TRUE + ) + LIMIT 1; + + SELECT id + INTO _default_priority_id + FROM task_priorities + WHERE value = 1; + + -- ------------------------------------------------------- + -- Pass 1: Insert parent tasks (parent_task_name IS NULL) + -- ------------------------------------------------------- + FOR _task IN + SELECT * FROM JSON_ARRAY_ELEMENTS(_tasks) + WHERE (value ->> 'parent_task_name') IS NULL + LOOP + _max_sort := _max_sort + 1; + + INSERT INTO tasks ( + name, priority_id, project_id, reporter_id, status_id, + sort_order, roadmap_sort_order, + status_sort_order, priority_sort_order, phase_sort_order, member_sort_order, + total_minutes + ) + VALUES ( + TRIM((_task ->> 'name')::TEXT), + _default_priority_id, + _project_id, + _user_id, + _default_status_id, + _max_sort, _max_sort, + _max_sort, _max_sort, _max_sort, _max_sort, + COALESCE((_task ->> 'total_minutes')::NUMERIC, 0) + ) + RETURNING id INTO _task_id_new; + + -- Log task creation activity + INSERT INTO task_activity_logs ( + task_id, team_id, attribute_type, user_id, log_type, + old_value, new_value, project_id + ) + VALUES ( + _task_id_new, + (SELECT team_id FROM projects WHERE id = _project_id), + 'status', + _user_id, + 'update', + NULL, + _default_status_id, + _project_id + ); + + -- Store name → new UUID mapping for subtask pass + _parent_map := _parent_map || JSONB_BUILD_OBJECT( + TRIM((_task ->> 'name')::TEXT), + _task_id_new::TEXT + ); + END LOOP; + + -- ------------------------------------------------------- + -- Pass 2: Insert subtasks (parent_task_name IS NOT NULL) + -- ------------------------------------------------------- + FOR _task IN + SELECT * FROM JSON_ARRAY_ELEMENTS(_tasks) + WHERE (value ->> 'parent_task_name') IS NOT NULL + LOOP + -- Resolve parent UUID from the mapping built in pass 1 + _parent_id := (_parent_map ->> TRIM((_task ->> 'parent_task_name')::TEXT))::UUID; + + -- Skip orphaned subtasks whose parent was not found (safety guard) + IF _parent_id IS NULL THEN + CONTINUE; + END IF; + + _max_sort := _max_sort + 1; + + INSERT INTO tasks ( + name, priority_id, project_id, reporter_id, status_id, + parent_task_id, + sort_order, roadmap_sort_order, + status_sort_order, priority_sort_order, phase_sort_order, member_sort_order, + total_minutes + ) + VALUES ( + TRIM((_task ->> 'name')::TEXT), + _default_priority_id, + _project_id, + _user_id, + _default_status_id, + _parent_id, + _max_sort, _max_sort, + _max_sort, _max_sort, _max_sort, _max_sort, + COALESCE((_task ->> 'total_minutes')::NUMERIC, 0) + ) + RETURNING id INTO _task_id_new; + + -- Log subtask creation activity + INSERT INTO task_activity_logs ( + task_id, team_id, attribute_type, user_id, log_type, + old_value, new_value, project_id + ) + VALUES ( + _task_id_new, + (SELECT team_id FROM projects WHERE id = _project_id), + 'status', + _user_id, + 'update', + NULL, + _default_status_id, + _project_id + ); + END LOOP; + + RETURN JSON_BUILD_OBJECT('id', _project_id); +END; +$$; + + `); +}; + +/** @param {import('node-pg-migrate').MigrationBuilder} pgm */ +exports.down = async (_pgm) => { + // Review manually before running migrate:down. +}; diff --git a/worklenz-backend/database/pg-migrations/20260821000036000_task_template_3level_subtask_support.js b/worklenz-backend/database/pg-migrations/20260821000036000_task_template_3level_subtask_support.js new file mode 100644 index 000000000..60ffa635a --- /dev/null +++ b/worklenz-backend/database/pg-migrations/20260821000036000_task_template_3level_subtask_support.js @@ -0,0 +1,358 @@ +'use strict'; +// Converted from database/migrations/20260520000002-task-template-3level-subtask-support.sql + +/** @type {import('node-pg-migrate').ColumnDefinitions | undefined} */ +exports.shorthands = undefined; + +/** @param {import('node-pg-migrate').MigrationBuilder} pgm */ +exports.up = async (pgm) => { + pgm.sql(` +-- ============================================================ +-- Migration: Task Template 3-Level Subtask Support +-- Extends the template functions to handle task > subtask > sub-subtask +-- (3 levels deep, matching the project task hierarchy limit). +-- +-- The task_templates_tasks table already has parent_task_name from the +-- previous migration. No schema change is needed — the same column +-- stores parent references at every depth level. +-- ============================================================ + +-- 1. Replace create_task_template to support 3-level nesting. +-- JSON shape per task: +-- { name, total_minutes, id?, sub_tasks: [{ name, total_minutes, sub_tasks: [...] }] } +CREATE OR REPLACE FUNCTION create_task_template(_name text, _team_id uuid, _tasks json) + RETURNS json + LANGUAGE plpgsql +AS +$$ +DECLARE + _template_id UUID; + _task JSON; + _subtask JSON; + _grandchild JSON; + _parent_name TEXT; + _subtask_name TEXT; +BEGIN + IF EXISTS ( + SELECT 1 FROM task_templates + WHERE LOWER(name) = LOWER(_name) AND team_id = _team_id + ) THEN + RAISE 'TASK_TEMPLATE_EXISTS_ERROR:%', _name; + END IF; + + INSERT INTO task_templates (name, team_id) + VALUES (_name, _team_id) + RETURNING id INTO _template_id; + + FOR _task IN SELECT * FROM JSON_ARRAY_ELEMENTS(_tasks) + LOOP + _parent_name := TRIM((_task ->> 'name')::TEXT); + + -- Level 1: parent task (parent_task_name = NULL) + INSERT INTO task_templates_tasks (template_id, name, total_minutes, parent_task_name) + VALUES ( + _template_id, + _parent_name, + COALESCE( + (SELECT total_minutes FROM tasks WHERE id = (_task ->> 'id')::UUID), + (_task ->> 'total_minutes')::NUMERIC, + 0 + ), + NULL + ); + + -- Level 2: subtasks of the parent + IF (_task -> 'sub_tasks') IS NOT NULL AND JSON_ARRAY_LENGTH(_task -> 'sub_tasks') > 0 THEN + FOR _subtask IN SELECT * FROM JSON_ARRAY_ELEMENTS(_task -> 'sub_tasks') + LOOP + _subtask_name := TRIM((_subtask ->> 'name')::TEXT); + + INSERT INTO task_templates_tasks (template_id, name, total_minutes, parent_task_name) + VALUES ( + _template_id, + _subtask_name, + COALESCE( + (SELECT total_minutes FROM tasks WHERE id = (_subtask ->> 'id')::UUID), + (_subtask ->> 'total_minutes')::NUMERIC, + 0 + ), + _parent_name + ); + + -- Level 3: sub-subtasks of the subtask + IF (_subtask -> 'sub_tasks') IS NOT NULL AND JSON_ARRAY_LENGTH(_subtask -> 'sub_tasks') > 0 THEN + FOR _grandchild IN SELECT * FROM JSON_ARRAY_ELEMENTS(_subtask -> 'sub_tasks') + LOOP + INSERT INTO task_templates_tasks (template_id, name, total_minutes, parent_task_name) + VALUES ( + _template_id, + TRIM((_grandchild ->> 'name')::TEXT), + COALESCE( + (SELECT total_minutes FROM tasks WHERE id = (_grandchild ->> 'id')::UUID), + (_grandchild ->> 'total_minutes')::NUMERIC, + 0 + ), + _subtask_name + ); + END LOOP; + END IF; + END LOOP; + END IF; + END LOOP; + + RETURN JSON_BUILD_OBJECT('id', _template_id, 'template_name', _name); +END +$$; + +-- 2. Replace update_task_template to support 3-level nesting. +CREATE OR REPLACE FUNCTION update_task_template(_id uuid, _name text, _tasks json, _team_id uuid) + RETURNS json + LANGUAGE plpgsql +AS +$$ +DECLARE + _task JSON; + _subtask JSON; + _grandchild JSON; + _parent_name TEXT; + _subtask_name TEXT; +BEGIN + IF EXISTS ( + SELECT 1 FROM task_templates + WHERE LOWER(name) = LOWER(_name) AND team_id = _team_id AND id != _id + ) THEN + RAISE 'TASK_TEMPLATE_EXISTS_ERROR:%', _name; + END IF; + + UPDATE task_templates + SET name = _name, updated_at = NOW() + WHERE id = _id + AND team_id = _team_id; + + IF NOT FOUND THEN + RAISE EXCEPTION 'TASK_TEMPLATE_NOT_FOUND:%', _id; + END IF; + + DELETE FROM task_templates_tasks WHERE template_id = _id; + + FOR _task IN SELECT * FROM JSON_ARRAY_ELEMENTS(_tasks) + LOOP + _parent_name := TRIM((_task ->> 'name')::TEXT); + + -- Level 1 + INSERT INTO task_templates_tasks (template_id, name, total_minutes, parent_task_name) + VALUES ( + _id, + _parent_name, + COALESCE((_task ->> 'total_minutes')::NUMERIC, 0), + NULL + ); + + -- Level 2 + IF (_task -> 'sub_tasks') IS NOT NULL AND JSON_ARRAY_LENGTH(_task -> 'sub_tasks') > 0 THEN + FOR _subtask IN SELECT * FROM JSON_ARRAY_ELEMENTS(_task -> 'sub_tasks') + LOOP + _subtask_name := TRIM((_subtask ->> 'name')::TEXT); + + INSERT INTO task_templates_tasks (template_id, name, total_minutes, parent_task_name) + VALUES ( + _id, + _subtask_name, + COALESCE((_subtask ->> 'total_minutes')::NUMERIC, 0), + _parent_name + ); + + -- Level 3 + IF (_subtask -> 'sub_tasks') IS NOT NULL AND JSON_ARRAY_LENGTH(_subtask -> 'sub_tasks') > 0 THEN + FOR _grandchild IN SELECT * FROM JSON_ARRAY_ELEMENTS(_subtask -> 'sub_tasks') + LOOP + INSERT INTO task_templates_tasks (template_id, name, total_minutes, parent_task_name) + VALUES ( + _id, + TRIM((_grandchild ->> 'name')::TEXT), + COALESCE((_grandchild ->> 'total_minutes')::NUMERIC, 0), + _subtask_name + ); + END LOOP; + END IF; + END LOOP; + END IF; + END LOOP; + + RETURN JSON_BUILD_OBJECT('id', _id, 'template_name', _name); +END +$$; + +-- 3. Replace import_tasks_from_template to support 3-level nesting. +-- +-- The frontend sends a flat array where each row has: +-- { name, total_minutes, parent_task_name } +-- +-- parent_task_name = NULL → level-1 (top-level task) +-- parent_task_name = → level-2 (subtask of L1) +-- parent_task_name = → level-3 (sub-subtask of L2) +-- +-- Three-pass approach: +-- Pass 1 – insert L1 tasks, build L1 name→UUID map (_l1_map) +-- Pass 2 – insert L2 tasks using _l1_map, build L2 name→UUID map (_l2_map) +-- Pass 3 – insert L3 tasks using _l2_map +-- +-- A row is L2 if its parent_task_name exists in _l1_map. +-- A row is L3 if its parent_task_name exists in _l2_map. +-- Orphaned rows (parent not found in either map) are skipped safely. +CREATE OR REPLACE FUNCTION import_tasks_from_template(_project_id uuid, _user_id uuid, _tasks json) + RETURNS json + LANGUAGE plpgsql +AS +$$ +DECLARE + _task JSON; + _max_sort INT; + _task_id_new UUID; + _default_status_id UUID; + _default_priority_id UUID; + _team_id UUID; + -- name → new UUID maps for each level + _l1_map JSONB := '{}'::JSONB; + _l2_map JSONB := '{}'::JSONB; + _parent_id UUID; +BEGIN + -- The current API identifies parents by name. Reject ambiguous input rather + -- than silently attaching a child to the last same-named row in a map. + IF EXISTS ( + SELECT 1 + FROM JSON_ARRAY_ELEMENTS(_tasks) child + WHERE (child ->> 'parent_task_name') IS NOT NULL + AND ( + SELECT COUNT(*) + FROM JSON_ARRAY_ELEMENTS(_tasks) candidate + WHERE TRIM((candidate ->> 'name')::TEXT) = TRIM((child ->> 'parent_task_name')::TEXT) + ) > 1 + ) THEN + RAISE EXCEPTION 'TASK_TEMPLATE_AMBIGUOUS_PARENT'; + END IF; + + SELECT COALESCE((SELECT MAX(sort_order) FROM tasks WHERE project_id = _project_id), 0) + INTO _max_sort; + + SELECT team_id INTO _team_id FROM projects WHERE id = _project_id; + + SELECT id INTO _default_status_id + FROM task_statuses + WHERE project_id = _project_id + AND category_id IN (SELECT id FROM sys_task_status_categories WHERE is_todo IS TRUE) + LIMIT 1; + + SELECT id INTO _default_priority_id + FROM task_priorities WHERE value = 1; + + -- ------------------------------------------------------- + -- Pass 1: Level-1 tasks (parent_task_name IS NULL) + -- ------------------------------------------------------- + FOR _task IN + SELECT * FROM JSON_ARRAY_ELEMENTS(_tasks) + WHERE (value ->> 'parent_task_name') IS NULL + LOOP + _max_sort := _max_sort + 1; + + INSERT INTO tasks ( + name, priority_id, project_id, reporter_id, status_id, + sort_order, roadmap_sort_order, + status_sort_order, priority_sort_order, phase_sort_order, member_sort_order, + total_minutes + ) + VALUES ( + TRIM((_task ->> 'name')::TEXT), + _default_priority_id, _project_id, _user_id, _default_status_id, + _max_sort, _max_sort, _max_sort, _max_sort, _max_sort, _max_sort, + COALESCE((_task ->> 'total_minutes')::NUMERIC, 0) + ) + RETURNING id INTO _task_id_new; + + INSERT INTO task_activity_logs (task_id, team_id, attribute_type, user_id, log_type, old_value, new_value, project_id) + VALUES (_task_id_new, _team_id, 'status', _user_id, 'update', NULL, _default_status_id, _project_id); + + _l1_map := _l1_map || JSONB_BUILD_OBJECT(TRIM((_task ->> 'name')::TEXT), _task_id_new::TEXT); + END LOOP; + + -- ------------------------------------------------------- + -- Pass 2: Level-2 tasks (parent_task_name matches an L1 name) + -- ------------------------------------------------------- + FOR _task IN + SELECT * FROM JSON_ARRAY_ELEMENTS(_tasks) + WHERE (value ->> 'parent_task_name') IS NOT NULL + LOOP + _parent_id := (_l1_map ->> TRIM((_task ->> 'parent_task_name')::TEXT))::UUID; + + -- Only process rows whose parent is a level-1 task + CONTINUE WHEN _parent_id IS NULL; + + _max_sort := _max_sort + 1; + + INSERT INTO tasks ( + name, priority_id, project_id, reporter_id, status_id, + parent_task_id, + sort_order, roadmap_sort_order, + status_sort_order, priority_sort_order, phase_sort_order, member_sort_order, + total_minutes + ) + VALUES ( + TRIM((_task ->> 'name')::TEXT), + _default_priority_id, _project_id, _user_id, _default_status_id, + _parent_id, + _max_sort, _max_sort, _max_sort, _max_sort, _max_sort, _max_sort, + COALESCE((_task ->> 'total_minutes')::NUMERIC, 0) + ) + RETURNING id INTO _task_id_new; + + INSERT INTO task_activity_logs (task_id, team_id, attribute_type, user_id, log_type, old_value, new_value, project_id) + VALUES (_task_id_new, _team_id, 'status', _user_id, 'update', NULL, _default_status_id, _project_id); + + _l2_map := _l2_map || JSONB_BUILD_OBJECT(TRIM((_task ->> 'name')::TEXT), _task_id_new::TEXT); + END LOOP; + + -- ------------------------------------------------------- + -- Pass 3: Level-3 tasks (parent_task_name matches an L2 name) + -- ------------------------------------------------------- + FOR _task IN + SELECT * FROM JSON_ARRAY_ELEMENTS(_tasks) + WHERE (value ->> 'parent_task_name') IS NOT NULL + LOOP + _parent_id := (_l2_map ->> TRIM((_task ->> 'parent_task_name')::TEXT))::UUID; + + -- Only process rows whose parent is a level-2 task + CONTINUE WHEN _parent_id IS NULL; + + _max_sort := _max_sort + 1; + + INSERT INTO tasks ( + name, priority_id, project_id, reporter_id, status_id, + parent_task_id, + sort_order, roadmap_sort_order, + status_sort_order, priority_sort_order, phase_sort_order, member_sort_order, + total_minutes + ) + VALUES ( + TRIM((_task ->> 'name')::TEXT), + _default_priority_id, _project_id, _user_id, _default_status_id, + _parent_id, + _max_sort, _max_sort, _max_sort, _max_sort, _max_sort, _max_sort, + COALESCE((_task ->> 'total_minutes')::NUMERIC, 0) + ) + RETURNING id INTO _task_id_new; + + INSERT INTO task_activity_logs (task_id, team_id, attribute_type, user_id, log_type, old_value, new_value, project_id) + VALUES (_task_id_new, _team_id, 'status', _user_id, 'update', NULL, _default_status_id, _project_id); + END LOOP; + + RETURN JSON_BUILD_OBJECT('id', _project_id); +END; +$$; + + `); +}; + +/** @param {import('node-pg-migrate').MigrationBuilder} pgm */ +exports.down = async (_pgm) => { + // Review manually before running migrate:down. +}; diff --git a/worklenz-backend/database/pg-migrations/20260821000037000_soft_delete_task_comments.js b/worklenz-backend/database/pg-migrations/20260821000037000_soft_delete_task_comments.js new file mode 100644 index 000000000..ae7129f39 --- /dev/null +++ b/worklenz-backend/database/pg-migrations/20260821000037000_soft_delete_task_comments.js @@ -0,0 +1,22 @@ +'use strict'; +// Converted from database/migrations/20260529000001-soft-delete-task-comments.sql + +/** @type {import('node-pg-migrate').ColumnDefinitions | undefined} */ +exports.shorthands = undefined; + +/** @param {import('node-pg-migrate').MigrationBuilder} pgm */ +exports.up = async (pgm) => { + pgm.sql(` +-- Add soft-delete support to task_comments +-- Deleted comments show a "This message was deleted" placeholder instead of disappearing. + +ALTER TABLE task_comments + ADD COLUMN IF NOT EXISTS is_deleted BOOLEAN NOT NULL DEFAULT FALSE; + + `); +}; + +/** @param {import('node-pg-migrate').MigrationBuilder} pgm */ +exports.down = async (_pgm) => { + // Review manually before running migrate:down. +}; diff --git a/worklenz-backend/database/pg-migrations/20260821000038000_add_phase_assignees_enabled.js b/worklenz-backend/database/pg-migrations/20260821000038000_add_phase_assignees_enabled.js new file mode 100644 index 000000000..10b99743f --- /dev/null +++ b/worklenz-backend/database/pg-migrations/20260821000038000_add_phase_assignees_enabled.js @@ -0,0 +1,191 @@ +'use strict'; +// Converted from database/migrations/20260616000000-add-phase-assignees-enabled.sql + +/** @type {import('node-pg-migrate').ColumnDefinitions | undefined} */ +exports.shorthands = undefined; + +/** @param {import('node-pg-migrate').MigrationBuilder} pgm */ +exports.up = async (pgm) => { + pgm.sql(` +-- Migration: Add phase_assignees_enabled feature flag to projects +-- Description: Adds a boolean toggle to enable phase-level assignee enforcement per project. +-- When enabled, each pipeline phase can have a dedicated assignee who is the only +-- one allowed to mark tasks complete in that phase (feature gate for Task 1). +-- Date: 2026-06-16 + +-- 1. Add project-level toggle column +ALTER TABLE projects + ADD COLUMN IF NOT EXISTS phase_assignees_enabled BOOLEAN DEFAULT FALSE; + +COMMENT ON COLUMN projects.phase_assignees_enabled IS + 'When TRUE, each project phase can have a dedicated assignee. Tasks can only be advanced by their phase assignee (or an admin override).'; + +-- 2. Update create_project to include phase_assignees_enabled +CREATE OR REPLACE FUNCTION create_project(_body json) RETURNS json + LANGUAGE plpgsql +AS +$$ +DECLARE + _user_id UUID; + _team_id UUID; + _client_id UUID; + _project_id UUID; + _client_name TEXT; + _project_name TEXT; + _team_member_id UUID; +BEGIN + _client_name = TRIM((_body ->> 'client_name')::TEXT); + _project_name = TRIM((_body ->> 'name')::TEXT); + _user_id = (_body ->> 'user_id')::UUID; + _team_id = (_body ->> 'team_id')::UUID; + + SELECT id FROM clients WHERE LOWER(name) = LOWER(_client_name) AND team_id = _team_id INTO _client_id; + SELECT id FROM team_members WHERE team_id = _team_id AND user_id = _user_id INTO _team_member_id; + + IF EXISTS(SELECT name FROM projects WHERE LOWER(name) = LOWER(_project_name) AND team_id = _team_id) + THEN + RAISE 'PROJECT_EXISTS_ERROR:%', _project_name; + END IF; + + IF is_null_or_empty(_client_id) IS TRUE AND is_null_or_empty(_client_name) IS FALSE + THEN + INSERT INTO clients (name, team_id) VALUES (_client_name, _team_id) RETURNING id INTO _client_id; + END IF; + + INSERT INTO projects (name, key, notes, color_code, team_id, client_id, owner_id, status_id, health_id, priority_id, start_date, + end_date, folder_id, category_id, estimated_working_days, estimated_man_days, hours_per_day, + use_manual_progress, use_weighted_progress, use_time_progress, auto_assign_task_creator, + restrict_task_creation, phase_assignees_enabled) + VALUES (_project_name, (_body ->> 'key')::TEXT, (_body ->> 'notes')::TEXT, (_body ->> 'color_code')::TEXT, _team_id, + _client_id, _user_id, (_body ->> 'status_id')::UUID, (_body ->> 'health_id')::UUID, + COALESCE((_body ->> 'priority_id')::UUID, (SELECT id FROM sys_project_priorities WHERE name = 'Medium' LIMIT 1)), + (_body ->> 'start_date')::TIMESTAMPTZ, (_body ->> 'end_date')::TIMESTAMPTZ, + (_body ->> 'folder_id')::UUID, (_body ->> 'category_id')::UUID, + (_body ->> 'working_days')::INTEGER, (_body ->> 'man_days')::INTEGER, (_body ->> 'hours_per_day')::INTEGER, + COALESCE((_body ->> 'use_manual_progress')::BOOLEAN, FALSE), + COALESCE((_body ->> 'use_weighted_progress')::BOOLEAN, FALSE), + COALESCE((_body ->> 'use_time_progress')::BOOLEAN, FALSE), + COALESCE((_body ->> 'auto_assign_task_creator')::BOOLEAN, FALSE), + COALESCE((_body ->> 'restrict_task_creation')::BOOLEAN, FALSE), + COALESCE((_body ->> 'phase_assignees_enabled')::BOOLEAN, FALSE)) + RETURNING id INTO _project_id; + + INSERT INTO project_logs (team_id, project_id, description) + VALUES (_team_id, _project_id, + REPLACE((_body ->> 'project_created_log')::TEXT, '@user', + (SELECT name FROM users WHERE id = _user_id))); + + INSERT INTO project_members (team_member_id, project_access_level_id, project_id, role_id) + VALUES (_team_member_id, (SELECT id FROM project_access_levels WHERE key = 'ADMIN'), + _project_id, + (SELECT id FROM roles WHERE team_id = _team_id AND default_role IS TRUE)); + + INSERT INTO task_statuses (name, project_id, team_id, category_id, sort_order) + VALUES ('To Do', _project_id, _team_id, (SELECT id FROM sys_task_status_categories WHERE is_todo IS TRUE), 0); + INSERT INTO task_statuses (name, project_id, team_id, category_id, sort_order) + VALUES ('Doing', _project_id, _team_id, (SELECT id FROM sys_task_status_categories WHERE is_doing IS TRUE), 1); + INSERT INTO task_statuses (name, project_id, team_id, category_id, sort_order) + VALUES ('Done', _project_id, _team_id, (SELECT id FROM sys_task_status_categories WHERE is_done IS TRUE), 2); + + PERFORM insert_task_list_columns(_project_id); + + RETURN JSON_BUILD_OBJECT('id', _project_id, 'name', (_body ->> 'name')::TEXT); +END; +$$; + +-- 3. Update update_project to include phase_assignees_enabled +CREATE OR REPLACE FUNCTION update_project(_body json) RETURNS json + LANGUAGE plpgsql +AS +$$ +DECLARE + _user_id UUID; + _team_id UUID; + _client_id UUID; + _project_id UUID; + _project_manager_team_member_id UUID; + _client_name TEXT; + _project_name TEXT; +BEGIN + _client_name = TRIM((_body ->> 'client_name')::TEXT); + _project_name = TRIM((_body ->> 'name')::TEXT); + _user_id = (_body ->> 'user_id')::UUID; + _team_id = (_body ->> 'team_id')::UUID; + _project_manager_team_member_id = (_body ->> 'team_member_id')::UUID; + + SELECT id FROM clients WHERE LOWER(name) = LOWER(_client_name) AND team_id = _team_id INTO _client_id; + + IF is_null_or_empty(_client_id) IS TRUE AND is_null_or_empty(_client_name) IS FALSE + THEN + INSERT INTO clients (name, team_id) VALUES (_client_name, _team_id) RETURNING id INTO _client_id; + END IF; + + IF EXISTS( + SELECT name FROM projects WHERE LOWER(name) = LOWER(_project_name) + AND team_id = _team_id AND id != (_body ->> 'id')::UUID + ) + THEN + RAISE 'PROJECT_EXISTS_ERROR:%', _project_name; + END IF; + + UPDATE projects + SET name = _project_name, + notes = (_body ->> 'notes')::TEXT, + color_code = (_body ->> 'color_code')::TEXT, + status_id = (_body ->> 'status_id')::UUID, + health_id = (_body ->> 'health_id')::UUID, + priority_id = COALESCE((_body ->> 'priority_id')::UUID, (SELECT id FROM sys_project_priorities WHERE name = 'Medium' LIMIT 1)), + key = (_body ->> 'key')::TEXT, + start_date = (_body ->> 'start_date')::TIMESTAMPTZ, + end_date = (_body ->> 'end_date')::TIMESTAMPTZ, + client_id = _client_id, + folder_id = (_body ->> 'folder_id')::UUID, + category_id = (_body ->> 'category_id')::UUID, + updated_at = CURRENT_TIMESTAMP, + estimated_working_days = (_body ->> 'working_days')::INTEGER, + estimated_man_days = (_body ->> 'man_days')::INTEGER, + hours_per_day = (_body ->> 'hours_per_day')::INTEGER, + use_manual_progress = COALESCE((_body ->> 'use_manual_progress')::BOOLEAN, FALSE), + use_weighted_progress = COALESCE((_body ->> 'use_weighted_progress')::BOOLEAN, FALSE), + use_time_progress = COALESCE((_body ->> 'use_time_progress')::BOOLEAN, FALSE), + auto_assign_task_creator = CASE + WHEN _body::JSONB ? 'auto_assign_task_creator' + THEN COALESCE((_body ->> 'auto_assign_task_creator')::BOOLEAN, FALSE) + ELSE COALESCE(auto_assign_task_creator, FALSE) + END, + restrict_task_creation = CASE + WHEN _body::JSONB ? 'restrict_task_creation' + THEN COALESCE((_body ->> 'restrict_task_creation')::BOOLEAN, FALSE) + ELSE COALESCE(restrict_task_creation, FALSE) + END, + phase_assignees_enabled = CASE + WHEN _body::JSONB ? 'phase_assignees_enabled' + THEN COALESCE((_body ->> 'phase_assignees_enabled')::BOOLEAN, FALSE) + ELSE COALESCE(phase_assignees_enabled, FALSE) + END + WHERE id = (_body ->> 'id')::UUID + AND team_id = _team_id + RETURNING id INTO _project_id; + + UPDATE project_members SET project_access_level_id = (SELECT id FROM project_access_levels WHERE key = 'MEMBER') WHERE project_id = _project_id; + + IF NOT (_project_manager_team_member_id IS NULL) + THEN + PERFORM update_project_manager(_project_manager_team_member_id, _project_id::UUID); + END IF; + + RETURN JSON_BUILD_OBJECT( + 'id', _project_id, + 'name', (_body ->> 'name')::TEXT, + 'project_manager_id', _project_manager_team_member_id::UUID + ); +END; +$$; + + `); +}; + +/** @param {import('node-pg-migrate').MigrationBuilder} pgm */ +exports.down = async (_pgm) => { + // Review manually before running migrate:down. +}; diff --git a/worklenz-backend/database/pg-migrations/20260821000039000_add_default_assignee_to_phases.js b/worklenz-backend/database/pg-migrations/20260821000039000_add_default_assignee_to_phases.js new file mode 100644 index 000000000..046c5aa4d --- /dev/null +++ b/worklenz-backend/database/pg-migrations/20260821000039000_add_default_assignee_to_phases.js @@ -0,0 +1,28 @@ +'use strict'; +// Converted from database/migrations/20260616000001-add-default-assignee-to-phases.sql + +/** @type {import('node-pg-migrate').ColumnDefinitions | undefined} */ +exports.shorthands = undefined; + +/** @param {import('node-pg-migrate').MigrationBuilder} pgm */ +exports.up = async (pgm) => { + pgm.sql(` +-- Migration: Add default_assignee_id to project_phases +-- Description: Adds a default assignee (team member) to each project phase. +-- When a task enters this phase and phase_assignees_enabled is ON for the project, +-- the task is automatically assigned to this team member. +-- Date: 2026-06-16 + +ALTER TABLE project_phases + ADD COLUMN IF NOT EXISTS default_assignee_id UUID REFERENCES team_members(id) ON DELETE SET NULL; + +COMMENT ON COLUMN project_phases.default_assignee_id IS + 'The team member automatically assigned to tasks when they enter this phase (requires project.phase_assignees_enabled = TRUE).'; + + `); +}; + +/** @param {import('node-pg-migrate').MigrationBuilder} pgm */ +exports.down = async (_pgm) => { + // Review manually before running migrate:down. +}; diff --git a/worklenz-backend/database/pg-migrations/20260821000040000_add_annual_pro_license_type.js b/worklenz-backend/database/pg-migrations/20260821000040000_add_annual_pro_license_type.js new file mode 100644 index 000000000..10308c8c3 --- /dev/null +++ b/worklenz-backend/database/pg-migrations/20260821000040000_add_annual_pro_license_type.js @@ -0,0 +1,35 @@ +'use strict'; +// Converted from database/migrations/release-v2.6/20260626000001-add-annual-pro-license-type.sql + +/** @type {import('node-pg-migrate').ColumnDefinitions | undefined} */ +exports.shorthands = undefined; + +/** @param {import('node-pg-migrate').MigrationBuilder} pgm */ +exports.up = async (pgm) => { + pgm.sql(` + INSERT INTO sys_license_types (id, key, name) + SELECT uuid_generate_v4(), 'ANNUAL_PRO', 'Annual Pro Plan' + WHERE NOT EXISTS (SELECT 1 FROM sys_license_types WHERE key = 'ANNUAL_PRO'); + + DO $$ + BEGIN + IF EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name = 'licensing_custom_subs') AND + EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name = 'licensing_custom_plan_pricing') THEN + UPDATE organizations + SET license_type_id = (SELECT id FROM sys_license_types WHERE key = 'ANNUAL_PRO') + WHERE user_id IN ( + SELECT lcs.user_id + FROM licensing_custom_subs lcs + JOIN licensing_custom_plan_pricing lpp ON lpp.id = lcs.plan_tier_id + WHERE lcs.status IN ('active', 'pending') + AND lpp.tier_name = 'pro' + ); + END IF; + END $$; + `); +}; + +/** @param {import('node-pg-migrate').MigrationBuilder} pgm */ +exports.down = async (_pgm) => { + // Review manually before running migrate:down. +}; diff --git a/worklenz-backend/database/pg-migrations/20260821000041000_add_comment_id_to_user_notifications.js b/worklenz-backend/database/pg-migrations/20260821000041000_add_comment_id_to_user_notifications.js new file mode 100644 index 000000000..0090e4815 --- /dev/null +++ b/worklenz-backend/database/pg-migrations/20260821000041000_add_comment_id_to_user_notifications.js @@ -0,0 +1,26 @@ +'use strict'; +// Converted from database/migrations/20260709000001-add-comment-id-to-user-notifications.sql + +/** @type {import('node-pg-migrate').ColumnDefinitions | undefined} */ +exports.shorthands = undefined; + +/** @param {import('node-pg-migrate').MigrationBuilder} pgm */ +exports.up = async (pgm) => { + pgm.sql(` +-- Add comment_id to user_notifications to support deep-linking to the exact +-- comment that triggered a notification. When a comment notification is created, +-- the comment's ID is stored so the frontend can scroll directly to it. +-- +-- Rollback: ALTER TABLE user_notifications DROP COLUMN IF EXISTS comment_id; +--2026/07/09 + +ALTER TABLE user_notifications + ADD COLUMN IF NOT EXISTS comment_id UUID REFERENCES task_comments(id) ON DELETE SET NULL; + + `); +}; + +/** @param {import('node-pg-migrate').MigrationBuilder} pgm */ +exports.down = async (_pgm) => { + // Review manually before running migrate:down. +}; diff --git a/worklenz-backend/database/pg-migrations/20260821000042000_fix_email_notifications_default.js b/worklenz-backend/database/pg-migrations/20260821000042000_fix_email_notifications_default.js new file mode 100644 index 000000000..2a3180c1a --- /dev/null +++ b/worklenz-backend/database/pg-migrations/20260821000042000_fix_email_notifications_default.js @@ -0,0 +1,34 @@ +'use strict'; +// Converted from database/migrations/20260714000000-fix-email-notifications-default.sql + +/** @type {import('node-pg-migrate').ColumnDefinitions | undefined} */ +exports.shorthands = undefined; + +/** @param {import('node-pg-migrate').MigrationBuilder} pgm */ +exports.up = async (pgm) => { + pgm.sql(` +-- Fix trigger to explicitly set email_notifications_enabled = TRUE for new team members. +-- Existing rows are not modified to preserve explicit user opt-outs. + +CREATE OR REPLACE FUNCTION notification_settings_insert_trigger_fn() RETURNS TRIGGER AS +$$ +DECLARE +BEGIN + IF (NOT EXISTS(SELECT 1 FROM notification_settings WHERE team_id = NEW.team_id AND user_id = NEW.user_id)) AND + (is_null_or_empty(NEW.user_id) IS FALSE) AND (EXISTS(SELECT 1 FROM users WHERE id = NEW.user_id)) + THEN + INSERT INTO notification_settings (email_notifications_enabled, popup_notifications_enabled, show_unread_items_count, user_id, team_id) + VALUES (TRUE, TRUE, TRUE, NEW.user_id, NEW.team_id); + END IF; + + RETURN NEW; +END +$$ LANGUAGE plpgsql; + + `); +}; + +/** @param {import('node-pg-migrate').MigrationBuilder} pgm */ +exports.down = async (_pgm) => { + // Review manually before running migrate:down. +}; diff --git a/worklenz-backend/database/pg-migrations/20260821000043000_add_base_currency_to_organizations.js b/worklenz-backend/database/pg-migrations/20260821000043000_add_base_currency_to_organizations.js new file mode 100644 index 000000000..5ec6f52a4 --- /dev/null +++ b/worklenz-backend/database/pg-migrations/20260821000043000_add_base_currency_to_organizations.js @@ -0,0 +1,43 @@ +'use strict'; +// Converted from database/migrations/release-v2.6/20260727000000-add-base-currency-to-organizations.sql + +/** @type {import('node-pg-migrate').ColumnDefinitions | undefined} */ +exports.shorthands = undefined; + +/** @param {import('node-pg-migrate').MigrationBuilder} pgm */ +exports.up = async (pgm) => { + pgm.sql(` +-- Migration: Add base_currency to organizations +-- Description: Adds an organization-level base currency setting. +-- Finance Overview KPI cards convert all project amounts to this +-- currency using live exchange rates for unified totals. +-- Defaults to USD; organizations can change it at any time. +-- Date: 2026-07-27 + +-- ============================================================ +-- UP (apply) +-- ============================================================ + +ALTER TABLE organizations + ADD COLUMN IF NOT EXISTS base_currency VARCHAR(10) NOT NULL DEFAULT 'USD'; + +-- Backfill any existing rows that somehow have a NULL (safety net) +UPDATE organizations SET base_currency = 'USD' WHERE base_currency IS NULL OR base_currency = ''; + +COMMENT ON COLUMN organizations.base_currency IS + 'Organization preferred base currency (ISO 4217 code, e.g. USD, EUR, LKR). + Finance Overview KPI totals are always converted to this currency. + Defaults to USD. Organizations can change it in Settings > Financial & Billing > Organization Currency.'; + +-- ============================================================ +-- DOWN (rollback) — run this block to undo the migration +-- ============================================================ +-- ALTER TABLE organizations DROP COLUMN IF EXISTS base_currency; + + `); +}; + +/** @param {import('node-pg-migrate').MigrationBuilder} pgm */ +exports.down = async (_pgm) => { + // Review manually before running migrate:down. +}; diff --git a/worklenz-backend/database/pg-migrations/20260821000044000_enforce_task_name_250_char_limit.js b/worklenz-backend/database/pg-migrations/20260821000044000_enforce_task_name_250_char_limit.js new file mode 100644 index 000000000..ccd9eab69 --- /dev/null +++ b/worklenz-backend/database/pg-migrations/20260821000044000_enforce_task_name_250_char_limit.js @@ -0,0 +1,154 @@ +'use strict'; +// Converted from database/migrations/20260728000001-enforce-task-name-250-char-limit.sql + +/** @type {import('node-pg-migrate').ColumnDefinitions | undefined} */ +exports.shorthands = undefined; + +/** @param {import('node-pg-migrate').MigrationBuilder} pgm */ +exports.up = async (pgm) => { + pgm.sql(` +-- Migration: Enforce 250-character limit on task names in create_quick_task +-- Date: 2026-07-28 +-- Problem: create_quick_task() was not truncating task names to 250 characters, +-- allowing names up to 500 characters, violating the business rule +-- Solution: Update create_quick_task() to use SUBSTRING(..., 1, 250) on task names + +CREATE OR REPLACE FUNCTION create_quick_task(_body json) RETURNS json + LANGUAGE plpgsql +AS +$$ +DECLARE + _task_id UUID; + _parent_task UUID; + _status_id UUID; + _priority_id UUID; + _start_date TIMESTAMP; + _end_date TIMESTAMP; + _schedule_id UUID; + _description TEXT; + _next_sort_order INTEGER; + _auto_assign_task_creator BOOLEAN; + _reporter_id UUID; + _project_id UUID; + _team_id UUID; + _team_member_id UUID; + _is_admin BOOLEAN; +BEGIN + _reporter_id = (_body ->> 'reporter_id')::UUID; + _project_id = (_body ->> 'project_id')::UUID; + _parent_task = (_body ->> 'parent_task_id')::UUID; + _schedule_id = (_body ->> 'schedule_id')::UUID; + _description = (_body ->> 'description')::TEXT; + + _status_id = COALESCE( + (_body ->> 'status_id')::UUID, + (SELECT id + FROM task_statuses + WHERE project_id = _project_id + AND category_id IN (SELECT id FROM sys_task_status_categories WHERE is_todo IS TRUE) + LIMIT 1) + ); + _priority_id = COALESCE((_body ->> 'priority_id')::UUID, (SELECT id FROM task_priorities WHERE value = 1)); + _start_date = (_body ->> 'start_date')::TIMESTAMP; + _end_date = (_body ->> 'end_date')::TIMESTAMP; + + -- Calculate the next sort order value once and apply it to every sort column. + -- Using GREATEST() across all six columns guarantees the new task lands at the + -- bottom regardless of which column had the highest current value. + SELECT COALESCE(MAX(GREATEST( + COALESCE(sort_order, 0), + COALESCE(roadmap_sort_order, 0), + COALESCE(status_sort_order, 0), + COALESCE(priority_sort_order, 0), + COALESCE(phase_sort_order, 0), + COALESCE(member_sort_order, 0) + )) + 1, 0) + INTO _next_sort_order + FROM tasks + WHERE project_id = _project_id; + + INSERT INTO tasks ( + name, + priority_id, + project_id, + reporter_id, + status_id, + parent_task_id, + sort_order, + roadmap_sort_order, + status_sort_order, + priority_sort_order, + phase_sort_order, + member_sort_order, + start_date, + end_date, + schedule_id, + description + ) + VALUES ( + SUBSTRING(TRIM((_body ->> 'name')::TEXT), 1, 250), + _priority_id, + _project_id, + _reporter_id, + _status_id, + _parent_task, + _next_sort_order, + _next_sort_order, + _next_sort_order, + _next_sort_order, + _next_sort_order, + _next_sort_order, + _start_date, + _end_date, + _schedule_id, + _description + ) + RETURNING id INTO _task_id; + + PERFORM handle_on_task_phase_change(_task_id, (_body ->> 'phase_id')::UUID); + + -- Check if auto-assign is enabled for this project + SELECT auto_assign_task_creator, team_id + INTO _auto_assign_task_creator, _team_id + FROM projects + WHERE id = _project_id; + + -- If auto-assign is enabled, assign the task creator + IF _auto_assign_task_creator IS TRUE THEN + -- Get the team_member_id and check if their role is admin or owner + SELECT tm.id, (r.admin_role OR r.owner) + INTO _team_member_id, _is_admin + FROM team_members tm + INNER JOIN roles r ON tm.role_id = r.id + WHERE tm.user_id = _reporter_id + AND tm.team_id = _team_id; + + IF _team_member_id IS NOT NULL THEN + -- Check if user is already a project member + IF NOT EXISTS ( + SELECT 1 FROM project_members + WHERE project_id = _project_id + AND team_member_id = _team_member_id + ) THEN + -- Only auto-add and assign if user is admin or owner + IF _is_admin IS TRUE THEN + PERFORM create_task_assignee(_team_member_id, _project_id, _task_id, _reporter_id); + END IF; + ELSE + -- User is already a project member, assign them to the task + PERFORM create_task_assignee(_team_member_id, _project_id, _task_id, _reporter_id); + END IF; + END IF; + END IF; + + RETURN get_single_task(_task_id); +END; +$$; + + `); +}; + +/** @param {import('node-pg-migrate').MigrationBuilder} pgm */ +exports.down = async (_pgm) => { + // Review manually before running migrate:down. +}; diff --git a/worklenz-backend/database/pg-migrations/20260821000045000_fix_task_name_constraint_to_250_chars.js b/worklenz-backend/database/pg-migrations/20260821000045000_fix_task_name_constraint_to_250_chars.js new file mode 100644 index 000000000..52e034043 --- /dev/null +++ b/worklenz-backend/database/pg-migrations/20260821000045000_fix_task_name_constraint_to_250_chars.js @@ -0,0 +1,36 @@ +'use strict'; +// Converted from database/migrations/20260728000002-fix-task-name-constraint-to-250-chars.sql + +/** @type {import('node-pg-migrate').ColumnDefinitions | undefined} */ +exports.shorthands = undefined; + +/** @param {import('node-pg-migrate').MigrationBuilder} pgm */ +exports.up = async (pgm) => { + pgm.sql(` +-- Migration: Fix tasks_name_check constraint from 500 to 250 characters +-- Date: 2026-07-28 +-- Problem: The tasks_name_check constraint was set to 500 characters max, +-- but the business requirement is 250 characters max +-- Solution: Drop the old constraint and create a new one with 250 character limit + +-- Drop the old constraint +ALTER TABLE tasks DROP CONSTRAINT IF EXISTS tasks_name_check; + +-- Ensure existing task names comply with 250 char limit before adding constraint +UPDATE tasks SET name = SUBSTRING(name, 1, 250) WHERE CHAR_LENGTH(name) > 250; + +-- Create new constraint with 250 character limit +DO $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'tasks_name_check' AND conrelid = 'tasks'::regclass) THEN + ALTER TABLE tasks ADD CONSTRAINT tasks_name_check CHECK (CHAR_LENGTH(name) <= 250); + END IF; +END $$; + + `); +}; + +/** @param {import('node-pg-migrate').MigrationBuilder} pgm */ +exports.down = async (_pgm) => { + // Review manually before running migrate:down. +}; diff --git a/worklenz-backend/database/pg-migrations/20260821000046000_add_timelog_backdate_limit.js b/worklenz-backend/database/pg-migrations/20260821000046000_add_timelog_backdate_limit.js new file mode 100644 index 000000000..81a799c78 --- /dev/null +++ b/worklenz-backend/database/pg-migrations/20260821000046000_add_timelog_backdate_limit.js @@ -0,0 +1,37 @@ +'use strict'; +// Converted from database/migrations/release-v2.6/20260731000000-add-timelog-backdate-limit.sql + +/** @type {import('node-pg-migrate').ColumnDefinitions | undefined} */ +exports.shorthands = undefined; + +/** @param {import('node-pg-migrate').MigrationBuilder} pgm */ +exports.up = async (pgm) => { + pgm.sql(` +-- Migration: Add timelog backdate limit +-- Description: Organization-level cap on how far back a user may date a manual time log. +-- 0 (the default) means unlimited, preserving existing behaviour for all orgs. +-- Date: 2026-07-31 + +ALTER TABLE organizations + ADD COLUMN IF NOT EXISTS timelog_backdate_limit_days INTEGER DEFAULT 0 NOT NULL; + +COMMENT ON COLUMN organizations.timelog_backdate_limit_days IS + 'Maximum number of days a manual time log may be backdated. 0 means no limit. Enforced on time log create and on edits that change the log date.'; + +ALTER TABLE organizations + DROP CONSTRAINT IF EXISTS organizations_timelog_backdate_limit_days_check; + +DO $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'organizations_timelog_backdate_limit_days_check') THEN + ALTER TABLE organizations ADD CONSTRAINT organizations_timelog_backdate_limit_days_check CHECK (timelog_backdate_limit_days >= 0 AND timelog_backdate_limit_days <= 365); + END IF; +END $$; + + `); +}; + +/** @param {import('node-pg-migrate').MigrationBuilder} pgm */ +exports.down = async (_pgm) => { + // Review manually before running migrate:down. +}; diff --git a/worklenz-backend/database/pg-migrations/20260821000047000_add_tasks_assignees_assigned_by_index.js b/worklenz-backend/database/pg-migrations/20260821000047000_add_tasks_assignees_assigned_by_index.js new file mode 100644 index 000000000..64a7b0883 --- /dev/null +++ b/worklenz-backend/database/pg-migrations/20260821000047000_add_tasks_assignees_assigned_by_index.js @@ -0,0 +1,28 @@ +'use strict'; +// Converted from database/migrations/20260731000000-add-tasks-assignees-assigned-by-index.sql + +/** @type {import('node-pg-migrate').ColumnDefinitions | undefined} */ +exports.shorthands = undefined; + +/** @param {import('node-pg-migrate').MigrationBuilder} pgm */ +exports.up = async (pgm) => { + pgm.sql(` +-- Index for "assigned by me" / "all" task lookups on Home > My Tasks +-- Migration: 20260731000000-add-tasks-assignees-assigned-by-index.sql +-- +-- getTasksByGroupClosure() (home-page-controller.ts) filters tasks_assignees +-- by assigned_by for GROUP_BY_ASSIGN_BY_ME / GROUP_BY_ALL. Previously this +-- branch referenced a nonexistent tasks_assignees.team_id column and errored +-- on every request, so the query never actually ran. Now that it's fixed, +-- this index is needed to avoid a full table scan on tasks_assignees. + +CREATE INDEX IF NOT EXISTS idx_tasks_assignees_assigned_by +ON tasks_assignees(assigned_by); + + `); +}; + +/** @param {import('node-pg-migrate').MigrationBuilder} pgm */ +exports.down = async (_pgm) => { + // Review manually before running migrate:down. +}; diff --git a/worklenz-backend/database/pg-migrations/20260821000048000_add_personal_todo_list_user_dates_index.js b/worklenz-backend/database/pg-migrations/20260821000048000_add_personal_todo_list_user_dates_index.js new file mode 100644 index 000000000..9a44ea576 --- /dev/null +++ b/worklenz-backend/database/pg-migrations/20260821000048000_add_personal_todo_list_user_dates_index.js @@ -0,0 +1,28 @@ +'use strict'; +// Converted from database/migrations/20260802000000-add-personal-todo-list-user-dates-index.sql + +/** @type {import('node-pg-migrate').ColumnDefinitions | undefined} */ +exports.shorthands = undefined; + +/** @param {import('node-pg-migrate').MigrationBuilder} pgm */ +exports.up = async (pgm) => { + pgm.sql(` +-- Index for personal to-do list date-range filtering +-- Migration: 20260802000000-add-personal-todo-list-user-dates-index.sql +-- +-- getPersonalTasks() (home-page-controller.ts) filters by user_id plus a +-- created_at/updated_at range (today/week/month/year) and sorts by one of +-- those two columns. The only existing index on personal_todo_list is a +-- UNIQUE constraint on (user_id, index), which doesn't help this query. +-- Runs on every Home > To-do page load, plus a 60-second poll per open tab. + +CREATE INDEX IF NOT EXISTS idx_personal_todo_list_user_dates +ON personal_todo_list(user_id, created_at, updated_at); + + `); +}; + +/** @param {import('node-pg-migrate').MigrationBuilder} pgm */ +exports.down = async (_pgm) => { + // Review manually before running migrate:down. +}; diff --git a/worklenz-backend/database/pg-migrations/20260821000049000_add_task_phase_phase_id_index.js b/worklenz-backend/database/pg-migrations/20260821000049000_add_task_phase_phase_id_index.js new file mode 100644 index 000000000..22016967f --- /dev/null +++ b/worklenz-backend/database/pg-migrations/20260821000049000_add_task_phase_phase_id_index.js @@ -0,0 +1,30 @@ +'use strict'; +// Converted from database/migrations/20260803000000-add-task-phase-phase-id-index.sql + +/** @type {import('node-pg-migrate').ColumnDefinitions | undefined} */ +exports.shorthands = undefined; + +/** @param {import('node-pg-migrate').MigrationBuilder} pgm */ +exports.up = async (pgm) => { + pgm.sql(` +-- Index for task_phase lookups by phase_id +-- Migration: 20260803000000-add-task-phase-phase-id-index.sql +-- +-- GanttController.getProjectPhases (gantt-controller.ts) joins +-- task_phase -> tasks -> task_statuses -> sys_task_status_categories and +-- filters/joins on tp.phase_id to compute per-phase task counts for the +-- Roadmap tab. The only existing index on task_phase is +-- idx_task_phase_task_phase(task_id, phase_id), which can't be used for an +-- index seek on phase_id since it's the trailing column. Runs on every +-- Roadmap tab load. + +CREATE INDEX IF NOT EXISTS idx_task_phase_phase_id +ON task_phase(phase_id, task_id); + + `); +}; + +/** @param {import('node-pg-migrate').MigrationBuilder} pgm */ +exports.down = async (_pgm) => { + // Review manually before running migrate:down. +}; diff --git a/worklenz-backend/database/sql/1_tables.sql b/worklenz-backend/database/sql/1_tables.sql index 70dbb0333..7cb543ad6 100644 --- a/worklenz-backend/database/sql/1_tables.sql +++ b/worklenz-backend/database/sql/1_tables.sql @@ -1309,7 +1309,8 @@ CREATE TABLE IF NOT EXISTS task_statuses ( project_id UUID NOT NULL, team_id UUID NOT NULL, category_id UUID NOT NULL, - sort_order INTEGER DEFAULT 0 NOT NULL + sort_order INTEGER DEFAULT 0 NOT NULL, + color_code TEXT DEFAULT NULL ); ALTER TABLE task_statuses @@ -2169,7 +2170,7 @@ ALTER TABLE task_recurring_schedules ON DELETE SET NULL; 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) WHERE schedule_id IS NOT NULL AND end_date IS NOT NULL; CREATE INDEX IF NOT EXISTS idx_task_recurring_schedules_timezone_id diff --git a/worklenz-backend/scripts/convert-unmigrated-sql.js b/worklenz-backend/scripts/convert-unmigrated-sql.js new file mode 100644 index 000000000..804aa17a6 --- /dev/null +++ b/worklenz-backend/scripts/convert-unmigrated-sql.js @@ -0,0 +1,152 @@ +#!/usr/bin/env node +'use strict'; + +/** + * Converts remaining unmigrated SQL files into standard node-pg-migrate JS migrations + * inside database/pg-migrations. + */ + +const fs = require('fs'); +const path = require('path'); + +const ROOT = path.join(__dirname, '..'); +const OUT_DIR = path.join(ROOT, 'database', 'pg-migrations'); + +function makeIdempotent(sql) { + let s = sql; + s = s.replace(/\bCREATE TABLE\b(?!\s+IF\s+NOT\s+EXISTS\b)/gi, 'CREATE TABLE IF NOT EXISTS'); + s = s.replace(/\bADD COLUMN\b(?!\s+IF\s+NOT\s+EXISTS\b)/gi, 'ADD COLUMN IF NOT EXISTS'); + s = s.replace(/\bCREATE UNIQUE INDEX\b(?!\s+IF\s+NOT\s+EXISTS\b)/gi, 'CREATE UNIQUE INDEX IF NOT EXISTS'); + s = s.replace(/\bCREATE INDEX\b(?!\s+IF\s+NOT\s+EXISTS\b)/gi, 'CREATE INDEX IF NOT EXISTS'); + s = s.replace(/\bCREATE SEQUENCE\b(?!\s+IF\s+NOT\s+EXISTS\b)/gi, 'CREATE SEQUENCE IF NOT EXISTS'); + s = s.replace(/\bDROP TABLE\b(?!\s+IF\s+EXISTS\b)/gi, 'DROP TABLE IF EXISTS'); + s = s.replace(/\bDROP INDEX\b(?!\s+IF\s+EXISTS\b)/gi, 'DROP INDEX IF EXISTS'); + s = s.replace(/\bDROP SEQUENCE\b(?!\s+IF\s+EXISTS\b)/gi, 'DROP SEQUENCE IF EXISTS'); + + // Wrap ADD CONSTRAINT in pg_constraint existence check + s = s.replace( + /ALTER\s+TABLE\s+([^\s;]+)\s+ADD\s+CONSTRAINT\s+([^\s;]+)\s+([^;]+);/gi, + (match, table, constraint, rest) => { + const cleanCon = constraint.replace(/["']/g, ''); + return `DO $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = '${cleanCon}' AND conrelid = '${table}'::regclass) THEN + ALTER TABLE ${table} ADD CONSTRAINT ${constraint} ${rest}; + END IF; +END $$;`; + } + ); + + return s; +} + + +function escapeForTemplateLiteral(str) { + return str.replace(/\\/g, '\\\\').replace(/`/g, '\\`').replace(/\$\{/g, '\\${'); +} + +function removeTopLevelTransactions(sql) { + return sql.replace(/^\s*(BEGIN|COMMIT)\s*;/gim, ''); +} + +function buildMigrationJS(description, sql) { + const safe = escapeForTemplateLiteral(makeIdempotent(removeTopLevelTransactions(sql))); + return `'use strict'; +// ${description} + +/** @type {import('node-pg-migrate').ColumnDefinitions | undefined} */ +exports.shorthands = undefined; + +/** @param {import('node-pg-migrate').MigrationBuilder} pgm */ +exports.up = async (pgm) => { + pgm.sql(\` +${safe} + \`); +}; + +/** @param {import('node-pg-migrate').MigrationBuilder} pgm */ +exports.down = async (_pgm) => { + // Review manually before running migrate:down. +}; +`; +} + +const FILES_TO_CONVERT = [ + { rel: 'database/migrations/import-tasks/20260101000000-create-imports.sql', name: 'create_import_tasks_tables' }, + { rel: 'database/migrations/import-tasks/20260106000001-add-position-import-hierarchy.sql', name: 'add_position_import_hierarchy' }, + { rel: 'database/migrations/import-tasks/20260318000000-set-import-jobs-id-default-uuid-generate-v4.sql', name: 'set_import_jobs_id_default_uuid' }, + { rel: 'database/migrations/release-v2.5/20260203000000-add-auto-assign-task-creator.sql', name: 'add_auto_assign_task_creator' }, + { rel: 'database/migrations/release-v2.5/20260212000000-add-recurring-mode-selection.sql', name: 'add_recurring_mode_selection' }, + { rel: 'database/migrations/release-v2.5/20260224000000-add-project-members-to-account-setup.sql', name: 'add_project_members_to_account_setup' }, + { rel: 'database/migrations/release-v2.5/20260316000000-add-business-plan-overrides.sql', name: 'add_business_plan_overrides' }, + { rel: 'database/migrations/release-v2.5/add_calculate_member_capacity_function.sql', name: 'add_calculate_member_capacity_function' }, + { rel: 'database/migrations/release-v2.5/update_register_db_functions_OWD.sql', name: 'update_register_db_functions_owd' }, + { rel: 'database/migrations/20260105000008-update-request-sequences-per-service.sql', name: 'update_request_sequences_per_service' }, + { rel: 'database/migrations/20260210000000-recurring-tasks-complete-fix.sql', name: 'recurring_tasks_complete_fix' }, + { rel: 'database/migrations/20260213000000-fix-notification-email-loop.sql', name: 'fix_notification_email_loop' }, + { rel: 'database/migrations/20260213000001-cleanup-stuck-notifications.sql', name: 'cleanup_stuck_notifications' }, + { rel: 'database/migrations/20260213000002-add-retry-mechanism.sql', name: 'add_retry_mechanism' }, + { rel: 'database/migrations/20260213000003-clear-pending-notifications.sql', name: 'clear_pending_notifications' }, + { rel: 'database/migrations/20260213000004-fix-notification-email-edge-cases.sql', name: 'fix_notification_email_edge_cases' }, + { rel: 'database/migrations/20260217000002-fix-quick-task-sort-order.sql', name: 'fix_quick_task_sort_order' }, + { rel: 'database/migrations/20260217000003-fix-template-import-sort-order.sql', name: 'fix_template_import_sort_order' }, + { rel: 'database/migrations/20260220000002-add-grouped-reporting-indexes.sql', name: 'add_grouped_reporting_indexes' }, + { rel: 'database/migrations/20260222000000-fix-task-activity-logs-cascade-delete.sql', name: 'fix_task_activity_logs_cascade_delete' }, + { rel: 'database/migrations/20260222000001-fix-bulk-delete-activity-logs.sql', name: 'fix_bulk_delete_activity_logs' }, + { rel: 'database/migrations/20260309000000-invalidate-bcrypt-reset-tokens.sql', name: 'invalidate_bcrypt_reset_tokens' }, + { rel: 'database/migrations/20260317000000-fix-create-task-auto-assign-task-creator.sql', name: 'fix_create_task_auto_assign_task_creator' }, + { rel: 'database/migrations/20260319000001-fix-bulk-archive-subtask-selection.sql', name: 'fix_bulk_archive_subtask_selection' }, + { rel: 'database/migrations/20260320000001-make-bulk-archive-recursive.sql', name: 'make_bulk_archive_recursive' }, + { rel: 'database/migrations/20260403000000-add-critical-task-priority.sql', name: 'add_critical_task_priority' }, + { rel: 'database/migrations/20260424000001-add-due-time-to-tasks.sql', name: 'add_due_time_to_tasks' }, + { rel: 'database/migrations/20260427000001-add-due-time-to-task-form-view-model.sql', name: 'add_due_time_to_task_form_view_model' }, + { rel: 'database/migrations/20260427000002-add-due-time-column-to-task-list.sql', name: 'add_due_time_column_to_task_list' }, + { rel: 'database/migrations/20260430000000-add-multiple-reaction-types.sql', name: 'add_multiple_reaction_types' }, + { rel: 'database/migrations/20260507000000-add-priority-to-projects.sql', name: 'add_priority_to_projects' }, + { rel: 'database/migrations/20260507000000-optimize-reporting-projects-grouped.sql', name: 'optimize_reporting_projects_grouped' }, + { rel: 'database/migrations/20260507000001-update-project-functions-with-priority.sql', name: 'update_project_functions_with_priority' }, + { rel: 'database/migrations/release-v2.6/20260515000000-add-restrict-task-creation.sql', name: 'add_restrict_task_creation' }, + { rel: 'database/migrations/20260520000001-task-template-subtask-support.sql', name: 'task_template_subtask_support' }, + { rel: 'database/migrations/20260520000002-task-template-3level-subtask-support.sql', name: 'task_template_3level_subtask_support' }, + { rel: 'database/migrations/20260529000001-soft-delete-task-comments.sql', name: 'soft_delete_task_comments' }, + { rel: 'database/migrations/20260616000000-add-phase-assignees-enabled.sql', name: 'add_phase_assignees_enabled' }, + { rel: 'database/migrations/20260616000001-add-default-assignee-to-phases.sql', name: 'add_default_assignee_to_phases' }, + { rel: 'database/migrations/release-v2.6/20260626000001-add-annual-pro-license-type.sql', name: 'add_annual_pro_license_type' }, + { rel: 'database/migrations/20260709000001-add-comment-id-to-user-notifications.sql', name: 'add_comment_id_to_user_notifications' }, + { rel: 'database/migrations/20260714000000-fix-email-notifications-default.sql', name: 'fix_email_notifications_default' }, + { rel: 'database/migrations/release-v2.6/20260727000000-add-base-currency-to-organizations.sql', name: 'add_base_currency_to_organizations' }, + { rel: 'database/migrations/20260728000001-enforce-task-name-250-char-limit.sql', name: 'enforce_task_name_250_char_limit' }, + { rel: 'database/migrations/20260728000002-fix-task-name-constraint-to-250-chars.sql', name: 'fix_task_name_constraint_to_250_chars' }, + { rel: 'database/migrations/release-v2.6/20260731000000-add-timelog-backdate-limit.sql', name: 'add_timelog_backdate_limit' }, + { rel: 'database/migrations/20260731000000-add-tasks-assignees-assigned-by-index.sql', name: 'add_tasks_assignees_assigned_by_index' }, + { rel: 'database/migrations/20260802000000-add-personal-todo-list-user-dates-index.sql', name: 'add_personal_todo_list_user_dates_index' }, + { rel: 'database/migrations/20260803000000-add-task-phase-phase-id-index.sql', name: 'add_task_phase_phase_id_index' }, +]; + +let created = 0; +let skipped = 0; + +FILES_TO_CONVERT.forEach((item, index) => { + const seq = String(index + 1).padStart(6, '0'); + const filename = '20260821' + seq + '000_' + item.name + '.js'; + const outPath = path.join(OUT_DIR, filename); + + if (fs.existsSync(outPath)) { + skipped++; + return; + } + + const srcPath = path.join(ROOT, item.rel); + if (!fs.existsSync(srcPath)) { + console.warn(`[WARN] Source file not found: ${item.rel}`); + return; + } + + const sql = fs.readFileSync(srcPath, 'utf8'); + const content = buildMigrationJS(`Converted from ${item.rel}`, sql); + fs.writeFileSync(outPath, content, 'utf8'); + created++; + console.log(`Created: ${filename}`); +}); + +console.log(`\nConversion complete: ${created} created, ${skipped} skipped.`); diff --git a/worklenz-backend/scripts/migrate-bootstrap.js b/worklenz-backend/scripts/migrate-bootstrap.js index 7ad0e4f01..b04e57820 100644 --- a/worklenz-backend/scripts/migrate-bootstrap.js +++ b/worklenz-backend/scripts/migrate-bootstrap.js @@ -55,12 +55,51 @@ const names = fs // Extract the Unix-ms timestamp prefix from a migration name like // "1740787200000_split_client_address_fields" function runOnFromName(name) { - const ts = parseInt(name.split('_')[0], 10); + const prefix = name.split('_')[0]; + if (/^\d{14}(?:\d{3})?$/.test(prefix)) { + const year = parseInt(prefix.substring(0, 4), 10); + const month = parseInt(prefix.substring(4, 6), 10) - 1; + const day = parseInt(prefix.substring(6, 8), 10); + const hour = parseInt(prefix.substring(8, 10), 10); + const min = parseInt(prefix.substring(10, 12), 10); + const sec = parseInt(prefix.substring(12, 14), 10); + const milliseconds = prefix.length === 17 ? parseInt(prefix.substring(14, 17), 10) : 0; + const d = new Date(Date.UTC(year, month, day, hour, min, sec, milliseconds)); + if ( + d.getUTCFullYear() === year && + d.getUTCMonth() === month && + d.getUTCDate() === day && + d.getUTCHours() === hour && + d.getUTCMinutes() === min && + d.getUTCSeconds() === sec && + d.getUTCMilliseconds() === milliseconds + ) { + return d; + } + throw new Error(`Invalid 14-digit date prefix: ${prefix} in ${name}`); + } + if (!/^\d+$/.test(prefix)) { + throw new Error(`Cannot parse numeric timestamp from: ${name}`); + } + const ts = parseInt(prefix, 10); if (!Number.isFinite(ts)) throw new Error(`Cannot parse timestamp from: ${name}`); return new Date(ts); } async function run() { + // Pre-validate all migration names before touching pgmigrations + const validated = []; + let lastRunOn = new Date(0); + for (const name of names) { + let run_on = runOnFromName(name); + // Ensure monotonic timestamps so ORDER BY run_on, id in DB matches file sort order + if (run_on <= lastRunOn) { + run_on = new Date(lastRunOn.getTime() + 1000); + } + lastRunOn = run_on; + validated.push({ name, run_on }); + } + const client = await pool.connect(); try { await client.query('BEGIN'); @@ -78,9 +117,8 @@ async function run() { const { rowCount: deleted } = await client.query('DELETE FROM pgmigrations'); console.log(` cleared ${deleted} existing row(s)`); - // Re-insert every migration with run_on = its own timestamp - for (const name of names) { - const run_on = runOnFromName(name); + // Re-insert every migration with monotonic run_on matching file sort order + for (const { name, run_on } of validated) { await client.query( 'INSERT INTO pgmigrations (name, run_on) VALUES ($1, $2)', [name, run_on] diff --git a/worklenz-backend/scripts/migrate.js b/worklenz-backend/scripts/migrate.js index e63517c91..31ca153fc 100644 --- a/worklenz-backend/scripts/migrate.js +++ b/worklenz-backend/scripts/migrate.js @@ -44,15 +44,7 @@ function run(dir) { ); if (result.stderr) { - // Filter out non-fatal warnings about 14-digit timestamp prefixes from node-pg-migrate - const filteredStderr = result.stderr - .split('\n') - .filter((line) => !line.includes("Can't determine timestamp for")) - .join('\n') - .trim(); - if (filteredStderr) { - process.stderr.write(filteredStderr + '\n'); - } + process.stderr.write(result.stderr); } if (result.error) {