Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
'use strict';

/** @type {import('node-pg-migrate').ColumnDefinitions | undefined} */
exports.shorthands = undefined;

/**
* Baseline gap: task_comments.is_edited and cpt_task_phase exist in released databases
* but are not created by database/sql/*.sql or any earlier public migration.
* Idempotent: a no-op where the objects already exist (deployed databases).
*
* @param {import('node-pg-migrate').MigrationBuilder} pgm
*/
exports.up = async (pgm) => {
pgm.sql(`
ALTER TABLE task_comments ADD COLUMN IF NOT EXISTS is_edited boolean NOT NULL DEFAULT false;

CREATE TABLE IF NOT EXISTS cpt_task_phase (
task_id uuid NOT NULL,
phase_id uuid NOT NULL
);
CREATE UNIQUE INDEX IF NOT EXISTS cpt_task_phase_cpt_task_phase_uindex ON cpt_task_phase USING btree (task_id, phase_id);
CREATE UNIQUE INDEX IF NOT EXISTS cpt_task_phase_task_id_uindex ON cpt_task_phase USING btree (task_id);
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'cpt_task_phase_phase_id_fk') THEN
ALTER TABLE cpt_task_phase ADD CONSTRAINT cpt_task_phase_phase_id_fk
FOREIGN KEY (phase_id) REFERENCES cpt_phases(id) ON DELETE CASCADE;
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'cpt_task_phase_task_id_fk') THEN
ALTER TABLE cpt_task_phase ADD CONSTRAINT cpt_task_phase_task_id_fk
FOREIGN KEY (task_id) REFERENCES cpt_tasks(id) ON DELETE CASCADE;
END IF;
END $$;
`);
};

exports.down = async (_pgm) => {};
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,13 @@
/** @type {import('node-pg-migrate').ColumnDefinitions | undefined} */
exports.shorthands = undefined;

/**
* Creates the table. It depends on client_portal_requests, which is created by
* 1753401600000_create_client_portal_tables, so on a fresh database this runs
* from 1753401610000_create_client_portal_request_comments_after_tables instead.
*/
/** @param {import('node-pg-migrate').MigrationBuilder} pgm */
exports.up = async (pgm) => {
const createRequestComments = (pgm) => {
pgm.sql(`
-- Migration: Create Client Portal Request Comments Table
-- Description: Creates a dedicated table for tracking comments on client portal requests
Expand Down Expand Up @@ -48,6 +53,16 @@ COMMENT ON COLUMN client_portal_request_comments.sender_name IS 'Cached name of
`);
};

exports.createRequestComments = createRequestComments;

/** @param {import('node-pg-migrate').MigrationBuilder} pgm */
exports.up = async (pgm) => {
const { rows } = await pgm.db.query("SELECT to_regclass('client_portal_requests') AS t");
if (rows[0].t) {
createRequestComments(pgm);
}
};

/** @param {import('node-pg-migrate').MigrationBuilder} pgm */
exports.down = async (_pgm) => {
// Historical SQL migration: no automatic rollback is available.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,23 @@ CREATE TABLE IF NOT EXISTS client_portal_services (
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
);

-- Client Relationships (for dual actor support)
CREATE TABLE IF NOT EXISTS client_relationships (
id UUID DEFAULT uuid_generate_v4() PRIMARY KEY,
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
client_id UUID NOT NULL REFERENCES clients(id) ON DELETE CASCADE,
organization_team_id UUID NOT NULL REFERENCES teams(id) ON DELETE CASCADE,
access_level TEXT DEFAULT 'view' CHECK (access_level IN ('view', 'comment', 'full')),
is_active BOOLEAN DEFAULT TRUE,
invited_by UUID REFERENCES users(id),
invited_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
accepted_at TIMESTAMP WITH TIME ZONE,
last_access_at TIMESTAMP WITH TIME ZONE,
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
UNIQUE(user_id, client_id, organization_team_id)
);

-- Client Portal Service Requests
CREATE TABLE IF NOT EXISTS client_portal_requests (
id UUID DEFAULT uuid_generate_v4() PRIMARY KEY,
Expand Down Expand Up @@ -108,23 +125,6 @@ CREATE TABLE IF NOT EXISTS client_portal_access (
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
);

-- Client Relationships (for dual actor support)
CREATE TABLE IF NOT EXISTS client_relationships (
id UUID DEFAULT uuid_generate_v4() PRIMARY KEY,
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
client_id UUID NOT NULL REFERENCES clients(id) ON DELETE CASCADE,
organization_team_id UUID NOT NULL REFERENCES teams(id) ON DELETE CASCADE,
access_level TEXT DEFAULT 'view' CHECK (access_level IN ('view', 'comment', 'full')),
is_active BOOLEAN DEFAULT TRUE,
invited_by UUID REFERENCES users(id),
invited_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
accepted_at TIMESTAMP WITH TIME ZONE,
last_access_at TIMESTAMP WITH TIME ZONE,
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
UNIQUE(user_id, client_id, organization_team_id)
);

-- Client Portal Sessions for separate authentication
CREATE TABLE IF NOT EXISTS client_portal_sessions (
id UUID DEFAULT uuid_generate_v4() PRIMARY KEY,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
'use strict';

const { createRequestComments } = require('./1735689609000_create_client_portal_request_comments');

/** @type {import('node-pg-migrate').ColumnDefinitions | undefined} */
exports.shorthands = undefined;

/**
* 1735689609000 skips itself on a fresh database because client_portal_requests
* does not exist yet. All statements are IF NOT EXISTS, so this is a no-op where
* the table was already created.
*
* @param {import('node-pg-migrate').MigrationBuilder} pgm
*/
exports.up = async (pgm) => {
createRequestComments(pgm);
};

exports.down = async (_pgm) => {};
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
'use strict';

/** @type {import('node-pg-migrate').ColumnDefinitions | undefined} */
exports.shorthands = undefined;

/**
* Columns present on released databases but never added by a public migration.
* Idempotent: a no-op where they already exist.
*
* @param {import('node-pg-migrate').MigrationBuilder} pgm
*/
exports.up = async (pgm) => {
pgm.sql(`
ALTER TABLE client_portal_invoices ADD COLUMN IF NOT EXISTS tax_rate numeric(5,2) DEFAULT 0;
ALTER TABLE client_portal_invoices ADD COLUMN IF NOT EXISTS tax_amount numeric(10,2) DEFAULT 0;
ALTER TABLE client_portal_invoices ADD COLUMN IF NOT EXISTS discount_type text DEFAULT 'percentage'::text;
ALTER TABLE client_portal_invoices ADD COLUMN IF NOT EXISTS discount_value numeric(10,2) DEFAULT 0;
ALTER TABLE client_portal_invoices ADD COLUMN IF NOT EXISTS discount_amount numeric(10,2) DEFAULT 0;
ALTER TABLE client_portal_invoices ADD COLUMN IF NOT EXISTS subtotal numeric(10,2) DEFAULT 0;
`);
};

exports.down = async (_pgm) => {};
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
'use strict';

/** @type {import('node-pg-migrate').ColumnDefinitions | undefined} */
exports.shorthands = undefined;

/**
* Soft-delete columns on client and client portal tables, present in released databases
* but never added by a public migration.
* Idempotent: a no-op where the objects already exist (deployed databases).
*
* @param {import('node-pg-migrate').MigrationBuilder} pgm
*/
exports.up = async (pgm) => {
pgm.sql(`
ALTER TABLE client_invitations ADD COLUMN IF NOT EXISTS deleted_at timestamp with time zone;
CREATE INDEX IF NOT EXISTS idx_client_invitations_deleted_at ON client_invitations USING btree (deleted_at);
ALTER TABLE client_portal_access ADD COLUMN IF NOT EXISTS deleted_at timestamp with time zone;
CREATE INDEX IF NOT EXISTS idx_client_portal_access_deleted_at ON client_portal_access USING btree (deleted_at);
ALTER TABLE client_portal_chat_messages ADD COLUMN IF NOT EXISTS deleted_at timestamp with time zone;
CREATE INDEX IF NOT EXISTS idx_client_portal_chat_messages_deleted_at ON client_portal_chat_messages USING btree (deleted_at);
ALTER TABLE client_portal_invoices ADD COLUMN IF NOT EXISTS deleted_at timestamp with time zone;
CREATE INDEX IF NOT EXISTS idx_client_portal_invoices_deleted_at ON client_portal_invoices USING btree (deleted_at);
ALTER TABLE clients ADD COLUMN IF NOT EXISTS deleted_at timestamp with time zone;
CREATE INDEX IF NOT EXISTS idx_clients_deleted_at ON clients USING btree (deleted_at);
ALTER TABLE client_portal_requests ADD COLUMN IF NOT EXISTS deleted_at timestamp with time zone;
CREATE INDEX IF NOT EXISTS idx_client_portal_requests_deleted_at ON client_portal_requests USING btree (deleted_at);
ALTER TABLE client_portal_services ADD COLUMN IF NOT EXISTS deleted_at timestamp with time zone;
CREATE INDEX IF NOT EXISTS idx_client_portal_services_deleted_at ON client_portal_services USING btree (deleted_at);
ALTER TABLE client_users ADD COLUMN IF NOT EXISTS deleted_at timestamp with time zone;
CREATE INDEX IF NOT EXISTS idx_client_users_deleted_at ON client_users USING btree (deleted_at);
`);
};

exports.down = async (_pgm) => {};
Original file line number Diff line number Diff line change
Expand Up @@ -34,8 +34,8 @@ ALTER TABLE task_activity_logs ADD COLUMN IF NOT EXISTS i18n_key TEXT;
ALTER TABLE task_activity_logs ADD COLUMN IF NOT EXISTS i18n_params JSONB;

-- CREATE INDEX IF NOT EXISTS for better performance on i18n_key queries
CREATE INDEX IF NOT EXISTS CONCURRENTLY IF NOT EXISTS idx_project_logs_i18n_key ON project_logs(i18n_key);
CREATE INDEX IF NOT EXISTS CONCURRENTLY IF NOT EXISTS idx_task_activity_logs_i18n_key ON task_activity_logs(i18n_key);
CREATE INDEX IF NOT EXISTS idx_project_logs_i18n_key ON project_logs(i18n_key);
CREATE INDEX IF NOT EXISTS idx_task_activity_logs_i18n_key ON task_activity_logs(i18n_key);

-- Create function to log project activities with i18n support
CREATE OR REPLACE FUNCTION log_project_activity_i18n(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ EXCEPTION
END $$;

-- Update status column to use enum
ALTER TABLE email_logs ALTER COLUMN status DROP DEFAULT;
ALTER TABLE email_logs ALTER COLUMN status TYPE email_status_type USING status::email_status_type;
ALTER TABLE email_logs ALTER COLUMN status SET DEFAULT 'pending'::email_status_type;

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
'use strict';

/** @type {import('node-pg-migrate').ColumnDefinitions | undefined} */
exports.shorthands = undefined;

/**
* client_portal_task_comments.deleted_at exists in released databases but was never added by a
* public migration. Idempotent: a no-op where it already exists.
*
* @param {import('node-pg-migrate').MigrationBuilder} pgm
*/
exports.up = async (pgm) => {
pgm.sql(`
ALTER TABLE client_portal_task_comments ADD COLUMN IF NOT EXISTS deleted_at timestamp with time zone;
CREATE INDEX IF NOT EXISTS idx_client_portal_task_comments_deleted_at ON client_portal_task_comments USING btree (deleted_at);
`);
};

exports.down = async (_pgm) => {};
Original file line number Diff line number Diff line change
Expand Up @@ -127,7 +127,6 @@ $$;

comment on function register_user(json) is 'Registers a new user and automatically starts a 14-day Business plan trial';

alter function register_user(json) owner to postgres;


-- Google Register user
Expand Down Expand Up @@ -238,7 +237,6 @@ $$;

comment on function register_google_user(json) is 'Registers a new Google OAuth user and automatically starts a 14-day Business plan trial';

alter function register_google_user(json) owner to postgres;


-- Apple Register user
Expand Down Expand Up @@ -398,7 +396,6 @@ $$;

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';

alter function register_apple_user(json) owner to postgres;



Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -51,15 +51,33 @@ WHERE schedule_id IS NOT NULL AND end_date IS NOT NULL;
-- PART 4: Add foreign key constraints
-- ============================================================================

ALTER TABLE task_recurring_schedules
ADD CONSTRAINT task_recurring_schedules_timezone_id_fk
FOREIGN KEY (timezone_id) REFERENCES timezones(id)
ON DELETE SET NULL;

ALTER TABLE task_recurring_schedules
ADD CONSTRAINT task_recurring_schedules_created_by_fk
FOREIGN KEY (created_by) REFERENCES users(id)
ON DELETE SET NULL;
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint
WHERE conrelid = 'task_recurring_schedules'::regclass
AND 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 conrelid = 'task_recurring_schedules'::regclass
AND 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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,35 +12,35 @@ exports.up = async (pgm) => {

-- Index for category grouping (most common grouping)
-- Helps optimize GROUP BY category_id queries
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_projects_category_team
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 CONCURRENTLY IF NOT EXISTS idx_projects_status_team
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 CONCURRENTLY IF NOT EXISTS idx_projects_health_team
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 CONCURRENTLY IF NOT EXISTS idx_tasks_project_archived
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 CONCURRENTLY IF NOT EXISTS idx_tasks_status_project
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
-- Note: not CONCURRENTLY - migrations run in a transaction, where it is not allowed.
-- This allows the migration to run on production without downtime

`);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -226,7 +226,6 @@ BEGIN
END;
$$;

alter function get_task_form_view_model(uuid, uuid, uuid, uuid) owner to postgres;

COMMIT;

Expand Down
7 changes: 7 additions & 0 deletions worklenz-frontend/src/api/api-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,13 @@ export const getCsrfToken = (): string | null => {
return csrfToken;
};

// Drop the cached token. csrf-sync stores the token in the server session, and
// login/signup/logout replace that session, so the cached token is no longer valid.
// The next state-changing request fetches a fresh one via ensureCsrfToken().
export const clearCsrfToken = (): void => {
csrfToken = null;
};

// Function to refresh CSRF token from server
export const refreshCsrfToken = async (): Promise<string | null> => {
try {
Expand Down
7 changes: 6 additions & 1 deletion worklenz-frontend/src/api/auth/auth.api.service.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { IServerResponse } from '@/types/common.types';
import apiClient from '../api-client';
import apiClient, { clearCsrfToken } from '../api-client';
import {
IUserLoginRequest,
IUserLoginResponse,
Expand All @@ -12,11 +12,14 @@ const rootUrl = `${AUTH_API_BASE_URL}`;
export const authApiService = {
async login(credentials: IUserLoginRequest): Promise<IAuthorizeResponse> {
const response = await apiClient.post<IAuthorizeResponse>(`${rootUrl}/login`, credentials);
// Login replaces the server session, which invalidates the cached CSRF token
clearCsrfToken();
return response.data;
},

async logout(): Promise<IServerResponse<void>> {
const response = await apiClient.get<IServerResponse<void>>(`${rootUrl}/logout`);
clearCsrfToken();
return response.data;
},

Expand All @@ -29,6 +32,8 @@ export const authApiService = {

async signUp(body: any): Promise<IServerResponse<void>> {
const response = await apiClient.post<IServerResponse<void>>(`${rootUrl}/signup`, body);
// Signup logs the user in, which replaces the server session
clearCsrfToken();
return response.data;
},

Expand Down
Loading