Compare commits

..

2 Commits

Author SHA1 Message Date
Chamika J
84b69e36d3 Merge pull request #226 from OminduHirushka/update/event-tracking
update event integrations
2025-07-04 13:36:54 +05:30
Omindu Hirushka
08ee87da17 updated 2025-07-03 08:36:41 +05:30
1567 changed files with 18181 additions and 123452 deletions

2
.gitignore vendored
View File

@@ -36,8 +36,6 @@ lerna-debug.log*
.vscode/*
!.vscode/extensions.json
.idea/
.cursor/
.claude/
.DS_Store
*.suo
*.ntvs*

405
README.md
View File

@@ -6,24 +6,6 @@
Worklenz
</h1>
<p align="center">
<a href="https://github.com/Worklenz/worklenz/blob/main/LICENSE">
<img src="https://img.shields.io/badge/license-AGPL--3.0-blue.svg" alt="License">
</a>
<a href="https://github.com/Worklenz/worklenz/releases">
<img src="https://img.shields.io/github/v/release/Worklenz/worklenz" alt="Release">
</a>
<a href="https://github.com/Worklenz/worklenz/stargazers">
<img src="https://img.shields.io/github/stars/Worklenz/worklenz" alt="Stars">
</a>
<a href="https://github.com/Worklenz/worklenz/network/members">
<img src="https://img.shields.io/github/forks/Worklenz/worklenz" alt="Forks">
</a>
<a href="https://github.com/Worklenz/worklenz/issues">
<img src="https://img.shields.io/github/issues/Worklenz/worklenz" alt="Issues">
</a>
</p>
<p align="center">
<a href="https://worklenz.com/task-management/">Task Management</a> |
<a href="https://worklenz.com/time-tracking/">Time Tracking</a> |
@@ -45,24 +27,6 @@
Worklenz is a project management tool designed to help organizations improve their efficiency. It provides a
comprehensive solution for managing projects, tasks, and collaboration within teams.
## Table of Contents
- [Features](#features)
- [Tech Stack](#tech-stack)
- [Getting Started](#getting-started)
- [Quick Start (Docker)](#-quick-start-docker---recommended)
- [Manual Installation](#-manual-installation-for-development)
- [Deployment](#deployment)
- [Local Development](#local-development-with-docker)
- [Remote Server Deployment](#remote-server-deployment)
- [Configuration](#configuration)
- [MinIO Integration](#minio-integration)
- [Security](#security)
- [Analytics](#analytics)
- [Screenshots](#screenshots)
- [Contributing](#contributing)
- [License](#license)
## Features
- **Project Planning**: Create and organize projects, assign tasks to team members.
@@ -86,80 +50,41 @@ This repository contains the frontend and backend code for Worklenz.
## Getting Started
Choose your preferred setup method below. Docker is recommended for quick setup and testing.
These instructions will help you set up and run the Worklenz project on your local machine for development and testing purposes.
### 🚀 Quick Start (Docker - Recommended)
### Prerequisites
The fastest way to get Worklenz running locally with all dependencies included.
**Prerequisites:**
- Docker and Docker Compose installed on your system
- Git
**Steps:**
1. Clone the repository:
```bash
git clone https://github.com/Worklenz/worklenz.git
cd worklenz
```
2. Start the Docker containers:
```bash
docker-compose up -d
```
3. Access the application:
- **Frontend**: http://localhost:5000
- **Backend API**: http://localhost:3000
- **MinIO Console**: http://localhost:9001 (login: minioadmin/minioadmin)
4. To stop the services:
```bash
docker-compose down
```
**Alternative startup methods:**
- **Windows**: Run `start.bat`
- **Linux/macOS**: Run `./start.sh`
**Video Guide**: For a visual walkthrough of the local Docker deployment process, check out our [step-by-step video guide](https://www.youtube.com/watch?v=AfwAKxJbqLg).
### 🛠️ Manual Installation (For Development)
For developers who want to run the services individually or customize the setup.
**Prerequisites:**
- Node.js (version 18 or higher)
- PostgreSQL (version 15 or higher)
- PostgreSQL database
- An S3-compatible storage service (like MinIO) or Azure Blob Storage
**Steps:**
### Option 1: Manual Installation
1. Clone the repository:
1. Clone the repository
```bash
git clone https://github.com/Worklenz/worklenz.git
cd worklenz
```
2. Set up environment variables:
```bash
cp worklenz-backend/.env.template worklenz-backend/.env
# Update the environment variables with your configuration
```
2. Set up environment variables
- Copy the example environment files
```bash
cp worklenz-backend/.env.template worklenz-backend/.env
```
- Update the environment variables with your configuration
3. Install dependencies:
3. Install dependencies
```bash
# Backend dependencies
# Install backend dependencies
cd worklenz-backend
npm install
# Frontend dependencies
# Install frontend dependencies
cd ../worklenz-frontend
npm install
```
4. Set up the database:
4. Set up the database
```bash
# Create a PostgreSQL database named worklenz_db
cd worklenz-backend
@@ -175,47 +100,49 @@ psql -U your_username -d worklenz_db -f database/sql/2_dml.sql
psql -U your_username -d worklenz_db -f database/sql/5_database_user.sql
```
5. Start the development servers:
5. Start the development servers
```bash
# Terminal 1: Start the backend
# In one terminal, start the backend
cd worklenz-backend
npm run dev
# Terminal 2: Start the frontend
# In another terminal, start the frontend
cd worklenz-frontend
npm run dev
```
6. Access the application at http://localhost:5000
## Deployment
### Option 2: Docker Setup
For local development, follow the [Quick Start (Docker)](#-quick-start-docker---recommended) section above.
The project includes a fully configured Docker setup with:
- Frontend React application
- Backend server
- PostgreSQL database
- MinIO for S3-compatible storage
### Remote Server Deployment
1. Clone the repository:
```bash
git clone https://github.com/Worklenz/worklenz.git
cd worklenz
```
When deploying to a remote server:
2. Start the Docker containers (choose one option):
1. Set up the environment files with your server's hostname:
```bash
# For HTTP/WS
./update-docker-env.sh your-server-hostname
# For HTTPS/WSS
./update-docker-env.sh your-server-hostname true
```
**Using Docker Compose directly**
```bash
docker-compose up -d
```
2. Pull and run the latest Docker images:
```bash
docker-compose pull
docker-compose up -d
```
3. The application will be available at:
- Frontend: http://localhost:5000
- Backend API: http://localhost:3000
- MinIO Console: http://localhost:9001 (login with minioadmin/minioadmin)
3. Access the application through your server's hostname:
- Frontend: http://your-server-hostname:5000
- Backend API: http://your-server-hostname:3000
4. **Video Guide**: For a complete walkthrough of deploying Worklenz to a remote server, check out our [deployment video guide](https://www.youtube.com/watch?v=CAZGu2iOXQs&t=10s).
4. To stop the services:
```bash
docker-compose down
```
## Configuration
@@ -230,46 +157,16 @@ Worklenz requires several environment variables to be configured for proper oper
Please refer to the `.env.example` files for a full list of required variables.
The Docker setup uses environment variables to configure the services:
- **Frontend:**
- `VITE_API_URL`: URL of the backend API (default: http://backend:3000 for container networking)
- `VITE_SOCKET_URL`: WebSocket URL for real-time communication (default: ws://backend:3000)
- **Backend:**
- Database connection parameters
- Storage configuration
- Other backend settings
For custom configuration, edit the `.env` file or the `update-docker-env.sh` script.
## MinIO Integration
### MinIO Integration
The project uses MinIO as an S3-compatible object storage service, which provides an open-source alternative to AWS S3 for development and production.
### Working with MinIO
MinIO provides an S3-compatible API, so any code that works with S3 will work with MinIO by simply changing the endpoint URL. The backend has been configured to use MinIO by default, with no additional configuration required.
- **MinIO Console**: http://localhost:9001
- Username: minioadmin
- Password: minioadmin
- **Default Bucket**: worklenz-bucket (created automatically when the containers start)
### Backend Storage Configuration
The backend is pre-configured to use MinIO with the following settings:
```javascript
// S3 credentials with MinIO defaults
export const REGION = process.env.AWS_REGION || "us-east-1";
export const BUCKET = process.env.AWS_BUCKET || "worklenz-bucket";
export const S3_URL = process.env.S3_URL || "http://minio:9000/worklenz-bucket";
export const S3_ACCESS_KEY_ID = process.env.AWS_ACCESS_KEY_ID || "minioadmin";
export const S3_SECRET_ACCESS_KEY = process.env.AWS_SECRET_ACCESS_KEY || "minioadmin";
```
### Security Considerations
For production deployments:
@@ -280,12 +177,20 @@ For production deployments:
4. Enable HTTPS for all public endpoints
5. Review and update dependencies regularly
## Contributing
We welcome contributions from the community! If you'd like to contribute, please follow our [contributing guidelines](CONTRIBUTING.md).
## Security
If you believe you have found a security vulnerability in Worklenz, we encourage you to responsibly disclose this and not open a public issue. We will investigate all legitimate reports.
Email [info@worklenz.com](mailto:info@worklenz.com) to disclose any security vulnerabilities.
## License
This project is licensed under the [MIT License](LICENSE).
## Analytics
Worklenz uses Google Analytics to understand how the application is being used. This helps us improve the application and make better decisions about future development.
@@ -355,13 +260,215 @@ If you've previously opted in and want to opt-out:
</a>
</p>
## Contributing
### Contributing
We welcome contributions from the community! If you'd like to contribute, please follow our [contributing guidelines](CONTRIBUTING.md).
We welcome contributions from the community! If you'd like to contribute, please follow
our [contributing guidelines](CONTRIBUTING.md).
## License
### License
Worklenz is open source and released under the [GNU Affero General Public License Version 3 (AGPLv3)](LICENSE).
By contributing to Worklenz, you agree that your contributions will be licensed under its AGPL.
# Worklenz React
This repository contains the React version of Worklenz with a Docker setup for easy development and deployment.
## Getting Started with Docker
The project includes a fully configured Docker setup with:
- Frontend React application
- Backend server
- PostgreSQL database
- MinIO for S3-compatible storage
### Prerequisites
- Docker and Docker Compose installed on your system
- Git
### Quick Start
1. Clone the repository:
```bash
git clone https://github.com/Worklenz/worklenz.git
cd worklenz
```
2. Start the Docker containers (choose one option):
**Option 1: Using the provided scripts (easiest)**
- On Windows:
```
start.bat
```
- On Linux/macOS:
```bash
./start.sh
```
**Option 2: Using Docker Compose directly**
```bash
docker-compose up -d
```
3. The application will be available at:
- Frontend: http://localhost:5000
- Backend API: http://localhost:3000
- MinIO Console: http://localhost:9001 (login with minioadmin/minioadmin)
4. To stop the services (choose one option):
**Option 1: Using the provided scripts**
- On Windows:
```
stop.bat
```
- On Linux/macOS:
```bash
./stop.sh
```
**Option 2: Using Docker Compose directly**
```bash
docker-compose down
```
## MinIO Integration
The project uses MinIO as an S3-compatible object storage service, which provides an open-source alternative to AWS S3 for development and production.
### Working with MinIO
MinIO provides an S3-compatible API, so any code that works with S3 will work with MinIO by simply changing the endpoint URL. The backend has been configured to use MinIO by default, with no additional configuration required.
- **MinIO Console**: http://localhost:9001
- Username: minioadmin
- Password: minioadmin
- **Default Bucket**: worklenz-bucket (created automatically when the containers start)
### Backend Storage Configuration
The backend is pre-configured to use MinIO with the following settings:
```javascript
// S3 credentials with MinIO defaults
export const REGION = process.env.AWS_REGION || "us-east-1";
export const BUCKET = process.env.AWS_BUCKET || "worklenz-bucket";
export const S3_URL = process.env.S3_URL || "http://minio:9000/worklenz-bucket";
export const S3_ACCESS_KEY_ID = process.env.AWS_ACCESS_KEY_ID || "minioadmin";
export const S3_SECRET_ACCESS_KEY = process.env.AWS_SECRET_ACCESS_KEY || "minioadmin";
```
The S3 client is initialized with special MinIO configuration:
```javascript
const s3Client = new S3Client({
region: REGION,
credentials: {
accessKeyId: S3_ACCESS_KEY_ID || "",
secretAccessKey: S3_SECRET_ACCESS_KEY || "",
},
endpoint: getEndpointFromUrl(), // Extracts endpoint from S3_URL
forcePathStyle: true, // Required for MinIO
});
```
### Environment Configuration
The project uses the following environment file structure:
- **Frontend**:
- `worklenz-frontend/.env.development` - Development environment variables
- `worklenz-frontend/.env.production` - Production build variables
- **Backend**:
- `worklenz-backend/.env` - Backend environment variables
### Setting Up Environment Files
The Docker environment script will create or overwrite all environment files:
```bash
# For HTTP/WS
./update-docker-env.sh your-hostname
# For HTTPS/WSS
./update-docker-env.sh your-hostname true
```
This script generates properly configured environment files for both development and production environments.
## Docker Deployment
### Local Development with Docker
1. Set up the environment files:
```bash
# For HTTP/WS
./update-docker-env.sh
# For HTTPS/WSS
./update-docker-env.sh localhost true
```
2. Run the application using Docker Compose:
```bash
docker-compose up -d
```
3. Access the application:
- Frontend: http://localhost:5000
- Backend API: http://localhost:3000 (or https://localhost:3000 with SSL)
4. Video Guide
For a visual walkthrough of the local Docker deployment process, check out our [step-by-step video guide](https://www.youtube.com/watch?v=AfwAKxJbqLg).
### Remote Server Deployment
When deploying to a remote server:
1. Set up the environment files with your server's hostname:
```bash
# For HTTP/WS
./update-docker-env.sh your-server-hostname
# For HTTPS/WSS
./update-docker-env.sh your-server-hostname true
```
This ensures that the frontend correctly connects to the backend API.
2. Pull and run the latest Docker images:
```bash
docker-compose pull
docker-compose up -d
```
3. Access the application through your server's hostname:
- Frontend: http://your-server-hostname:5000
- Backend API: http://your-server-hostname:3000
4. Video Guide
For a complete walkthrough of deploying Worklenz to a remote server, check out our [deployment video guide](https://www.youtube.com/watch?v=CAZGu2iOXQs&t=10s).
### Environment Configuration
The Docker setup uses environment variables to configure the services:
- Frontend:
- `VITE_API_URL`: URL of the backend API (default: http://backend:3000 for container networking)
- `VITE_SOCKET_URL`: WebSocket URL for real-time communication (default: ws://backend:3000)
- Backend:
- Database connection parameters
- Storage configuration
- Other backend settings
For custom configuration, edit the `.env` file or the `update-docker-env.sh` script.

View File

@@ -4,7 +4,7 @@ Getting started with development is a breeze! Follow these steps and you'll be c
## Requirements
- Node.js version v20 or newer - [Node.js](https://nodejs.org/en/download/)
- Node.js version v16 or newer - [Node.js](https://nodejs.org/en/download/)
- PostgreSQL version v15 or newer - [PostgreSQL](https://www.postgresql.org/download/)
- S3-compatible storage (like MinIO) for file storage
@@ -38,7 +38,7 @@ Getting started with development is a breeze! Follow these steps and you'll be c
npm start
```
4. Navigate to [http://localhost:5173](http://localhost:5173) (development server)
4. Navigate to [http://localhost:5173](http://localhost:5173)
### Backend installation
@@ -126,7 +126,7 @@ For an easier setup, you can use Docker and Docker Compose:
```
3. Access the application:
- Frontend: http://localhost:5000 (Docker production build)
- Frontend: http://localhost:5000
- Backend API: http://localhost:3000
- MinIO Console: http://localhost:9001 (login with minioadmin/minioadmin)

View File

@@ -1,16 +0,0 @@
#!/bin/bash
set -eu
# Adjust these as needed:
CONTAINER=worklenz_db
DB_NAME=worklenz_db
DB_USER=postgres
BACKUP_DIR=./pg_backups
mkdir -p "$BACKUP_DIR"
timestamp=$(date +%Y-%m-%d_%H-%M-%S)
outfile="${BACKUP_DIR}/${DB_NAME}_${timestamp}.sql"
echo "Creating backup $outfile ..."
docker exec -t "$CONTAINER" pg_dump -U "$DB_USER" -d "$DB_NAME" > "$outfile"
echo "Backup saved to $outfile"

View File

@@ -83,11 +83,7 @@ services:
POSTGRES_DB: ${DB_NAME:-worklenz_db}
POSTGRES_PASSWORD: ${DB_PASSWORD:-password}
healthcheck:
test:
[
"CMD-SHELL",
"pg_isready -d ${DB_NAME:-worklenz_db} -U ${DB_USER:-postgres}",
]
test: [ "CMD-SHELL", "pg_isready -d ${DB_NAME:-worklenz_db} -U ${DB_USER:-postgres}" ]
interval: 10s
timeout: 5s
retries: 5
@@ -97,65 +93,23 @@ services:
volumes:
- worklenz_postgres_data:/var/lib/postgresql/data
- type: bind
source: ./worklenz-backend/database/sql
target: /docker-entrypoint-initdb.d/sql
source: ./worklenz-backend/database
target: /docker-entrypoint-initdb.d
consistency: cached
- type: bind
source: ./worklenz-backend/database/migrations
target: /docker-entrypoint-initdb.d/migrations
consistency: cached
- type: bind
source: ./worklenz-backend/database/00_init.sh
target: /docker-entrypoint-initdb.d/00_init.sh
consistency: cached
- type: bind
source: ./pg_backups
target: /docker-entrypoint-initdb.d/pg_backups
command: >
bash -c '
if command -v apt-get >/dev/null 2>&1; then
apt-get update && apt-get install -y dos2unix
elif command -v apk >/dev/null 2>&1; then
apk add --no-cache dos2unix
fi
find /docker-entrypoint-initdb.d -type f -name "*.sh" -exec sh -c '"'"'
for f; do
dos2unix "$f" 2>/dev/null || true
chmod +x "$f"
done
'"'"' sh {} +
exec docker-entrypoint.sh postgres
'
db-backup:
image: postgres:15
container_name: worklenz_db_backup
environment:
POSTGRES_USER: ${DB_USER:-postgres}
POSTGRES_DB: ${DB_NAME:-worklenz_db}
POSTGRES_PASSWORD: ${DB_PASSWORD:-password}
depends_on:
db:
condition: service_healthy
volumes:
- ./pg_backups:/pg_backups #host dir for backups files
#setup bassh loop to backup data evey 24h
command: >
bash -c 'while true; do
sleep 86400;
PGPASSWORD=$$POSTGRES_PASSWORD pg_dump -h worklenz_db -U $$POSTGRES_USER -d $$POSTGRES_DB \
> /pg_backups/worklenz_db_$$(date +%Y-%m-%d_%H-%M-%S).sql;
find /pg_backups -type f -name "*.sql" -mtime +30 -delete;
done'
restart: unless-stopped
networks:
- worklenz
bash -c ' if command -v apt-get >/dev/null 2>&1; then
apt-get update && apt-get install -y dos2unix
elif command -v apk >/dev/null 2>&1; then
apk add --no-cache dos2unix
fi && find /docker-entrypoint-initdb.d -type f -name "*.sh" -exec sh -c '\''
dos2unix "{}" 2>/dev/null || true
chmod +x "{}"
'\'' \; && exec docker-entrypoint.sh postgres '
volumes:
worklenz_postgres_data:
worklenz_minio_data:
pgdata:
networks:
worklenz:

View File

@@ -1,429 +0,0 @@
# Enhanced Task Management: Technical Guide
## Overview
The Enhanced Task Management system is a comprehensive React-based interface built on top of WorkLenz's existing task infrastructure. It provides a modern, grouped view with drag-and-drop functionality, bulk operations, and responsive design.
## Architecture
### Component Structure
```
src/components/task-management/
├── TaskListBoard.tsx # Main container with DnD context
├── TaskGroup.tsx # Individual group with collapse/expand
├── TaskRow.tsx # Task display with rich metadata
├── GroupingSelector.tsx # Grouping method switcher
└── BulkActionBar.tsx # Bulk operations toolbar
```
### Integration Points
The system integrates with existing WorkLenz infrastructure:
- **Redux Store:** Uses `tasks.slice.ts` for state management
- **Types:** Leverages existing TypeScript interfaces
- **API Services:** Works with existing task API endpoints
- **WebSocket:** Supports real-time updates via existing socket system
## Core Components
### TaskListBoard.tsx
Main orchestrator component that provides:
- **DnD Context:** @dnd-kit drag-and-drop functionality
- **State Management:** Redux integration for task data
- **Event Handling:** Drag events and bulk operations
- **Layout Structure:** Header controls and group container
#### Key Props
```typescript
interface TaskListBoardProps {
projectId: string; // Required: Project identifier
className?: string; // Optional: Additional CSS classes
}
```
#### Redux Selectors Used
```typescript
const {
taskGroups, // ITaskListGroup[] - Grouped task data
loadingGroups, // boolean - Loading state
error, // string | null - Error state
groupBy, // IGroupBy - Current grouping method
search, // string | null - Search filter
archived, // boolean - Show archived tasks
} = useSelector((state: RootState) => state.taskReducer);
```
### TaskGroup.tsx
Renders individual task groups with:
- **Collapsible Headers:** Expand/collapse functionality
- **Progress Indicators:** Visual completion progress
- **Drop Zones:** Accept dropped tasks from other groups
- **Group Statistics:** Task counts and completion rates
#### Key Props
```typescript
interface TaskGroupProps {
group: ITaskListGroup; // Group data with tasks
projectId: string; // Project context
currentGrouping: IGroupBy; // Current grouping mode
selectedTaskIds: string[]; // Selected task IDs
onAddTask?: (groupId: string) => void;
onToggleCollapse?: (groupId: string) => void;
}
```
### TaskRow.tsx
Individual task display featuring:
- **Rich Metadata:** Progress, assignees, labels, due dates
- **Drag Handles:** Sortable within and between groups
- **Selection:** Multi-select with checkboxes
- **Subtask Support:** Expandable hierarchy display
#### Key Props
```typescript
interface TaskRowProps {
task: IProjectTask; // Task data
projectId: string; // Project context
groupId: string; // Parent group ID
currentGrouping: IGroupBy; // Current grouping mode
isSelected: boolean; // Selection state
isDragOverlay?: boolean; // Drag overlay rendering
index?: number; // Position in group
onSelect?: (taskId: string, selected: boolean) => void;
onToggleSubtasks?: (taskId: string) => void;
}
```
## State Management
### Redux Integration
The system uses existing WorkLenz Redux patterns:
```typescript
// Primary slice used
import {
fetchTaskGroups, // Async thunk for loading data
reorderTasks, // Update task order/group
setGroup, // Change grouping method
updateTaskStatus, // Update individual task status
updateTaskPriority, // Update individual task priority
// ... other existing actions
} from '@/features/tasks/tasks.slice';
```
### Data Flow
1. **Component Mount:** `TaskListBoard` dispatches `fetchTaskGroups(projectId)`
2. **Group Changes:** `setGroup(newGroupBy)` triggers data reorganization
3. **Drag Operations:** `reorderTasks()` updates task positions and properties
4. **Real-time Updates:** WebSocket events update Redux state automatically
## Drag and Drop Implementation
### DnD Kit Integration
Uses @dnd-kit for modern, accessible drag-and-drop:
```typescript
// Sensors for different input methods
const sensors = useSensors(
useSensor(PointerSensor, {
activationConstraint: { distance: 8 }
}),
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates
})
);
```
### Drag Event Handling
```typescript
const handleDragEnd = (event: DragEndEvent) => {
const { active, over } = event;
// Determine source and target
const sourceGroup = findTaskGroup(active.id);
const targetGroup = findTargetGroup(over?.id);
// Update task arrays and dispatch changes
dispatch(reorderTasks({
activeGroupId: sourceGroup.id,
overGroupId: targetGroup.id,
fromIndex: sourceIndex,
toIndex: targetIndex,
task: movedTask,
updatedSourceTasks,
updatedTargetTasks,
}));
};
```
### Smart Property Updates
When tasks are moved between groups, properties update automatically:
- **Status Grouping:** Moving to "Done" group sets task status to "done"
- **Priority Grouping:** Moving to "High" group sets task priority to "high"
- **Phase Grouping:** Moving to "Testing" group sets task phase to "testing"
## Bulk Operations
### Selection State Management
```typescript
// Local state for task selection
const [selectedTaskIds, setSelectedTaskIds] = useState<string[]>([]);
// Selection handlers
const handleTaskSelect = (taskId: string, selected: boolean) => {
if (selected) {
setSelectedTaskIds(prev => [...prev, taskId]);
} else {
setSelectedTaskIds(prev => prev.filter(id => id !== taskId));
}
};
```
### Context-Aware Actions
Bulk actions adapt to current grouping:
```typescript
// Only show status changes when not grouped by status
{currentGrouping !== 'status' && (
<Dropdown overlay={statusMenu}>
<Button>Change Status</Button>
</Dropdown>
)}
```
## Performance Optimizations
### Memoized Selectors
```typescript
// Expensive group calculations are memoized
const taskGroups = useMemo(() => {
return createGroupsFromTasks(tasks, currentGrouping);
}, [tasks, currentGrouping]);
```
### Virtual Scrolling Ready
For large datasets, the system is prepared for react-window integration:
```typescript
// Large group detection
const shouldVirtualize = group.tasks.length > 100;
return shouldVirtualize ? (
<VirtualizedTaskList tasks={group.tasks} />
) : (
<StandardTaskList tasks={group.tasks} />
);
```
### Optimistic Updates
UI updates immediately while API calls process in background:
```typescript
// Immediate UI update
dispatch(updateTaskStatusOptimistically(taskId, newStatus));
// API call with rollback on error
try {
await updateTaskStatus(taskId, newStatus);
} catch (error) {
dispatch(rollbackTaskStatusUpdate(taskId));
}
```
## Responsive Design
### Breakpoint Strategy
```css
/* Mobile-first responsive design */
.task-row {
padding: 12px;
}
@media (min-width: 768px) {
.task-row {
padding: 16px;
}
}
@media (min-width: 1024px) {
.task-row {
padding: 20px;
}
}
```
### Progressive Enhancement
- **Mobile:** Essential information only
- **Tablet:** Additional metadata visible
- **Desktop:** Full feature set with optimal layout
## Accessibility
### ARIA Implementation
```typescript
// Proper ARIA labels for screen readers
<div
role="button"
aria-label={`Move task ${task.name}`}
tabIndex={0}
{...dragHandleProps}
>
<DragOutlined />
</div>
```
### Keyboard Navigation
- **Tab:** Navigate between elements
- **Space:** Select/deselect tasks
- **Enter:** Activate buttons
- **Arrows:** Navigate sortable lists with keyboard sensor
### Focus Management
```typescript
// Maintain focus during dynamic updates
useEffect(() => {
if (shouldFocusTask) {
taskRef.current?.focus();
}
}, [taskGroups]);
```
## WebSocket Integration
### Real-time Updates
The system subscribes to existing WorkLenz WebSocket events:
```typescript
// Socket event handlers (existing WorkLenz patterns)
socket.on('TASK_STATUS_CHANGED', (data) => {
dispatch(updateTaskStatus(data));
});
socket.on('TASK_PROGRESS_UPDATED', (data) => {
dispatch(updateTaskProgress(data));
});
```
### Live Collaboration
- Multiple users can work simultaneously
- Changes appear in real-time
- Conflict resolution through server-side validation
## API Integration
### Existing Endpoints Used
```typescript
// Uses existing WorkLenz API services
import { tasksApiService } from '@/api/tasks/tasks.api.service';
// Task data fetching
tasksApiService.getTaskList(config);
// Task updates
tasksApiService.updateTask(taskId, changes);
// Bulk operations
tasksApiService.bulkUpdateTasks(taskIds, changes);
```
### Error Handling
```typescript
try {
await dispatch(fetchTaskGroups(projectId));
} catch (error) {
// Display user-friendly error message
message.error('Failed to load tasks. Please try again.');
logger.error('Task loading error:', error);
}
```
## Testing Strategy
### Component Testing
```typescript
// Example test structure
describe('TaskListBoard', () => {
it('should render task groups correctly', () => {
const mockTasks = generateMockTasks(10);
render(<TaskListBoard projectId="test-project" />);
expect(screen.getByText('Tasks (10)')).toBeInTheDocument();
});
it('should handle drag and drop operations', async () => {
// Test drag and drop functionality
});
});
```
### Integration Testing
- Redux state management
- API service integration
- WebSocket event handling
- Drag and drop operations
## Development Guidelines
### Code Organization
- Follow existing WorkLenz patterns
- Use TypeScript strictly
- Implement proper error boundaries
- Maintain accessibility standards
### Performance Considerations
- Memoize expensive calculations
- Implement virtual scrolling for large datasets
- Debounce user input operations
- Optimize re-render cycles
### Styling Standards
- Use existing Ant Design components
- Follow WorkLenz design system
- Implement responsive breakpoints
- Maintain dark mode compatibility
## Future Enhancements
### Planned Features
- Custom column integration
- Advanced filtering capabilities
- Kanban board view
- Enhanced time tracking
- Task templates
### Extension Points
The system is designed for easy extension:
```typescript
// Plugin architecture ready
interface TaskViewPlugin {
name: string;
component: React.ComponentType;
supportedGroupings: IGroupBy[];
}
const plugins: TaskViewPlugin[] = [
{ name: 'kanban', component: KanbanView, supportedGroupings: ['status'] },
{ name: 'timeline', component: TimelineView, supportedGroupings: ['phase'] },
];
```
## Deployment Considerations
### Bundle Size
- Tree-shake unused dependencies
- Code-split large components
- Optimize asset loading
### Browser Compatibility
- Modern browsers (ES2020+)
- Graceful degradation for older browsers
- Progressive enhancement approach
### Performance Monitoring
- Track component render times
- Monitor API response times
- Measure user interaction latency

View File

@@ -1,275 +0,0 @@
# Enhanced Task Management: User Guide
## What Is Enhanced Task Management?
The Enhanced Task Management system provides a modern, grouped view of your tasks with advanced features like drag-and-drop, bulk operations, and dynamic grouping. This system builds on WorkLenz's existing task infrastructure while offering improved productivity and organization tools.
## Why Use Enhanced Task Management?
- **Better Organization:** Group tasks by Status, Priority, or Phase for clearer project overview
- **Increased Productivity:** Bulk operations let you update multiple tasks at once
- **Intuitive Interface:** Drag-and-drop functionality makes task management feel natural
- **Rich Task Display:** See progress, assignees, labels, and due dates at a glance
- **Responsive Design:** Works seamlessly on desktop, tablet, and mobile devices
## Getting Started
### Accessing Enhanced Task Management
1. Navigate to your project workspace
2. Look for the enhanced task view option in your project interface
3. The system will display your tasks grouped by the current grouping method (default: Status)
### Understanding the Interface
The enhanced task management interface consists of several key areas:
- **Header Controls:** Task count, grouping selector, and action buttons
- **Task Groups:** Collapsible sections containing related tasks
- **Individual Tasks:** Rich task cards with metadata and actions
- **Bulk Action Bar:** Appears when multiple tasks are selected (blue bar)
## Task Grouping
### Available Grouping Options
You can organize your tasks using three different grouping methods:
#### 1. Status Grouping (Default)
Groups tasks by their current status:
- **To Do:** Tasks not yet started
- **Doing:** Tasks currently in progress
- **Done:** Completed tasks
#### 2. Priority Grouping
Groups tasks by their priority level:
- **Critical:** Highest priority, urgent tasks
- **High:** Important tasks requiring attention
- **Medium:** Standard priority tasks
- **Low:** Tasks that can be addressed later
#### 3. Phase Grouping
Groups tasks by project phases:
- **Planning:** Tasks in the planning stage
- **Development:** Implementation and development tasks
- **Testing:** Quality assurance and testing tasks
- **Deployment:** Release and deployment tasks
### Switching Between Groupings
1. Locate the "Group by" dropdown in the header controls
2. Select your preferred grouping method (Status, Priority, or Phase)
3. Tasks will automatically reorganize into the new groups
4. Your grouping preference is saved for future sessions
### Group Features
Each task group includes:
- **Color-coded headers** with visual indicators
- **Task count badges** showing the number of tasks in each group
- **Progress indicators** showing completion percentage
- **Collapse/expand functionality** to hide or show group contents
- **Add task buttons** to quickly create tasks in specific groups
## Drag and Drop
### Moving Tasks Within Groups
1. Hover over a task to reveal the drag handle (⋮⋮ icon)
2. Click and hold the drag handle
3. Drag the task to your desired position within the same group
4. Release to drop the task in its new position
### Moving Tasks Between Groups
1. Click and hold the drag handle on any task
2. Drag the task over a different group
3. The target group will highlight to show it can accept the task
4. Release to drop the task into the new group
5. The task's properties (status, priority, or phase) will automatically update
### Drag and Drop Benefits
- **Instant Updates:** Task properties change automatically when moved between groups
- **Visual Feedback:** Clear indicators show where tasks can be dropped
- **Keyboard Accessible:** Alternative keyboard controls for accessibility
- **Mobile Friendly:** Touch-friendly drag operations on mobile devices
## Multi-Select and Bulk Operations
### Selecting Tasks
You can select multiple tasks using several methods:
#### Individual Selection
- Click the checkbox next to any task to select it
- Click again to deselect
#### Range Selection
- Select the first task in your desired range
- Hold Shift and click the last task in the range
- All tasks between the first and last will be selected
#### Multiple Selection
- Hold Ctrl (or Cmd on Mac) while clicking tasks
- This allows you to select non-consecutive tasks
### Bulk Actions
When you have tasks selected, a blue bulk action bar appears with these options:
#### Change Status (when not grouped by Status)
- Update the status of all selected tasks at once
- Choose from available status options in your project
#### Set Priority (when not grouped by Priority)
- Assign the same priority level to all selected tasks
- Options include Critical, High, Medium, and Low
#### More Actions
Additional bulk operations include:
- **Assign to Member:** Add team members to multiple tasks
- **Add Labels:** Apply labels to selected tasks
- **Archive Tasks:** Move multiple tasks to archive
#### Delete Tasks
- Permanently remove multiple tasks at once
- Confirmation dialog prevents accidental deletions
### Bulk Action Tips
- The bulk action bar only shows relevant options based on your current grouping
- You can clear your selection at any time using the "Clear" button
- Bulk operations provide immediate feedback and can be undone if needed
## Task Display Features
### Rich Task Information
Each task displays comprehensive information:
#### Basic Information
- **Task Key:** Unique identifier (e.g., PROJ-123)
- **Task Name:** Clear, descriptive title
- **Description:** Additional details when available
#### Visual Indicators
- **Progress Bar:** Shows completion percentage (0-100%)
- **Priority Indicator:** Color-coded dot showing task importance
- **Status Color:** Left border color indicates current status
#### Team and Collaboration
- **Assignee Avatars:** Profile pictures of assigned team members (up to 3 visible)
- **Labels:** Color-coded tags for categorization
- **Comment Count:** Number of comments and discussions
- **Attachment Count:** Number of files attached to the task
#### Timing Information
- **Due Dates:** When tasks are scheduled to complete
- Red text: Overdue tasks
- Orange text: Due today or within 3 days
- Gray text: Future due dates
- **Time Tracking:** Estimated vs. logged time when available
### Subtask Support
Tasks with subtasks include additional features:
#### Expanding Subtasks
- Click the "+X" button next to task names to expand subtasks
- Subtasks appear indented below the parent task
- Click "X" to collapse subtasks
#### Subtask Progress
- Parent task progress reflects completion of all subtasks
- Individual subtask progress is visible when expanded
- Subtask counts show total number of child tasks
## Advanced Features
### Real-time Updates
- Changes made by team members appear instantly
- Live collaboration with multiple users
- WebSocket connections ensure data synchronization
### Search and Filtering
- Use existing project search and filter capabilities
- Enhanced task management respects current filter settings
- Search results maintain grouping organization
### Responsive Design
The interface adapts to different screen sizes:
#### Desktop (Large Screens)
- Full feature set with all metadata visible
- Optimal drag-and-drop experience
- Multi-column layouts where appropriate
#### Tablet (Medium Screens)
- Condensed but functional interface
- Touch-friendly interactions
- Simplified metadata display
#### Mobile (Small Screens)
- Stacked layout for easy navigation
- Large touch targets for selections
- Essential information prioritized
## Best Practices
### Organizing Your Tasks
1. **Choose the Right Grouping:** Select the grouping method that best fits your workflow
2. **Use Labels Consistently:** Apply meaningful labels for better categorization
3. **Keep Groups Balanced:** Avoid having too many tasks in a single group
4. **Regular Maintenance:** Review and update task organization periodically
### Collaboration Tips
1. **Clear Task Names:** Use descriptive titles that everyone understands
2. **Proper Assignment:** Assign tasks to appropriate team members
3. **Progress Updates:** Keep progress percentages current for accurate project tracking
4. **Use Comments:** Communicate about tasks using the comment system
### Productivity Techniques
1. **Batch Similar Operations:** Use bulk actions for efficiency
2. **Prioritize Effectively:** Use priority grouping during planning phases
3. **Track Progress:** Monitor completion rates using group progress indicators
4. **Plan Ahead:** Use due dates and time estimates for better scheduling
## Keyboard Shortcuts
### Navigation
- **Tab:** Move focus between elements
- **Enter:** Activate focused button or link
- **Esc:** Close open dialogs or clear selections
### Selection
- **Space:** Select/deselect focused task
- **Shift + Click:** Range selection
- **Ctrl + Click:** Multi-selection (Cmd + Click on Mac)
### Actions
- **Delete:** Remove selected tasks (with confirmation)
- **Ctrl + A:** Select all visible tasks (Cmd + A on Mac)
## Troubleshooting
### Common Issues
#### Tasks Not Moving Between Groups
- Ensure you have edit permissions for the tasks
- Check that you're dragging from the drag handle (⋮⋮ icon)
- Verify the target group allows the task type
#### Bulk Actions Not Working
- Confirm tasks are actually selected (checkboxes checked)
- Ensure you have appropriate permissions
- Check that the action is available for your current grouping
#### Missing Task Information
- Some metadata may be hidden on smaller screens
- Try expanding to full screen or using desktop view
- Check that task has the required information (assignees, labels, etc.)
### Performance Tips
- For projects with hundreds of tasks, consider using filters to reduce visible tasks
- Collapse groups you're not actively working with
- Clear selections when not performing bulk operations
## Getting Help
- Contact your workspace administrator for permission-related issues
- Check the main WorkLenz documentation for general task management help
- Report bugs or feature requests through your organization's support channels
## What's New
This enhanced task management system builds on WorkLenz's solid foundation while adding:
- Modern drag-and-drop interfaces
- Flexible grouping options
- Powerful bulk operation capabilities
- Rich visual task displays
- Mobile-responsive design
- Improved accessibility features

View File

@@ -1,60 +0,0 @@
# Recurring Tasks: User Guide
## What Are Recurring Tasks?
Recurring tasks are tasks that repeat automatically on a schedule you choose. This helps you save time and ensures important work is never forgotten. For example, you can set up a recurring task for weekly team meetings, monthly reports, or daily check-ins.
## Why Use Recurring Tasks?
- **Save time:** No need to create the same task over and over.
- **Stay organized:** Tasks appear automatically when needed.
- **Never miss a deadline:** Tasks are created on time, every time.
## How to Set Up a Recurring Task
1. Go to the tasks section in your workspace.
2. Choose to create a new task and look for the option to make it recurring.
3. Fill in the task details (name, description, assignees, etc.).
4. Select your preferred schedule (see options below).
5. Save the task. It will now be created automatically based on your chosen schedule.
## Schedule Options
You can choose how often your task repeats. Here are the available options:
- **Daily:** The task is created every day.
- **Weekly:** The task is created once a week. You can pick one or more days (e.g., every Monday and Thursday).
- **Monthly:** The task is created once a month. You have two options:
- **On a specific date:** Choose a date from 1 to 28 (limited to 28 to ensure consistency across all months)
- **On a specific day:** Choose a week (first, second, third, fourth, or last) and a day of the week
- **Every X Days:** The task is created every specified number of days (e.g., every 3 days)
- **Every X Weeks:** The task is created every specified number of weeks (e.g., every 2 weeks)
- **Every X Months:** The task is created every specified number of months (e.g., every 3 months)
### Examples
- "Send team update" every Friday (weekly)
- "Submit expense report" on the 15th of each month (monthly, specific date)
- "Monthly team meeting" on the first Monday of each month (monthly, specific day)
- "Check backups" every day (daily)
- "Review project status" every Monday and Thursday (weekly, multiple days)
- "Quarterly report" every 3 months (every X months)
## Future Task Creation
The system automatically creates tasks up to a certain point in the future to ensure timely scheduling:
- **Daily Tasks:** Created up to 7 days in advance
- **Weekly Tasks:** Created up to 2 weeks in advance
- **Monthly Tasks:** Created up to 2 months in advance
- **Every X Days/Weeks/Months:** Created up to 2 intervals in advance
This ensures that:
- You always have upcoming tasks visible in your schedule
- Tasks are created at appropriate intervals
- The system maintains a reasonable number of future tasks
## Tips
- You can edit or stop a recurring task at any time.
- Assign team members and labels to recurring tasks for better organization.
- Check your task list regularly to see newly created recurring tasks.
- For monthly tasks, dates are limited to 1-28 to ensure the task occurs on the same date every month.
- Tasks are created automatically within the future limit window - you don't need to manually create them.
- If you need to see tasks further in the future, they will be created automatically as the current tasks are completed.
## Need Help?
If you have questions or need help setting up recurring tasks, contact your workspace admin or support team.

View File

@@ -1,104 +0,0 @@
# Recurring Tasks Cron Job Documentation
## Overview
The recurring tasks cron job automates the creation of tasks based on predefined templates and schedules. It ensures that tasks are generated at the correct intervals without manual intervention, supporting efficient project management and timely task assignment.
## Purpose
- Automatically create tasks according to recurring schedules defined in the database.
- Prevent duplicate task creation for the same schedule and date.
- Assign team members and labels to newly created tasks as specified in the template.
## Scheduling Logic
- The cron job is scheduled using the [cron](https://www.npmjs.com/package/cron) package.
- The schedule is defined by a cron expression (e.g., `*/2 * * * *` for every 2 minutes, or `0 11 */1 * 1-5` for 11:00 UTC on weekdays).
- On each tick, the job:
1. Fetches all recurring task templates and their schedules.
2. Determines the next occurrence for each template using `calculateNextEndDate`.
3. Checks if a task for the next occurrence already exists.
4. Creates a new task if it does not exist and the next occurrence is within the allowed future window.
## Future Limit Logic
The system implements different future limits based on the schedule type to maintain an appropriate number of future tasks:
```typescript
const FUTURE_LIMITS = {
daily: moment.duration(7, 'days'),
weekly: moment.duration(2, 'weeks'),
monthly: moment.duration(2, 'months'),
every_x_days: (interval: number) => moment.duration(interval * 2, 'days'),
every_x_weeks: (interval: number) => moment.duration(interval * 2, 'weeks'),
every_x_months: (interval: number) => moment.duration(interval * 2, 'months')
};
```
### Implementation Details
- **Base Calculation:**
```typescript
const futureLimit = moment(template.last_checked_at || template.created_at)
.add(getFutureLimit(schedule.schedule_type, schedule.interval), 'days');
```
- **Task Creation Rules:**
1. Only create tasks if the next occurrence is before the future limit
2. Skip creation if a task already exists for that date
3. Update `last_checked_at` after processing
- **Benefits:**
- Prevents excessive task creation
- Maintains system performance
- Ensures timely task visibility
- Allows for schedule modifications
## Date Handling
- **Monthly Tasks:**
- Dates are limited to 1-28 to ensure consistency across all months
- This prevents issues with months having different numbers of days
- No special handling needed for February or months with 30/31 days
- **Weekly Tasks:**
- Supports multiple days of the week (0-6, where 0 is Sunday)
- Tasks are created for each selected day
- **Interval-based Tasks:**
- Every X days/weeks/months from the last task's end date
- Minimum interval is 1 day/week/month
- No maximum limit, but tasks are only created up to the future limit
## Database Interactions
- **Templates and Schedules:**
- Templates are stored in `task_recurring_templates`.
- Schedules are stored in `task_recurring_schedules`.
- The job joins these tables to get all necessary data for task creation.
- **Task Creation:**
- Uses a stored procedure `create_quick_task` to insert new tasks.
- Assigns team members and labels by calling appropriate functions/controllers.
- **State Tracking:**
- Updates `last_checked_at` and `last_created_task_end_date` in the schedule after processing.
- Maintains future limits based on schedule type.
## Task Creation Process
1. **Fetch Templates:** Retrieve all templates and their associated schedules.
2. **Determine Next Occurrence:** Use the last task's end date or the schedule's creation date to calculate the next due date.
3. **Check for Existing Task:** Ensure no duplicate task is created for the same schedule and date.
4. **Create Task:**
- Insert the new task using the template's data.
- Assign team members and labels as specified.
5. **Update Schedule:** Record the last checked and created dates for accurate future runs.
## Configuration & Extension Points
- **Cron Expression:** Modify the `TIME` constant in the code to change the schedule.
- **Task Template Structure:** Extend the template or schedule interfaces to support additional fields.
- **Task Creation Logic:** Customize the task creation process or add new assignment/labeling logic as needed.
- **Future Window:** Adjust the future limits by modifying the `FUTURE_LIMITS` configuration.
## Error Handling
- Errors are logged using the `log_error` utility.
- The job continues processing other templates even if one fails.
- Failed task creations are not retried automatically.
## References
- Source: `src/cron_jobs/recurring-tasks.ts`
- Utilities: `src/shared/utils.ts`
- Database: `src/config/db.ts`
- Controllers: `src/controllers/tasks-controller.ts`
---
For further customization or troubleshooting, refer to the source code and update the documentation as needed.

View File

@@ -1,223 +0,0 @@
# WorkLenz Task Progress Guide for Users
## Introduction
WorkLenz offers three different ways to track and calculate task progress, each designed for different project management needs. This guide explains how each method works and when to use them.
## Default Progress Method
WorkLenz uses a simple completion-based approach as the default progress calculation method. This method is applied when no special progress methods are enabled.
### Example
If you have a parent task with four subtasks and two of the subtasks are marked complete:
- Parent task: Not done
- 2 subtasks: Done
- 2 subtasks: Not done
The parent task will show as 40% complete (2 completed out of 5 total tasks).
## Available Progress Tracking Methods
WorkLenz provides these progress tracking methods:
1. **Manual Progress** - Directly input progress percentages for tasks
2. **Weighted Progress** - Assign importance levels (weights) to tasks
3. **Time-based Progress** - Calculate progress based on estimated time
Only one method can be enabled at a time for a project. If none are enabled, progress will be calculated based on task completion status.
## How to Select a Progress Method
1. Open the project drawer by clicking on the project settings icon or creating a new project
2. In the project settings, find the "Progress Calculation Method" section
3. Select your preferred method
4. Save your changes
## Manual Progress Method
### How It Works
- You directly enter progress percentages (0-100%) for tasks without subtasks
- Parent task progress is calculated as the average of all subtask progress values
- Progress is updated in real-time as you adjust values
### When to Use Manual Progress
- For creative or subjective work where completion can't be measured objectively
- When task progress doesn't follow a linear path
- For projects where team members need flexibility in reporting progress
### Example
If you have a parent task with three subtasks:
- Subtask A: 30% complete
- Subtask B: 60% complete
- Subtask C: 90% complete
The parent task will show as 60% complete (average of 30%, 60%, and 90%).
## Weighted Progress Method
### How It Works
- You assign "weight" values to tasks to indicate their importance
- More important tasks have higher weights and influence the overall progress more
- You still enter manual progress percentages for tasks without subtasks
- Parent task progress is calculated using a weighted average
### When to Use Weighted Progress
- When some tasks are more important or time-consuming than others
- For projects where all tasks aren't equal
- When you want key deliverables to have more impact on overall progress
### Example
If you have a parent task with three subtasks:
- Subtask A: 50% complete, Weight 60% (important task)
- Subtask B: 75% complete, Weight 20% (less important task)
- Subtask C: 25% complete, Weight 100% (critical task)
The parent task will be approximately 39% complete, with Subtask C having the greatest impact due to its higher weight.
### Important Notes About Weights
- Default weight is 100% if not specified
- Weights range from 0% to 100%
- Setting a weight to 0% removes that task from progress calculations
- Only explicitly set weights for tasks that should have different importance
- Weights are only relevant for subtasks, not for independent tasks
### Detailed Weighted Progress Calculation Example
To understand how weighted progress works with different weight values, consider this example:
For a parent task with two subtasks:
- Subtask A: 80% complete, Weight 50%
- Subtask B: 40% complete, Weight 100%
The calculation works as follows:
1. Each subtask's contribution is: (weight × progress) ÷ (sum of all weights)
2. For Subtask A: (50 × 80%) ÷ (50 + 100) = 26.7%
3. For Subtask B: (100 × 40%) ÷ (50 + 100) = 26.7%
4. Total parent progress: 26.7% + 26.7% = 53.3%
The parent task would be approximately 53% complete.
This shows how the subtask with twice the weight (Subtask B) has twice the influence on the overall progress calculation, even though it has a lower completion percentage.
## Time-based Progress Method
### How It Works
- Use the task's time estimate as its "weight" in the progress calculation
- You still enter manual progress percentages for tasks without subtasks
- Tasks with longer time estimates have more influence on overall progress
- Parent task progress is calculated based on time-weighted averages
### When to Use Time-based Progress
- For projects with well-defined time estimates
- When task importance correlates with its duration
- For billing or time-tracking focused projects
- When you already maintain accurate time estimates
### Example
If you have a parent task with three subtasks:
- Subtask A: 40% complete, Estimated Time 2.5 hours
- Subtask B: 80% complete, Estimated Time 1 hour
- Subtask C: 10% complete, Estimated Time 4 hours
The parent task will be approximately 29% complete, with the lengthy Subtask C pulling down the overall progress despite Subtask B being mostly complete.
### Important Notes About Time Estimates
- Tasks without time estimates don't influence progress calculations
- Time is converted to minutes internally (a 2-hour task = 120 minutes)
- Setting a time estimate to 0 removes that task from progress calculations
- Time estimates serve dual purposes: scheduling/resource planning and progress weighting
### Detailed Time-based Progress Calculation Example
To understand how time-based progress works with different time estimates, consider this example:
For a parent task with three subtasks:
- Subtask A: 40% complete, Estimated Time 2.5 hours
- Subtask B: 80% complete, Estimated Time 1 hour
- Subtask C: 10% complete, Estimated Time 4 hours
The calculation works as follows:
1. Convert hours to minutes: A = 150 min, B = 60 min, C = 240 min
2. Total estimated time: 150 + 60 + 240 = 450 minutes
3. Each subtask's contribution is: (time estimate × progress) ÷ (total time)
4. For Subtask A: (150 × 40%) ÷ 450 = 13.3%
5. For Subtask B: (60 × 80%) ÷ 450 = 10.7%
6. For Subtask C: (240 × 10%) ÷ 450 = 5.3%
7. Total parent progress: 13.3% + 10.7% + 5.3% = 29.3%
The parent task would be approximately 29% complete.
This demonstrates how tasks with longer time estimates (like Subtask C) have more influence on the overall progress calculation. Even though Subtask B is 80% complete, its shorter time estimate means it contributes less to the overall progress than the partially-completed but longer Subtask A.
### How It Works
- Tasks are either 0% (not done) or 100% (done)
- Parent task progress = (completed tasks / total tasks) × 100%
- Both the parent task and all subtasks count in this calculation
### When to Use Default Progress
- For simple projects with clear task completion criteria
- When binary task status (done/not done) is sufficient
- For teams new to project management who want simplicity
### Example
If you have a parent task with four subtasks and two of the subtasks are marked complete:
- Parent task: Not done
- 2 subtasks: Done
- 2 subtasks: Not done
The parent task will show as 40% complete (2 completed out of 5 total tasks).
## Best Practices
1. **Choose the Right Method for Your Project**
- Consider your team's workflow and reporting needs
- Match the method to your project's complexity
2. **Be Consistent**
- Stick with one method throughout the project
- Changing methods mid-project can cause confusion
3. **For Manual Progress**
- Update progress regularly
- Establish guidelines for progress reporting
4. **For Weighted Progress**
- Assign weights based on objective criteria
- Don't overuse extreme weights
5. **For Time-based Progress**
- Keep time estimates accurate and up to date
- Consider using time tracking to validate estimates
## Frequently Asked Questions
**Q: Can I change the progress method mid-project?**
A: Yes, but it may cause progress values to change significantly. It's best to select a method at the project start.
**Q: What happens to task progress when I mark a task complete?**
A: When a task is marked complete, its progress automatically becomes 100%, regardless of the progress method.
**Q: How do I enter progress for a task?**
A: Open the task drawer, go to the Info tab, and use the progress slider for tasks without subtasks.
**Q: Can different projects use different progress methods?**
A: Yes, each project can have its own progress method.
**Q: What if I don't see progress fields in my task drawer?**
A: Progress input is only visible for tasks without subtasks. Parent tasks' progress is automatically calculated.

View File

@@ -1,550 +0,0 @@
# Task Progress Tracking Methods in WorkLenz
## Overview
WorkLenz supports three different methods for tracking task progress, each suitable for different project management approaches:
1. **Manual Progress** - Direct input of progress percentages
2. **Weighted Progress** - Tasks have weights that affect overall progress calculation
3. **Time-based Progress** - Progress calculated based on estimated time vs. time spent
These modes can be selected when creating or editing a project in the project drawer. Only one progress method can be enabled at a time. If none of these methods are enabled, progress will be calculated based on task completion status as described in the "Default Progress Tracking" section below.
## 1. Manual Progress Mode
This mode allows direct input of progress percentages for individual tasks without subtasks.
**Implementation:**
- Enabled by setting `use_manual_progress` to true in the project settings
- Progress is updated through the `on-update-task-progress.ts` socket event handler
- The UI shows a manual progress input slider in the task drawer for tasks without subtasks
- Updates the database with `progress_value` and sets `manual_progress` flag to true
**Calculation Logic:**
- For tasks without subtasks: Uses the manually set progress value
- For parent tasks: Calculates the average of all subtask progress values
- Subtask progress comes from either manual values or completion status (0% or 100%)
**Code Example:**
```typescript
// Manual progress update via socket.io
socket?.emit(SocketEvents.UPDATE_TASK_PROGRESS.toString(), JSON.stringify({
task_id: task.id,
progress_value: value,
parent_task_id: task.parent_task_id
}));
```
## 2. Weighted Progress Mode
This mode allows assigning different weights to subtasks to reflect their relative importance in the overall task or project progress.
**Implementation:**
- Enabled by setting `use_weighted_progress` to true in the project settings
- Weights are updated through the `on-update-task-weight.ts` socket event handler
- The UI shows a weight input for subtasks in the task drawer
- Manual progress input is still required for tasks without subtasks
- Default weight is 100 if not specified
- Weight values range from 0 to 100%
**Calculation Logic:**
- For tasks without subtasks: Uses the manually entered progress value
- Progress is calculated using a weighted average: `SUM(progress_value * weight) / SUM(weight)`
- This gives more influence to tasks with higher weights
- A parent task's progress is the weighted average of its subtasks' progress values
**Code Example:**
```typescript
// Weight update via socket.io
socket?.emit(SocketEvents.UPDATE_TASK_WEIGHT.toString(), JSON.stringify({
task_id: task.id,
weight: value,
parent_task_id: task.parent_task_id
}));
```
## 3. Time-based Progress Mode
This mode calculates progress based on estimated time vs. actual time spent.
**Implementation:**
- Enabled by setting `use_time_progress` to true in the project settings
- Uses task time estimates (hours and minutes) for calculation
- Manual progress input is still required for tasks without subtasks
- No separate socket handler needed as it's calculated automatically
**Calculation Logic:**
- For tasks without subtasks: Uses the manually entered progress value
- Progress is calculated using time as the weight: `SUM(progress_value * estimated_minutes) / SUM(estimated_minutes)`
- For tasks with time tracking, estimated vs. actual time can be factored in
- Parent task progress is weighted by the estimated time of each subtask
**SQL Example:**
```sql
WITH subtask_progress AS (
SELECT
CASE
WHEN manual_progress IS TRUE AND progress_value IS NOT NULL THEN
progress_value
ELSE
CASE
WHEN EXISTS(
SELECT 1
FROM tasks_with_status_view
WHERE tasks_with_status_view.task_id = t.id
AND is_done IS TRUE
) THEN 100
ELSE 0
END
END AS progress_value,
COALESCE(total_hours * 60 + total_minutes, 0) AS estimated_minutes
FROM tasks t
WHERE t.parent_task_id = _task_id
AND t.archived IS FALSE
)
SELECT COALESCE(
SUM(progress_value * estimated_minutes) / NULLIF(SUM(estimated_minutes), 0),
0
)
FROM subtask_progress
INTO _ratio;
```
## Default Progress Tracking (when no special mode is selected)
If no specific progress mode is enabled, the system falls back to a traditional completion-based calculation:
**Implementation:**
- Default mode when all three special modes are disabled
- Based on task completion status only
**Calculation Logic:**
- For tasks without subtasks: 0% if not done, 100% if done
- For parent tasks: `(completed_tasks / total_tasks) * 100`
- Counts both the parent and all subtasks in the calculation
**SQL Example:**
```sql
-- Traditional calculation based on completion status
SELECT (CASE
WHEN EXISTS(SELECT 1
FROM tasks_with_status_view
WHERE tasks_with_status_view.task_id = _task_id
AND is_done IS TRUE) THEN 1
ELSE 0 END)
INTO _parent_task_done;
SELECT COUNT(*)
FROM tasks_with_status_view
WHERE parent_task_id = _task_id
AND is_done IS TRUE
INTO _sub_tasks_done;
_total_completed = _parent_task_done + _sub_tasks_done;
_total_tasks = _sub_tasks_count + 1; -- +1 for the parent task
IF _total_tasks = 0 THEN
_ratio = 0;
ELSE
_ratio = (_total_completed / _total_tasks) * 100;
END IF;
```
## Technical Implementation Details
The progress calculation logic is implemented in PostgreSQL functions, primarily in the `get_task_complete_ratio` function. Progress updates flow through the system as follows:
1. **User Action**: User updates task progress or weight in the UI
2. **Socket Event**: Client emits socket event (UPDATE_TASK_PROGRESS or UPDATE_TASK_WEIGHT)
3. **Server Handler**: Server processes the event in the respective handler function
4. **Database Update**: Progress/weight value is updated in the database
5. **Recalculation**: If needed, parent task progress is recalculated
6. **Broadcast**: Changes are broadcast to all clients in the project room
7. **UI Update**: Client UI updates to reflect the new progress values
This architecture allows for real-time updates and consistent progress calculation across all clients.
## Manual Progress Input Implementation
Regardless of which progress tracking method is selected for a project, tasks without subtasks (leaf tasks) require manual progress input. This section details how manual progress input is implemented and used across all progress tracking methods.
### UI Component
The manual progress input component is implemented in `worklenz-frontend/src/components/task-drawer/shared/info-tab/details/task-drawer-progress/task-drawer-progress.tsx` and includes:
1. **Progress Slider**: A slider UI control that allows users to set progress values from 0% to 100%
2. **Progress Input Field**: A numeric input field that accepts direct entry of progress percentage
3. **Progress Display**: Visual representation of the current progress value
The component is conditionally rendered in the task drawer for tasks that don't have subtasks.
**Usage Across Progress Methods:**
- In **Manual Progress Mode**: Only the progress slider/input is shown
- In **Weighted Progress Mode**: Both the progress slider/input and weight input are shown
- In **Time-based Progress Mode**: The progress slider/input is shown alongside time estimate fields
### Progress Update Flow
When a user updates a task's progress manually, the following process occurs:
1. **User Input**: User adjusts the progress slider or enters a value in the input field
2. **UI Event Handler**: The UI component captures the change event and validates the input
3. **Socket Event Emission**: The component emits a `UPDATE_TASK_PROGRESS` socket event with:
```typescript
{
task_id: task.id,
progress_value: value, // The new progress value (0-100)
parent_task_id: task.parent_task_id // For recalculation
}
```
4. **Server Processing**: The socket event handler on the server:
- Updates the task's `progress_value` in the database
- Sets the `manual_progress` flag to true
- Triggers recalculation of parent task progress
### Progress Calculation Across Methods
The calculation of progress differs based on the active progress method:
1. **For Leaf Tasks (no subtasks)** in all methods:
- Progress is always the manually entered value (`progress_value`)
- If the task is marked as completed, progress is automatically set to 100%
2. **For Parent Tasks**:
- **Manual Progress Mode**: Simple average of all subtask progress values
- **Weighted Progress Mode**: Weighted average where each subtask's progress is multiplied by its weight
- **Time-based Progress Mode**: Weighted average where each subtask's progress is multiplied by its estimated time
- **Default Mode**: Percentage of completed tasks (including parent) vs. total tasks
### Detailed Calculation for Weighted Progress Method
In Weighted Progress mode, both the manual progress input and weight assignment are critical components:
1. **Manual Progress Input**:
- For leaf tasks (without subtasks), users must manually input progress percentages (0-100%)
- If a leaf task is marked as complete, its progress is automatically set to 100%
- If a leaf task's progress is not manually set, it defaults to 0% (or 100% if completed)
2. **Weight Assignment**:
- Each task can be assigned a weight value between 0-100% (default 100% if not specified)
- Higher weight values give tasks more influence in parent task progress calculations
- A weight of 0% means the task doesn't contribute to the parent's progress calculation
3. **Parent Task Calculation**:
The weighted progress formula is:
```
ParentProgress = ∑(SubtaskProgress * SubtaskWeight) / ∑(SubtaskWeight)
```
**Example Calculation**:
Consider a parent task with three subtasks:
- Subtask A: Progress 50%, Weight 60%
- Subtask B: Progress 75%, Weight 20%
- Subtask C: Progress 25%, Weight 100%
Calculation:
```
ParentProgress = ((50 * 60) + (75 * 20) + (25 * 100)) / (60 + 20 + 100)
ParentProgress = (3000 + 1500 + 2500) / 180
ParentProgress = 7000 / 180
ParentProgress = 38.89%
```
Notice that Subtask C, despite having the lowest progress, has a significant impact on the parent task progress due to its higher weight.
4. **Zero Weight Handling**:
Tasks with zero weight are excluded from the calculation:
- Subtask A: Progress 40%, Weight 50%
- Subtask B: Progress 80%, Weight 0%
Calculation:
```
ParentProgress = ((40 * 50) + (80 * 0)) / (50 + 0)
ParentProgress = 2000 / 50
ParentProgress = 40%
```
In this case, only Subtask A influences the parent task progress because Subtask B has a weight of 0%.
5. **Default Weight Behavior**:
When weights aren't explicitly assigned to some tasks:
- Subtask A: Progress 30%, Weight 60% (explicitly set)
- Subtask B: Progress 70%, Weight not set (defaults to 100%)
- Subtask C: Progress 90%, Weight not set (defaults to 100%)
Calculation:
```
ParentProgress = ((30 * 60) + (70 * 100) + (90 * 100)) / (60 + 100 + 100)
ParentProgress = (1800 + 7000 + 9000) / 260
ParentProgress = 17800 / 260
ParentProgress = 68.46%
```
Note that Subtasks B and C have more influence than Subtask A because they have higher default weights.
6. **All Zero Weights Edge Case**:
If all subtasks have zero weight, the progress is calculated as 0%:
```
ParentProgress = SUM(progress_value * 0) / SUM(0) = 0 / 0 = undefined
```
The SQL implementation handles this with `NULLIF` and `COALESCE` to return 0% in this case.
4. **Actual SQL Implementation**:
The database function implements the weighted calculation as follows:
```sql
WITH subtask_progress AS (
SELECT
CASE
-- If subtask has manual progress, use that value
WHEN manual_progress IS TRUE AND progress_value IS NOT NULL THEN
progress_value
-- Otherwise use completion status (0 or 100)
ELSE
CASE
WHEN EXISTS(
SELECT 1
FROM tasks_with_status_view
WHERE tasks_with_status_view.task_id = t.id
AND is_done IS TRUE
) THEN 100
ELSE 0
END
END AS progress_value,
COALESCE(weight, 100) AS weight
FROM tasks t
WHERE t.parent_task_id = _task_id
AND t.archived IS FALSE
)
SELECT COALESCE(
SUM(progress_value * weight) / NULLIF(SUM(weight), 0),
0
)
FROM subtask_progress
INTO _ratio;
```
This SQL implementation:
- Gets all non-archived subtasks of the parent task
- For each subtask, determines its progress value:
- If manual progress is set, uses that value
- Otherwise, uses 100% if the task is done or 0% if not done
- Uses COALESCE to default weight to 100 if not specified
- Calculates the weighted average, handling the case where sum of weights might be zero
- Returns 0 if there are no subtasks with weights
### Detailed Calculation for Time-based Progress Method
In Time-based Progress mode, the task's estimated time serves as its weight in progress calculations:
1. **Manual Progress Input**:
- As with weighted progress, leaf tasks require manual progress input
- Progress is entered as a percentage (0-100%)
- Completed tasks are automatically set to 100% progress
2. **Time Estimation**:
- Each task has an estimated time in hours and minutes
- These values are stored in `total_hours` and `total_minutes` fields
- Time estimates effectively function as weights in progress calculations
- Tasks with longer estimated durations have more influence on parent task progress
- Tasks with zero or no time estimate don't contribute to the parent's progress calculation
3. **Parent Task Calculation**:
The time-based progress formula is:
```
ParentProgress = ∑(SubtaskProgress * SubtaskEstimatedMinutes) / ∑(SubtaskEstimatedMinutes)
```
where `SubtaskEstimatedMinutes = (SubtaskHours * 60) + SubtaskMinutes`
**Example Calculation**:
Consider a parent task with three subtasks:
- Subtask A: Progress 40%, Estimated Time 2h 30m (150 minutes)
- Subtask B: Progress 80%, Estimated Time 1h (60 minutes)
- Subtask C: Progress 10%, Estimated Time 4h (240 minutes)
Calculation:
```
ParentProgress = ((40 * 150) + (80 * 60) + (10 * 240)) / (150 + 60 + 240)
ParentProgress = (6000 + 4800 + 2400) / 450
ParentProgress = 13200 / 450
ParentProgress = 29.33%
```
Note how Subtask C, with its large time estimate, significantly pulls down the overall progress despite Subtask B being mostly complete.
4. **Zero Time Estimate Handling**:
Tasks with zero time estimate are excluded from the calculation:
- Subtask A: Progress 40%, Estimated Time 3h (180 minutes)
- Subtask B: Progress 80%, Estimated Time 0h (0 minutes)
Calculation:
```
ParentProgress = ((40 * 180) + (80 * 0)) / (180 + 0)
ParentProgress = 7200 / 180
ParentProgress = 40%
```
In this case, only Subtask A influences the parent task progress because Subtask B has no time estimate.
5. **All Zero Time Estimates Edge Case**:
If all subtasks have zero time estimates, the progress is calculated as 0%:
```
ParentProgress = SUM(progress_value * 0) / SUM(0) = 0 / 0 = undefined
```
The SQL implementation handles this with `NULLIF` and `COALESCE` to return 0% in this case.
6. **Actual SQL Implementation**:
The SQL function for this calculation first converts hours to minutes for consistent measurement:
```sql
WITH subtask_progress AS (
SELECT
CASE
-- If subtask has manual progress, use that value
WHEN manual_progress IS TRUE AND progress_value IS NOT NULL THEN
progress_value
-- Otherwise use completion status (0 or 100)
ELSE
CASE
WHEN EXISTS(
SELECT 1
FROM tasks_with_status_view
WHERE tasks_with_status_view.task_id = t.id
AND is_done IS TRUE
) THEN 100
ELSE 0
END
END AS progress_value,
COALESCE(total_hours * 60 + total_minutes, 0) AS estimated_minutes
FROM tasks t
WHERE t.parent_task_id = _task_id
AND t.archived IS FALSE
)
SELECT COALESCE(
SUM(progress_value * estimated_minutes) / NULLIF(SUM(estimated_minutes), 0),
0
)
FROM subtask_progress
INTO _ratio;
```
This implementation:
- Gets all non-archived subtasks of the parent task
- Determines each subtask's progress value (manual or completion-based)
- Calculates total minutes by converting hours to minutes and adding them together
- Uses COALESCE to treat NULL time estimates as 0 minutes
- Uses NULLIF to handle cases where all time estimates are zero
- Returns 0% progress if there are no subtasks with time estimates
### Common Implementation Considerations
For both weighted and time-based progress calculation:
1. **Null Handling**:
- Tasks with NULL progress values are treated as 0% progress (unless completed)
- Tasks with NULL weights default to 100 in weighted mode
- Tasks with NULL time estimates are treated as 0 minutes in time-based mode
2. **Progress Propagation**:
- When a leaf task's progress changes, all ancestor tasks are recalculated
- Progress updates are propagated through socket events to all connected clients
- The recalculation happens server-side to ensure consistency
3. **Edge Cases**:
- If all subtasks have zero weight/time, the system falls back to a simple average
- If a parent task has no subtasks, its own manual progress value is used
- If a task is archived, it's excluded from parent task calculations
### Database Implementation
The manual progress value is stored in the `tasks` table with these relevant fields:
```sql
tasks (
-- other fields
progress_value FLOAT, -- The manually entered progress value (0-100)
manual_progress BOOLEAN, -- Flag indicating if progress was manually set
weight INTEGER DEFAULT 100, -- For weighted progress calculation
total_hours INTEGER, -- For time-based progress calculation
total_minutes INTEGER -- For time-based progress calculation
)
```
### Integration with Parent Task Calculation
When a subtask's progress is updated manually, the parent task's progress is automatically recalculated based on the active progress method:
```typescript
// Pseudocode for parent task recalculation
function recalculateParentTaskProgress(taskId, parentTaskId) {
if (!parentTaskId) return;
// Get project settings to determine active progress method
const project = getProjectByTaskId(taskId);
if (project.use_manual_progress) {
// Calculate average of all subtask progress values
updateParentProgress(parentTaskId, calculateAverageProgress(parentTaskId));
}
else if (project.use_weighted_progress) {
// Calculate weighted average using subtask weights
updateParentProgress(parentTaskId, calculateWeightedProgress(parentTaskId));
}
else if (project.use_time_progress) {
// Calculate weighted average using time estimates
updateParentProgress(parentTaskId, calculateTimeBasedProgress(parentTaskId));
}
else {
// Default: Calculate based on task completion
updateParentProgress(parentTaskId, calculateCompletionBasedProgress(parentTaskId));
}
// If this parent has a parent, continue recalculation up the tree
const grandparentId = getParentTaskId(parentTaskId);
if (grandparentId) {
recalculateParentTaskProgress(parentTaskId, grandparentId);
}
}
```
This recursive approach ensures that changes to any task's progress are properly propagated up the task hierarchy.
## Associated Files and Components
### Backend Files
1. **Socket Event Handlers**:
- `worklenz-backend/src/socket.io/commands/on-update-task-progress.ts` - Handles manual progress updates
- `worklenz-backend/src/socket.io/commands/on-update-task-weight.ts` - Handles task weight updates
2. **Database Functions**:
- `worklenz-backend/database/migrations/20250423000000-subtask-manual-progress.sql` - Contains the `get_task_complete_ratio` function that calculates progress based on the selected method
- Functions that support project creation/updates with progress mode settings:
- `create_project`
- `update_project`
3. **Controllers**:
- `worklenz-backend/src/controllers/project-workload/workload-gannt-base.ts` - Contains the `calculateTaskCompleteRatio` method
- `worklenz-backend/src/controllers/projects-controller.ts` - Handles project-level progress calculations
### Frontend Files
1. **Project Configuration**:
- `worklenz-frontend/src/components/projects/project-drawer/project-drawer.tsx` - Contains UI for selecting progress method when creating/editing projects
2. **Progress Visualization Components**:
- `worklenz-frontend/src/components/project-list/project-list-table/project-list-progress/progress-list-progress.tsx` - Displays project progress
- `worklenz-frontend/src/pages/projects/project-view-1/taskList/taskListTable/taskListTableCells/TaskProgress.tsx` - Displays task progress
- `worklenz-frontend/src/pages/projects/projectView/taskList/task-list-table/task-list-table-cells/task-list-progress-cell/task-list-progress-cell.tsx` - Alternative task progress cell
3. **Progress Input Components**:
- `worklenz-frontend/src/components/task-drawer/shared/info-tab/details/task-drawer-progress/task-drawer-progress.tsx` - Component for inputting task progress/weight
## Choosing the Right Progress Method
Each progress method is suitable for different types of projects:
- **Manual Progress**: Best for creative work where progress is subjective
- **Weighted Progress**: Ideal for projects where some tasks are more significant than others
- **Time-based Progress**: Perfect for projects where time estimates are reliable and important
Project managers can choose the appropriate method when creating or editing a project in the project drawer, based on their team's workflow and project requirements.

6
package-lock.json generated
View File

@@ -1,6 +0,0 @@
{
"name": "worklenz",
"lockfileVersion": 3,
"requires": true,
"packages": {}
}

View File

@@ -1,244 +0,0 @@
# Task Progress Tracking Methods in WorkLenz
## Overview
WorkLenz supports three different methods for tracking task progress, each suitable for different project management approaches:
1. **Manual Progress** - Direct input of progress percentages
2. **Weighted Progress** - Tasks have weights that affect overall progress calculation
3. **Time-based Progress** - Progress calculated based on estimated time vs. time spent
These modes can be selected when creating or editing a project in the project drawer.
## 1. Manual Progress Mode
This mode allows direct input of progress percentages for individual tasks without subtasks.
**Implementation:**
- Enabled by setting `use_manual_progress` to true in the project settings
- Progress is updated through the `on-update-task-progress.ts` socket event handler
- The UI shows a manual progress input slider in the task drawer for tasks without subtasks
- Updates the database with `progress_value` and sets `manual_progress` flag to true
**Calculation Logic:**
- For tasks without subtasks: Uses the manually set progress value
- For parent tasks: Calculates the average of all subtask progress values
- Subtask progress comes from either manual values or completion status (0% or 100%)
**Code Example:**
```typescript
// Manual progress update via socket.io
socket?.emit(SocketEvents.UPDATE_TASK_PROGRESS.toString(), JSON.stringify({
task_id: task.id,
progress_value: value,
parent_task_id: task.parent_task_id
}));
```
### Showing Progress in Subtask Rows
When manual progress is enabled in a project, progress is shown in the following ways:
1. **In Task List Views**:
- Subtasks display their individual progress values in the progress column
- Parent tasks display the calculated average progress of all subtasks
2. **Implementation Details**:
- The progress values are stored in the `progress_value` column in the database
- For subtasks with manual progress set, the value is shown directly
- For subtasks without manual progress, the completion status determines the value (0% or 100%)
- The task view model includes both `progress` and `complete_ratio` properties
**Relevant Components:**
```typescript
// From task-list-progress-cell.tsx
const TaskListProgressCell = ({ task }: TaskListProgressCellProps) => {
return task.is_sub_task ? null : (
<Tooltip title={`${task.completed_count || 0} / ${task.total_tasks_count || 0}`}>
<Progress
percent={task.complete_ratio || 0}
type="circle"
size={24}
style={{ cursor: 'default' }}
/>
</Tooltip>
);
};
```
**Task Progress Calculation in Backend:**
```typescript
// From tasks-controller-base.ts
// For tasks without subtasks, respect manual progress if set
if (task.manual_progress === true && task.progress_value !== null) {
// For manually set progress, use that value directly
task.progress = parseInt(task.progress_value);
task.complete_ratio = parseInt(task.progress_value);
}
```
## 2. Weighted Progress Mode
This mode allows assigning different weights to subtasks to reflect their relative importance in the overall task or project progress.
**Implementation:**
- Enabled by setting `use_weighted_progress` to true in the project settings
- Weights are updated through the `on-update-task-weight.ts` socket event handler
- The UI shows a weight input for subtasks in the task drawer
- Default weight is 100 if not specified
**Calculation Logic:**
- Progress is calculated using a weighted average: `SUM(progress_value * weight) / SUM(weight)`
- This gives more influence to tasks with higher weights
- A parent task's progress is the weighted average of its subtasks' progress
**Code Example:**
```typescript
// Weight update via socket.io
socket?.emit(SocketEvents.UPDATE_TASK_WEIGHT.toString(), JSON.stringify({
task_id: task.id,
weight: value,
parent_task_id: task.parent_task_id
}));
```
## 3. Time-based Progress Mode
This mode calculates progress based on estimated time vs. actual time spent.
**Implementation:**
- Enabled by setting `use_time_progress` to true in the project settings
- Uses task time estimates (hours and minutes) for calculation
- No separate socket handler needed as it's calculated automatically
**Calculation Logic:**
- Progress is calculated using time as the weight: `SUM(progress_value * estimated_minutes) / SUM(estimated_minutes)`
- For tasks with time tracking, estimated vs. actual time can be factored in
- Parent task progress is weighted by the estimated time of each subtask
**SQL Example:**
```sql
WITH subtask_progress AS (
SELECT
CASE
WHEN manual_progress IS TRUE AND progress_value IS NOT NULL THEN
progress_value
ELSE
CASE
WHEN EXISTS(
SELECT 1
FROM tasks_with_status_view
WHERE tasks_with_status_view.task_id = t.id
AND is_done IS TRUE
) THEN 100
ELSE 0
END
END AS progress_value,
COALESCE(total_hours * 60 + total_minutes, 0) AS estimated_minutes
FROM tasks t
WHERE t.parent_task_id = _task_id
AND t.archived IS FALSE
)
SELECT COALESCE(
SUM(progress_value * estimated_minutes) / NULLIF(SUM(estimated_minutes), 0),
0
)
FROM subtask_progress
INTO _ratio;
```
## Default Progress Tracking (when no special mode is selected)
If no specific progress mode is enabled, the system falls back to a traditional completion-based calculation:
**Implementation:**
- Default mode when all three special modes are disabled
- Based on task completion status only
**Calculation Logic:**
- For tasks without subtasks: 0% if not done, 100% if done
- For parent tasks: `(completed_tasks / total_tasks) * 100`
- Counts both the parent and all subtasks in the calculation
**SQL Example:**
```sql
-- Traditional calculation based on completion status
SELECT (CASE
WHEN EXISTS(SELECT 1
FROM tasks_with_status_view
WHERE tasks_with_status_view.task_id = _task_id
AND is_done IS TRUE) THEN 1
ELSE 0 END)
INTO _parent_task_done;
SELECT COUNT(*)
FROM tasks_with_status_view
WHERE parent_task_id = _task_id
AND is_done IS TRUE
INTO _sub_tasks_done;
_total_completed = _parent_task_done + _sub_tasks_done;
_total_tasks = _sub_tasks_count + 1; -- +1 for the parent task
IF _total_tasks = 0 THEN
_ratio = 0;
ELSE
_ratio = (_total_completed / _total_tasks) * 100;
END IF;
```
## Technical Implementation Details
The progress calculation logic is implemented in PostgreSQL functions, primarily in the `get_task_complete_ratio` function. Progress updates flow through the system as follows:
1. **User Action**: User updates task progress or weight in the UI
2. **Socket Event**: Client emits socket event (UPDATE_TASK_PROGRESS or UPDATE_TASK_WEIGHT)
3. **Server Handler**: Server processes the event in the respective handler function
4. **Database Update**: Progress/weight value is updated in the database
5. **Recalculation**: If needed, parent task progress is recalculated
6. **Broadcast**: Changes are broadcast to all clients in the project room
7. **UI Update**: Client UI updates to reflect the new progress values
This architecture allows for real-time updates and consistent progress calculation across all clients.
## Associated Files and Components
### Backend Files
1. **Socket Event Handlers**:
- `worklenz-backend/src/socket.io/commands/on-update-task-progress.ts` - Handles manual progress updates
- `worklenz-backend/src/socket.io/commands/on-update-task-weight.ts` - Handles task weight updates
2. **Database Functions**:
- `worklenz-backend/database/migrations/20250423000000-subtask-manual-progress.sql` - Contains the `get_task_complete_ratio` function that calculates progress based on the selected method
- Functions that support project creation/updates with progress mode settings:
- `create_project`
- `update_project`
3. **Controllers**:
- `worklenz-backend/src/controllers/project-workload/workload-gannt-base.ts` - Contains the `calculateTaskCompleteRatio` method
- `worklenz-backend/src/controllers/projects-controller.ts` - Handles project-level progress calculations
- `worklenz-backend/src/controllers/tasks-controller-base.ts` - Handles task progress calculation and updates task view models
### Frontend Files
1. **Project Configuration**:
- `worklenz-frontend/src/components/projects/project-drawer/project-drawer.tsx` - Contains UI for selecting progress method when creating/editing projects
2. **Progress Visualization Components**:
- `worklenz-frontend/src/components/project-list/project-list-table/project-list-progress/progress-list-progress.tsx` - Displays project progress
- `worklenz-frontend/src/pages/projects/project-view-1/taskList/taskListTable/taskListTableCells/TaskProgress.tsx` - Displays task progress
- `worklenz-frontend/src/pages/projects/projectView/taskList/task-list-table/task-list-table-cells/task-list-progress-cell/task-list-progress-cell.tsx` - Alternative task progress cell
- `worklenz-frontend/src/components/task-list-common/task-row/task-row-progress/task-row-progress.tsx` - Displays progress in task rows
3. **Progress Input Components**:
- `worklenz-frontend/src/components/task-drawer/shared/info-tab/details/task-drawer-progress/task-drawer-progress.tsx` - Component for inputting task progress/weight
## Choosing the Right Progress Method
Each progress method is suitable for different types of projects:
- **Manual Progress**: Best for creative work where progress is subjective
- **Weighted Progress**: Ideal for projects where some tasks are more significant than others
- **Time-based Progress**: Perfect for projects where time estimates are reliable and important
Project managers can choose the appropriate method when creating or editing a project in the project drawer, based on their team's workflow and project requirements.

View File

@@ -47,17 +47,12 @@ FRONTEND_URL=http://localhost:5000
# STORAGE
STORAGE_PROVIDER=s3 # values s3 or azure
# AWS - SES
# AWS
AWS_REGION="your_aws_region"
AWS_ACCESS_KEY_ID="your_aws_access_key_id"
AWS_SECRET_ACCESS_KEY="your_aws_secret_access_key"
# S3
S3_REGION="S3_REGION"
S3_BUCKET="your_s3_bucket"
AWS_BUCKET="your_s3_bucket"
S3_URL="your_s3_url"
S3_ACCESS_KEY_ID="S3_ACCESS_KEY_ID"
S3_SECRET_ACCESS_KEY="S3_SECRET_ACCESS_KEY"
# Azure Storage
AZURE_STORAGE_ACCOUNT_NAME="your_storage_account_name"
@@ -78,8 +73,4 @@ GOOGLE_CAPTCHA_SECRET_KEY=your_captcha_secret_key
GOOGLE_CAPTCHA_PASS_SCORE=0.8
# Email Cronjobs
ENABLE_EMAIL_CRONJOBS=true
# RECURRING_JOBS
ENABLE_RECURRING_JOBS=true
RECURRING_JOBS_INTERVAL="0 11 */1 * 1-5"
ENABLE_EMAIL_CRONJOBS=true

View File

@@ -20,6 +20,9 @@ coverage
# nyc test coverage
.nyc_output
# Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files)
.grunt
# Bower dependency directory (https://bower.io/)
bower_components

View File

@@ -0,0 +1,131 @@
module.exports = function (grunt) {
// Project configuration.
grunt.initConfig({
pkg: grunt.file.readJSON("package.json"),
clean: {
dist: "build"
},
compress: require("./grunt/grunt-compress"),
copy: {
main: {
files: [
{expand: true, cwd: "src", src: ["public/**"], dest: "build"},
{expand: true, cwd: "src", src: ["views/**"], dest: "build"},
{expand: true, cwd: "landing-page-assets", src: ["**"], dest: "build/public/assets"},
{expand: true, cwd: "src", src: ["shared/sample-data.json"], dest: "build", filter: "isFile"},
{expand: true, cwd: "src", src: ["shared/templates/**"], dest: "build", filter: "isFile"},
{expand: true, cwd: "src", src: ["shared/postgresql-error-codes.json"], dest: "build", filter: "isFile"},
]
},
packages: {
files: [
{expand: true, cwd: "", src: [".env"], dest: "build", filter: "isFile"},
{expand: true, cwd: "", src: [".gitignore"], dest: "build", filter: "isFile"},
{expand: true, cwd: "", src: ["release"], dest: "build", filter: "isFile"},
{expand: true, cwd: "", src: ["jest.config.js"], dest: "build", filter: "isFile"},
{expand: true, cwd: "", src: ["package.json"], dest: "build", filter: "isFile"},
{expand: true, cwd: "", src: ["package-lock.json"], dest: "build", filter: "isFile"},
{expand: true, cwd: "", src: ["common_modules/**"], dest: "build"}
]
}
},
sync: {
main: {
files: [
{cwd: "src", src: ["views/**", "public/**"], dest: "build/"}, // makes all src relative to cwd
],
verbose: true,
failOnError: true,
compareUsing: "md5"
}
},
uglify: {
all: {
files: [{
expand: true,
cwd: "build",
src: "**/*.js",
dest: "build"
}]
},
controllers: {
files: [{
expand: true,
cwd: "build",
src: "controllers/*.js",
dest: "build"
}]
},
routes: {
files: [{
expand: true,
cwd: "build",
src: "routes/**/*.js",
dest: "build"
}]
},
assets: {
files: [{
expand: true,
cwd: "build",
src: "public/assets/**/*.js",
dest: "build"
}]
}
},
shell: {
tsc: {
command: "tsc --build tsconfig.prod.json"
},
esbuild: {
// command: "esbuild `find src -type f -name '*.ts'` --platform=node --minify=false --target=esnext --format=cjs --tsconfig=tsconfig.prod.json --outdir=build"
command: "node esbuild && node cli/esbuild-patch"
},
tsc_dev: {
command: "tsc --build tsconfig.json"
},
swagger: {
command: "node ./cli/swagger"
},
inline_queries: {
command: "node ./cli/inline-queries"
}
},
watch: {
scripts: {
files: ["src/**/*.ts"],
tasks: ["shell:tsc_dev"],
options: {
debounceDelay: 250,
spawn: false,
}
},
other: {
files: ["src/**/*.pug", "landing-page-assets/**"],
tasks: ["sync"]
}
}
});
grunt.registerTask("clean", ["clean"]);
grunt.registerTask("copy", ["copy:main"]);
grunt.registerTask("swagger", ["shell:swagger"]);
grunt.registerTask("build:tsc", ["shell:tsc"]);
grunt.registerTask("build", ["clean", "shell:tsc", "copy:main", "compress"]);
grunt.registerTask("build:es", ["clean", "shell:esbuild", "copy:main", "uglify:assets", "compress"]);
grunt.registerTask("build:strict", ["clean", "shell:tsc", "copy:packages", "uglify:all", "copy:main", "compress"]);
grunt.registerTask("dev", ["clean", "copy:main", "shell:tsc_dev", "shell:inline_queries", "watch"]);
// Load the plugin that provides the "uglify" task.
grunt.loadNpmTasks("grunt-contrib-watch");
grunt.loadNpmTasks("grunt-contrib-clean");
grunt.loadNpmTasks("grunt-contrib-copy");
grunt.loadNpmTasks("grunt-contrib-uglify");
grunt.loadNpmTasks("grunt-contrib-compress");
grunt.loadNpmTasks("grunt-shell");
grunt.loadNpmTasks("grunt-sync");
// Default task(s).
grunt.registerTask("default", []);
};

View File

@@ -0,0 +1,55 @@
#!/bin/bash
set -e
# This script controls the order of SQL file execution during database initialization
echo "Starting database initialization..."
# Check if we have SQL files in expected locations
if [ -f "/docker-entrypoint-initdb.d/sql/0_extensions.sql" ]; then
SQL_DIR="/docker-entrypoint-initdb.d/sql"
echo "Using SQL files from sql/ subdirectory"
elif [ -f "/docker-entrypoint-initdb.d/0_extensions.sql" ]; then
# First time setup - move files to subdirectory
echo "Moving SQL files to sql/ subdirectory..."
mkdir -p /docker-entrypoint-initdb.d/sql
# Move all SQL files (except this script) to the subdirectory
for f in /docker-entrypoint-initdb.d/*.sql; do
if [ -f "$f" ]; then
cp "$f" /docker-entrypoint-initdb.d/sql/
echo "Copied $f to sql/ subdirectory"
fi
done
SQL_DIR="/docker-entrypoint-initdb.d/sql"
else
echo "SQL files not found in expected locations!"
exit 1
fi
# Execute SQL files in the correct order
echo "Executing 0_extensions.sql..."
psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" -f "$SQL_DIR/0_extensions.sql"
echo "Executing 1_tables.sql..."
psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" -f "$SQL_DIR/1_tables.sql"
echo "Executing indexes.sql..."
psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" -f "$SQL_DIR/indexes.sql"
echo "Executing 4_functions.sql..."
psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" -f "$SQL_DIR/4_functions.sql"
echo "Executing triggers.sql..."
psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" -f "$SQL_DIR/triggers.sql"
echo "Executing 3_views.sql..."
psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" -f "$SQL_DIR/3_views.sql"
echo "Executing 2_dml.sql..."
psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" -f "$SQL_DIR/2_dml.sql"
echo "Executing 5_database_user.sql..."
psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" -f "$SQL_DIR/5_database_user.sql"
echo "Database initialization completed successfully"

View File

@@ -1,88 +0,0 @@
#!/bin/bash
set -e
echo "Starting database initialization..."
SQL_DIR="/docker-entrypoint-initdb.d/sql"
MIGRATIONS_DIR="/docker-entrypoint-initdb.d/migrations"
BACKUP_DIR="/docker-entrypoint-initdb.d/pg_backups"
# --------------------------------------------
# 🗄️ STEP 1: Attempt to restore latest backup
# --------------------------------------------
if [ -d "$BACKUP_DIR" ]; then
LATEST_BACKUP=$(ls -t "$BACKUP_DIR"/*.sql 2>/dev/null | head -n 1)
else
LATEST_BACKUP=""
fi
if [ -f "$LATEST_BACKUP" ]; then
echo "🗄️ Found latest backup: $LATEST_BACKUP"
echo "⏳ Restoring from backup..."
psql -U "$POSTGRES_USER" -d "$POSTGRES_DB" < "$LATEST_BACKUP"
echo "✅ Backup restoration complete. Skipping schema and migrations."
exit 0
else
echo " No valid backup found. Proceeding with base schema and migrations."
fi
# --------------------------------------------
# 🏗️ STEP 2: Continue with base schema setup
# --------------------------------------------
# Create migrations table if it doesn't exist
psql -U "$POSTGRES_USER" -d "$POSTGRES_DB" -c "
CREATE TABLE IF NOT EXISTS schema_migrations (
version TEXT PRIMARY KEY,
applied_at TIMESTAMP DEFAULT now()
);
"
# List of base schema files to execute in order
BASE_SQL_FILES=(
"0_extensions.sql"
"1_tables.sql"
"indexes.sql"
"4_functions.sql"
"triggers.sql"
"3_views.sql"
"2_dml.sql"
"5_database_user.sql"
)
echo "Running base schema SQL files in order..."
for file in "${BASE_SQL_FILES[@]}"; do
full_path="$SQL_DIR/$file"
if [ -f "$full_path" ]; then
echo "Executing $file..."
psql -v ON_ERROR_STOP=1 -U "$POSTGRES_USER" -d "$POSTGRES_DB" -f "$full_path"
else
echo "WARNING: $file not found, skipping."
fi
done
echo "✅ Base schema SQL execution complete."
# --------------------------------------------
# 🚀 STEP 3: Apply SQL migrations
# --------------------------------------------
if [ -d "$MIGRATIONS_DIR" ] && compgen -G "$MIGRATIONS_DIR/*.sql" > /dev/null; then
echo "Applying migrations..."
for f in "$MIGRATIONS_DIR"/*.sql; do
version=$(basename "$f")
if ! psql -U "$POSTGRES_USER" -d "$POSTGRES_DB" -tAc "SELECT 1 FROM schema_migrations WHERE version = '$version'" | grep -q 1; then
echo "Applying migration: $version"
psql -U "$POSTGRES_USER" -d "$POSTGRES_DB" -f "$f"
psql -U "$POSTGRES_USER" -d "$POSTGRES_DB" -c "INSERT INTO schema_migrations (version) VALUES ('$version');"
else
echo "Skipping already applied migration: $version"
fi
done
else
echo "No migration files found or directory is empty, skipping migrations."
fi
echo "🎉 Database initialization completed successfully."

View File

@@ -1,135 +0,0 @@
-- Performance indexes for optimized tasks queries
-- Migration: 20250115000000-performance-indexes.sql
-- Composite index for main task filtering
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_tasks_project_archived_parent
ON tasks(project_id, archived, parent_task_id)
WHERE archived = FALSE;
-- Index for status joins
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_tasks_status_project
ON tasks(status_id, project_id)
WHERE archived = FALSE;
-- Index for assignees lookup
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_tasks_assignees_task_member
ON tasks_assignees(task_id, team_member_id);
-- Index for phase lookup
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_task_phase_task_phase
ON task_phase(task_id, phase_id);
-- Index for subtask counting
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_tasks_parent_archived
ON tasks(parent_task_id, archived)
WHERE parent_task_id IS NOT NULL AND archived = FALSE;
-- Index for labels
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_task_labels_task_label
ON task_labels(task_id, label_id);
-- Index for comments count
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_task_comments_task
ON task_comments(task_id);
-- Index for attachments count
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_task_attachments_task
ON task_attachments(task_id);
-- Index for work log aggregation
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_task_work_log_task
ON task_work_log(task_id);
-- Index for subscribers check
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_task_subscribers_task
ON task_subscribers(task_id);
-- Index for dependencies check
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_task_dependencies_task
ON task_dependencies(task_id);
-- Index for timers lookup
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_task_timers_task_user
ON task_timers(task_id, user_id);
-- Index for custom columns
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_cc_column_values_task
ON cc_column_values(task_id);
-- Index for team member info view optimization
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_team_members_team_user
ON team_members(team_id, user_id)
WHERE active = TRUE;
-- Index for notification settings
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_notification_settings_user_team
ON notification_settings(user_id, team_id);
-- Index for task status categories
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_task_statuses_category
ON task_statuses(category_id, project_id);
-- Index for project phases
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_project_phases_project_sort
ON project_phases(project_id, sort_index);
-- Index for task priorities
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_task_priorities_value
ON task_priorities(value);
-- Index for team labels
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_team_labels_team
ON team_labels(team_id);
-- NEW INDEXES FOR PERFORMANCE OPTIMIZATION --
-- Composite index for task main query optimization (covers most WHERE conditions)
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_tasks_performance_main
ON tasks(project_id, archived, parent_task_id, status_id, priority_id)
WHERE archived = FALSE;
-- Index for sorting by sort_order with project filter
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_tasks_project_sort_order
ON tasks(project_id, sort_order)
WHERE archived = FALSE;
-- Index for email_invitations to optimize team_member_info_view
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_email_invitations_team_member
ON email_invitations(team_member_id);
-- Covering index for task status with category information
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_task_statuses_covering
ON task_statuses(id, category_id, project_id);
-- Index for task aggregation queries (parent task progress calculation)
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_tasks_parent_status_archived
ON tasks(parent_task_id, status_id, archived)
WHERE archived = FALSE;
-- Index for project team member filtering
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_team_members_project_lookup
ON team_members(team_id, active, user_id)
WHERE active = TRUE;
-- Covering index for tasks with frequently accessed columns
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_tasks_covering_main
ON tasks(id, project_id, archived, parent_task_id, status_id, priority_id, sort_order, name)
WHERE archived = FALSE;
-- Index for task search functionality
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_tasks_name_search
ON tasks USING gin(to_tsvector('english', name))
WHERE archived = FALSE;
-- Index for date-based filtering (if used)
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_tasks_dates
ON tasks(project_id, start_date, end_date)
WHERE archived = FALSE;
-- Index for task timers with user filtering
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_task_timers_user_task
ON task_timers(user_id, task_id);
-- Index for sys_task_status_categories lookups
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_sys_task_status_categories_covering
ON sys_task_status_categories(id, color_code, color_code_dark, is_done, is_doing, is_todo);

View File

@@ -1,143 +0,0 @@
-- Fix window function error in task sort optimized functions
-- Error: window functions are not allowed in UPDATE
-- Replace the optimized sort functions to avoid CTE usage in UPDATE statements
CREATE OR REPLACE FUNCTION handle_task_list_sort_between_groups_optimized(_from_index integer, _to_index integer, _task_id uuid, _project_id uuid, _batch_size integer DEFAULT 100) RETURNS void
LANGUAGE plpgsql
AS
$$
DECLARE
_offset INT := 0;
_affected_rows INT;
BEGIN
-- PERFORMANCE OPTIMIZATION: Use direct updates without CTE in UPDATE
IF (_to_index = -1)
THEN
_to_index = COALESCE((SELECT MAX(sort_order) + 1 FROM tasks WHERE project_id = _project_id), 0);
END IF;
-- PERFORMANCE OPTIMIZATION: Batch updates for large datasets
IF _to_index > _from_index
THEN
LOOP
UPDATE tasks
SET sort_order = sort_order - 1
WHERE project_id = _project_id
AND sort_order > _from_index
AND sort_order < _to_index
AND sort_order > _offset
AND sort_order <= _offset + _batch_size;
GET DIAGNOSTICS _affected_rows = ROW_COUNT;
EXIT WHEN _affected_rows = 0;
_offset := _offset + _batch_size;
END LOOP;
UPDATE tasks SET sort_order = _to_index - 1 WHERE id = _task_id AND project_id = _project_id;
END IF;
IF _to_index < _from_index
THEN
_offset := 0;
LOOP
UPDATE tasks
SET sort_order = sort_order + 1
WHERE project_id = _project_id
AND sort_order > _to_index
AND sort_order < _from_index
AND sort_order > _offset
AND sort_order <= _offset + _batch_size;
GET DIAGNOSTICS _affected_rows = ROW_COUNT;
EXIT WHEN _affected_rows = 0;
_offset := _offset + _batch_size;
END LOOP;
UPDATE tasks SET sort_order = _to_index + 1 WHERE id = _task_id AND project_id = _project_id;
END IF;
END
$$;
-- Replace the second optimized sort function
CREATE OR REPLACE FUNCTION handle_task_list_sort_inside_group_optimized(_from_index integer, _to_index integer, _task_id uuid, _project_id uuid, _batch_size integer DEFAULT 100) RETURNS void
LANGUAGE plpgsql
AS
$$
DECLARE
_offset INT := 0;
_affected_rows INT;
BEGIN
-- PERFORMANCE OPTIMIZATION: Batch updates for large datasets without CTE in UPDATE
IF _to_index > _from_index
THEN
LOOP
UPDATE tasks
SET sort_order = sort_order - 1
WHERE project_id = _project_id
AND sort_order > _from_index
AND sort_order <= _to_index
AND sort_order > _offset
AND sort_order <= _offset + _batch_size;
GET DIAGNOSTICS _affected_rows = ROW_COUNT;
EXIT WHEN _affected_rows = 0;
_offset := _offset + _batch_size;
END LOOP;
END IF;
IF _to_index < _from_index
THEN
_offset := 0;
LOOP
UPDATE tasks
SET sort_order = sort_order + 1
WHERE project_id = _project_id
AND sort_order >= _to_index
AND sort_order < _from_index
AND sort_order > _offset
AND sort_order <= _offset + _batch_size;
GET DIAGNOSTICS _affected_rows = ROW_COUNT;
EXIT WHEN _affected_rows = 0;
_offset := _offset + _batch_size;
END LOOP;
END IF;
UPDATE tasks SET sort_order = _to_index WHERE id = _task_id AND project_id = _project_id;
END
$$;
-- Add simple bulk update function as alternative
CREATE OR REPLACE FUNCTION update_task_sort_orders_bulk(_updates json) RETURNS void
LANGUAGE plpgsql
AS
$$
DECLARE
_update_record RECORD;
BEGIN
-- Simple approach: update each task's sort_order from the provided array
FOR _update_record IN
SELECT
(item->>'task_id')::uuid as task_id,
(item->>'sort_order')::int as sort_order,
(item->>'status_id')::uuid as status_id,
(item->>'priority_id')::uuid as priority_id,
(item->>'phase_id')::uuid as phase_id
FROM json_array_elements(_updates) as item
LOOP
UPDATE tasks
SET
sort_order = _update_record.sort_order,
status_id = COALESCE(_update_record.status_id, status_id),
priority_id = COALESCE(_update_record.priority_id, priority_id)
WHERE id = _update_record.task_id;
-- Handle phase updates separately since it's in a different table
IF _update_record.phase_id IS NOT NULL THEN
INSERT INTO task_phase (task_id, phase_id)
VALUES (_update_record.task_id, _update_record.phase_id)
ON CONFLICT (task_id) DO UPDATE SET phase_id = _update_record.phase_id;
END IF;
END LOOP;
END
$$;

View File

@@ -1,85 +0,0 @@
-- Create holiday types table
CREATE TABLE IF NOT EXISTS holiday_types (
id UUID DEFAULT uuid_generate_v4() NOT NULL,
name TEXT NOT NULL,
description TEXT,
color_code WL_HEX_COLOR NOT NULL,
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP NOT NULL,
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP NOT NULL
);
ALTER TABLE holiday_types
ADD CONSTRAINT holiday_types_pk
PRIMARY KEY (id);
-- Insert default holiday types
INSERT INTO holiday_types (name, description, color_code) VALUES
('Public Holiday', 'Official public holidays', '#f37070'),
('Company Holiday', 'Company-specific holidays', '#70a6f3'),
('Personal Holiday', 'Personal or optional holidays', '#75c997'),
('Religious Holiday', 'Religious observances', '#fbc84c')
ON CONFLICT DO NOTHING;
-- Create organization holidays table
CREATE TABLE IF NOT EXISTS organization_holidays (
id UUID DEFAULT uuid_generate_v4() NOT NULL,
organization_id UUID NOT NULL,
holiday_type_id UUID NOT NULL,
name TEXT NOT NULL,
description TEXT,
date DATE NOT NULL,
is_recurring BOOLEAN DEFAULT FALSE NOT NULL,
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP NOT NULL,
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP NOT NULL
);
ALTER TABLE organization_holidays
ADD CONSTRAINT organization_holidays_pk
PRIMARY KEY (id);
ALTER TABLE organization_holidays
ADD CONSTRAINT organization_holidays_organization_id_fk
FOREIGN KEY (organization_id) REFERENCES organizations
ON DELETE CASCADE;
ALTER TABLE organization_holidays
ADD CONSTRAINT organization_holidays_holiday_type_id_fk
FOREIGN KEY (holiday_type_id) REFERENCES holiday_types
ON DELETE RESTRICT;
-- Add unique constraint to prevent duplicate holidays on the same date for an organization
ALTER TABLE organization_holidays
ADD CONSTRAINT organization_holidays_organization_date_unique
UNIQUE (organization_id, date);
-- Create country holidays table for predefined holidays
CREATE TABLE IF NOT EXISTS country_holidays (
id UUID DEFAULT uuid_generate_v4() NOT NULL,
country_code CHAR(2) NOT NULL,
name TEXT NOT NULL,
description TEXT,
date DATE NOT NULL,
is_recurring BOOLEAN DEFAULT TRUE NOT NULL,
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP NOT NULL,
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP NOT NULL
);
ALTER TABLE country_holidays
ADD CONSTRAINT country_holidays_pk
PRIMARY KEY (id);
ALTER TABLE country_holidays
ADD CONSTRAINT country_holidays_country_code_fk
FOREIGN KEY (country_code) REFERENCES countries(code)
ON DELETE CASCADE;
-- Add unique constraint to prevent duplicate holidays for the same country, name, and date
ALTER TABLE country_holidays
ADD CONSTRAINT country_holidays_country_name_date_unique
UNIQUE (country_code, name, date);
-- Create indexes for better performance
CREATE INDEX IF NOT EXISTS idx_organization_holidays_organization_id ON organization_holidays(organization_id);
CREATE INDEX IF NOT EXISTS idx_organization_holidays_date ON organization_holidays(date);
CREATE INDEX IF NOT EXISTS idx_country_holidays_country_code ON country_holidays(country_code);
CREATE INDEX IF NOT EXISTS idx_country_holidays_date ON country_holidays(date);

View File

@@ -1,60 +0,0 @@
-- ================================================================
-- Sri Lankan Holidays Migration
-- ================================================================
-- This migration populates Sri Lankan holidays from verified sources
--
-- SOURCES & VERIFICATION:
-- - 2025 data: Verified from official government sources
-- - Fixed holidays: Independence Day, May Day, Christmas (all years)
-- - Variable holidays: Added only when officially verified
--
-- MAINTENANCE:
-- - Use scripts/update-sri-lankan-holidays.js for updates
-- - See docs/sri-lankan-holiday-update-process.md for process
-- ================================================================
-- Insert fixed holidays for multiple years (these never change dates)
DO $$
DECLARE
current_year INT;
BEGIN
FOR current_year IN 2020..2050 LOOP
INSERT INTO country_holidays (country_code, name, description, date, is_recurring)
VALUES
('LK', 'Independence Day', 'Commemorates the independence of Sri Lanka from British rule in 1948',
make_date(current_year, 2, 4), true),
('LK', 'May Day', 'International Workers'' Day',
make_date(current_year, 5, 1), true),
('LK', 'Christmas Day', 'Christian celebration of the birth of Jesus Christ',
make_date(current_year, 12, 25), true)
ON CONFLICT (country_code, name, date) DO NOTHING;
END LOOP;
END $$;
-- Insert specific holidays for years 2025-2028 (from our JSON data)
-- 2025 holidays
INSERT INTO country_holidays (country_code, name, description, date, is_recurring)
VALUES
('LK', 'Duruthu Full Moon Poya Day', 'Commemorates the first visit of Buddha to Sri Lanka', '2025-01-13', false),
('LK', 'Navam Full Moon Poya Day', 'Commemorates the appointment of Sariputta and Moggallana as Buddha''s chief disciples', '2025-02-12', false),
('LK', 'Medin Full Moon Poya Day', 'Commemorates Buddha''s first visit to his father''s palace after enlightenment', '2025-03-14', false),
('LK', 'Eid al-Fitr', 'Festival marking the end of Ramadan', '2025-03-31', false),
('LK', 'Bak Full Moon Poya Day', 'Commemorates Buddha''s second visit to Sri Lanka', '2025-04-12', false),
('LK', 'Good Friday', 'Christian commemoration of the crucifixion of Jesus Christ', '2025-04-18', false),
('LK', 'Vesak Full Moon Poya Day', 'Most sacred day for Buddhists - commemorates birth, enlightenment and passing of Buddha', '2025-05-12', false),
('LK', 'Day after Vesak Full Moon Poya Day', 'Additional day for Vesak celebrations', '2025-05-13', false),
('LK', 'Eid al-Adha', 'Islamic festival of sacrifice', '2025-06-07', false),
('LK', 'Poson Full Moon Poya Day', 'Commemorates the introduction of Buddhism to Sri Lanka by Arahat Mahinda', '2025-06-11', false),
('LK', 'Esala Full Moon Poya Day', 'Commemorates Buddha''s first sermon and the arrival of the Sacred Tooth Relic', '2025-07-10', false),
('LK', 'Nikini Full Moon Poya Day', 'Commemorates the first Buddhist council', '2025-08-09', false),
('LK', 'Binara Full Moon Poya Day', 'Commemorates Buddha''s visit to heaven to preach to his mother', '2025-09-07', false),
('LK', 'Vap Full Moon Poya Day', 'Marks the end of Buddhist Lent and Buddha''s return from heaven', '2025-10-07', false),
('LK', 'Deepavali', 'Hindu Festival of Lights', '2025-10-20', false),
('LK', 'Il Full Moon Poya Day', 'Commemorates Buddha''s ordination of sixty disciples', '2025-11-05', false),
('LK', 'Unduvap Full Moon Poya Day', 'Commemorates the arrival of Sanghamitta Theri with the Sacred Bo sapling', '2025-12-04', false)
ON CONFLICT (country_code, name, date) DO NOTHING;
-- NOTE: Data for 2026+ should be added only after verification from official sources
-- Use the holiday management script to generate templates for new years:
-- node update-sri-lankan-holidays.js --poya-template YYYY

View File

@@ -1,78 +0,0 @@
-- Migration: Add manual task progress
-- Date: 2025-04-22
-- Version: 1.0.0
BEGIN;
-- Add manual progress fields to tasks table
ALTER TABLE tasks
ADD COLUMN IF NOT EXISTS manual_progress BOOLEAN DEFAULT FALSE,
ADD COLUMN IF NOT EXISTS progress_value INTEGER DEFAULT NULL,
ADD COLUMN IF NOT EXISTS weight INTEGER DEFAULT NULL;
-- Update function to consider manual progress
CREATE OR REPLACE FUNCTION get_task_complete_ratio(_task_id uuid) RETURNS json
LANGUAGE plpgsql
AS
$$
DECLARE
_parent_task_done FLOAT = 0;
_sub_tasks_done FLOAT = 0;
_sub_tasks_count FLOAT = 0;
_total_completed FLOAT = 0;
_total_tasks FLOAT = 0;
_ratio FLOAT = 0;
_is_manual BOOLEAN = FALSE;
_manual_value INTEGER = NULL;
BEGIN
-- Check if manual progress is set
SELECT manual_progress, progress_value
FROM tasks
WHERE id = _task_id
INTO _is_manual, _manual_value;
-- If manual progress is enabled and has a value, use it directly
IF _is_manual IS TRUE AND _manual_value IS NOT NULL THEN
RETURN JSON_BUILD_OBJECT(
'ratio', _manual_value,
'total_completed', 0,
'total_tasks', 0,
'is_manual', TRUE
);
END IF;
-- Otherwise calculate automatically as before
SELECT (CASE
WHEN EXISTS(SELECT 1
FROM tasks_with_status_view
WHERE tasks_with_status_view.task_id = _task_id
AND is_done IS TRUE) THEN 1
ELSE 0 END)
INTO _parent_task_done;
SELECT COUNT(*) FROM tasks WHERE parent_task_id = _task_id AND archived IS FALSE INTO _sub_tasks_count;
SELECT COUNT(*)
FROM tasks_with_status_view
WHERE parent_task_id = _task_id
AND is_done IS TRUE
INTO _sub_tasks_done;
_total_completed = _parent_task_done + _sub_tasks_done;
_total_tasks = _sub_tasks_count; -- +1 for the parent task
IF _total_tasks > 0 THEN
_ratio = (_total_completed / _total_tasks) * 100;
ELSE
_ratio = _parent_task_done * 100;
END IF;
RETURN JSON_BUILD_OBJECT(
'ratio', _ratio,
'total_completed', _total_completed,
'total_tasks', _total_tasks,
'is_manual', FALSE
);
END
$$;
COMMIT;

View File

@@ -1,687 +0,0 @@
-- Migration: Enhance manual task progress with subtask support
-- Date: 2025-04-23
-- Version: 1.0.0
BEGIN;
-- Update function to consider subtask manual progress when calculating parent task progress
CREATE OR REPLACE FUNCTION get_task_complete_ratio(_task_id uuid) RETURNS json
LANGUAGE plpgsql
AS
$$
DECLARE
_parent_task_done FLOAT = 0;
_sub_tasks_done FLOAT = 0;
_sub_tasks_count FLOAT = 0;
_total_completed FLOAT = 0;
_total_tasks FLOAT = 0;
_ratio FLOAT = 0;
_is_manual BOOLEAN = FALSE;
_manual_value INTEGER = NULL;
_project_id UUID;
_use_manual_progress BOOLEAN = FALSE;
_use_weighted_progress BOOLEAN = FALSE;
_use_time_progress BOOLEAN = FALSE;
BEGIN
-- Check if manual progress is set for this task
SELECT manual_progress, progress_value, project_id
FROM tasks
WHERE id = _task_id
INTO _is_manual, _manual_value, _project_id;
-- Check if the project uses manual progress
IF _project_id IS NOT NULL THEN
SELECT COALESCE(use_manual_progress, FALSE),
COALESCE(use_weighted_progress, FALSE),
COALESCE(use_time_progress, FALSE)
FROM projects
WHERE id = _project_id
INTO _use_manual_progress, _use_weighted_progress, _use_time_progress;
END IF;
-- Get all subtasks
SELECT COUNT(*)
FROM tasks
WHERE parent_task_id = _task_id AND archived IS FALSE
INTO _sub_tasks_count;
-- If manual progress is enabled and has a value AND there are no subtasks, use it directly
IF _is_manual IS TRUE AND _manual_value IS NOT NULL AND _sub_tasks_count = 0 THEN
RETURN JSON_BUILD_OBJECT(
'ratio', _manual_value,
'total_completed', 0,
'total_tasks', 0,
'is_manual', TRUE
);
END IF;
-- If there are no subtasks, just use the parent task's status
IF _sub_tasks_count = 0 THEN
SELECT (CASE
WHEN EXISTS(SELECT 1
FROM tasks_with_status_view
WHERE tasks_with_status_view.task_id = _task_id
AND is_done IS TRUE) THEN 1
ELSE 0 END)
INTO _parent_task_done;
_ratio = _parent_task_done * 100;
ELSE
-- If project uses manual progress, calculate based on subtask manual progress values
IF _use_manual_progress IS TRUE THEN
WITH subtask_progress AS (
SELECT
CASE
-- If subtask has manual progress, use that value
WHEN manual_progress IS TRUE AND progress_value IS NOT NULL THEN
progress_value
-- Otherwise use completion status (0 or 100)
ELSE
CASE
WHEN EXISTS(
SELECT 1
FROM tasks_with_status_view
WHERE tasks_with_status_view.task_id = t.id
AND is_done IS TRUE
) THEN 100
ELSE 0
END
END AS progress_value
FROM tasks t
WHERE t.parent_task_id = _task_id
AND t.archived IS FALSE
)
SELECT COALESCE(AVG(progress_value), 0)
FROM subtask_progress
INTO _ratio;
-- If project uses weighted progress, calculate based on subtask weights
ELSIF _use_weighted_progress IS TRUE THEN
WITH subtask_progress AS (
SELECT
CASE
-- If subtask has manual progress, use that value
WHEN manual_progress IS TRUE AND progress_value IS NOT NULL THEN
progress_value
-- Otherwise use completion status (0 or 100)
ELSE
CASE
WHEN EXISTS(
SELECT 1
FROM tasks_with_status_view
WHERE tasks_with_status_view.task_id = t.id
AND is_done IS TRUE
) THEN 100
ELSE 0
END
END AS progress_value,
COALESCE(weight, 100) AS weight
FROM tasks t
WHERE t.parent_task_id = _task_id
AND t.archived IS FALSE
)
SELECT COALESCE(
SUM(progress_value * weight) / NULLIF(SUM(weight), 0),
0
)
FROM subtask_progress
INTO _ratio;
-- If project uses time-based progress, calculate based on estimated time
ELSIF _use_time_progress IS TRUE THEN
WITH subtask_progress AS (
SELECT
CASE
-- If subtask has manual progress, use that value
WHEN manual_progress IS TRUE AND progress_value IS NOT NULL THEN
progress_value
-- Otherwise use completion status (0 or 100)
ELSE
CASE
WHEN EXISTS(
SELECT 1
FROM tasks_with_status_view
WHERE tasks_with_status_view.task_id = t.id
AND is_done IS TRUE
) THEN 100
ELSE 0
END
END AS progress_value,
COALESCE(total_minutes, 0) AS estimated_minutes
FROM tasks t
WHERE t.parent_task_id = _task_id
AND t.archived IS FALSE
)
SELECT COALESCE(
SUM(progress_value * estimated_minutes) / NULLIF(SUM(estimated_minutes), 0),
0
)
FROM subtask_progress
INTO _ratio;
ELSE
-- Traditional calculation based on completion status
SELECT (CASE
WHEN EXISTS(SELECT 1
FROM tasks_with_status_view
WHERE tasks_with_status_view.task_id = _task_id
AND is_done IS TRUE) THEN 1
ELSE 0 END)
INTO _parent_task_done;
SELECT COUNT(*)
FROM tasks_with_status_view
WHERE parent_task_id = _task_id
AND is_done IS TRUE
INTO _sub_tasks_done;
_total_completed = _parent_task_done + _sub_tasks_done;
_total_tasks = _sub_tasks_count + 1; -- +1 for the parent task
IF _total_tasks = 0 THEN
_ratio = 0;
ELSE
_ratio = (_total_completed / _total_tasks) * 100;
END IF;
END IF;
END IF;
-- Ensure ratio is between 0 and 100
IF _ratio < 0 THEN
_ratio = 0;
ELSIF _ratio > 100 THEN
_ratio = 100;
END IF;
RETURN JSON_BUILD_OBJECT(
'ratio', _ratio,
'total_completed', _total_completed,
'total_tasks', _total_tasks,
'is_manual', _is_manual
);
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
-- need a test, can be throw errors
_client_name = TRIM((_body ->> 'client_name')::TEXT);
_project_name = TRIM((_body ->> 'name')::TEXT);
-- add inside the controller
_user_id = (_body ->> 'user_id')::UUID;
_team_id = (_body ->> 'team_id')::UUID;
_project_manager_team_member_id = (_body ->> 'team_member_id')::UUID;
-- cache exists client if exists
SELECT id FROM clients WHERE LOWER(name) = LOWER(_client_name) AND team_id = _team_id INTO _client_id;
-- insert client if not exists
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;
-- check whether the project name is already in
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 the project
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)
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;
$$;
-- 3. Also modify the create_project function to handle the new fields during project creation
CREATE OR REPLACE FUNCTION create_project(_body json) RETURNS json
LANGUAGE plpgsql
AS
$$
DECLARE
_project_id UUID;
_user_id UUID;
_team_id UUID;
_team_member_id UUID;
_client_id UUID;
_client_name TEXT;
_project_name TEXT;
_project_created_log TEXT;
_project_member_added_log TEXT;
_project_created_log_id UUID;
_project_manager_team_member_id UUID;
_project_key TEXT;
BEGIN
_client_name = TRIM((_body ->> 'client_name')::TEXT);
_project_name = TRIM((_body ->> 'name')::TEXT);
_project_key = TRIM((_body ->> 'key')::TEXT);
_project_created_log = (_body ->> 'project_created_log')::TEXT;
_project_member_added_log = (_body ->> 'project_member_added_log')::TEXT;
_user_id = (_body ->> 'user_id')::UUID;
_team_id = (_body ->> 'team_id')::UUID;
_project_manager_team_member_id = (_body ->> 'project_manager_id')::UUID;
SELECT id FROM team_members WHERE user_id = _user_id AND team_id = _team_id INTO _team_member_id;
-- cache exists client if exists
SELECT id FROM clients WHERE LOWER(name) = LOWER(_client_name) AND team_id = _team_id INTO _client_id;
-- insert client if not exists
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;
-- check whether the project name is already in
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;
-- create the project
INSERT
INTO projects (name, key, color_code, start_date, end_date, team_id, notes, owner_id, status_id, health_id, folder_id,
category_id, estimated_working_days, estimated_man_days, hours_per_day,
use_manual_progress, use_weighted_progress, use_time_progress, client_id)
VALUES (_project_name,
UPPER(_project_key),
(_body ->> 'color_code')::TEXT,
(_body ->> 'start_date')::TIMESTAMPTZ,
(_body ->> 'end_date')::TIMESTAMPTZ,
_team_id,
(_body ->> 'notes')::TEXT,
_user_id,
(_body ->> 'status_id')::UUID,
(_body ->> 'health_id')::UUID,
(_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),
_client_id)
RETURNING id INTO _project_id;
-- register the project log
INSERT INTO project_logs (project_id, team_id, description)
VALUES (_project_id, _team_id, _project_created_log)
RETURNING id INTO _project_created_log_id;
-- insert the project creator as a project member
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 statuses
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);
-- insert default project columns
PERFORM insert_task_list_columns(_project_id);
-- add project manager role if exists
IF NOT is_null_or_empty(_project_manager_team_member_id) THEN
PERFORM update_project_manager(_project_manager_team_member_id, _project_id);
END IF;
RETURN JSON_BUILD_OBJECT(
'id', _project_id,
'name', _project_name,
'project_created_log_id', _project_created_log_id
);
END;
$$;
-- 4. Update the getById function to include the new fields in the response
CREATE OR REPLACE FUNCTION getProjectById(_project_id UUID, _team_id UUID) RETURNS JSON
LANGUAGE plpgsql
AS
$$
DECLARE
_result JSON;
BEGIN
SELECT ROW_TO_JSON(rec) INTO _result
FROM (SELECT p.id,
p.name,
p.key,
p.color_code,
p.start_date,
p.end_date,
c.name AS client_name,
c.id AS client_id,
p.notes,
p.created_at,
p.updated_at,
ts.name AS status,
ts.color_code AS status_color,
ts.icon AS status_icon,
ts.id AS status_id,
h.name AS health,
h.color_code AS health_color,
h.icon AS health_icon,
h.id AS health_id,
pc.name AS category_name,
pc.color_code AS category_color,
pc.id AS category_id,
p.phase_label,
p.estimated_man_days AS man_days,
p.estimated_working_days AS working_days,
p.hours_per_day,
p.use_manual_progress,
p.use_weighted_progress,
-- Additional fields
COALESCE((SELECT ARRAY_TO_JSON(ARRAY_AGG(ROW_TO_JSON(t)))
FROM (SELECT pm.id,
pm.project_id,
tm.id AS team_member_id,
tm.user_id,
u.name,
u.email,
u.avatar_url,
u.phone_number,
pal.name AS access_level,
pal.key AS access_level_key,
pal.id AS access_level_id,
EXISTS(SELECT 1
FROM project_members
INNER JOIN project_access_levels ON
project_members.project_access_level_id = project_access_levels.id
WHERE project_id = p.id
AND project_access_levels.key = 'PROJECT_MANAGER'
AND team_member_id = tm.id) AS is_project_manager
FROM project_members pm
INNER JOIN team_members tm ON pm.team_member_id = tm.id
INNER JOIN users u ON tm.user_id = u.id
INNER JOIN project_access_levels pal ON pm.project_access_level_id = pal.id
WHERE pm.project_id = p.id) t), '[]'::JSON) AS members,
(SELECT COUNT(DISTINCT (id))
FROM tasks
WHERE archived IS FALSE
AND project_id = p.id) AS task_count,
(SELECT ARRAY_TO_JSON(ARRAY_AGG(ROW_TO_JSON(t)))
FROM (SELECT project_members.id,
project_members.project_id,
team_members.id AS team_member_id,
team_members.user_id,
users.name,
users.email,
users.avatar_url,
project_access_levels.name AS access_level,
project_access_levels.key AS access_level_key,
project_access_levels.id AS access_level_id
FROM project_members
INNER JOIN team_members ON project_members.team_member_id = team_members.id
INNER JOIN users ON team_members.user_id = users.id
INNER JOIN project_access_levels
ON project_members.project_access_level_id = project_access_levels.id
WHERE project_id = p.id
AND project_access_levels.key = 'PROJECT_MANAGER'
LIMIT 1) t) AS project_manager,
(SELECT EXISTS(SELECT 1
FROM project_subscribers
WHERE project_id = p.id
AND user_id = (SELECT user_id
FROM project_members
WHERE team_member_id = (SELECT id
FROM team_members
WHERE user_id IN
(SELECT user_id FROM is_member_of_project_cte))
AND project_id = p.id))) AS subscribed,
(SELECT name
FROM users
WHERE id =
(SELECT owner_id FROM projects WHERE id = p.id)) AS project_owner,
(SELECT default_view
FROM project_members
WHERE project_id = p.id
AND team_member_id IN (SELECT id FROM is_member_of_project_cte)) AS team_member_default_view,
(SELECT EXISTS(SELECT user_id
FROM archived_projects
WHERE user_id IN (SELECT user_id FROM is_member_of_project_cte)
AND project_id = p.id)) AS archived,
(SELECT EXISTS(SELECT user_id
FROM favorite_projects
WHERE user_id IN (SELECT user_id FROM is_member_of_project_cte)
AND project_id = p.id)) AS favorite
FROM projects p
LEFT JOIN sys_project_statuses ts ON p.status_id = ts.id
LEFT JOIN sys_project_healths h ON p.health_id = h.id
LEFT JOIN project_categories pc ON p.category_id = pc.id
LEFT JOIN clients c ON p.client_id = c.id,
LATERAL (SELECT id, user_id
FROM team_members
WHERE id = (SELECT team_member_id
FROM project_members
WHERE project_id = p.id
AND team_member_id IN (SELECT id
FROM team_members
WHERE team_id = _team_id)
LIMIT 1)) is_member_of_project_cte
WHERE p.id = _project_id
AND p.team_id = _team_id) rec;
RETURN _result;
END
$$;
CREATE OR REPLACE FUNCTION public.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;
BEGIN
-- Select task info
SELECT COALESCE(ROW_TO_JSON(rec), '{}'::JSON)
INTO _task
FROM (WITH RECURSIVE task_hierarchy AS (
-- Base case: Start with the given task
SELECT id,
parent_task_id,
0 AS level
FROM tasks
WHERE id = _task_id
UNION ALL
-- Recursive case: Traverse up to parent tasks
SELECT t.id,
t.parent_task_id,
th.level + 1 AS level
FROM tasks t
INNER JOIN task_hierarchy th ON t.id = th.parent_task_id
WHERE th.parent_task_id IS NOT NULL)
SELECT id,
name,
description,
start_date,
end_date,
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 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,
progress_value,
weight,
(SELECT MAX(level) FROM task_hierarchy) AS task_level
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;
RETURN JSON_BUILD_OBJECT(
'task', _task,
'priorities', _priorities,
'projects', _projects,
'statuses', _statuses,
'team_members', _team_members,
'assignees', _assignees,
'phases', _phases
);
END;
$$;
-- Add use_manual_progress, use_weighted_progress, and use_time_progress to projects table if they don't exist
ALTER TABLE projects
ADD COLUMN IF NOT EXISTS use_manual_progress BOOLEAN DEFAULT FALSE,
ADD COLUMN IF NOT EXISTS use_weighted_progress BOOLEAN DEFAULT FALSE,
ADD COLUMN IF NOT EXISTS use_time_progress BOOLEAN DEFAULT FALSE;
-- Add a trigger to reset manual progress when a task gets a new subtask
CREATE OR REPLACE FUNCTION reset_parent_task_manual_progress() RETURNS TRIGGER AS
$$
BEGIN
-- When a task gets a new subtask (parent_task_id is set), reset the parent's manual_progress flag
IF NEW.parent_task_id IS NOT NULL THEN
UPDATE tasks
SET manual_progress = false
WHERE id = NEW.parent_task_id
AND manual_progress = true;
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
-- Create the trigger on the tasks table
DROP TRIGGER IF EXISTS reset_parent_manual_progress_trigger ON tasks;
CREATE TRIGGER reset_parent_manual_progress_trigger
AFTER INSERT OR UPDATE OF parent_task_id ON tasks
FOR EACH ROW
EXECUTE FUNCTION reset_parent_task_manual_progress();
COMMIT;

View File

@@ -1,157 +0,0 @@
-- Migration: Add progress and weight activity types support
-- Date: 2025-04-24
-- Version: 1.0.0
BEGIN;
-- Update the get_activity_logs_by_task function to handle progress and weight attribute types
CREATE OR REPLACE FUNCTION get_activity_logs_by_task(_task_id uuid) RETURNS json
LANGUAGE plpgsql
AS
$$
DECLARE
_result JSON;
BEGIN
SELECT ROW_TO_JSON(rec)
INTO _result
FROM (SELECT (SELECT tasks.created_at FROM tasks WHERE tasks.id = _task_id),
(SELECT name
FROM users
WHERE id = (SELECT reporter_id FROM tasks WHERE id = _task_id)),
(SELECT avatar_url
FROM users
WHERE id = (SELECT reporter_id FROM tasks WHERE id = _task_id)),
(SELECT COALESCE(ARRAY_TO_JSON(ARRAY_AGG(ROW_TO_JSON(rec2))), '[]'::JSON)
FROM (SELECT task_id,
created_at,
attribute_type,
log_type,
-- Case for previous value
(CASE
WHEN (attribute_type = 'status')
THEN (SELECT name FROM task_statuses WHERE id = old_value::UUID)
WHEN (attribute_type = 'priority')
THEN (SELECT name FROM task_priorities WHERE id = old_value::UUID)
WHEN (attribute_type = 'phase' AND old_value <> 'Unmapped')
THEN (SELECT name FROM project_phases WHERE id = old_value::UUID)
WHEN (attribute_type = 'progress' OR attribute_type = 'weight')
THEN old_value
ELSE (old_value) END) AS previous,
-- Case for current value
(CASE
WHEN (attribute_type = 'assignee')
THEN (SELECT name FROM users WHERE id = new_value::UUID)
WHEN (attribute_type = 'label')
THEN (SELECT name FROM team_labels WHERE id = new_value::UUID)
WHEN (attribute_type = 'status')
THEN (SELECT name FROM task_statuses WHERE id = new_value::UUID)
WHEN (attribute_type = 'priority')
THEN (SELECT name FROM task_priorities WHERE id = new_value::UUID)
WHEN (attribute_type = 'phase' AND new_value <> 'Unmapped')
THEN (SELECT name FROM project_phases WHERE id = new_value::UUID)
WHEN (attribute_type = 'progress' OR attribute_type = 'weight')
THEN new_value
ELSE (new_value) END) AS current,
-- Case for assigned user
(CASE
WHEN (attribute_type = 'assignee')
THEN (SELECT ROW_TO_JSON(rec)
FROM (SELECT (CASE
WHEN (new_value IS NOT NULL)
THEN (SELECT name FROM users WHERE users.id = new_value::UUID)
ELSE (next_string) END) AS name,
(SELECT avatar_url FROM users WHERE users.id = new_value::UUID)) rec)
ELSE (NULL) END) AS assigned_user,
-- Case for label data
(CASE
WHEN (attribute_type = 'label')
THEN (SELECT ROW_TO_JSON(rec)
FROM (SELECT (SELECT name FROM team_labels WHERE id = new_value::UUID),
(SELECT color_code FROM team_labels WHERE id = new_value::UUID)) rec)
ELSE (NULL) END) AS label_data,
-- Case for previous status
(CASE
WHEN (attribute_type = 'status')
THEN (SELECT ROW_TO_JSON(rec)
FROM (SELECT (SELECT name FROM task_statuses WHERE id = old_value::UUID),
(SELECT color_code
FROM sys_task_status_categories
WHERE id = (SELECT category_id FROM task_statuses WHERE id = old_value::UUID)),
(SELECT color_code_dark
FROM sys_task_status_categories
WHERE id = (SELECT category_id FROM task_statuses WHERE id = old_value::UUID))) rec)
ELSE (NULL) END) AS previous_status,
-- Case for next status
(CASE
WHEN (attribute_type = 'status')
THEN (SELECT ROW_TO_JSON(rec)
FROM (SELECT (SELECT name FROM task_statuses WHERE id = new_value::UUID),
(SELECT color_code
FROM sys_task_status_categories
WHERE id = (SELECT category_id FROM task_statuses WHERE id = new_value::UUID)),
(SELECT color_code_dark
FROM sys_task_status_categories
WHERE id = (SELECT category_id FROM task_statuses WHERE id = new_value::UUID))) rec)
ELSE (NULL) END) AS next_status,
-- Case for previous priority
(CASE
WHEN (attribute_type = 'priority')
THEN (SELECT ROW_TO_JSON(rec)
FROM (SELECT (SELECT name FROM task_priorities WHERE id = old_value::UUID),
(SELECT color_code FROM task_priorities WHERE id = old_value::UUID)) rec)
ELSE (NULL) END) AS previous_priority,
-- Case for next priority
(CASE
WHEN (attribute_type = 'priority')
THEN (SELECT ROW_TO_JSON(rec)
FROM (SELECT (SELECT name FROM task_priorities WHERE id = new_value::UUID),
(SELECT color_code FROM task_priorities WHERE id = new_value::UUID)) rec)
ELSE (NULL) END) AS next_priority,
-- Case for previous phase
(CASE
WHEN (attribute_type = 'phase' AND old_value <> 'Unmapped')
THEN (SELECT ROW_TO_JSON(rec)
FROM (SELECT (SELECT name FROM project_phases WHERE id = old_value::UUID),
(SELECT color_code FROM project_phases WHERE id = old_value::UUID)) rec)
ELSE (NULL) END) AS previous_phase,
-- Case for next phase
(CASE
WHEN (attribute_type = 'phase' AND new_value <> 'Unmapped')
THEN (SELECT ROW_TO_JSON(rec)
FROM (SELECT (SELECT name FROM project_phases WHERE id = new_value::UUID),
(SELECT color_code FROM project_phases WHERE id = new_value::UUID)) rec)
ELSE (NULL) END) AS next_phase,
-- Case for done by
(SELECT ROW_TO_JSON(rec)
FROM (SELECT (SELECT name FROM users WHERE users.id = tal.user_id),
(SELECT avatar_url FROM users WHERE users.id = tal.user_id)) rec) AS done_by,
-- Add log text for progress and weight
(CASE
WHEN (attribute_type = 'progress')
THEN 'updated the progress of'
WHEN (attribute_type = 'weight')
THEN 'updated the weight of'
ELSE ''
END) AS log_text
FROM task_activity_logs tal
WHERE task_id = _task_id
ORDER BY created_at DESC) rec2) AS logs) rec;
RETURN _result;
END;
$$;
COMMIT;

View File

@@ -1,243 +0,0 @@
-- Migration: Update time-based progress mode to work for all tasks
-- Date: 2025-04-25
-- Version: 1.0.0
BEGIN;
-- Update function to use time-based progress for all tasks
CREATE OR REPLACE FUNCTION get_task_complete_ratio(_task_id uuid) RETURNS json
LANGUAGE plpgsql
AS
$$
DECLARE
_parent_task_done FLOAT = 0;
_sub_tasks_done FLOAT = 0;
_sub_tasks_count FLOAT = 0;
_total_completed FLOAT = 0;
_total_tasks FLOAT = 0;
_ratio FLOAT = 0;
_is_manual BOOLEAN = FALSE;
_manual_value INTEGER = NULL;
_project_id UUID;
_use_manual_progress BOOLEAN = FALSE;
_use_weighted_progress BOOLEAN = FALSE;
_use_time_progress BOOLEAN = FALSE;
_task_complete BOOLEAN = FALSE;
BEGIN
-- Check if manual progress is set for this task
SELECT manual_progress, progress_value, project_id,
EXISTS(
SELECT 1
FROM tasks_with_status_view
WHERE tasks_with_status_view.task_id = tasks.id
AND is_done IS TRUE
) AS is_complete
FROM tasks
WHERE id = _task_id
INTO _is_manual, _manual_value, _project_id, _task_complete;
-- Check if the project uses manual progress
IF _project_id IS NOT NULL THEN
SELECT COALESCE(use_manual_progress, FALSE),
COALESCE(use_weighted_progress, FALSE),
COALESCE(use_time_progress, FALSE)
FROM projects
WHERE id = _project_id
INTO _use_manual_progress, _use_weighted_progress, _use_time_progress;
END IF;
-- Get all subtasks
SELECT COUNT(*)
FROM tasks
WHERE parent_task_id = _task_id AND archived IS FALSE
INTO _sub_tasks_count;
-- If task is complete, always return 100%
IF _task_complete IS TRUE THEN
RETURN JSON_BUILD_OBJECT(
'ratio', 100,
'total_completed', 1,
'total_tasks', 1,
'is_manual', FALSE
);
END IF;
-- Use manual progress value in two cases:
-- 1. When task has manual_progress = TRUE and progress_value is set
-- 2. When project has use_manual_progress = TRUE and progress_value is set
IF (_is_manual IS TRUE AND _manual_value IS NOT NULL) OR
(_use_manual_progress IS TRUE AND _manual_value IS NOT NULL) THEN
RETURN JSON_BUILD_OBJECT(
'ratio', _manual_value,
'total_completed', 0,
'total_tasks', 0,
'is_manual', TRUE
);
END IF;
-- If there are no subtasks, just use the parent task's status (unless in time-based mode)
IF _sub_tasks_count = 0 THEN
-- Use time-based estimation for tasks without subtasks if enabled
IF _use_time_progress IS TRUE THEN
-- For time-based tasks without subtasks, we still need some progress calculation
-- If the task is completed, return 100%
-- Otherwise, use the progress value if set manually, or 0
SELECT
CASE
WHEN _task_complete IS TRUE THEN 100
ELSE COALESCE(_manual_value, 0)
END
INTO _ratio;
ELSE
-- Traditional calculation for non-time-based tasks
SELECT (CASE WHEN _task_complete IS TRUE THEN 1 ELSE 0 END)
INTO _parent_task_done;
_ratio = _parent_task_done * 100;
END IF;
ELSE
-- If project uses manual progress, calculate based on subtask manual progress values
IF _use_manual_progress IS TRUE THEN
WITH subtask_progress AS (
SELECT
t.id,
t.manual_progress,
t.progress_value,
EXISTS(
SELECT 1
FROM tasks_with_status_view
WHERE tasks_with_status_view.task_id = t.id
AND is_done IS TRUE
) AS is_complete
FROM tasks t
WHERE t.parent_task_id = _task_id
AND t.archived IS FALSE
),
subtask_with_values AS (
SELECT
CASE
-- For completed tasks, always use 100%
WHEN is_complete IS TRUE THEN 100
-- For tasks with progress value set, use it regardless of manual_progress flag
WHEN progress_value IS NOT NULL THEN progress_value
-- Default to 0 for incomplete tasks with no progress value
ELSE 0
END AS progress_value
FROM subtask_progress
)
SELECT COALESCE(AVG(progress_value), 0)
FROM subtask_with_values
INTO _ratio;
-- If project uses weighted progress, calculate based on subtask weights
ELSIF _use_weighted_progress IS TRUE THEN
WITH subtask_progress AS (
SELECT
t.id,
t.manual_progress,
t.progress_value,
EXISTS(
SELECT 1
FROM tasks_with_status_view
WHERE tasks_with_status_view.task_id = t.id
AND is_done IS TRUE
) AS is_complete,
COALESCE(t.weight, 100) AS weight
FROM tasks t
WHERE t.parent_task_id = _task_id
AND t.archived IS FALSE
),
subtask_with_values AS (
SELECT
CASE
-- For completed tasks, always use 100%
WHEN is_complete IS TRUE THEN 100
-- For tasks with progress value set, use it regardless of manual_progress flag
WHEN progress_value IS NOT NULL THEN progress_value
-- Default to 0 for incomplete tasks with no progress value
ELSE 0
END AS progress_value,
weight
FROM subtask_progress
)
SELECT COALESCE(
SUM(progress_value * weight) / NULLIF(SUM(weight), 0),
0
)
FROM subtask_with_values
INTO _ratio;
-- If project uses time-based progress, calculate based on estimated time
ELSIF _use_time_progress IS TRUE THEN
WITH subtask_progress AS (
SELECT
t.id,
t.manual_progress,
t.progress_value,
EXISTS(
SELECT 1
FROM tasks_with_status_view
WHERE tasks_with_status_view.task_id = t.id
AND is_done IS TRUE
) AS is_complete,
COALESCE(t.total_minutes, 0) AS estimated_minutes
FROM tasks t
WHERE t.parent_task_id = _task_id
AND t.archived IS FALSE
),
subtask_with_values AS (
SELECT
CASE
-- For completed tasks, always use 100%
WHEN is_complete IS TRUE THEN 100
-- For tasks with progress value set, use it regardless of manual_progress flag
WHEN progress_value IS NOT NULL THEN progress_value
-- Default to 0 for incomplete tasks with no progress value
ELSE 0
END AS progress_value,
estimated_minutes
FROM subtask_progress
)
SELECT COALESCE(
SUM(progress_value * estimated_minutes) / NULLIF(SUM(estimated_minutes), 0),
0
)
FROM subtask_with_values
INTO _ratio;
ELSE
-- Traditional calculation based on completion status
SELECT (CASE WHEN _task_complete IS TRUE THEN 1 ELSE 0 END)
INTO _parent_task_done;
SELECT COUNT(*)
FROM tasks_with_status_view
WHERE parent_task_id = _task_id
AND is_done IS TRUE
INTO _sub_tasks_done;
_total_completed = _parent_task_done + _sub_tasks_done;
_total_tasks = _sub_tasks_count + 1; -- +1 for the parent task
IF _total_tasks = 0 THEN
_ratio = 0;
ELSE
_ratio = (_total_completed / _total_tasks) * 100;
END IF;
END IF;
END IF;
-- Ensure ratio is between 0 and 100
IF _ratio < 0 THEN
_ratio = 0;
ELSIF _ratio > 100 THEN
_ratio = 100;
END IF;
RETURN JSON_BUILD_OBJECT(
'ratio', _ratio,
'total_completed', _total_completed,
'total_tasks', _total_tasks,
'is_manual', _is_manual
);
END
$$;
COMMIT;

View File

@@ -1,289 +0,0 @@
-- Migration: Improve parent task progress calculation using weights and time estimation
-- Date: 2025-04-26
-- Version: 1.0.0
BEGIN;
-- Update function to better calculate parent task progress based on subtask weights or time estimations
CREATE OR REPLACE FUNCTION get_task_complete_ratio(_task_id uuid) RETURNS json
LANGUAGE plpgsql
AS
$$
DECLARE
_parent_task_done FLOAT = 0;
_sub_tasks_done FLOAT = 0;
_sub_tasks_count FLOAT = 0;
_total_completed FLOAT = 0;
_total_tasks FLOAT = 0;
_ratio FLOAT = 0;
_is_manual BOOLEAN = FALSE;
_manual_value INTEGER = NULL;
_project_id UUID;
_use_manual_progress BOOLEAN = FALSE;
_use_weighted_progress BOOLEAN = FALSE;
_use_time_progress BOOLEAN = FALSE;
BEGIN
-- Check if manual progress is set for this task
SELECT manual_progress, progress_value, project_id
FROM tasks
WHERE id = _task_id
INTO _is_manual, _manual_value, _project_id;
-- Check if the project uses manual progress
IF _project_id IS NOT NULL THEN
SELECT COALESCE(use_manual_progress, FALSE),
COALESCE(use_weighted_progress, FALSE),
COALESCE(use_time_progress, FALSE)
FROM projects
WHERE id = _project_id
INTO _use_manual_progress, _use_weighted_progress, _use_time_progress;
END IF;
-- Get all subtasks
SELECT COUNT(*)
FROM tasks
WHERE parent_task_id = _task_id AND archived IS FALSE
INTO _sub_tasks_count;
-- Only respect manual progress for tasks without subtasks
IF _is_manual IS TRUE AND _manual_value IS NOT NULL AND _sub_tasks_count = 0 THEN
RETURN JSON_BUILD_OBJECT(
'ratio', _manual_value,
'total_completed', 0,
'total_tasks', 0,
'is_manual', TRUE
);
END IF;
-- If there are no subtasks, just use the parent task's status
IF _sub_tasks_count = 0 THEN
-- For tasks without subtasks in time-based mode
IF _use_time_progress IS TRUE THEN
SELECT
CASE
WHEN EXISTS(
SELECT 1
FROM tasks_with_status_view
WHERE tasks_with_status_view.task_id = _task_id
AND is_done IS TRUE
) THEN 100
ELSE COALESCE(_manual_value, 0)
END
INTO _ratio;
ELSE
-- Traditional calculation for non-time-based tasks
SELECT (CASE
WHEN EXISTS(SELECT 1
FROM tasks_with_status_view
WHERE tasks_with_status_view.task_id = _task_id
AND is_done IS TRUE) THEN 1
ELSE 0 END)
INTO _parent_task_done;
_ratio = _parent_task_done * 100;
END IF;
ELSE
-- For parent tasks with subtasks, always use the appropriate calculation based on project mode
-- If project uses manual progress, calculate based on subtask manual progress values
IF _use_manual_progress IS TRUE THEN
WITH subtask_progress AS (
SELECT
CASE
-- If subtask has manual progress, use that value
WHEN manual_progress IS TRUE AND progress_value IS NOT NULL THEN
progress_value
-- Otherwise use completion status (0 or 100)
ELSE
CASE
WHEN EXISTS(
SELECT 1
FROM tasks_with_status_view
WHERE tasks_with_status_view.task_id = t.id
AND is_done IS TRUE
) THEN 100
ELSE 0
END
END AS progress_value
FROM tasks t
WHERE t.parent_task_id = _task_id
AND t.archived IS FALSE
)
SELECT COALESCE(AVG(progress_value), 0)
FROM subtask_progress
INTO _ratio;
-- If project uses weighted progress, calculate based on subtask weights
ELSIF _use_weighted_progress IS TRUE THEN
WITH subtask_progress AS (
SELECT
CASE
-- If subtask has manual progress, use that value
WHEN manual_progress IS TRUE AND progress_value IS NOT NULL THEN
progress_value
-- Otherwise use completion status (0 or 100)
ELSE
CASE
WHEN EXISTS(
SELECT 1
FROM tasks_with_status_view
WHERE tasks_with_status_view.task_id = t.id
AND is_done IS TRUE
) THEN 100
ELSE 0
END
END AS progress_value,
COALESCE(weight, 100) AS weight -- Default weight is 100 if not specified
FROM tasks t
WHERE t.parent_task_id = _task_id
AND t.archived IS FALSE
)
SELECT COALESCE(
SUM(progress_value * weight) / NULLIF(SUM(weight), 0),
0
)
FROM subtask_progress
INTO _ratio;
-- If project uses time-based progress, calculate based on estimated time (total_minutes)
ELSIF _use_time_progress IS TRUE THEN
WITH subtask_progress AS (
SELECT
CASE
-- If subtask has manual progress, use that value
WHEN manual_progress IS TRUE AND progress_value IS NOT NULL THEN
progress_value
-- Otherwise use completion status (0 or 100)
ELSE
CASE
WHEN EXISTS(
SELECT 1
FROM tasks_with_status_view
WHERE tasks_with_status_view.task_id = t.id
AND is_done IS TRUE
) THEN 100
ELSE 0
END
END AS progress_value,
COALESCE(total_minutes, 0) AS estimated_minutes -- Use time estimation for weighting
FROM tasks t
WHERE t.parent_task_id = _task_id
AND t.archived IS FALSE
)
SELECT COALESCE(
SUM(progress_value * estimated_minutes) / NULLIF(SUM(estimated_minutes), 0),
0
)
FROM subtask_progress
INTO _ratio;
ELSE
-- Traditional calculation based on completion status when no special mode is enabled
SELECT (CASE
WHEN EXISTS(SELECT 1
FROM tasks_with_status_view
WHERE tasks_with_status_view.task_id = _task_id
AND is_done IS TRUE) THEN 1
ELSE 0 END)
INTO _parent_task_done;
SELECT COUNT(*)
FROM tasks_with_status_view
WHERE parent_task_id = _task_id
AND is_done IS TRUE
INTO _sub_tasks_done;
_total_completed = _parent_task_done + _sub_tasks_done;
_total_tasks = _sub_tasks_count + 1; -- +1 for the parent task
IF _total_tasks = 0 THEN
_ratio = 0;
ELSE
_ratio = (_total_completed / _total_tasks) * 100;
END IF;
END IF;
END IF;
-- Ensure ratio is between 0 and 100
IF _ratio < 0 THEN
_ratio = 0;
ELSIF _ratio > 100 THEN
_ratio = 100;
END IF;
RETURN JSON_BUILD_OBJECT(
'ratio', _ratio,
'total_completed', _total_completed,
'total_tasks', _total_tasks,
'is_manual', _is_manual
);
END
$$;
-- Make sure we recalculate parent task progress when subtask progress changes
CREATE OR REPLACE FUNCTION update_parent_task_progress() RETURNS TRIGGER AS
$$
DECLARE
_parent_task_id UUID;
_project_id UUID;
_ratio FLOAT;
BEGIN
-- Check if this is a subtask
IF NEW.parent_task_id IS NOT NULL THEN
_parent_task_id := NEW.parent_task_id;
-- Force any parent task with subtasks to NOT use manual progress
UPDATE tasks
SET manual_progress = FALSE
WHERE id = _parent_task_id;
END IF;
-- If this task has progress value of 100 and doesn't have subtasks, we might want to prompt the user
-- to mark it as done. We'll annotate this in a way that the socket handler can detect.
IF NEW.progress_value = 100 OR NEW.weight = 100 OR NEW.total_minutes > 0 THEN
-- Check if task has status in "done" category
SELECT project_id FROM tasks WHERE id = NEW.id INTO _project_id;
-- Get the progress ratio for this task
SELECT get_task_complete_ratio(NEW.id)->>'ratio' INTO _ratio;
IF _ratio::FLOAT >= 100 THEN
-- Log that this task is at 100% progress
RAISE NOTICE 'Task % progress is at 100%%, may need status update', NEW.id;
END IF;
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
-- Create trigger for updates to task progress
DROP TRIGGER IF EXISTS update_parent_task_progress_trigger ON tasks;
CREATE TRIGGER update_parent_task_progress_trigger
AFTER UPDATE OF progress_value, weight, total_minutes ON tasks
FOR EACH ROW
EXECUTE FUNCTION update_parent_task_progress();
-- Create a function to ensure parent tasks never have manual progress when they have subtasks
CREATE OR REPLACE FUNCTION ensure_parent_task_without_manual_progress() RETURNS TRIGGER AS
$$
BEGIN
-- If this is a new subtask being created or a task is being converted to a subtask
IF NEW.parent_task_id IS NOT NULL THEN
-- Force the parent task to NOT use manual progress
UPDATE tasks
SET manual_progress = FALSE
WHERE id = NEW.parent_task_id;
-- Log that we've reset manual progress for a parent task
RAISE NOTICE 'Reset manual progress for parent task % because it has subtasks', NEW.parent_task_id;
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
-- Create trigger for when tasks are created or updated with a parent_task_id
DROP TRIGGER IF EXISTS ensure_parent_task_without_manual_progress_trigger ON tasks;
CREATE TRIGGER ensure_parent_task_without_manual_progress_trigger
AFTER INSERT OR UPDATE OF parent_task_id ON tasks
FOR EACH ROW
EXECUTE FUNCTION ensure_parent_task_without_manual_progress();
COMMIT;

View File

@@ -1,150 +0,0 @@
-- Migration: Update socket event handlers to set progress-mode handlers
-- Date: 2025-04-26
-- Version: 1.0.0
BEGIN;
-- Create ENUM type for progress modes
CREATE TYPE progress_mode_type AS ENUM ('manual', 'weighted', 'time', 'default');
-- Alter tasks table to use ENUM type
ALTER TABLE tasks
ALTER COLUMN progress_mode TYPE progress_mode_type
USING progress_mode::text::progress_mode_type;
-- Update the on_update_task_progress function to set progress_mode
CREATE OR REPLACE FUNCTION on_update_task_progress(_body json) RETURNS json
LANGUAGE plpgsql
AS
$$
DECLARE
_task_id UUID;
_progress_value INTEGER;
_parent_task_id UUID;
_project_id UUID;
_current_mode progress_mode_type;
BEGIN
_task_id = (_body ->> 'task_id')::UUID;
_progress_value = (_body ->> 'progress_value')::INTEGER;
_parent_task_id = (_body ->> 'parent_task_id')::UUID;
-- Get the project ID and determine the current progress mode
SELECT project_id INTO _project_id FROM tasks WHERE id = _task_id;
IF _project_id IS NOT NULL THEN
SELECT
CASE
WHEN use_manual_progress IS TRUE THEN 'manual'
WHEN use_weighted_progress IS TRUE THEN 'weighted'
WHEN use_time_progress IS TRUE THEN 'time'
ELSE 'default'
END
INTO _current_mode
FROM projects
WHERE id = _project_id;
ELSE
_current_mode := 'default';
END IF;
-- Update the task with progress value and set the progress mode
UPDATE tasks
SET progress_value = _progress_value,
manual_progress = TRUE,
progress_mode = _current_mode,
updated_at = CURRENT_TIMESTAMP
WHERE id = _task_id;
-- Return the updated task info
RETURN JSON_BUILD_OBJECT(
'task_id', _task_id,
'progress_value', _progress_value,
'progress_mode', _current_mode
);
END;
$$;
-- Update the on_update_task_weight function to set progress_mode when weight is updated
CREATE OR REPLACE FUNCTION on_update_task_weight(_body json) RETURNS json
LANGUAGE plpgsql
AS
$$
DECLARE
_task_id UUID;
_weight INTEGER;
_parent_task_id UUID;
_project_id UUID;
BEGIN
_task_id = (_body ->> 'task_id')::UUID;
_weight = (_body ->> 'weight')::INTEGER;
_parent_task_id = (_body ->> 'parent_task_id')::UUID;
-- Get the project ID
SELECT project_id INTO _project_id FROM tasks WHERE id = _task_id;
-- Update the task with weight value and set progress_mode to 'weighted'
UPDATE tasks
SET weight = _weight,
progress_mode = 'weighted',
updated_at = CURRENT_TIMESTAMP
WHERE id = _task_id;
-- Return the updated task info
RETURN JSON_BUILD_OBJECT(
'task_id', _task_id,
'weight', _weight
);
END;
$$;
-- Create a function to reset progress values when switching project progress modes
CREATE OR REPLACE FUNCTION reset_project_progress_values() RETURNS TRIGGER
LANGUAGE plpgsql
AS
$$
DECLARE
_old_mode progress_mode_type;
_new_mode progress_mode_type;
_project_id UUID;
BEGIN
_project_id := NEW.id;
-- Determine old and new modes
_old_mode :=
CASE
WHEN OLD.use_manual_progress IS TRUE THEN 'manual'
WHEN OLD.use_weighted_progress IS TRUE THEN 'weighted'
WHEN OLD.use_time_progress IS TRUE THEN 'time'
ELSE 'default'
END;
_new_mode :=
CASE
WHEN NEW.use_manual_progress IS TRUE THEN 'manual'
WHEN NEW.use_weighted_progress IS TRUE THEN 'weighted'
WHEN NEW.use_time_progress IS TRUE THEN 'time'
ELSE 'default'
END;
-- If mode has changed, reset progress values for tasks with the old mode
IF _old_mode <> _new_mode THEN
-- Reset progress values for tasks that were set in the old mode
UPDATE tasks
SET progress_value = NULL,
progress_mode = NULL
WHERE project_id = _project_id
AND progress_mode = _old_mode;
END IF;
RETURN NEW;
END;
$$;
-- Create trigger to reset progress values when project progress mode changes
DROP TRIGGER IF EXISTS reset_progress_on_mode_change ON projects;
CREATE TRIGGER reset_progress_on_mode_change
AFTER UPDATE OF use_manual_progress, use_weighted_progress, use_time_progress
ON projects
FOR EACH ROW
EXECUTE FUNCTION reset_project_progress_values();
COMMIT;

View File

@@ -1,160 +0,0 @@
-- Migration: Fix progress_mode_type ENUM and casting issues
-- Date: 2025-04-27
-- Version: 1.0.0
BEGIN;
-- First, let's ensure the ENUM type exists with the correct values
DO $$
BEGIN
-- Check if the type exists
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'progress_mode_type') THEN
CREATE TYPE progress_mode_type AS ENUM ('manual', 'weighted', 'time', 'default');
ELSE
-- Add any missing values to the existing ENUM
BEGIN
ALTER TYPE progress_mode_type ADD VALUE IF NOT EXISTS 'manual';
ALTER TYPE progress_mode_type ADD VALUE IF NOT EXISTS 'weighted';
ALTER TYPE progress_mode_type ADD VALUE IF NOT EXISTS 'time';
ALTER TYPE progress_mode_type ADD VALUE IF NOT EXISTS 'default';
EXCEPTION
WHEN duplicate_object THEN
-- Ignore if values already exist
NULL;
END;
END IF;
END $$;
-- Update functions to use proper type casting
CREATE OR REPLACE FUNCTION on_update_task_progress(_body json) RETURNS json
LANGUAGE plpgsql
AS
$$
DECLARE
_task_id UUID;
_progress_value INTEGER;
_parent_task_id UUID;
_project_id UUID;
_current_mode progress_mode_type;
BEGIN
_task_id = (_body ->> 'task_id')::UUID;
_progress_value = (_body ->> 'progress_value')::INTEGER;
_parent_task_id = (_body ->> 'parent_task_id')::UUID;
-- Get the project ID and determine the current progress mode
SELECT project_id INTO _project_id FROM tasks WHERE id = _task_id;
IF _project_id IS NOT NULL THEN
SELECT
CASE
WHEN use_manual_progress IS TRUE THEN 'manual'::progress_mode_type
WHEN use_weighted_progress IS TRUE THEN 'weighted'::progress_mode_type
WHEN use_time_progress IS TRUE THEN 'time'::progress_mode_type
ELSE 'default'::progress_mode_type
END
INTO _current_mode
FROM projects
WHERE id = _project_id;
ELSE
_current_mode := 'default'::progress_mode_type;
END IF;
-- Update the task with progress value and set the progress mode
UPDATE tasks
SET progress_value = _progress_value,
manual_progress = TRUE,
progress_mode = _current_mode,
updated_at = CURRENT_TIMESTAMP
WHERE id = _task_id;
-- Return the updated task info
RETURN JSON_BUILD_OBJECT(
'task_id', _task_id,
'progress_value', _progress_value,
'progress_mode', _current_mode
);
END;
$$;
-- Update the on_update_task_weight function to use proper type casting
CREATE OR REPLACE FUNCTION on_update_task_weight(_body json) RETURNS json
LANGUAGE plpgsql
AS
$$
DECLARE
_task_id UUID;
_weight INTEGER;
_parent_task_id UUID;
_project_id UUID;
BEGIN
_task_id = (_body ->> 'task_id')::UUID;
_weight = (_body ->> 'weight')::INTEGER;
_parent_task_id = (_body ->> 'parent_task_id')::UUID;
-- Get the project ID
SELECT project_id INTO _project_id FROM tasks WHERE id = _task_id;
-- Update the task with weight value and set progress_mode to 'weighted'
UPDATE tasks
SET weight = _weight,
progress_mode = 'weighted'::progress_mode_type,
updated_at = CURRENT_TIMESTAMP
WHERE id = _task_id;
-- Return the updated task info
RETURN JSON_BUILD_OBJECT(
'task_id', _task_id,
'weight', _weight
);
END;
$$;
-- Update the reset_project_progress_values function to use proper type casting
CREATE OR REPLACE FUNCTION reset_project_progress_values() RETURNS TRIGGER
LANGUAGE plpgsql
AS
$$
DECLARE
_old_mode progress_mode_type;
_new_mode progress_mode_type;
_project_id UUID;
BEGIN
_project_id := NEW.id;
-- Determine old and new modes with proper type casting
_old_mode :=
CASE
WHEN OLD.use_manual_progress IS TRUE THEN 'manual'::progress_mode_type
WHEN OLD.use_weighted_progress IS TRUE THEN 'weighted'::progress_mode_type
WHEN OLD.use_time_progress IS TRUE THEN 'time'::progress_mode_type
ELSE 'default'::progress_mode_type
END;
_new_mode :=
CASE
WHEN NEW.use_manual_progress IS TRUE THEN 'manual'::progress_mode_type
WHEN NEW.use_weighted_progress IS TRUE THEN 'weighted'::progress_mode_type
WHEN NEW.use_time_progress IS TRUE THEN 'time'::progress_mode_type
ELSE 'default'::progress_mode_type
END;
-- If mode has changed, reset progress values for tasks with the old mode
IF _old_mode <> _new_mode THEN
-- Reset progress values for tasks that were set in the old mode
UPDATE tasks
SET progress_value = NULL,
progress_mode = NULL
WHERE project_id = _project_id
AND progress_mode::text::progress_mode_type = _old_mode;
END IF;
RETURN NEW;
END;
$$;
-- Update the tasks table to ensure proper type casting for existing values
UPDATE tasks
SET progress_mode = progress_mode::text::progress_mode_type
WHERE progress_mode IS NOT NULL;
COMMIT;

View File

@@ -1,166 +0,0 @@
-- Migration: Fix multilevel subtask progress calculation for weighted and manual progress
-- Date: 2025-05-06
-- Version: 1.0.0
BEGIN;
-- Update the trigger function to recursively recalculate parent task progress up the entire hierarchy
CREATE OR REPLACE FUNCTION update_parent_task_progress() RETURNS TRIGGER AS
$$
DECLARE
_parent_task_id UUID;
_project_id UUID;
_ratio FLOAT;
BEGIN
-- Check if this is a subtask
IF NEW.parent_task_id IS NOT NULL THEN
_parent_task_id := NEW.parent_task_id;
-- Force any parent task with subtasks to NOT use manual progress
UPDATE tasks
SET manual_progress = FALSE
WHERE id = _parent_task_id;
-- Calculate and update the parent's progress value
SELECT (get_task_complete_ratio(_parent_task_id)->>'ratio')::FLOAT INTO _ratio;
-- Update the parent's progress value
UPDATE tasks
SET progress_value = _ratio
WHERE id = _parent_task_id;
-- Recursively propagate changes up the hierarchy by using a recursive CTE
WITH RECURSIVE task_hierarchy AS (
-- Base case: Start with the parent task
SELECT
id,
parent_task_id
FROM tasks
WHERE id = _parent_task_id
UNION ALL
-- Recursive case: Go up to each ancestor
SELECT
t.id,
t.parent_task_id
FROM tasks t
JOIN task_hierarchy th ON t.id = th.parent_task_id
WHERE t.id IS NOT NULL
)
-- For each ancestor, recalculate its progress
UPDATE tasks
SET
manual_progress = FALSE,
progress_value = (SELECT (get_task_complete_ratio(task_hierarchy.id)->>'ratio')::FLOAT)
FROM task_hierarchy
WHERE tasks.id = task_hierarchy.id
AND task_hierarchy.parent_task_id IS NOT NULL;
-- Log the recalculation for debugging
RAISE NOTICE 'Updated progress for task % to %', _parent_task_id, _ratio;
END IF;
-- If this task has progress value of 100 and doesn't have subtasks, we might want to prompt the user
-- to mark it as done. We'll annotate this in a way that the socket handler can detect.
IF NEW.progress_value = 100 OR NEW.weight = 100 OR NEW.total_minutes > 0 THEN
-- Check if task has status in "done" category
SELECT project_id FROM tasks WHERE id = NEW.id INTO _project_id;
-- Get the progress ratio for this task
SELECT (get_task_complete_ratio(NEW.id)->>'ratio')::FLOAT INTO _ratio;
IF _ratio >= 100 THEN
-- Log that this task is at 100% progress
RAISE NOTICE 'Task % progress is at 100%%, may need status update', NEW.id;
END IF;
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
-- Update existing trigger or create a new one to handle more changes
DROP TRIGGER IF EXISTS update_parent_task_progress_trigger ON tasks;
CREATE TRIGGER update_parent_task_progress_trigger
AFTER UPDATE OF progress_value, weight, total_minutes, parent_task_id, manual_progress ON tasks
FOR EACH ROW
EXECUTE FUNCTION update_parent_task_progress();
-- Also add a trigger for when a new task is inserted
DROP TRIGGER IF EXISTS update_parent_task_progress_on_insert_trigger ON tasks;
CREATE TRIGGER update_parent_task_progress_on_insert_trigger
AFTER INSERT ON tasks
FOR EACH ROW
WHEN (NEW.parent_task_id IS NOT NULL)
EXECUTE FUNCTION update_parent_task_progress();
-- Add a comment to explain the fix
COMMENT ON FUNCTION update_parent_task_progress() IS
'This function recursively updates progress values for all ancestors when a task''s progress changes.
The previous version only updated the immediate parent, which led to incorrect progress values for
higher-level parent tasks when using weighted or manual progress calculations with multi-level subtasks.';
-- Add a function to immediately recalculate all task progress values in the correct order
-- This will fix existing data where parent tasks don't have proper progress values
CREATE OR REPLACE FUNCTION recalculate_all_task_progress() RETURNS void AS
$$
BEGIN
-- First, reset manual_progress flag for all tasks that have subtasks
UPDATE tasks AS t
SET manual_progress = FALSE
WHERE EXISTS (
SELECT 1
FROM tasks
WHERE parent_task_id = t.id
AND archived IS FALSE
);
-- Start recalculation from leaf tasks (no subtasks) and propagate upward
-- This ensures calculations are done in the right order
WITH RECURSIVE task_hierarchy AS (
-- Base case: Start with all leaf tasks (no subtasks)
SELECT
id,
parent_task_id,
0 AS level
FROM tasks
WHERE NOT EXISTS (
SELECT 1 FROM tasks AS sub
WHERE sub.parent_task_id = tasks.id
AND sub.archived IS FALSE
)
AND archived IS FALSE
UNION ALL
-- Recursive case: Move up to parent tasks, but only after processing all their children
SELECT
t.id,
t.parent_task_id,
th.level + 1
FROM tasks t
JOIN task_hierarchy th ON t.id = th.parent_task_id
WHERE t.archived IS FALSE
)
-- Sort by level to ensure we calculate in the right order (leaves first, then parents)
-- This ensures we're using already updated progress values
UPDATE tasks
SET progress_value = (SELECT (get_task_complete_ratio(tasks.id)->>'ratio')::FLOAT)
FROM (
SELECT id, level
FROM task_hierarchy
ORDER BY level
) AS ordered_tasks
WHERE tasks.id = ordered_tasks.id
AND (manual_progress IS FALSE OR manual_progress IS NULL);
-- Log the completion of the recalculation
RAISE NOTICE 'Finished recalculating all task progress values';
END;
$$ LANGUAGE plpgsql;
-- Execute the function to fix existing data
SELECT recalculate_all_task_progress();
COMMIT;

View File

@@ -1,63 +0,0 @@
-- Create organization holiday settings table
CREATE TABLE IF NOT EXISTS organization_holiday_settings (
id UUID DEFAULT uuid_generate_v4() NOT NULL,
organization_id UUID NOT NULL,
country_code CHAR(2),
state_code TEXT,
auto_sync_holidays BOOLEAN DEFAULT TRUE NOT NULL,
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP NOT NULL,
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP NOT NULL
);
ALTER TABLE organization_holiday_settings
ADD CONSTRAINT organization_holiday_settings_pk
PRIMARY KEY (id);
ALTER TABLE organization_holiday_settings
ADD CONSTRAINT organization_holiday_settings_organization_id_fk
FOREIGN KEY (organization_id) REFERENCES organizations
ON DELETE CASCADE;
ALTER TABLE organization_holiday_settings
ADD CONSTRAINT organization_holiday_settings_country_code_fk
FOREIGN KEY (country_code) REFERENCES countries(code)
ON DELETE SET NULL;
-- Ensure one settings record per organization
ALTER TABLE organization_holiday_settings
ADD CONSTRAINT organization_holiday_settings_organization_unique
UNIQUE (organization_id);
-- Create index for better performance
CREATE INDEX IF NOT EXISTS idx_organization_holiday_settings_organization_id ON organization_holiday_settings(organization_id);
-- Add state holidays table for more granular holiday data
CREATE TABLE IF NOT EXISTS state_holidays (
id UUID DEFAULT uuid_generate_v4() NOT NULL,
country_code CHAR(2) NOT NULL,
state_code TEXT NOT NULL,
name TEXT NOT NULL,
description TEXT,
date DATE NOT NULL,
is_recurring BOOLEAN DEFAULT TRUE NOT NULL,
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP NOT NULL,
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP NOT NULL
);
ALTER TABLE state_holidays
ADD CONSTRAINT state_holidays_pk
PRIMARY KEY (id);
ALTER TABLE state_holidays
ADD CONSTRAINT state_holidays_country_code_fk
FOREIGN KEY (country_code) REFERENCES countries(code)
ON DELETE CASCADE;
-- Add unique constraint to prevent duplicate holidays for the same state, name, and date
ALTER TABLE state_holidays
ADD CONSTRAINT state_holidays_state_name_date_unique
UNIQUE (country_code, state_code, name, date);
-- Create indexes for better performance
CREATE INDEX IF NOT EXISTS idx_state_holidays_country_state ON state_holidays(country_code, state_code);
CREATE INDEX IF NOT EXISTS idx_state_holidays_date ON state_holidays(date);

View File

@@ -1,300 +0,0 @@
-- Fix Duplicate Sort Orders Script
-- This script detects and fixes duplicate sort order values that break task ordering
-- 1. DETECTION QUERIES - Run these first to see the scope of the problem
-- Check for duplicates in main sort_order column
SELECT
project_id,
sort_order,
COUNT(*) as duplicate_count,
STRING_AGG(id::text, ', ') as task_ids
FROM tasks
WHERE project_id IS NOT NULL
GROUP BY project_id, sort_order
HAVING COUNT(*) > 1
ORDER BY project_id, sort_order;
-- Check for duplicates in status_sort_order
SELECT
project_id,
status_sort_order,
COUNT(*) as duplicate_count,
STRING_AGG(id::text, ', ') as task_ids
FROM tasks
WHERE project_id IS NOT NULL
GROUP BY project_id, status_sort_order
HAVING COUNT(*) > 1
ORDER BY project_id, status_sort_order;
-- Check for duplicates in priority_sort_order
SELECT
project_id,
priority_sort_order,
COUNT(*) as duplicate_count,
STRING_AGG(id::text, ', ') as task_ids
FROM tasks
WHERE project_id IS NOT NULL
GROUP BY project_id, priority_sort_order
HAVING COUNT(*) > 1
ORDER BY project_id, priority_sort_order;
-- Check for duplicates in phase_sort_order
SELECT
project_id,
phase_sort_order,
COUNT(*) as duplicate_count,
STRING_AGG(id::text, ', ') as task_ids
FROM tasks
WHERE project_id IS NOT NULL
GROUP BY project_id, phase_sort_order
HAVING COUNT(*) > 1
ORDER BY project_id, phase_sort_order;
-- Note: member_sort_order removed - no longer used
-- 2. CLEANUP FUNCTIONS
-- Fix duplicates in main sort_order column
CREATE OR REPLACE FUNCTION fix_sort_order_duplicates() RETURNS void
LANGUAGE plpgsql
AS
$$
DECLARE
_project RECORD;
_task RECORD;
_counter INTEGER;
BEGIN
-- For each project, reassign sort_order values to ensure uniqueness
FOR _project IN
SELECT DISTINCT project_id
FROM tasks
WHERE project_id IS NOT NULL
LOOP
_counter := 0;
-- Reassign sort_order values sequentially for this project
FOR _task IN
SELECT id
FROM tasks
WHERE project_id = _project.project_id
ORDER BY sort_order, created_at
LOOP
UPDATE tasks
SET sort_order = _counter
WHERE id = _task.id;
_counter := _counter + 1;
END LOOP;
END LOOP;
RAISE NOTICE 'Fixed sort_order duplicates for all projects';
END
$$;
-- Fix duplicates in status_sort_order column
CREATE OR REPLACE FUNCTION fix_status_sort_order_duplicates() RETURNS void
LANGUAGE plpgsql
AS
$$
DECLARE
_project RECORD;
_task RECORD;
_counter INTEGER;
BEGIN
FOR _project IN
SELECT DISTINCT project_id
FROM tasks
WHERE project_id IS NOT NULL
LOOP
_counter := 0;
FOR _task IN
SELECT id
FROM tasks
WHERE project_id = _project.project_id
ORDER BY status_sort_order, created_at
LOOP
UPDATE tasks
SET status_sort_order = _counter
WHERE id = _task.id;
_counter := _counter + 1;
END LOOP;
END LOOP;
RAISE NOTICE 'Fixed status_sort_order duplicates for all projects';
END
$$;
-- Fix duplicates in priority_sort_order column
CREATE OR REPLACE FUNCTION fix_priority_sort_order_duplicates() RETURNS void
LANGUAGE plpgsql
AS
$$
DECLARE
_project RECORD;
_task RECORD;
_counter INTEGER;
BEGIN
FOR _project IN
SELECT DISTINCT project_id
FROM tasks
WHERE project_id IS NOT NULL
LOOP
_counter := 0;
FOR _task IN
SELECT id
FROM tasks
WHERE project_id = _project.project_id
ORDER BY priority_sort_order, created_at
LOOP
UPDATE tasks
SET priority_sort_order = _counter
WHERE id = _task.id;
_counter := _counter + 1;
END LOOP;
END LOOP;
RAISE NOTICE 'Fixed priority_sort_order duplicates for all projects';
END
$$;
-- Fix duplicates in phase_sort_order column
CREATE OR REPLACE FUNCTION fix_phase_sort_order_duplicates() RETURNS void
LANGUAGE plpgsql
AS
$$
DECLARE
_project RECORD;
_task RECORD;
_counter INTEGER;
BEGIN
FOR _project IN
SELECT DISTINCT project_id
FROM tasks
WHERE project_id IS NOT NULL
LOOP
_counter := 0;
FOR _task IN
SELECT id
FROM tasks
WHERE project_id = _project.project_id
ORDER BY phase_sort_order, created_at
LOOP
UPDATE tasks
SET phase_sort_order = _counter
WHERE id = _task.id;
_counter := _counter + 1;
END LOOP;
END LOOP;
RAISE NOTICE 'Fixed phase_sort_order duplicates for all projects';
END
$$;
-- Note: fix_member_sort_order_duplicates() removed - no longer needed
-- Master function to fix all sort order duplicates
CREATE OR REPLACE FUNCTION fix_all_duplicate_sort_orders() RETURNS void
LANGUAGE plpgsql
AS
$$
BEGIN
RAISE NOTICE 'Starting sort order cleanup for all columns...';
PERFORM fix_sort_order_duplicates();
PERFORM fix_status_sort_order_duplicates();
PERFORM fix_priority_sort_order_duplicates();
PERFORM fix_phase_sort_order_duplicates();
RAISE NOTICE 'Completed sort order cleanup for all columns';
END
$$;
-- 3. VERIFICATION FUNCTION
-- Verify that duplicates have been fixed
CREATE OR REPLACE FUNCTION verify_sort_order_integrity() RETURNS TABLE(
column_name text,
project_id uuid,
duplicate_count bigint,
status text
)
LANGUAGE plpgsql
AS
$$
BEGIN
-- Check sort_order duplicates
RETURN QUERY
SELECT
'sort_order'::text as column_name,
t.project_id,
COUNT(*) as duplicate_count,
CASE WHEN COUNT(*) > 1 THEN 'DUPLICATES FOUND' ELSE 'OK' END as status
FROM tasks t
WHERE t.project_id IS NOT NULL
GROUP BY t.project_id, t.sort_order
HAVING COUNT(*) > 1;
-- Check status_sort_order duplicates
RETURN QUERY
SELECT
'status_sort_order'::text as column_name,
t.project_id,
COUNT(*) as duplicate_count,
CASE WHEN COUNT(*) > 1 THEN 'DUPLICATES FOUND' ELSE 'OK' END as status
FROM tasks t
WHERE t.project_id IS NOT NULL
GROUP BY t.project_id, t.status_sort_order
HAVING COUNT(*) > 1;
-- Check priority_sort_order duplicates
RETURN QUERY
SELECT
'priority_sort_order'::text as column_name,
t.project_id,
COUNT(*) as duplicate_count,
CASE WHEN COUNT(*) > 1 THEN 'DUPLICATES FOUND' ELSE 'OK' END as status
FROM tasks t
WHERE t.project_id IS NOT NULL
GROUP BY t.project_id, t.priority_sort_order
HAVING COUNT(*) > 1;
-- Check phase_sort_order duplicates
RETURN QUERY
SELECT
'phase_sort_order'::text as column_name,
t.project_id,
COUNT(*) as duplicate_count,
CASE WHEN COUNT(*) > 1 THEN 'DUPLICATES FOUND' ELSE 'OK' END as status
FROM tasks t
WHERE t.project_id IS NOT NULL
GROUP BY t.project_id, t.phase_sort_order
HAVING COUNT(*) > 1;
-- Note: member_sort_order verification removed - column no longer used
END
$$;
-- 4. USAGE INSTRUCTIONS
/*
USAGE:
1. First, run the detection queries to see which projects have duplicates
2. Then run this to fix all duplicates:
SELECT fix_all_duplicate_sort_orders();
3. Finally, verify the fix worked:
SELECT * FROM verify_sort_order_integrity();
If verification returns no rows, all duplicates have been fixed successfully.
WARNING: This will reassign sort order values based on current order + creation time.
Make sure to backup your database before running these functions.
*/

View File

@@ -1,37 +0,0 @@
-- Migration: Add separate sort order columns for different grouping types
-- This allows users to maintain different task orders when switching between grouping views
-- Add new sort order columns
ALTER TABLE tasks ADD COLUMN IF NOT EXISTS status_sort_order INTEGER DEFAULT 0;
ALTER TABLE tasks ADD COLUMN IF NOT EXISTS priority_sort_order INTEGER DEFAULT 0;
ALTER TABLE tasks ADD COLUMN IF NOT EXISTS phase_sort_order INTEGER DEFAULT 0;
ALTER TABLE tasks ADD COLUMN IF NOT EXISTS member_sort_order INTEGER DEFAULT 0;
-- Initialize new columns with current sort_order values
UPDATE tasks SET
status_sort_order = sort_order,
priority_sort_order = sort_order,
phase_sort_order = sort_order,
member_sort_order = sort_order
WHERE status_sort_order = 0
OR priority_sort_order = 0
OR phase_sort_order = 0
OR member_sort_order = 0;
-- Add constraints to ensure non-negative values
ALTER TABLE tasks ADD CONSTRAINT tasks_status_sort_order_check CHECK (status_sort_order >= 0);
ALTER TABLE tasks ADD CONSTRAINT tasks_priority_sort_order_check CHECK (priority_sort_order >= 0);
ALTER TABLE tasks ADD CONSTRAINT tasks_phase_sort_order_check CHECK (phase_sort_order >= 0);
ALTER TABLE tasks ADD CONSTRAINT tasks_member_sort_order_check CHECK (member_sort_order >= 0);
-- Add indexes for performance (since these will be used for ordering)
CREATE INDEX IF NOT EXISTS idx_tasks_status_sort_order ON tasks(project_id, status_sort_order);
CREATE INDEX IF NOT EXISTS idx_tasks_priority_sort_order ON tasks(project_id, priority_sort_order);
CREATE INDEX IF NOT EXISTS idx_tasks_phase_sort_order ON tasks(project_id, phase_sort_order);
CREATE INDEX IF NOT EXISTS idx_tasks_member_sort_order ON tasks(project_id, member_sort_order);
-- Update comments for documentation
COMMENT ON COLUMN tasks.status_sort_order IS 'Sort order when grouped by status';
COMMENT ON COLUMN tasks.priority_sort_order IS 'Sort order when grouped by priority';
COMMENT ON COLUMN tasks.phase_sort_order IS 'Sort order when grouped by phase';
COMMENT ON COLUMN tasks.member_sort_order IS 'Sort order when grouped by members/assignees';

View File

@@ -1,172 +0,0 @@
-- Migration: Update database functions to handle grouping-specific sort orders
-- Function to get the appropriate sort column name based on grouping type
CREATE OR REPLACE FUNCTION get_sort_column_name(_group_by TEXT) RETURNS TEXT
LANGUAGE plpgsql
AS
$$
BEGIN
CASE _group_by
WHEN 'status' THEN RETURN 'status_sort_order';
WHEN 'priority' THEN RETURN 'priority_sort_order';
WHEN 'phase' THEN RETURN 'phase_sort_order';
WHEN 'members' THEN RETURN 'member_sort_order';
ELSE RETURN 'sort_order'; -- fallback to general sort_order
END CASE;
END;
$$;
-- Updated bulk sort order function to handle different sort columns
CREATE OR REPLACE FUNCTION update_task_sort_orders_bulk(_updates json, _group_by text DEFAULT 'status') RETURNS void
LANGUAGE plpgsql
AS
$$
DECLARE
_update_record RECORD;
_sort_column TEXT;
_sql TEXT;
BEGIN
-- Get the appropriate sort column based on grouping
_sort_column := get_sort_column_name(_group_by);
-- Simple approach: update each task's sort_order from the provided array
FOR _update_record IN
SELECT
(item->>'task_id')::uuid as task_id,
(item->>'sort_order')::int as sort_order,
(item->>'status_id')::uuid as status_id,
(item->>'priority_id')::uuid as priority_id,
(item->>'phase_id')::uuid as phase_id
FROM json_array_elements(_updates) as item
LOOP
-- Update the appropriate sort column and other fields using dynamic SQL
-- Only update sort_order if we're using the default sorting
IF _sort_column = 'sort_order' THEN
UPDATE tasks SET
sort_order = _update_record.sort_order,
status_id = COALESCE(_update_record.status_id, status_id),
priority_id = COALESCE(_update_record.priority_id, priority_id)
WHERE id = _update_record.task_id;
ELSE
-- Update only the grouping-specific sort column, not the main sort_order
_sql := 'UPDATE tasks SET ' || _sort_column || ' = $1, ' ||
'status_id = COALESCE($2, status_id), ' ||
'priority_id = COALESCE($3, priority_id) ' ||
'WHERE id = $4';
EXECUTE _sql USING
_update_record.sort_order,
_update_record.status_id,
_update_record.priority_id,
_update_record.task_id;
END IF;
-- Handle phase updates separately since it's in a different table
IF _update_record.phase_id IS NOT NULL THEN
INSERT INTO task_phase (task_id, phase_id)
VALUES (_update_record.task_id, _update_record.phase_id)
ON CONFLICT (task_id) DO UPDATE SET phase_id = _update_record.phase_id;
END IF;
END LOOP;
END;
$$;
-- Updated main sort order change handler
CREATE OR REPLACE FUNCTION handle_task_list_sort_order_change(_body json) RETURNS void
LANGUAGE plpgsql
AS
$$
DECLARE
_from_index INT;
_to_index INT;
_task_id UUID;
_project_id UUID;
_from_group UUID;
_to_group UUID;
_group_by TEXT;
_batch_size INT := 100;
_sort_column TEXT;
_sql TEXT;
BEGIN
_project_id = (_body ->> 'project_id')::UUID;
_task_id = (_body ->> 'task_id')::UUID;
_from_index = (_body ->> 'from_index')::INT;
_to_index = (_body ->> 'to_index')::INT;
_from_group = (_body ->> 'from_group')::UUID;
_to_group = (_body ->> 'to_group')::UUID;
_group_by = (_body ->> 'group_by')::TEXT;
-- Get the appropriate sort column
_sort_column := get_sort_column_name(_group_by);
-- Handle group changes
IF (_from_group <> _to_group OR (_from_group <> _to_group) IS NULL) THEN
IF (_group_by = 'status') THEN
UPDATE tasks
SET status_id = _to_group
WHERE id = _task_id
AND status_id = _from_group
AND project_id = _project_id;
END IF;
IF (_group_by = 'priority') THEN
UPDATE tasks
SET priority_id = _to_group
WHERE id = _task_id
AND priority_id = _from_group
AND project_id = _project_id;
END IF;
IF (_group_by = 'phase') THEN
IF (is_null_or_empty(_to_group) IS FALSE) THEN
INSERT INTO task_phase (task_id, phase_id)
VALUES (_task_id, _to_group)
ON CONFLICT (task_id) DO UPDATE SET phase_id = _to_group;
ELSE
DELETE FROM task_phase WHERE task_id = _task_id;
END IF;
END IF;
END IF;
-- Handle sort order changes using dynamic SQL
IF (_from_index <> _to_index) THEN
-- For the main sort_order column, we need to be careful about unique constraints
IF _sort_column = 'sort_order' THEN
-- Use a transaction-safe approach for the main sort_order column
IF (_to_index > _from_index) THEN
-- Moving down: decrease sort_order for items between old and new position
UPDATE tasks SET sort_order = sort_order - 1
WHERE project_id = _project_id
AND sort_order > _from_index
AND sort_order <= _to_index;
ELSE
-- Moving up: increase sort_order for items between new and old position
UPDATE tasks SET sort_order = sort_order + 1
WHERE project_id = _project_id
AND sort_order >= _to_index
AND sort_order < _from_index;
END IF;
-- Set the new sort_order for the moved task
UPDATE tasks SET sort_order = _to_index WHERE id = _task_id;
ELSE
-- For grouping-specific columns, use dynamic SQL since there's no unique constraint
IF (_to_index > _from_index) THEN
-- Moving down: decrease sort_order for items between old and new position
_sql := 'UPDATE tasks SET ' || _sort_column || ' = ' || _sort_column || ' - 1 ' ||
'WHERE project_id = $1 AND ' || _sort_column || ' > $2 AND ' || _sort_column || ' <= $3';
EXECUTE _sql USING _project_id, _from_index, _to_index;
ELSE
-- Moving up: increase sort_order for items between new and old position
_sql := 'UPDATE tasks SET ' || _sort_column || ' = ' || _sort_column || ' + 1 ' ||
'WHERE project_id = $1 AND ' || _sort_column || ' >= $2 AND ' || _sort_column || ' < $3';
EXECUTE _sql USING _project_id, _to_index, _from_index;
END IF;
-- Set the new sort_order for the moved task
_sql := 'UPDATE tasks SET ' || _sort_column || ' = $1 WHERE id = $2';
EXECUTE _sql USING _to_index, _task_id;
END IF;
END IF;
END;
$$;

View File

@@ -1,179 +0,0 @@
-- Migration: Fix sort order constraint violations
-- First, let's ensure all existing tasks have unique sort_order values within each project
-- This is a one-time fix to ensure data consistency
DO $$
DECLARE
_project RECORD;
_task RECORD;
_counter INTEGER;
BEGIN
-- For each project, reassign sort_order values to ensure uniqueness
FOR _project IN
SELECT DISTINCT project_id
FROM tasks
WHERE project_id IS NOT NULL
LOOP
_counter := 0;
-- Reassign sort_order values sequentially for this project
FOR _task IN
SELECT id
FROM tasks
WHERE project_id = _project.project_id
ORDER BY sort_order, created_at
LOOP
UPDATE tasks
SET sort_order = _counter
WHERE id = _task.id;
_counter := _counter + 1;
END LOOP;
END LOOP;
END
$$;
-- Now create a better version of our functions that properly handles the constraints
-- Updated bulk sort order function that avoids sort_order conflicts
CREATE OR REPLACE FUNCTION update_task_sort_orders_bulk(_updates json, _group_by text DEFAULT 'status') RETURNS void
LANGUAGE plpgsql
AS
$$
DECLARE
_update_record RECORD;
_sort_column TEXT;
_sql TEXT;
BEGIN
-- Get the appropriate sort column based on grouping
_sort_column := get_sort_column_name(_group_by);
-- Process each update record
FOR _update_record IN
SELECT
(item->>'task_id')::uuid as task_id,
(item->>'sort_order')::int as sort_order,
(item->>'status_id')::uuid as status_id,
(item->>'priority_id')::uuid as priority_id,
(item->>'phase_id')::uuid as phase_id
FROM json_array_elements(_updates) as item
LOOP
-- Update the grouping-specific sort column and other fields
_sql := 'UPDATE tasks SET ' || _sort_column || ' = $1, ' ||
'status_id = COALESCE($2, status_id), ' ||
'priority_id = COALESCE($3, priority_id), ' ||
'updated_at = CURRENT_TIMESTAMP ' ||
'WHERE id = $4';
EXECUTE _sql USING
_update_record.sort_order,
_update_record.status_id,
_update_record.priority_id,
_update_record.task_id;
-- Handle phase updates separately since it's in a different table
IF _update_record.phase_id IS NOT NULL THEN
INSERT INTO task_phase (task_id, phase_id)
VALUES (_update_record.task_id, _update_record.phase_id)
ON CONFLICT (task_id) DO UPDATE SET phase_id = _update_record.phase_id;
END IF;
END LOOP;
END;
$$;
-- Also update the helper function to be more explicit
CREATE OR REPLACE FUNCTION get_sort_column_name(_group_by TEXT) RETURNS TEXT
LANGUAGE plpgsql
AS
$$
BEGIN
CASE _group_by
WHEN 'status' THEN RETURN 'status_sort_order';
WHEN 'priority' THEN RETURN 'priority_sort_order';
WHEN 'phase' THEN RETURN 'phase_sort_order';
WHEN 'members' THEN RETURN 'member_sort_order';
-- For backward compatibility, still support general sort_order but be explicit
WHEN 'general' THEN RETURN 'sort_order';
ELSE RETURN 'status_sort_order'; -- Default to status sorting
END CASE;
END;
$$;
-- Updated main sort order change handler that avoids conflicts
CREATE OR REPLACE FUNCTION handle_task_list_sort_order_change(_body json) RETURNS void
LANGUAGE plpgsql
AS
$$
DECLARE
_from_index INT;
_to_index INT;
_task_id UUID;
_project_id UUID;
_from_group UUID;
_to_group UUID;
_group_by TEXT;
_sort_column TEXT;
_sql TEXT;
BEGIN
_project_id = (_body ->> 'project_id')::UUID;
_task_id = (_body ->> 'task_id')::UUID;
_from_index = (_body ->> 'from_index')::INT;
_to_index = (_body ->> 'to_index')::INT;
_from_group = (_body ->> 'from_group')::UUID;
_to_group = (_body ->> 'to_group')::UUID;
_group_by = (_body ->> 'group_by')::TEXT;
-- Get the appropriate sort column
_sort_column := get_sort_column_name(_group_by);
-- Handle group changes first
IF (_from_group <> _to_group OR (_from_group <> _to_group) IS NULL) THEN
IF (_group_by = 'status') THEN
UPDATE tasks
SET status_id = _to_group, updated_at = CURRENT_TIMESTAMP
WHERE id = _task_id
AND project_id = _project_id;
END IF;
IF (_group_by = 'priority') THEN
UPDATE tasks
SET priority_id = _to_group, updated_at = CURRENT_TIMESTAMP
WHERE id = _task_id
AND project_id = _project_id;
END IF;
IF (_group_by = 'phase') THEN
IF (is_null_or_empty(_to_group) IS FALSE) THEN
INSERT INTO task_phase (task_id, phase_id)
VALUES (_task_id, _to_group)
ON CONFLICT (task_id) DO UPDATE SET phase_id = _to_group;
ELSE
DELETE FROM task_phase WHERE task_id = _task_id;
END IF;
END IF;
END IF;
-- Handle sort order changes for the grouping-specific column only
IF (_from_index <> _to_index) THEN
-- Update the grouping-specific sort order (no unique constraint issues)
IF (_to_index > _from_index) THEN
-- Moving down: decrease sort order for items between old and new position
_sql := 'UPDATE tasks SET ' || _sort_column || ' = ' || _sort_column || ' - 1, ' ||
'updated_at = CURRENT_TIMESTAMP ' ||
'WHERE project_id = $1 AND ' || _sort_column || ' > $2 AND ' || _sort_column || ' <= $3';
EXECUTE _sql USING _project_id, _from_index, _to_index;
ELSE
-- Moving up: increase sort order for items between new and old position
_sql := 'UPDATE tasks SET ' || _sort_column || ' = ' || _sort_column || ' + 1, ' ||
'updated_at = CURRENT_TIMESTAMP ' ||
'WHERE project_id = $1 AND ' || _sort_column || ' >= $2 AND ' || _sort_column || ' < $3';
EXECUTE _sql USING _project_id, _to_index, _from_index;
END IF;
-- Set the new sort order for the moved task
_sql := 'UPDATE tasks SET ' || _sort_column || ' = $1, updated_at = CURRENT_TIMESTAMP WHERE id = $2';
EXECUTE _sql USING _to_index, _task_id;
END IF;
END;
$$;

View File

@@ -1,93 +0,0 @@
-- Migration: Add survey tables for account setup questionnaire
-- Date: 2025-07-24
-- Description: Creates tables to store survey questions and user responses for account setup flow
BEGIN;
-- Create surveys table to define different types of surveys
CREATE TABLE IF NOT EXISTS surveys (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name VARCHAR(255) NOT NULL,
description TEXT,
survey_type VARCHAR(50) DEFAULT 'account_setup' NOT NULL, -- 'account_setup', 'onboarding', 'feedback'
is_active BOOLEAN DEFAULT TRUE NOT NULL,
created_at TIMESTAMP DEFAULT now() NOT NULL,
updated_at TIMESTAMP DEFAULT now() NOT NULL
);
-- Create survey_questions table to store individual questions
CREATE TABLE IF NOT EXISTS survey_questions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
survey_id UUID REFERENCES surveys(id) ON DELETE CASCADE NOT NULL,
question_key VARCHAR(100) NOT NULL, -- Used for localization keys
question_type VARCHAR(50) NOT NULL, -- 'single_choice', 'multiple_choice', 'text'
is_required BOOLEAN DEFAULT FALSE NOT NULL,
sort_order INTEGER DEFAULT 0 NOT NULL,
options JSONB, -- For choice questions, store options as JSON array
created_at TIMESTAMP DEFAULT now() NOT NULL,
updated_at TIMESTAMP DEFAULT now() NOT NULL
);
-- Create survey_responses table to track user responses to surveys
CREATE TABLE IF NOT EXISTS survey_responses (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
survey_id UUID REFERENCES surveys(id) ON DELETE CASCADE NOT NULL,
user_id UUID REFERENCES users(id) ON DELETE CASCADE NOT NULL,
is_completed BOOLEAN DEFAULT FALSE NOT NULL,
started_at TIMESTAMP DEFAULT now() NOT NULL,
completed_at TIMESTAMP,
created_at TIMESTAMP DEFAULT now() NOT NULL,
updated_at TIMESTAMP DEFAULT now() NOT NULL
);
-- Create survey_answers table to store individual question answers
CREATE TABLE IF NOT EXISTS survey_answers (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
response_id UUID REFERENCES survey_responses(id) ON DELETE CASCADE NOT NULL,
question_id UUID REFERENCES survey_questions(id) ON DELETE CASCADE NOT NULL,
answer_text TEXT,
answer_json JSONB, -- For multiple choice answers stored as array
created_at TIMESTAMP DEFAULT now() NOT NULL,
updated_at TIMESTAMP DEFAULT now() NOT NULL
);
-- Add performance indexes
CREATE INDEX IF NOT EXISTS idx_surveys_type_active ON surveys(survey_type, is_active);
CREATE INDEX IF NOT EXISTS idx_survey_questions_survey_order ON survey_questions(survey_id, sort_order);
CREATE INDEX IF NOT EXISTS idx_survey_responses_user_survey ON survey_responses(user_id, survey_id);
CREATE INDEX IF NOT EXISTS idx_survey_responses_completed ON survey_responses(survey_id, is_completed);
CREATE INDEX IF NOT EXISTS idx_survey_answers_response ON survey_answers(response_id);
-- Add constraints
ALTER TABLE survey_questions ADD CONSTRAINT survey_questions_sort_order_check CHECK (sort_order >= 0);
ALTER TABLE survey_questions ADD CONSTRAINT survey_questions_type_check CHECK (question_type IN ('single_choice', 'multiple_choice', 'text'));
-- Add unique constraint to prevent duplicate responses per user per survey
ALTER TABLE survey_responses ADD CONSTRAINT unique_user_survey_response UNIQUE (user_id, survey_id);
-- Add unique constraint to prevent duplicate answers per question per response
ALTER TABLE survey_answers ADD CONSTRAINT unique_response_question_answer UNIQUE (response_id, question_id);
-- Insert the default account setup survey
INSERT INTO surveys (name, description, survey_type, is_active) VALUES
('Account Setup Survey', 'Initial questionnaire during account setup to understand user needs', 'account_setup', true)
ON CONFLICT DO NOTHING;
-- Get the survey ID for inserting questions
DO $$
DECLARE
survey_uuid UUID;
BEGIN
SELECT id INTO survey_uuid FROM surveys WHERE survey_type = 'account_setup' AND name = 'Account Setup Survey' LIMIT 1;
-- Insert survey questions
INSERT INTO survey_questions (survey_id, question_key, question_type, is_required, sort_order, options) VALUES
(survey_uuid, 'organization_type', 'single_choice', true, 1, '["freelancer", "startup", "small_medium_business", "agency", "enterprise", "other"]'),
(survey_uuid, 'user_role', 'single_choice', true, 2, '["founder_ceo", "project_manager", "software_developer", "designer", "operations", "other"]'),
(survey_uuid, 'main_use_cases', 'multiple_choice', true, 3, '["task_management", "team_collaboration", "resource_planning", "client_communication", "time_tracking", "other"]'),
(survey_uuid, 'previous_tools', 'text', false, 4, null),
(survey_uuid, 'how_heard_about', 'single_choice', false, 5, '["google_search", "twitter", "linkedin", "friend_colleague", "blog_article", "other"]')
ON CONFLICT DO NOTHING;
END $$;
COMMIT;

View File

@@ -1,72 +0,0 @@
# Node-pg-migrate Migrations
This directory contains database migrations managed by node-pg-migrate.
## Migration Commands
- `npm run migrate:create -- migration-name` - Create a new migration file
- `npm run migrate:up` - Run all pending migrations
- `npm run migrate:down` - Rollback the last migration
- `npm run migrate:redo` - Rollback and re-run the last migration
## Migration File Format
Migrations are JavaScript files with timestamp prefixes (e.g., `20250115000000_performance-indexes.js`).
Each migration file exports two functions:
- `exports.up` - Contains the forward migration logic
- `exports.down` - Contains the rollback logic
## Best Practices
1. **Always use IF EXISTS/IF NOT EXISTS checks** to make migrations idempotent
2. **Test migrations locally** before deploying to production
3. **Include rollback logic** in the `down` function for all changes
4. **Use descriptive names** for migration files
5. **Keep migrations focused** - one logical change per migration
## Example Migration
```javascript
exports.up = pgm => {
// Create table with IF NOT EXISTS
pgm.createTable('users', {
id: 'id',
name: { type: 'varchar(100)', notNull: true },
created_at: {
type: 'timestamp',
notNull: true,
default: pgm.func('current_timestamp')
}
}, { ifNotExists: true });
// Add index with IF NOT EXISTS
pgm.createIndex('users', 'name', {
name: 'idx_users_name',
ifNotExists: true
});
};
exports.down = pgm => {
// Drop in reverse order
pgm.dropIndex('users', 'name', {
name: 'idx_users_name',
ifExists: true
});
pgm.dropTable('users', { ifExists: true });
};
```
## Migration History
The `pgmigrations` table tracks which migrations have been run. Do not modify this table manually.
## Converting from SQL Migrations
When converting SQL migrations to node-pg-migrate format:
1. Wrap SQL statements in `pgm.sql()` calls
2. Use node-pg-migrate helper methods where possible (createTable, addColumns, etc.)
3. Always include `IF EXISTS/IF NOT EXISTS` checks
4. Ensure proper rollback logic in the `down` function

View File

@@ -12,7 +12,7 @@ CREATE TYPE DEPENDENCY_TYPE AS ENUM ('blocked_by');
CREATE TYPE SCHEDULE_TYPE AS ENUM ('daily', 'weekly', 'yearly', 'monthly', 'every_x_days', 'every_x_weeks', 'every_x_months');
CREATE TYPE LANGUAGE_TYPE AS ENUM ('en', 'es', 'pt', 'alb', 'de', 'zh_cn');
CREATE TYPE LANGUAGE_TYPE AS ENUM ('en', 'es', 'pt', 'alb', 'de');
-- START: Users
CREATE SEQUENCE IF NOT EXISTS users_user_no_seq START 1;
@@ -1391,30 +1391,27 @@ ALTER TABLE task_work_log
CHECK (time_spent >= (0)::NUMERIC);
CREATE TABLE IF NOT EXISTS tasks (
id UUID DEFAULT uuid_generate_v4() NOT NULL,
name TEXT NOT NULL,
description TEXT,
done BOOLEAN DEFAULT FALSE NOT NULL,
total_minutes NUMERIC DEFAULT 0 NOT NULL,
archived BOOLEAN DEFAULT FALSE NOT NULL,
task_no BIGINT NOT NULL,
start_date TIMESTAMP WITH TIME ZONE,
end_date TIMESTAMP WITH TIME ZONE,
priority_id UUID NOT NULL,
project_id UUID NOT NULL,
reporter_id UUID NOT NULL,
parent_task_id UUID,
status_id UUID NOT NULL,
completed_at TIMESTAMP WITH TIME ZONE,
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP NOT NULL,
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP NOT NULL,
sort_order INTEGER DEFAULT 0 NOT NULL,
roadmap_sort_order INTEGER DEFAULT 0 NOT NULL,
status_sort_order INTEGER DEFAULT 0 NOT NULL,
priority_sort_order INTEGER DEFAULT 0 NOT NULL,
phase_sort_order INTEGER DEFAULT 0 NOT NULL,
billable BOOLEAN DEFAULT TRUE,
schedule_id UUID
id UUID DEFAULT uuid_generate_v4() NOT NULL,
name TEXT NOT NULL,
description TEXT,
done BOOLEAN DEFAULT FALSE NOT NULL,
total_minutes NUMERIC DEFAULT 0 NOT NULL,
archived BOOLEAN DEFAULT FALSE NOT NULL,
task_no BIGINT NOT NULL,
start_date TIMESTAMP WITH TIME ZONE,
end_date TIMESTAMP WITH TIME ZONE,
priority_id UUID NOT NULL,
project_id UUID NOT NULL,
reporter_id UUID NOT NULL,
parent_task_id UUID,
status_id UUID NOT NULL,
completed_at TIMESTAMP WITH TIME ZONE,
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP NOT NULL,
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP NOT NULL,
sort_order INTEGER DEFAULT 0 NOT NULL,
roadmap_sort_order INTEGER DEFAULT 0 NOT NULL,
billable BOOLEAN DEFAULT TRUE,
schedule_id UUID
);
ALTER TABLE tasks
@@ -1502,21 +1499,6 @@ ALTER TABLE tasks
ADD CONSTRAINT tasks_total_minutes_check
CHECK ((total_minutes >= (0)::NUMERIC) AND (total_minutes <= (999999)::NUMERIC));
-- Add constraints for new sort order columns
ALTER TABLE tasks ADD CONSTRAINT tasks_status_sort_order_check CHECK (status_sort_order >= 0);
ALTER TABLE tasks ADD CONSTRAINT tasks_priority_sort_order_check CHECK (priority_sort_order >= 0);
ALTER TABLE tasks ADD CONSTRAINT tasks_phase_sort_order_check CHECK (phase_sort_order >= 0);
-- Add indexes for performance on new sort order columns
CREATE INDEX IF NOT EXISTS idx_tasks_status_sort_order ON tasks(project_id, status_sort_order);
CREATE INDEX IF NOT EXISTS idx_tasks_priority_sort_order ON tasks(project_id, priority_sort_order);
CREATE INDEX IF NOT EXISTS idx_tasks_phase_sort_order ON tasks(project_id, phase_sort_order);
-- Add comments for documentation
COMMENT ON COLUMN tasks.status_sort_order IS 'Sort order when grouped by status';
COMMENT ON COLUMN tasks.priority_sort_order IS 'Sort order when grouped by priority';
COMMENT ON COLUMN tasks.phase_sort_order IS 'Sort order when grouped by phase';
CREATE TABLE IF NOT EXISTS tasks_assignees (
task_id UUID NOT NULL,
project_member_id UUID NOT NULL,
@@ -2297,60 +2279,3 @@ ALTER TABLE organization_working_days
ALTER TABLE organization_working_days
ADD CONSTRAINT org_organization_id_fk
FOREIGN KEY (organization_id) REFERENCES organizations;
-- Survey tables for account setup questionnaire
CREATE TABLE IF NOT EXISTS surveys (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name VARCHAR(255) NOT NULL,
description TEXT,
survey_type VARCHAR(50) DEFAULT 'account_setup' NOT NULL,
is_active BOOLEAN DEFAULT TRUE NOT NULL,
created_at TIMESTAMP DEFAULT now() NOT NULL,
updated_at TIMESTAMP DEFAULT now() NOT NULL
);
CREATE TABLE IF NOT EXISTS survey_questions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
survey_id UUID REFERENCES surveys(id) ON DELETE CASCADE NOT NULL,
question_key VARCHAR(100) NOT NULL,
question_type VARCHAR(50) NOT NULL,
is_required BOOLEAN DEFAULT FALSE NOT NULL,
sort_order INTEGER DEFAULT 0 NOT NULL,
options JSONB,
created_at TIMESTAMP DEFAULT now() NOT NULL,
updated_at TIMESTAMP DEFAULT now() NOT NULL
);
CREATE TABLE IF NOT EXISTS survey_responses (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
survey_id UUID REFERENCES surveys(id) ON DELETE CASCADE NOT NULL,
user_id UUID REFERENCES users(id) ON DELETE CASCADE NOT NULL,
is_completed BOOLEAN DEFAULT FALSE NOT NULL,
started_at TIMESTAMP DEFAULT now() NOT NULL,
completed_at TIMESTAMP,
created_at TIMESTAMP DEFAULT now() NOT NULL,
updated_at TIMESTAMP DEFAULT now() NOT NULL
);
CREATE TABLE IF NOT EXISTS survey_answers (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
response_id UUID REFERENCES survey_responses(id) ON DELETE CASCADE NOT NULL,
question_id UUID REFERENCES survey_questions(id) ON DELETE CASCADE NOT NULL,
answer_text TEXT,
answer_json JSONB,
created_at TIMESTAMP DEFAULT now() NOT NULL,
updated_at TIMESTAMP DEFAULT now() NOT NULL
);
-- Survey table indexes
CREATE INDEX IF NOT EXISTS idx_surveys_type_active ON surveys(survey_type, is_active);
CREATE INDEX IF NOT EXISTS idx_survey_questions_survey_order ON survey_questions(survey_id, sort_order);
CREATE INDEX IF NOT EXISTS idx_survey_responses_user_survey ON survey_responses(user_id, survey_id);
CREATE INDEX IF NOT EXISTS idx_survey_responses_completed ON survey_responses(survey_id, is_completed);
CREATE INDEX IF NOT EXISTS idx_survey_answers_response ON survey_answers(response_id);
-- Survey table constraints
ALTER TABLE survey_questions ADD CONSTRAINT survey_questions_sort_order_check CHECK (sort_order >= 0);
ALTER TABLE survey_questions ADD CONSTRAINT survey_questions_type_check CHECK (question_type IN ('single_choice', 'multiple_choice', 'text'));
ALTER TABLE survey_responses ADD CONSTRAINT unique_user_survey_response UNIQUE (user_id, survey_id);
ALTER TABLE survey_answers ADD CONSTRAINT unique_response_question_answer UNIQUE (response_id, question_id);

View File

@@ -142,25 +142,3 @@ DROP FUNCTION sys_insert_license_types();
INSERT INTO timezones (name, abbrev, utc_offset)
SELECT name, abbrev, utc_offset
FROM pg_timezone_names;
-- Insert default account setup survey
INSERT INTO surveys (name, description, survey_type, is_active) VALUES
('Account Setup Survey', 'Initial questionnaire during account setup to understand user needs', 'account_setup', true)
ON CONFLICT DO NOTHING;
-- Insert survey questions for account setup survey
DO $$
DECLARE
survey_uuid UUID;
BEGIN
SELECT id INTO survey_uuid FROM surveys WHERE survey_type = 'account_setup' AND name = 'Account Setup Survey' LIMIT 1;
-- Insert survey questions
INSERT INTO survey_questions (survey_id, question_key, question_type, is_required, sort_order, options) VALUES
(survey_uuid, 'organization_type', 'single_choice', true, 1, '["freelancer", "startup", "small_medium_business", "agency", "enterprise", "other"]'),
(survey_uuid, 'user_role', 'single_choice', true, 2, '["founder_ceo", "project_manager", "software_developer", "designer", "operations", "other"]'),
(survey_uuid, 'main_use_cases', 'multiple_choice', true, 3, '["task_management", "team_collaboration", "resource_planning", "client_communication", "time_tracking", "other"]'),
(survey_uuid, 'previous_tools', 'text', false, 4, null),
(survey_uuid, 'how_heard_about', 'single_choice', false, 5, '["google_search", "twitter", "linkedin", "friend_colleague", "blog_article", "other"]')
ON CONFLICT DO NOTHING;
END $$;

View File

@@ -32,37 +32,3 @@ SELECT u.avatar_url,
FROM team_members
LEFT JOIN users u ON team_members.user_id = u.id;
-- PERFORMANCE OPTIMIZATION: Create materialized view for team member info
-- This pre-calculates the expensive joins and subqueries from team_member_info_view
CREATE MATERIALIZED VIEW IF NOT EXISTS team_member_info_mv AS
SELECT
u.avatar_url,
COALESCE(u.email, ei.email) AS email,
COALESCE(u.name, ei.name) AS name,
u.id AS user_id,
tm.id AS team_member_id,
tm.team_id,
tm.active,
u.socket_id
FROM team_members tm
LEFT JOIN users u ON tm.user_id = u.id
LEFT JOIN email_invitations ei ON ei.team_member_id = tm.id
WHERE tm.active = TRUE;
-- Create unique index on the materialized view for fast lookups
CREATE UNIQUE INDEX IF NOT EXISTS idx_team_member_info_mv_team_member_id
ON team_member_info_mv(team_member_id);
CREATE INDEX IF NOT EXISTS idx_team_member_info_mv_team_user
ON team_member_info_mv(team_id, user_id);
-- Function to refresh the materialized view
CREATE OR REPLACE FUNCTION refresh_team_member_info_mv()
RETURNS void
LANGUAGE plpgsql
AS $$
BEGIN
REFRESH MATERIALIZED VIEW CONCURRENTLY team_member_info_mv;
END;
$$;

View File

@@ -3351,15 +3351,15 @@ BEGIN
SELECT COALESCE(ARRAY_TO_JSON(ARRAY_AGG(ROW_TO_JSON(rec))), '[]'::JSON)
FROM (SELECT team_member_id,
project_member_id,
COALESCE((SELECT name FROM team_member_info_view WHERE team_member_info_view.team_member_id = tm.id), '') as name,
COALESCE((SELECT email_notifications_enabled
(SELECT name FROM team_member_info_view WHERE team_member_info_view.team_member_id = tm.id),
(SELECT email_notifications_enabled
FROM notification_settings
WHERE team_id = tm.team_id
AND notification_settings.user_id = u.id), false) AS email_notifications_enabled,
COALESCE(u.avatar_url, '') as avatar_url,
AND notification_settings.user_id = u.id) AS email_notifications_enabled,
u.avatar_url,
u.id AS user_id,
COALESCE(u.email, '') as email,
COALESCE(u.socket_id, '') as socket_id,
u.email,
u.socket_id AS socket_id,
tm.team_id AS team_id
FROM tasks_assignees
INNER JOIN team_members tm ON tm.id = tasks_assignees.team_member_id
@@ -4066,14 +4066,14 @@ DECLARE
_schedule_id JSON;
_task_completed_at TIMESTAMPTZ;
BEGIN
SELECT COALESCE(name, '') FROM tasks WHERE id = _task_id INTO _task_name;
SELECT name FROM tasks WHERE id = _task_id INTO _task_name;
SELECT COALESCE(name, '')
SELECT name
FROM task_statuses
WHERE id = (SELECT status_id FROM tasks WHERE id = _task_id)
INTO _previous_status_name;
SELECT COALESCE(name, '') FROM task_statuses WHERE id = _status_id INTO _new_status_name;
SELECT name FROM task_statuses WHERE id = _status_id INTO _new_status_name;
IF (_previous_status_name != _new_status_name)
THEN
@@ -4081,22 +4081,14 @@ BEGIN
SELECT get_task_complete_info(_task_id, _status_id) INTO _task_info;
SELECT COALESCE(name, '') FROM users WHERE id = _user_id INTO _updater_name;
SELECT name FROM users WHERE id = _user_id INTO _updater_name;
_message = CONCAT(_updater_name, ' transitioned "', _task_name, '" from ', _previous_status_name, '',
_new_status_name);
END IF;
SELECT completed_at FROM tasks WHERE id = _task_id INTO _task_completed_at;
-- Handle schedule_id properly for recurring tasks
SELECT CASE
WHEN schedule_id IS NULL THEN 'null'::json
ELSE json_build_object('id', schedule_id)
END
FROM tasks
WHERE id = _task_id
INTO _schedule_id;
SELECT schedule_id FROM tasks WHERE id = _task_id INTO _schedule_id;
SELECT COALESCE(ROW_TO_JSON(r), '{}'::JSON)
FROM (SELECT is_done, is_doing, is_todo
@@ -4105,7 +4097,7 @@ BEGIN
INTO _status_category;
RETURN JSON_BUILD_OBJECT(
'message', COALESCE(_message, ''),
'message', _message,
'project_id', (SELECT project_id FROM tasks WHERE id = _task_id),
'parent_done', (CASE
WHEN EXISTS(SELECT 1
@@ -4113,14 +4105,14 @@ BEGIN
WHERE tasks_with_status_view.task_id = _task_id
AND is_done IS TRUE) THEN 1
ELSE 0 END),
'color_code', COALESCE((_task_info ->> 'color_code')::TEXT, ''),
'color_code_dark', COALESCE((_task_info ->> 'color_code_dark')::TEXT, ''),
'total_tasks', COALESCE((_task_info ->> 'total_tasks')::INT, 0),
'total_completed', COALESCE((_task_info ->> 'total_completed')::INT, 0),
'members', COALESCE((_task_info ->> 'members')::JSON, '[]'::JSON),
'color_code', (_task_info ->> 'color_code')::TEXT,
'color_code_dark', (_task_info ->> 'color_code_dark')::TEXT,
'total_tasks', (_task_info ->> 'total_tasks')::INT,
'total_completed', (_task_info ->> 'total_completed')::INT,
'members', (_task_info ->> 'members')::JSON,
'completed_at', _task_completed_at,
'status_category', COALESCE(_status_category, '{}'::JSON),
'schedule_id', COALESCE(_schedule_id, 'null'::JSON)
'status_category', _status_category,
'schedule_id', _schedule_id
);
END
$$;
@@ -4313,24 +4305,6 @@ BEGIN
END
$$;
-- Helper function to get the appropriate sort column name based on grouping type
CREATE OR REPLACE FUNCTION get_sort_column_name(_group_by TEXT) RETURNS TEXT
LANGUAGE plpgsql
AS
$$
BEGIN
CASE _group_by
WHEN 'status' THEN RETURN 'status_sort_order';
WHEN 'priority' THEN RETURN 'priority_sort_order';
WHEN 'phase' THEN RETURN 'phase_sort_order';
WHEN 'members' THEN RETURN 'member_sort_order';
-- For backward compatibility, still support general sort_order but be explicit
WHEN 'general' THEN RETURN 'sort_order';
ELSE RETURN 'status_sort_order'; -- Default to status sorting
END CASE;
END;
$$;
CREATE OR REPLACE FUNCTION handle_task_list_sort_order_change(_body json) RETURNS void
LANGUAGE plpgsql
AS
@@ -4343,67 +4317,54 @@ DECLARE
_from_group UUID;
_to_group UUID;
_group_by TEXT;
_sort_column TEXT;
_sql TEXT;
BEGIN
_project_id = (_body ->> 'project_id')::UUID;
_task_id = (_body ->> 'task_id')::UUID;
_from_index = (_body ->> 'from_index')::INT;
_to_index = (_body ->> 'to_index')::INT;
_from_index = (_body ->> 'from_index')::INT; -- from sort_order
_to_index = (_body ->> 'to_index')::INT; -- to sort_order
_from_group = (_body ->> 'from_group')::UUID;
_to_group = (_body ->> 'to_group')::UUID;
_group_by = (_body ->> 'group_by')::TEXT;
-- Get the appropriate sort column
_sort_column := get_sort_column_name(_group_by);
-- Handle group changes first
IF (_from_group <> _to_group OR (_from_group <> _to_group) IS NULL) THEN
IF (_group_by = 'status') THEN
UPDATE tasks
SET status_id = _to_group, updated_at = CURRENT_TIMESTAMP
WHERE id = _task_id
AND project_id = _project_id;
IF (_from_group <> _to_group OR (_from_group <> _to_group) IS NULL)
THEN
IF (_group_by = 'status')
THEN
UPDATE tasks SET status_id = _to_group WHERE id = _task_id AND status_id = _from_group;
END IF;
IF (_group_by = 'priority') THEN
UPDATE tasks
SET priority_id = _to_group, updated_at = CURRENT_TIMESTAMP
WHERE id = _task_id
AND project_id = _project_id;
IF (_group_by = 'priority')
THEN
UPDATE tasks SET priority_id = _to_group WHERE id = _task_id AND priority_id = _from_group;
END IF;
IF (_group_by = 'phase') THEN
IF (is_null_or_empty(_to_group) IS FALSE) THEN
IF (_group_by = 'phase')
THEN
IF (is_null_or_empty(_to_group) IS FALSE)
THEN
INSERT INTO task_phase (task_id, phase_id)
VALUES (_task_id, _to_group)
ON CONFLICT (task_id) DO UPDATE SET phase_id = _to_group;
ELSE
DELETE FROM task_phase WHERE task_id = _task_id;
END IF;
IF (is_null_or_empty(_to_group) IS TRUE)
THEN
DELETE
FROM task_phase
WHERE task_id = _task_id;
END IF;
END IF;
END IF;
-- Handle sort order changes for the grouping-specific column only
IF (_from_index <> _to_index) THEN
-- Update the grouping-specific sort order (no unique constraint issues)
IF (_to_index > _from_index) THEN
-- Moving down: decrease sort order for items between old and new position
_sql := 'UPDATE tasks SET ' || _sort_column || ' = ' || _sort_column || ' - 1, ' ||
'updated_at = CURRENT_TIMESTAMP ' ||
'WHERE project_id = $1 AND ' || _sort_column || ' > $2 AND ' || _sort_column || ' <= $3';
EXECUTE _sql USING _project_id, _from_index, _to_index;
IF ((_body ->> 'to_last_index')::BOOLEAN IS TRUE AND _from_index < _to_index)
THEN
PERFORM handle_task_list_sort_inside_group(_from_index, _to_index, _task_id, _project_id);
ELSE
-- Moving up: increase sort order for items between new and old position
_sql := 'UPDATE tasks SET ' || _sort_column || ' = ' || _sort_column || ' + 1, ' ||
'updated_at = CURRENT_TIMESTAMP ' ||
'WHERE project_id = $1 AND ' || _sort_column || ' >= $2 AND ' || _sort_column || ' < $3';
EXECUTE _sql USING _project_id, _to_index, _from_index;
PERFORM handle_task_list_sort_between_groups(_from_index, _to_index, _task_id, _project_id);
END IF;
-- Set the new sort order for the moved task
_sql := 'UPDATE tasks SET ' || _sort_column || ' = $1, updated_at = CURRENT_TIMESTAMP WHERE id = $2';
EXECUTE _sql USING _to_index, _task_id;
ELSE
PERFORM handle_task_list_sort_inside_group(_from_index, _to_index, _task_id, _project_id);
END IF;
END
$$;
@@ -4608,31 +4569,31 @@ BEGIN
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);
VALUES (_project_id, 'Members', 'ASSIGNEES', 4, TRUE);
INSERT INTO project_task_list_cols (project_id, name, key, index, pinned)
VALUES (_project_id, 'Members', 'ASSIGNEES', 5, TRUE);
VALUES (_project_id, 'Labels', 'LABELS', 5, TRUE);
INSERT INTO project_task_list_cols (project_id, name, key, index, pinned)
VALUES (_project_id, 'Labels', 'LABELS', 6, TRUE);
VALUES (_project_id, 'Status', 'STATUS', 6, TRUE);
INSERT INTO project_task_list_cols (project_id, name, key, index, pinned)
VALUES (_project_id, 'Phase', 'PHASE', 7, TRUE);
VALUES (_project_id, 'Priority', 'PRIORITY', 7, TRUE);
INSERT INTO project_task_list_cols (project_id, name, key, index, pinned)
VALUES (_project_id, 'Priority', 'PRIORITY', 8, TRUE);
VALUES (_project_id, 'Time Tracking', 'TIME_TRACKING', 8, TRUE);
INSERT INTO project_task_list_cols (project_id, name, key, index, pinned)
VALUES (_project_id, 'Time Tracking', 'TIME_TRACKING', 9, TRUE);
VALUES (_project_id, 'Estimation', 'ESTIMATION', 9, FALSE);
INSERT INTO project_task_list_cols (project_id, name, key, index, pinned)
VALUES (_project_id, 'Estimation', 'ESTIMATION', 10, FALSE);
VALUES (_project_id, 'Start Date', 'START_DATE', 10, FALSE);
INSERT INTO project_task_list_cols (project_id, name, key, index, pinned)
VALUES (_project_id, 'Start Date', 'START_DATE', 11, FALSE);
VALUES (_project_id, 'Due Date', 'DUE_DATE', 11, TRUE);
INSERT INTO project_task_list_cols (project_id, name, key, index, pinned)
VALUES (_project_id, 'Due Date', 'DUE_DATE', 12, TRUE);
VALUES (_project_id, 'Completed Date', 'COMPLETED_DATE', 12, FALSE);
INSERT INTO project_task_list_cols (project_id, name, key, index, pinned)
VALUES (_project_id, 'Completed Date', 'COMPLETED_DATE', 13, FALSE);
VALUES (_project_id, 'Created Date', 'CREATED_DATE', 13, FALSE);
INSERT INTO project_task_list_cols (project_id, name, key, index, pinned)
VALUES (_project_id, 'Created Date', 'CREATED_DATE', 14, FALSE);
VALUES (_project_id, 'Last Updated', 'LAST_UPDATED', 14, FALSE);
INSERT INTO project_task_list_cols (project_id, name, key, index, pinned)
VALUES (_project_id, 'Last Updated', 'LAST_UPDATED', 15, FALSE);
VALUES (_project_id, 'Reporter', 'REPORTER', 15, FALSE);
INSERT INTO project_task_list_cols (project_id, name, key, index, pinned)
VALUES (_project_id, 'Reporter', 'REPORTER', 16, FALSE);
VALUES (_project_id, 'Phase', 'PHASE', 16, FALSE);
END
$$;
@@ -5516,15 +5477,8 @@ $$
DECLARE
_iterator NUMERIC := 0;
_status_id TEXT;
_project_id UUID;
_base_sort_order NUMERIC;
BEGIN
-- Get the project_id from the first status to ensure we update all statuses in the same project
SELECT project_id INTO _project_id
FROM task_statuses
WHERE id = (SELECT TRIM(BOTH '"' FROM JSON_ARRAY_ELEMENTS(_status_ids)::TEXT) LIMIT 1)::UUID;
-- Update the sort_order for statuses in the provided order
FOR _status_id IN SELECT * FROM JSON_ARRAY_ELEMENTS((_status_ids)::JSON)
LOOP
UPDATE task_statuses
@@ -5533,29 +5487,6 @@ BEGIN
_iterator := _iterator + 1;
END LOOP;
-- Get the base sort order for remaining statuses (simple count approach)
SELECT COUNT(*) INTO _base_sort_order
FROM task_statuses ts2
WHERE ts2.project_id = _project_id
AND ts2.id = ANY(SELECT (TRIM(BOTH '"' FROM JSON_ARRAY_ELEMENTS(_status_ids)::TEXT))::UUID);
-- Update remaining statuses with simple sequential numbering
-- Reset iterator to start from base_sort_order
_iterator := _base_sort_order;
-- Use a cursor approach to avoid window functions
FOR _status_id IN
SELECT id::TEXT FROM task_statuses
WHERE project_id = _project_id
AND id NOT IN (SELECT (TRIM(BOTH '"' FROM JSON_ARRAY_ELEMENTS(_status_ids)::TEXT))::UUID)
ORDER BY sort_order
LOOP
UPDATE task_statuses
SET sort_order = _iterator
WHERE id = _status_id::UUID;
_iterator := _iterator + 1;
END LOOP;
RETURN;
END
$$;
@@ -6217,434 +6148,3 @@ BEGIN
RETURN v_new_id;
END;
$$;
CREATE OR REPLACE FUNCTION transfer_team_ownership(_team_id UUID, _new_owner_id UUID) RETURNS json
LANGUAGE plpgsql
AS
$$
DECLARE
_old_owner_id UUID;
_owner_role_id UUID;
_admin_role_id UUID;
_old_org_id UUID;
_new_org_id UUID;
_has_license BOOLEAN;
_old_owner_role_id UUID;
_new_owner_role_id UUID;
_has_active_coupon BOOLEAN;
_other_teams_count INTEGER;
_new_owner_org_id UUID;
_license_type_id UUID;
_has_valid_license BOOLEAN;
BEGIN
-- Get the current owner's ID and organization
SELECT t.user_id, t.organization_id
INTO _old_owner_id, _old_org_id
FROM teams t
WHERE t.id = _team_id;
IF _old_owner_id IS NULL THEN
RAISE EXCEPTION 'Team not found';
END IF;
-- Get the new owner's organization
SELECT organization_id INTO _new_owner_org_id
FROM organizations
WHERE user_id = _new_owner_id;
-- Get the old organization
SELECT id INTO _old_org_id
FROM organizations
WHERE id = _old_org_id;
IF _old_org_id IS NULL THEN
RAISE EXCEPTION 'Organization not found';
END IF;
-- Check if new owner has any valid license type
SELECT EXISTS (
SELECT 1
FROM (
-- Check regular subscriptions
SELECT lus.user_id, lus.status, lus.active
FROM licensing_user_subscriptions lus
WHERE lus.user_id = _new_owner_id
AND lus.active = TRUE
AND lus.status IN ('active', 'trialing')
UNION ALL
-- Check custom subscriptions
SELECT lcs.user_id, lcs.subscription_status as status, TRUE as active
FROM licensing_custom_subs lcs
WHERE lcs.user_id = _new_owner_id
AND lcs.end_date > CURRENT_DATE
UNION ALL
-- Check trial status in organizations
SELECT o.user_id, o.subscription_status as status, TRUE as active
FROM organizations o
WHERE o.user_id = _new_owner_id
AND o.trial_in_progress = TRUE
AND o.trial_expire_date > CURRENT_DATE
) valid_licenses
) INTO _has_valid_license;
IF NOT _has_valid_license THEN
RAISE EXCEPTION 'New owner does not have a valid license (subscription, custom subscription, or trial)';
END IF;
-- Check if new owner has any active coupon codes
SELECT EXISTS (
SELECT 1
FROM licensing_coupon_codes lcc
WHERE lcc.redeemed_by = _new_owner_id
AND lcc.is_redeemed = TRUE
AND lcc.is_refunded = FALSE
) INTO _has_active_coupon;
IF _has_active_coupon THEN
RAISE EXCEPTION 'New owner has active coupon codes that need to be handled before transfer';
END IF;
-- Count other teams in the organization for information purposes
SELECT COUNT(*) INTO _other_teams_count
FROM teams
WHERE organization_id = _old_org_id
AND id != _team_id;
-- If new owner has their own organization, move the team to their organization
IF _new_owner_org_id IS NOT NULL THEN
-- Update the team to use the new owner's organization
UPDATE teams
SET user_id = _new_owner_id,
organization_id = _new_owner_org_id
WHERE id = _team_id;
-- Create notification about organization change
PERFORM create_notification(
_old_owner_id,
_team_id,
NULL,
NULL,
CONCAT('Team <b>', (SELECT name FROM teams WHERE id = _team_id), '</b> has been moved to a different organization')
);
PERFORM create_notification(
_new_owner_id,
_team_id,
NULL,
NULL,
CONCAT('Team <b>', (SELECT name FROM teams WHERE id = _team_id), '</b> has been moved to your organization')
);
ELSE
-- If new owner doesn't have an organization, transfer the old organization to them
UPDATE organizations
SET user_id = _new_owner_id
WHERE id = _old_org_id;
-- Update the team to use the same organization
UPDATE teams
SET user_id = _new_owner_id,
organization_id = _old_org_id
WHERE id = _team_id;
-- Notify both users about organization ownership transfer
PERFORM create_notification(
_old_owner_id,
NULL,
NULL,
NULL,
CONCAT('You are no longer the owner of organization <b>', (SELECT organization_name FROM organizations WHERE id = _old_org_id), '</b>')
);
PERFORM create_notification(
_new_owner_id,
NULL,
NULL,
NULL,
CONCAT('You are now the owner of organization <b>', (SELECT organization_name FROM organizations WHERE id = _old_org_id), '</b>')
);
END IF;
-- Get the owner and admin role IDs
SELECT id INTO _owner_role_id FROM roles WHERE team_id = _team_id AND owner = TRUE;
SELECT id INTO _admin_role_id FROM roles WHERE team_id = _team_id AND admin_role = TRUE;
-- Get current role IDs for both users
SELECT role_id INTO _old_owner_role_id
FROM team_members
WHERE team_id = _team_id AND user_id = _old_owner_id;
SELECT role_id INTO _new_owner_role_id
FROM team_members
WHERE team_id = _team_id AND user_id = _new_owner_id;
-- Update the old owner's role to admin if they want to stay in the team
IF _old_owner_role_id IS NOT NULL THEN
UPDATE team_members
SET role_id = _admin_role_id
WHERE team_id = _team_id AND user_id = _old_owner_id;
END IF;
-- Update the new owner's role to owner
IF _new_owner_role_id IS NOT NULL THEN
UPDATE team_members
SET role_id = _owner_role_id
WHERE team_id = _team_id AND user_id = _new_owner_id;
ELSE
-- If new owner is not a team member yet, add them
INSERT INTO team_members (user_id, team_id, role_id)
VALUES (_new_owner_id, _team_id, _owner_role_id);
END IF;
-- Create notification for both users about team ownership
PERFORM create_notification(
_old_owner_id,
_team_id,
NULL,
NULL,
CONCAT('You are no longer the owner of team <b>', (SELECT name FROM teams WHERE id = _team_id), '</b>')
);
PERFORM create_notification(
_new_owner_id,
_team_id,
NULL,
NULL,
CONCAT('You are now the owner of team <b>', (SELECT name FROM teams WHERE id = _team_id), '</b>')
);
RETURN json_build_object(
'success', TRUE,
'old_owner_id', _old_owner_id,
'new_owner_id', _new_owner_id,
'team_id', _team_id,
'old_org_id', _old_org_id,
'new_org_id', COALESCE(_new_owner_org_id, _old_org_id),
'old_role_id', _old_owner_role_id,
'new_role_id', _new_owner_role_id,
'has_valid_license', _has_valid_license,
'has_active_coupon', _has_active_coupon,
'other_teams_count', _other_teams_count,
'org_ownership_transferred', _new_owner_org_id IS NULL,
'team_moved_to_new_org', _new_owner_org_id IS NOT NULL
);
END;
$$;
-- PERFORMANCE OPTIMIZATION: Optimized version with batching for large datasets
CREATE OR REPLACE FUNCTION handle_task_list_sort_between_groups_optimized(_from_index integer, _to_index integer, _task_id uuid, _project_id uuid, _batch_size integer DEFAULT 100) RETURNS void
LANGUAGE plpgsql
AS
$$
DECLARE
_offset INT := 0;
_affected_rows INT;
BEGIN
-- PERFORMANCE OPTIMIZATION: Use direct updates without CTE in UPDATE
IF (_to_index = -1)
THEN
_to_index = COALESCE((SELECT MAX(sort_order) + 1 FROM tasks WHERE project_id = _project_id), 0);
END IF;
-- PERFORMANCE OPTIMIZATION: Batch updates for large datasets
IF _to_index > _from_index
THEN
LOOP
UPDATE tasks
SET sort_order = sort_order - 1
WHERE project_id = _project_id
AND sort_order > _from_index
AND sort_order < _to_index
AND sort_order > _offset
AND sort_order <= _offset + _batch_size;
GET DIAGNOSTICS _affected_rows = ROW_COUNT;
EXIT WHEN _affected_rows = 0;
_offset := _offset + _batch_size;
END LOOP;
UPDATE tasks SET sort_order = _to_index - 1 WHERE id = _task_id AND project_id = _project_id;
END IF;
IF _to_index < _from_index
THEN
_offset := 0;
LOOP
UPDATE tasks
SET sort_order = sort_order + 1
WHERE project_id = _project_id
AND sort_order > _to_index
AND sort_order < _from_index
AND sort_order > _offset
AND sort_order <= _offset + _batch_size;
GET DIAGNOSTICS _affected_rows = ROW_COUNT;
EXIT WHEN _affected_rows = 0;
_offset := _offset + _batch_size;
END LOOP;
UPDATE tasks SET sort_order = _to_index + 1 WHERE id = _task_id AND project_id = _project_id;
END IF;
END
$$;
-- PERFORMANCE OPTIMIZATION: Optimized version with batching for large datasets
CREATE OR REPLACE FUNCTION handle_task_list_sort_inside_group_optimized(_from_index integer, _to_index integer, _task_id uuid, _project_id uuid, _batch_size integer DEFAULT 100) RETURNS void
LANGUAGE plpgsql
AS
$$
DECLARE
_offset INT := 0;
_affected_rows INT;
BEGIN
-- PERFORMANCE OPTIMIZATION: Batch updates for large datasets without CTE in UPDATE
IF _to_index > _from_index
THEN
LOOP
UPDATE tasks
SET sort_order = sort_order - 1
WHERE project_id = _project_id
AND sort_order > _from_index
AND sort_order <= _to_index
AND sort_order > _offset
AND sort_order <= _offset + _batch_size;
GET DIAGNOSTICS _affected_rows = ROW_COUNT;
EXIT WHEN _affected_rows = 0;
_offset := _offset + _batch_size;
END LOOP;
END IF;
IF _to_index < _from_index
THEN
_offset := 0;
LOOP
UPDATE tasks
SET sort_order = sort_order + 1
WHERE project_id = _project_id
AND sort_order >= _to_index
AND sort_order < _from_index
AND sort_order > _offset
AND sort_order <= _offset + _batch_size;
GET DIAGNOSTICS _affected_rows = ROW_COUNT;
EXIT WHEN _affected_rows = 0;
_offset := _offset + _batch_size;
END LOOP;
END IF;
UPDATE tasks SET sort_order = _to_index WHERE id = _task_id AND project_id = _project_id;
END
$$;
-- Updated bulk sort order function that avoids sort_order conflicts
CREATE OR REPLACE FUNCTION update_task_sort_orders_bulk(_updates json, _group_by text DEFAULT 'status') RETURNS void
LANGUAGE plpgsql
AS
$$
DECLARE
_update_record RECORD;
_sort_column TEXT;
_sql TEXT;
BEGIN
-- Get the appropriate sort column based on grouping
_sort_column := get_sort_column_name(_group_by);
-- Process each update record
FOR _update_record IN
SELECT
(item->>'task_id')::uuid as task_id,
(item->>'sort_order')::int as sort_order,
(item->>'status_id')::uuid as status_id,
(item->>'priority_id')::uuid as priority_id,
(item->>'phase_id')::uuid as phase_id
FROM json_array_elements(_updates) as item
LOOP
-- Update the grouping-specific sort column and other fields
_sql := 'UPDATE tasks SET ' || _sort_column || ' = $1, ' ||
'status_id = COALESCE($2, status_id), ' ||
'priority_id = COALESCE($3, priority_id), ' ||
'updated_at = CURRENT_TIMESTAMP ' ||
'WHERE id = $4';
EXECUTE _sql USING
_update_record.sort_order,
_update_record.status_id,
_update_record.priority_id,
_update_record.task_id;
-- Handle phase updates separately since it's in a different table
IF _update_record.phase_id IS NOT NULL THEN
INSERT INTO task_phase (task_id, phase_id)
VALUES (_update_record.task_id, _update_record.phase_id)
ON CONFLICT (task_id) DO UPDATE SET phase_id = _update_record.phase_id;
END IF;
END LOOP;
END
$$;
-- Function to get the appropriate sort column name based on grouping type
CREATE OR REPLACE FUNCTION get_sort_column_name(_group_by TEXT) RETURNS TEXT
LANGUAGE plpgsql
AS
$$
BEGIN
CASE _group_by
WHEN 'status' THEN RETURN 'status_sort_order';
WHEN 'priority' THEN RETURN 'priority_sort_order';
WHEN 'phase' THEN RETURN 'phase_sort_order';
-- For backward compatibility, still support general sort_order but be explicit
WHEN 'general' THEN RETURN 'sort_order';
ELSE RETURN 'status_sort_order'; -- Default to status sorting
END CASE;
END;
$$;
-- Updated bulk sort order function to handle different sort columns
CREATE OR REPLACE FUNCTION update_task_sort_orders_bulk(_updates json, _group_by text DEFAULT 'status') RETURNS void
LANGUAGE plpgsql
AS
$$
DECLARE
_update_record RECORD;
_sort_column TEXT;
_sql TEXT;
BEGIN
-- Get the appropriate sort column based on grouping
_sort_column := get_sort_column_name(_group_by);
-- Process each update record
FOR _update_record IN
SELECT
(item->>'task_id')::uuid as task_id,
(item->>'sort_order')::int as sort_order,
(item->>'status_id')::uuid as status_id,
(item->>'priority_id')::uuid as priority_id,
(item->>'phase_id')::uuid as phase_id
FROM json_array_elements(_updates) as item
LOOP
-- Update the grouping-specific sort column and other fields
_sql := 'UPDATE tasks SET ' || _sort_column || ' = $1, ' ||
'status_id = COALESCE($2, status_id), ' ||
'priority_id = COALESCE($3, priority_id), ' ||
'updated_at = CURRENT_TIMESTAMP ' ||
'WHERE id = $4';
EXECUTE _sql USING
_update_record.sort_order,
_update_record.status_id,
_update_record.priority_id,
_update_record.task_id;
-- Handle phase updates separately since it's in a different table
IF _update_record.phase_id IS NOT NULL THEN
INSERT INTO task_phase (task_id, phase_id)
VALUES (_update_record.task_id, _update_record.phase_id)
ON CONFLICT (task_id) DO UPDATE SET phase_id = _update_record.phase_id;
END IF;
END LOOP;
END;
$$;

View File

@@ -132,139 +132,3 @@ CREATE INDEX IF NOT EXISTS projects_team_id_index
CREATE INDEX IF NOT EXISTS projects_team_id_name_index
ON projects (team_id, name);
-- Performance indexes for optimized tasks queries
-- From migration: 20250115000000-performance-indexes.sql
-- Composite index for main task filtering
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_tasks_project_archived_parent
ON tasks(project_id, archived, parent_task_id)
WHERE archived = FALSE;
-- Index for status joins
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_tasks_status_project
ON tasks(status_id, project_id)
WHERE archived = FALSE;
-- Index for assignees lookup
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_tasks_assignees_task_member
ON tasks_assignees(task_id, team_member_id);
-- Index for phase lookup
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_task_phase_task_phase
ON task_phase(task_id, phase_id);
-- Index for subtask counting
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_tasks_parent_archived
ON tasks(parent_task_id, archived)
WHERE parent_task_id IS NOT NULL AND archived = FALSE;
-- Index for labels
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_task_labels_task_label
ON task_labels(task_id, label_id);
-- Index for comments count
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_task_comments_task
ON task_comments(task_id);
-- Index for attachments count
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_task_attachments_task
ON task_attachments(task_id);
-- Index for work log aggregation
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_task_work_log_task
ON task_work_log(task_id);
-- Index for subscribers check
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_task_subscribers_task
ON task_subscribers(task_id);
-- Index for dependencies check
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_task_dependencies_task
ON task_dependencies(task_id);
-- Index for timers lookup
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_task_timers_task_user
ON task_timers(task_id, user_id);
-- Index for custom columns
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_cc_column_values_task
ON cc_column_values(task_id);
-- Index for team member info view optimization
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_team_members_team_user
ON team_members(team_id, user_id)
WHERE active = TRUE;
-- Index for notification settings
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_notification_settings_user_team
ON notification_settings(user_id, team_id);
-- Index for task status categories
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_task_statuses_category
ON task_statuses(category_id, project_id);
-- Index for project phases
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_project_phases_project_sort
ON project_phases(project_id, sort_index);
-- Index for task priorities
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_task_priorities_value
ON task_priorities(value);
-- Index for team labels
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_team_labels_team
ON team_labels(team_id);
-- Advanced performance indexes for task optimization
-- Composite index for task main query optimization (covers most WHERE conditions)
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_tasks_performance_main
ON tasks(project_id, archived, parent_task_id, status_id, priority_id)
WHERE archived = FALSE;
-- Index for sorting by sort_order with project filter
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_tasks_project_sort_order
ON tasks(project_id, sort_order)
WHERE archived = FALSE;
-- Index for email_invitations to optimize team_member_info_view
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_email_invitations_team_member
ON email_invitations(team_member_id);
-- Covering index for task status with category information
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_task_statuses_covering
ON task_statuses(id, category_id, project_id);
-- Index for task aggregation queries (parent task progress calculation)
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_tasks_parent_status_archived
ON tasks(parent_task_id, status_id, archived)
WHERE archived = FALSE;
-- Index for project team member filtering
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_team_members_project_lookup
ON team_members(team_id, active, user_id)
WHERE active = TRUE;
-- Covering index for tasks with frequently accessed columns
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_tasks_covering_main
ON tasks(id, project_id, archived, parent_task_id, status_id, priority_id, sort_order, name)
WHERE archived = FALSE;
-- Index for task search functionality
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_tasks_name_search
ON tasks USING gin(to_tsvector('english', name))
WHERE archived = FALSE;
-- Index for date-based filtering (if used)
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_tasks_dates
ON tasks(project_id, start_date, end_date)
WHERE archived = FALSE;
-- Index for task timers with user filtering
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_task_timers_user_task
ON task_timers(user_id, task_id);
-- Index for sys_task_status_categories lookups
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_sys_task_status_categories_covering
ON sys_task_status_categories(id, color_code, color_code_dark, is_done, is_doing, is_todo);

View File

@@ -1,352 +0,0 @@
# 🌍 Holiday Calendar System
The Worklenz Holiday Calendar System provides comprehensive holiday management for organizations operating globally.
## 📋 Features
- **200+ Countries Supported** - Comprehensive holiday data for countries worldwide
- **Multiple Holiday Types** - Public, Company, Personal, and Religious holidays
- **Import Country Holidays** - Bulk import official holidays from any supported country
- **Manual Holiday Management** - Add, edit, and delete custom holidays
- **Recurring Holidays** - Support for annual recurring holidays
- **Visual Calendar** - Interactive calendar with color-coded holiday display
- **Dark/Light Mode** - Full theme support
## 🚀 Quick Start
### 1. Database Setup
Run the migration to create the holiday tables:
```bash
# Run the migration
psql -d your_database -f database/migrations/20250130000000-add-holiday-calendar.sql
```
### 2. Populate Country Holidays
Use the npm package to populate holidays for 200+ countries:
```bash
# Run the holiday population script
./scripts/run-holiday-population.sh
```
This will populate holidays for years 2020-2030 for all supported countries.
### 3. Access the Holiday Calendar
Navigate to **Admin Center → Overview** to access the holiday calendar.
## 🌐 Supported Countries
The system includes **200+ countries** across all continents:
### North America
- United States 🇺🇸
- Canada 🇨🇦
- Mexico 🇲🇽
### Europe
- United Kingdom 🇬🇧
- Germany 🇩🇪
- France 🇫🇷
- Italy 🇮🇹
- Spain 🇪🇸
- Netherlands 🇳🇱
- Belgium 🇧🇪
- Switzerland 🇨🇭
- Austria 🇦🇹
- Sweden 🇸🇪
- Norway 🇳🇴
- Denmark 🇩🇰
- Finland 🇫🇮
- Poland 🇵🇱
- Czech Republic 🇨🇿
- Hungary 🇭🇺
- Romania 🇷🇴
- Bulgaria 🇧🇬
- Croatia 🇭🇷
- Slovenia 🇸🇮
- Slovakia 🇸🇰
- Lithuania 🇱🇹
- Latvia 🇱🇻
- Estonia 🇪🇪
- Ireland 🇮🇪
- Portugal 🇵🇹
- Greece 🇬🇷
- Cyprus 🇨🇾
- Malta 🇲🇹
- Luxembourg 🇱🇺
- Iceland 🇮🇸
### Asia
- China 🇨🇳
- Japan 🇯🇵
- South Korea 🇰🇷
- India 🇮🇳
- Pakistan 🇵🇰
- Bangladesh 🇧🇩
- Sri Lanka 🇱🇰
- Nepal 🇳🇵
- Thailand 🇹🇭
- Vietnam 🇻🇳
- Malaysia 🇲🇾
- Singapore 🇸🇬
- Indonesia 🇮🇩
- Philippines 🇵🇭
- Myanmar 🇲🇲
- Cambodia 🇰🇭
- Laos 🇱🇦
- Brunei 🇧🇳
- Timor-Leste 🇹🇱
- Mongolia 🇲🇳
- Kazakhstan 🇰🇿
- Uzbekistan 🇺🇿
- Kyrgyzstan 🇰🇬
- Tajikistan 🇹🇯
- Turkmenistan 🇹🇲
- Afghanistan 🇦🇫
- Iran 🇮🇷
- Iraq 🇮🇶
- Saudi Arabia 🇸🇦
- UAE 🇦🇪
- Qatar 🇶🇦
- Kuwait 🇰🇼
- Bahrain 🇧🇭
- Oman 🇴🇲
- Yemen 🇾🇪
- Jordan 🇯🇴
- Lebanon 🇱🇧
- Syria 🇸🇾
- Israel 🇮🇱
- Palestine 🇵🇸
- Turkey 🇹🇷
- Georgia 🇬🇪
- Armenia 🇦🇲
- Azerbaijan 🇦🇿
### Oceania
- Australia 🇦🇺
- New Zealand 🇳🇿
- Fiji 🇫🇯
- Papua New Guinea 🇵🇬
- Solomon Islands 🇸🇧
- Vanuatu 🇻🇺
- New Caledonia 🇳🇨
- French Polynesia 🇵🇫
- Tonga 🇹🇴
- Samoa 🇼🇸
- Kiribati 🇰🇮
- Tuvalu 🇹🇻
- Nauru 🇳🇷
- Palau 🇵🇼
- Marshall Islands 🇲🇭
- Micronesia 🇫🇲
### Africa
- South Africa 🇿🇦
- Egypt 🇪🇬
- Nigeria 🇳🇬
- Kenya 🇰🇪
- Ethiopia 🇪🇹
- Tanzania 🇹🇿
- Uganda 🇺🇬
- Ghana 🇬🇭
- Ivory Coast 🇨🇮
- Senegal 🇸🇳
- Mali 🇲🇱
- Burkina Faso 🇧🇫
- Niger 🇳🇪
- Chad 🇹🇩
- Cameroon 🇨🇲
- Central African Republic 🇨🇫
- Republic of the Congo 🇨🇬
- Democratic Republic of the Congo 🇨🇩
- Gabon 🇬🇦
- Equatorial Guinea 🇬🇶
- São Tomé and Príncipe 🇸🇹
- Angola 🇦🇴
- Zambia 🇿🇲
- Zimbabwe 🇿🇼
- Botswana 🇧🇼
- Namibia 🇳🇦
- Lesotho 🇱🇸
- Eswatini 🇸🇿
- Madagascar 🇲🇬
- Mauritius 🇲🇺
- Seychelles 🇸🇨
- Comoros 🇰🇲
- Djibouti 🇩🇯
- Somalia 🇸🇴
- Eritrea 🇪🇷
- Sudan 🇸🇩
- South Sudan 🇸🇸
- Libya 🇱🇾
- Tunisia 🇹🇳
- Algeria 🇩🇿
- Morocco 🇲🇦
- Western Sahara 🇪🇭
- Mauritania 🇲🇷
- Gambia 🇬🇲
- Guinea-Bissau 🇬🇼
- Guinea 🇬🇳
- Sierra Leone 🇸🇱
- Liberia 🇱🇷
- Togo 🇹🇬
- Benin 🇧🇯
### South America
- Brazil 🇧🇷
- Argentina 🇦🇷
- Chile 🇨🇱
- Colombia 🇨🇴
- Peru 🇵🇪
- Venezuela 🇻🇪
- Ecuador 🇪🇨
- Bolivia 🇧🇴
- Paraguay 🇵🇾
- Uruguay 🇺🇾
- Guyana 🇬🇾
- Suriname 🇸🇷
- Falkland Islands 🇫🇰
- French Guiana 🇬🇫
### Central America & Caribbean
- Mexico 🇲🇽
- Guatemala 🇬🇹
- Belize 🇧🇿
- El Salvador 🇸🇻
- Honduras 🇭🇳
- Nicaragua 🇳🇮
- Costa Rica 🇨🇷
- Panama 🇵🇦
- Cuba 🇨🇺
- Jamaica 🇯🇲
- Haiti 🇭🇹
- Dominican Republic 🇩🇴
- Puerto Rico 🇵🇷
- Trinidad and Tobago 🇹🇹
- Barbados 🇧🇧
- Grenada 🇬🇩
- Saint Lucia 🇱🇨
- Saint Vincent and the Grenadines 🇻🇨
- Antigua and Barbuda 🇦🇬
- Saint Kitts and Nevis 🇰🇳
- Dominica 🇩🇲
- Bahamas 🇧🇸
- Turks and Caicos Islands 🇹🇨
- Cayman Islands 🇰🇾
- Bermuda 🇧🇲
- Anguilla 🇦🇮
- British Virgin Islands 🇻🇬
- U.S. Virgin Islands 🇻🇮
- Aruba 🇦🇼
- Curaçao 🇨🇼
- Sint Maarten 🇸🇽
- Saint Martin 🇲🇫
- Saint Barthélemy 🇧🇱
- Guadeloupe 🇬🇵
- Martinique 🇲🇶
## 🔧 API Endpoints
### Holiday Types
```http
GET /api/holidays/types
```
### Organization Holidays
```http
GET /api/holidays/organization?year=2024
POST /api/holidays/organization
PUT /api/holidays/organization/:id
DELETE /api/holidays/organization/:id
```
### Country Holidays
```http
GET /api/holidays/countries
GET /api/holidays/countries/:country_code?year=2024
POST /api/holidays/import
```
### Calendar View
```http
GET /api/holidays/calendar?year=2024&month=1
```
## 📊 Holiday Types
The system supports four types of holidays:
1. **Public Holiday** - Official government holidays (Red)
2. **Company Holiday** - Organization-specific holidays (Blue)
3. **Personal Holiday** - Personal or optional holidays (Green)
4. **Religious Holiday** - Religious observances (Yellow)
## 🎯 Usage Examples
### Import US Holidays
```javascript
const result = await holidayApiService.importCountryHolidays({
country_code: 'US',
year: 2024
});
```
### Add Custom Holiday
```javascript
const holiday = await holidayApiService.createOrganizationHoliday({
name: 'Company Retreat',
description: 'Annual team building event',
date: '2024-06-15',
holiday_type_id: 'company-holiday-id',
is_recurring: true
});
```
### Get Calendar View
```javascript
const calendar = await holidayApiService.getHolidayCalendar(2024, 1);
```
## 🔄 Data Sources
The holiday data is sourced from the `date-holidays` npm package, which provides:
- **Official government holidays** for 200+ countries
- **Religious holidays** (Christian, Islamic, Jewish, Hindu, Buddhist)
- **Cultural and traditional holidays**
- **Historical and commemorative days**
## 🛠️ Maintenance
### Adding New Countries
1. Add the country to the `countries` table
2. Update the `populate-holidays.js` script
3. Run the population script
### Updating Holiday Data
```bash
# Re-run the holiday population script
./scripts/run-holiday-population.sh
```
## 📝 Notes
- Holidays are stored for years 2020-2030 by default
- The system prevents duplicate holidays on the same date
- Imported holidays are automatically classified as "Public Holiday" type
- All holidays support recurring annual patterns
- The calendar view combines organization and country holidays
## 🎉 Benefits
- **Global Compliance** - Ensure compliance with local holiday regulations
- **Resource Planning** - Better project scheduling and resource allocation
- **Team Coordination** - Improved team communication and planning
- **Cost Management** - Accurate billing and time tracking
- **Cultural Awareness** - Respect for diverse cultural and religious practices

View File

@@ -0,0 +1,28 @@
module.exports = {
brotli_js: {
options: {
mode: "brotli",
brotli: {
mode: 1
}
},
expand: true,
cwd: "build/public",
src: ["**/*.js"],
dest: "build/public",
extDot: "last",
ext: ".js.br"
},
gzip_js: {
options: {
mode: "gzip"
},
files: [{
expand: true,
cwd: "build/public",
src: ["**/*.js"],
dest: "build/public",
ext: ".js.gz"
}]
}
};

File diff suppressed because it is too large Load Diff

View File

@@ -4,37 +4,23 @@
"private": true,
"engines": {
"npm": ">=8.11.0",
"node": ">=20.0.0",
"node": ">=16.13.0",
"yarn": "WARNING: Please use npm package manager instead of yarn"
},
"main": "build/bin/www",
"repository": "GITHUB_REPO_HERE",
"author": "worklenz.com",
"scripts": {
"test": "jest",
"start": "node build/bin/www.js",
"dev": "npm run build:dev && npm run watch",
"build": "npm run clean && npm run compile && npm run copy && npm run compress",
"build:dev": "npm run clean && npm run compile:dev && npm run copy",
"build:prod": "npm run clean && npm run compile:prod && npm run copy && npm run minify && npm run compress",
"clean": "rimraf build",
"compile": "tsc --build tsconfig.prod.json",
"compile:dev": "tsc --build tsconfig.json",
"compile:prod": "tsc --build tsconfig.prod.json",
"copy": "npm run copy:assets && npm run copy:views && npm run copy:config && npm run copy:shared",
"copy:assets": "npx cpx2 \"src/public/**\" build/public",
"copy:views": "npx cpx2 \"src/views/**\" build/views",
"copy:config": "npx cpx2 \".env\" build && npx cpx2 \"package.json\" build",
"copy:shared": "npx cpx2 \"src/shared/postgresql-error-codes.json\" build/shared && npx cpx2 \"src/shared/sample-data.json\" build/shared && npx cpx2 \"src/shared/templates/**\" build/shared/templates",
"watch": "concurrently \"npm run watch:ts\" \"npm run watch:assets\"",
"watch:ts": "tsc --build tsconfig.json --watch",
"watch:assets": "npx cpx2 \"src/{public,views}/**\" build --watch",
"minify": "terser build/**/*.js --compress --mangle --output-dir build",
"compress": "node scripts/compress.js",
"swagger": "node ./cli/swagger",
"inline-queries": "node ./cli/inline-queries",
"start": "node ./build/bin/www",
"tcs": "grunt build:tsc",
"build": "grunt build",
"watch": "grunt watch",
"dev": "grunt dev",
"es": "esbuild `find src -type f -name '*.ts'` --platform=node --minify=true --watch=true --target=esnext --format=cjs --tsconfig=tsconfig.prod.json --outdir=dist",
"copy": "grunt copy",
"sonar": "sonar-scanner -Dproject.settings=sonar-project-dev.properties",
"tsc": "tsc",
"test": "jest --setupFiles dotenv/config",
"test:watch": "jest --watch --setupFiles dotenv/config"
},
"jestSonar": {
@@ -59,9 +45,7 @@
"cors": "^2.8.5",
"cron": "^2.4.0",
"crypto-js": "^4.1.1",
"csrf-sync": "^4.2.1",
"csurf": "^1.11.0",
"date-holidays": "^3.24.4",
"debug": "^4.3.4",
"dotenv": "^16.3.1",
"exceljs": "^4.3.0",
@@ -86,6 +70,7 @@
"passport-local": "^1.0.0",
"path": "^0.12.7",
"pg": "^8.14.1",
"pg-native": "^3.3.0",
"pug": "^3.0.2",
"redis": "^4.6.7",
"sanitize-html": "^2.11.0",
@@ -93,10 +78,8 @@
"sharp": "^0.32.6",
"slugify": "^1.6.6",
"socket.io": "^4.7.1",
"tinymce": "^7.8.0",
"uglify-js": "^3.17.4",
"winston": "^3.10.0",
"worklenz-backend": "file:",
"xss-filters": "^1.2.7"
},
"devDependencies": {
@@ -104,17 +87,15 @@
"@babel/preset-typescript": "^7.22.5",
"@types/bcrypt": "^5.0.0",
"@types/bluebird": "^3.5.38",
"@types/body-parser": "^1.19.2",
"@types/compression": "^1.7.2",
"@types/connect-flash": "^0.0.37",
"@types/cookie-parser": "^1.4.3",
"@types/cron": "^2.0.1",
"@types/crypto-js": "^4.2.2",
"@types/csurf": "^1.11.2",
"@types/express": "^4.17.21",
"@types/express": "^4.17.17",
"@types/express-brute": "^1.0.2",
"@types/express-brute-redis": "^0.0.4",
"@types/express-serve-static-core": "^4.17.34",
"@types/express-session": "^1.17.7",
"@types/fs-extra": "^9.0.13",
"@types/hpp": "^0.2.2",
@@ -139,22 +120,26 @@
"@typescript-eslint/eslint-plugin": "^5.62.0",
"@typescript-eslint/parser": "^5.62.0",
"chokidar": "^3.5.3",
"concurrently": "^9.1.2",
"cpx2": "^8.0.0",
"esbuild": "^0.17.19",
"esbuild-envfile-plugin": "^1.0.5",
"esbuild-node-externals": "^1.8.0",
"eslint": "^8.45.0",
"eslint-plugin-security": "^1.7.1",
"fs-extra": "^10.1.0",
"grunt": "^1.6.1",
"grunt-contrib-clean": "^2.0.1",
"grunt-contrib-compress": "^2.0.0",
"grunt-contrib-copy": "^1.0.0",
"grunt-contrib-uglify": "^5.2.2",
"grunt-contrib-watch": "^1.1.0",
"grunt-shell": "^4.0.0",
"grunt-sync": "^0.8.2",
"highcharts": "^11.1.0",
"jest": "^28.1.3",
"jest-sonar-reporter": "^2.0.0",
"ncp": "^2.0.0",
"nodeman": "^1.1.2",
"rimraf": "^6.0.1",
"swagger-jsdoc": "^6.2.8",
"terser": "^5.40.0",
"ts-jest": "^28.0.8",
"ts-node": "^10.9.1",
"tslint": "^6.1.3",

View File

@@ -1,53 +0,0 @@
const fs = require('fs');
const path = require('path');
const { createGzip } = require('zlib');
const { pipeline } = require('stream');
async function compressFile(inputPath, outputPath) {
return new Promise((resolve, reject) => {
const gzip = createGzip();
const source = fs.createReadStream(inputPath);
const destination = fs.createWriteStream(outputPath);
pipeline(source, gzip, destination, (err) => {
if (err) {
reject(err);
} else {
resolve();
}
});
});
}
async function compressDirectory(dir) {
const files = fs.readdirSync(dir, { withFileTypes: true });
for (const file of files) {
const fullPath = path.join(dir, file.name);
if (file.isDirectory()) {
await compressDirectory(fullPath);
} else if (file.name.endsWith('.js') || file.name.endsWith('.css')) {
const gzPath = fullPath + '.gz';
await compressFile(fullPath, gzPath);
console.log(`Compressed: ${fullPath} -> ${gzPath}`);
}
}
}
async function main() {
try {
const buildDir = path.join(__dirname, '../build');
if (fs.existsSync(buildDir)) {
await compressDirectory(buildDir);
console.log('Compression complete!');
} else {
console.log('Build directory not found. Run build first.');
}
} catch (error) {
console.error('Compression failed:', error);
process.exit(1);
}
}
main();

View File

@@ -1,265 +0,0 @@
const Holidays = require("date-holidays");
const { Pool } = require("pg");
const config = require("../build/config/db-config").default;
// Database connection
const pool = new Pool(config);
// Countries to populate with holidays
const countries = [
{ code: "US", name: "United States" },
{ code: "GB", name: "United Kingdom" },
{ code: "CA", name: "Canada" },
{ code: "AU", name: "Australia" },
{ code: "DE", name: "Germany" },
{ code: "FR", name: "France" },
{ code: "IT", name: "Italy" },
{ code: "ES", name: "Spain" },
{ code: "NL", name: "Netherlands" },
{ code: "BE", name: "Belgium" },
{ code: "CH", name: "Switzerland" },
{ code: "AT", name: "Austria" },
{ code: "SE", name: "Sweden" },
{ code: "NO", name: "Norway" },
{ code: "DK", name: "Denmark" },
{ code: "FI", name: "Finland" },
{ code: "PL", name: "Poland" },
{ code: "CZ", name: "Czech Republic" },
{ code: "HU", name: "Hungary" },
{ code: "RO", name: "Romania" },
{ code: "BG", name: "Bulgaria" },
{ code: "HR", name: "Croatia" },
{ code: "SI", name: "Slovenia" },
{ code: "SK", name: "Slovakia" },
{ code: "LT", name: "Lithuania" },
{ code: "LV", name: "Latvia" },
{ code: "EE", name: "Estonia" },
{ code: "IE", name: "Ireland" },
{ code: "PT", name: "Portugal" },
{ code: "GR", name: "Greece" },
{ code: "CY", name: "Cyprus" },
{ code: "MT", name: "Malta" },
{ code: "LU", name: "Luxembourg" },
{ code: "IS", name: "Iceland" },
{ code: "CN", name: "China" },
{ code: "JP", name: "Japan" },
{ code: "KR", name: "South Korea" },
{ code: "IN", name: "India" },
{ code: "PK", name: "Pakistan" },
{ code: "BD", name: "Bangladesh" },
{ code: "LK", name: "Sri Lanka" },
{ code: "NP", name: "Nepal" },
{ code: "TH", name: "Thailand" },
{ code: "VN", name: "Vietnam" },
{ code: "MY", name: "Malaysia" },
{ code: "SG", name: "Singapore" },
{ code: "ID", name: "Indonesia" },
{ code: "PH", name: "Philippines" },
{ code: "MM", name: "Myanmar" },
{ code: "KH", name: "Cambodia" },
{ code: "LA", name: "Laos" },
{ code: "BN", name: "Brunei" },
{ code: "TL", name: "Timor-Leste" },
{ code: "MN", name: "Mongolia" },
{ code: "KZ", name: "Kazakhstan" },
{ code: "UZ", name: "Uzbekistan" },
{ code: "KG", name: "Kyrgyzstan" },
{ code: "TJ", name: "Tajikistan" },
{ code: "TM", name: "Turkmenistan" },
{ code: "AF", name: "Afghanistan" },
{ code: "IR", name: "Iran" },
{ code: "IQ", name: "Iraq" },
{ code: "SA", name: "Saudi Arabia" },
{ code: "AE", name: "United Arab Emirates" },
{ code: "QA", name: "Qatar" },
{ code: "KW", name: "Kuwait" },
{ code: "BH", name: "Bahrain" },
{ code: "OM", name: "Oman" },
{ code: "YE", name: "Yemen" },
{ code: "JO", name: "Jordan" },
{ code: "LB", name: "Lebanon" },
{ code: "SY", name: "Syria" },
{ code: "IL", name: "Israel" },
{ code: "PS", name: "Palestine" },
{ code: "TR", name: "Turkey" },
{ code: "GE", name: "Georgia" },
{ code: "AM", name: "Armenia" },
{ code: "AZ", name: "Azerbaijan" },
{ code: "NZ", name: "New Zealand" },
{ code: "FJ", name: "Fiji" },
{ code: "PG", name: "Papua New Guinea" },
{ code: "SB", name: "Solomon Islands" },
{ code: "VU", name: "Vanuatu" },
{ code: "NC", name: "New Caledonia" },
{ code: "PF", name: "French Polynesia" },
{ code: "TO", name: "Tonga" },
{ code: "WS", name: "Samoa" },
{ code: "KI", name: "Kiribati" },
{ code: "TV", name: "Tuvalu" },
{ code: "NR", name: "Nauru" },
{ code: "PW", name: "Palau" },
{ code: "MH", name: "Marshall Islands" },
{ code: "FM", name: "Micronesia" },
{ code: "ZA", name: "South Africa" },
{ code: "EG", name: "Egypt" },
{ code: "NG", name: "Nigeria" },
{ code: "KE", name: "Kenya" },
{ code: "ET", name: "Ethiopia" },
{ code: "TZ", name: "Tanzania" },
{ code: "UG", name: "Uganda" },
{ code: "GH", name: "Ghana" },
{ code: "CI", name: "Ivory Coast" },
{ code: "SN", name: "Senegal" },
{ code: "ML", name: "Mali" },
{ code: "BF", name: "Burkina Faso" },
{ code: "NE", name: "Niger" },
{ code: "TD", name: "Chad" },
{ code: "CM", name: "Cameroon" },
{ code: "CF", name: "Central African Republic" },
{ code: "CG", name: "Republic of the Congo" },
{ code: "CD", name: "Democratic Republic of the Congo" },
{ code: "GA", name: "Gabon" },
{ code: "GQ", name: "Equatorial Guinea" },
{ code: "ST", name: "São Tomé and Príncipe" },
{ code: "AO", name: "Angola" },
{ code: "ZM", name: "Zambia" },
{ code: "ZW", name: "Zimbabwe" },
{ code: "BW", name: "Botswana" },
{ code: "NA", name: "Namibia" },
{ code: "LS", name: "Lesotho" },
{ code: "SZ", name: "Eswatini" },
{ code: "MG", name: "Madagascar" },
{ code: "MU", name: "Mauritius" },
{ code: "SC", name: "Seychelles" },
{ code: "KM", name: "Comoros" },
{ code: "DJ", name: "Djibouti" },
{ code: "SO", name: "Somalia" },
{ code: "ER", name: "Eritrea" },
{ code: "SD", name: "Sudan" },
{ code: "SS", name: "South Sudan" },
{ code: "LY", name: "Libya" },
{ code: "TN", name: "Tunisia" },
{ code: "DZ", name: "Algeria" },
{ code: "MA", name: "Morocco" },
{ code: "EH", name: "Western Sahara" },
{ code: "MR", name: "Mauritania" },
{ code: "GM", name: "Gambia" },
{ code: "GW", name: "Guinea-Bissau" },
{ code: "GN", name: "Guinea" },
{ code: "SL", name: "Sierra Leone" },
{ code: "LR", name: "Liberia" },
{ code: "TG", name: "Togo" },
{ code: "BJ", name: "Benin" },
{ code: "BR", name: "Brazil" },
{ code: "AR", name: "Argentina" },
{ code: "CL", name: "Chile" },
{ code: "CO", name: "Colombia" },
{ code: "PE", name: "Peru" },
{ code: "VE", name: "Venezuela" },
{ code: "EC", name: "Ecuador" },
{ code: "BO", name: "Bolivia" },
{ code: "PY", name: "Paraguay" },
{ code: "UY", name: "Uruguay" },
{ code: "GY", name: "Guyana" },
{ code: "SR", name: "Suriname" },
{ code: "FK", name: "Falkland Islands" },
{ code: "GF", name: "French Guiana" },
{ code: "MX", name: "Mexico" },
{ code: "GT", name: "Guatemala" },
{ code: "BZ", name: "Belize" },
{ code: "SV", name: "El Salvador" },
{ code: "HN", name: "Honduras" },
{ code: "NI", name: "Nicaragua" },
{ code: "CR", name: "Costa Rica" },
{ code: "PA", name: "Panama" },
{ code: "CU", name: "Cuba" },
{ code: "JM", name: "Jamaica" },
{ code: "HT", name: "Haiti" },
{ code: "DO", name: "Dominican Republic" },
{ code: "PR", name: "Puerto Rico" },
{ code: "TT", name: "Trinidad and Tobago" },
{ code: "BB", name: "Barbados" },
{ code: "GD", name: "Grenada" },
{ code: "LC", name: "Saint Lucia" },
{ code: "VC", name: "Saint Vincent and the Grenadines" },
{ code: "AG", name: "Antigua and Barbuda" },
{ code: "KN", name: "Saint Kitts and Nevis" },
{ code: "DM", name: "Dominica" },
{ code: "BS", name: "Bahamas" },
{ code: "TC", name: "Turks and Caicos Islands" },
{ code: "KY", name: "Cayman Islands" },
{ code: "BM", name: "Bermuda" },
{ code: "AI", name: "Anguilla" },
{ code: "VG", name: "British Virgin Islands" },
{ code: "VI", name: "U.S. Virgin Islands" },
{ code: "AW", name: "Aruba" },
{ code: "CW", name: "Curaçao" },
{ code: "SX", name: "Sint Maarten" },
{ code: "MF", name: "Saint Martin" },
{ code: "BL", name: "Saint Barthélemy" },
{ code: "GP", name: "Guadeloupe" },
{ code: "MQ", name: "Martinique" }
];
async function populateHolidays() {
const client = await pool.connect();
try {
console.log("Starting holiday population...");
for (const country of countries) {
console.log(`Processing ${country.name} (${country.code})...`);
try {
const hd = new Holidays(country.code);
// Get holidays for multiple years (2020-2030)
for (let year = 2020; year <= 2030; year++) {
const holidays = hd.getHolidays(year);
for (const holiday of holidays) {
// Skip if holiday is not a date object
if (!holiday.date || typeof holiday.date !== "object") {
continue;
}
const dateStr = holiday.date.toISOString().split("T")[0];
const name = holiday.name || "Unknown Holiday";
const description = holiday.type || "Public Holiday";
// Insert holiday into database
const query = `
INSERT INTO country_holidays (country_code, name, description, date, is_recurring)
VALUES ($1, $2, $3, $4, $5)
ON CONFLICT (country_code, name, date) DO NOTHING
`;
await client.query(query, [
country.code,
name,
description,
dateStr,
true // Most holidays are recurring
]);
}
}
console.log(`✓ Completed ${country.name}`);
} catch (error) {
console.log(`✗ Error processing ${country.name}: ${error.message}`);
}
}
console.log("Holiday population completed!");
} catch (error) {
console.error("Database error:", error);
} finally {
client.release();
await pool.end();
}
}
// Run the script
populateHolidays().catch(console.error);

View File

@@ -1,25 +0,0 @@
#!/bin/bash
echo "🌍 Starting Holiday Population Script..."
echo "This will populate the database with holidays for 200+ countries using the date-holidays npm package."
echo ""
# Check if Node.js is installed
if ! command -v node &> /dev/null; then
echo "❌ Node.js is not installed. Please install Node.js first."
exit 1
fi
# Check if the script exists
if [ ! -f "scripts/populate-holidays.js" ]; then
echo "❌ Holiday population script not found."
exit 1
fi
# Run the holiday population script
echo "🚀 Running holiday population script..."
node scripts/populate-holidays.js
echo ""
echo "✅ Holiday population completed!"
echo "You can now use the holiday import feature in the admin center."

View File

@@ -6,7 +6,7 @@ import logger from "morgan";
import helmet from "helmet";
import compression from "compression";
import passport from "passport";
import { csrfSync } from "csrf-sync";
import csurf from "csurf";
import rateLimit from "express-rate-limit";
import cors from "cors";
import flash from "connect-flash";
@@ -112,13 +112,17 @@ function isLoggedIn(req: Request, _res: Response, next: NextFunction) {
return req.user ? next() : next(createError(401));
}
// CSRF configuration using csrf-sync for session-based authentication
const {
invalidCsrfTokenError,
generateToken,
csrfSynchronisedProtection,
} = csrfSync({
getTokenFromRequest: (req: Request) => req.headers["x-csrf-token"] as string || (req.body && req.body["_csrf"])
// CSRF configuration
const csrfProtection = csurf({
cookie: {
key: "XSRF-TOKEN",
path: "/",
httpOnly: false,
secure: isProduction(), // Only secure in production
sameSite: isProduction() ? "none" : "lax", // Different settings for dev vs prod
domain: isProduction() ? ".worklenz.com" : undefined // Only set domain in production
},
ignoreMethods: ["HEAD", "OPTIONS"]
});
// Apply CSRF selectively (exclude webhooks and public routes)
@@ -131,25 +135,38 @@ app.use((req, res, next) => {
) {
next();
} else {
csrfSynchronisedProtection(req, res, next);
csrfProtection(req, res, next);
}
});
// Set CSRF token method on request object for compatibility
// Set CSRF token cookie
app.use((req: Request, res: Response, next: NextFunction) => {
// Add csrfToken method to request object for compatibility
if (!req.csrfToken && generateToken) {
req.csrfToken = (overwrite?: boolean) => generateToken(req, overwrite);
if (req.csrfToken) {
const token = req.csrfToken();
res.cookie("XSRF-TOKEN", token, {
httpOnly: false,
secure: isProduction(),
sameSite: isProduction() ? "none" : "lax",
domain: isProduction() ? ".worklenz.com" : undefined,
path: "/"
});
}
next();
});
// CSRF token refresh endpoint
app.get("/csrf-token", (req: Request, res: Response) => {
try {
const token = generateToken(req);
res.status(200).json({ done: true, message: "CSRF token refreshed", token });
} catch (error) {
if (req.csrfToken) {
const token = req.csrfToken();
res.cookie("XSRF-TOKEN", token, {
httpOnly: false,
secure: isProduction(),
sameSite: isProduction() ? "none" : "lax",
domain: isProduction() ? ".worklenz.com" : undefined,
path: "/"
});
res.status(200).json({ done: true, message: "CSRF token refreshed" });
} else {
res.status(500).json({ done: false, message: "Failed to generate CSRF token" });
}
});
@@ -202,7 +219,7 @@ if (isInternalServer()) {
// CSRF error handler
app.use((err: any, req: Request, res: Response, next: NextFunction) => {
if (err === invalidCsrfTokenError) {
if (err.code === "EBADCSRFTOKEN") {
return res.status(403).json({
done: false,
message: "Invalid CSRF token",

File diff suppressed because it is too large Load Diff

View File

@@ -35,18 +35,8 @@ export default class AuthController extends WorklenzControllerBase {
const auth_error = errors.length > 0 ? errors[0] : null;
const message = messages.length > 0 ? messages[0] : null;
// Determine title based on authentication status and strategy
let title = null;
if (req.query.strategy) {
if (auth_error) {
// Show failure title only when there's an actual error
title = req.query.strategy === "login" ? "Login Failed!" : "Signup Failed!";
} else if (req.isAuthenticated() && message) {
// Show success title when authenticated and there's a success message
title = req.query.strategy === "login" ? "Login Successful!" : "Signup Successful!";
}
// If no error and not authenticated, don't show any title (this might be a redirect without completion)
}
const midTitle = req.query.strategy === "login" ? "Login Failed!" : "Signup Failed!";
const title = req.query.strategy ? midTitle : null;
if (req.user)
req.user.build_v = FileConstants.getRelease();

View File

@@ -1,416 +0,0 @@
import { IWorkLenzRequest } from "../interfaces/worklenz-request";
import { IWorkLenzResponse } from "../interfaces/worklenz-response";
import db from "../config/db";
import { ServerResponse } from "../models/server-response";
import WorklenzControllerBase from "./worklenz-controller-base";
import HandleExceptions from "../decorators/handle-exceptions";
import {
ICreateHolidayRequest,
IUpdateHolidayRequest,
IImportCountryHolidaysRequest,
} from "../interfaces/holiday.interface";
export default class HolidayController extends WorklenzControllerBase {
@HandleExceptions()
public static async getHolidayTypes(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
const q = `SELECT id, name, description, color_code, created_at, updated_at
FROM holiday_types
ORDER BY name;`;
const result = await db.query(q);
return res.status(200).send(new ServerResponse(true, result.rows));
}
@HandleExceptions()
public static async getOrganizationHolidays(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
const { year } = req.query;
const yearFilter = year ? `AND EXTRACT(YEAR FROM date) = $2` : "";
const params = year ? [req.user?.owner_id, year] : [req.user?.owner_id];
const q = `SELECT oh.id, oh.organization_id, oh.holiday_type_id, oh.name, oh.description,
oh.date, oh.is_recurring, oh.created_at, oh.updated_at,
ht.name as holiday_type_name, ht.color_code
FROM organization_holidays oh
JOIN holiday_types ht ON oh.holiday_type_id = ht.id
WHERE oh.organization_id = (
SELECT id FROM organizations WHERE user_id = $1
) ${yearFilter}
ORDER BY oh.date;`;
const result = await db.query(q, params);
return res.status(200).send(new ServerResponse(true, result.rows));
}
@HandleExceptions()
public static async createOrganizationHoliday(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
const { name, description, date, holiday_type_id, is_recurring = false }: ICreateHolidayRequest = req.body;
const q = `INSERT INTO organization_holidays (organization_id, holiday_type_id, name, description, date, is_recurring)
VALUES (
(SELECT id FROM organizations WHERE user_id = $1),
$2, $3, $4, $5, $6
)
RETURNING id;`;
const result = await db.query(q, [req.user?.owner_id, holiday_type_id, name, description, date, is_recurring]);
return res.status(201).send(new ServerResponse(true, result.rows[0]));
}
@HandleExceptions()
public static async updateOrganizationHoliday(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
const { id } = req.params;
const { name, description, date, holiday_type_id, is_recurring }: IUpdateHolidayRequest = req.body;
const updateFields = [];
const values = [req.user?.owner_id, id];
let paramIndex = 3;
if (name !== undefined) {
updateFields.push(`name = $${paramIndex++}`);
values.push(name);
}
if (description !== undefined) {
updateFields.push(`description = $${paramIndex++}`);
values.push(description);
}
if (date !== undefined) {
updateFields.push(`date = $${paramIndex++}`);
values.push(date);
}
if (holiday_type_id !== undefined) {
updateFields.push(`holiday_type_id = $${paramIndex++}`);
values.push(holiday_type_id);
}
if (is_recurring !== undefined) {
updateFields.push(`is_recurring = $${paramIndex++}`);
values.push(is_recurring.toString());
}
if (updateFields.length === 0) {
return res.status(400).send(new ServerResponse(false, "No fields to update"));
}
const q = `UPDATE organization_holidays
SET ${updateFields.join(", ")}, updated_at = CURRENT_TIMESTAMP
WHERE id = $2 AND organization_id = (
SELECT id FROM organizations WHERE user_id = $1
)
RETURNING id;`;
const result = await db.query(q, values);
if (result.rows.length === 0) {
return res.status(404).send(new ServerResponse(false, "Holiday not found"));
}
return res.status(200).send(new ServerResponse(true, result.rows[0]));
}
@HandleExceptions()
public static async deleteOrganizationHoliday(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
const { id } = req.params;
const q = `DELETE FROM organization_holidays
WHERE id = $2 AND organization_id = (
SELECT id FROM organizations WHERE user_id = $1
)
RETURNING id;`;
const result = await db.query(q, [req.user?.owner_id, id]);
if (result.rows.length === 0) {
return res.status(404).send(new ServerResponse(false, "Holiday not found"));
}
return res.status(200).send(new ServerResponse(true, { message: "Holiday deleted successfully" }));
}
@HandleExceptions()
public static async getCountryHolidays(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
const { country_code, year } = req.query;
if (!country_code) {
return res.status(400).send(new ServerResponse(false, "Country code is required"));
}
const yearFilter = year ? `AND EXTRACT(YEAR FROM date) = $2` : "";
const params = year ? [country_code, year] : [country_code];
const q = `SELECT id, country_code, name, description, date, is_recurring, created_at, updated_at
FROM country_holidays
WHERE country_code = $1 ${yearFilter}
ORDER BY date;`;
const result = await db.query(q, params);
return res.status(200).send(new ServerResponse(true, result.rows));
}
@HandleExceptions()
public static async getAvailableCountries(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
const q = `SELECT DISTINCT c.code, c.name
FROM countries c
JOIN country_holidays ch ON c.code = ch.country_code
ORDER BY c.name;`;
const result = await db.query(q);
return res.status(200).send(new ServerResponse(true, result.rows));
}
@HandleExceptions()
public static async importCountryHolidays(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
const { country_code, year }: IImportCountryHolidaysRequest = req.body;
if (!country_code) {
return res.status(400).send(new ServerResponse(false, "Country code is required"));
}
// Get organization ID
const orgQ = `SELECT id FROM organizations WHERE user_id = $1`;
const orgResult = await db.query(orgQ, [req.user?.owner_id]);
const organizationId = orgResult.rows[0]?.id;
if (!organizationId) {
return res.status(404).send(new ServerResponse(false, "Organization not found"));
}
// Get default holiday type (Public Holiday)
const typeQ = `SELECT id FROM holiday_types WHERE name = 'Public Holiday' LIMIT 1`;
const typeResult = await db.query(typeQ);
const holidayTypeId = typeResult.rows[0]?.id;
if (!holidayTypeId) {
return res.status(404).send(new ServerResponse(false, "Default holiday type not found"));
}
// Get country holidays for the specified year
const yearFilter = year ? `AND EXTRACT(YEAR FROM date) = $2` : "";
const params = year ? [country_code, year] : [country_code];
const holidaysQ = `SELECT name, description, date, is_recurring
FROM country_holidays
WHERE country_code = $1 ${yearFilter}`;
const holidaysResult = await db.query(holidaysQ, params);
if (holidaysResult.rows.length === 0) {
return res.status(404).send(new ServerResponse(false, "No holidays found for this country and year"));
}
// Import holidays to organization
const importQ = `INSERT INTO organization_holidays (organization_id, holiday_type_id, name, description, date, is_recurring)
VALUES ($1, $2, $3, $4, $5, $6)
ON CONFLICT (organization_id, date) DO NOTHING`;
let importedCount = 0;
for (const holiday of holidaysResult.rows) {
try {
await db.query(importQ, [
organizationId,
holidayTypeId,
holiday.name,
holiday.description,
holiday.date,
holiday.is_recurring
]);
importedCount++;
} catch (error) {
// Skip duplicates
continue;
}
}
return res.status(200).send(new ServerResponse(true, {
message: `Successfully imported ${importedCount} holidays`,
imported_count: importedCount
}));
}
@HandleExceptions()
public static async getHolidayCalendar(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
const { year, month } = req.query;
if (!year || !month) {
return res.status(400).send(new ServerResponse(false, "Year and month are required"));
}
const q = `SELECT oh.id, oh.name, oh.description, oh.date, oh.is_recurring,
ht.name as holiday_type_name, ht.color_code,
'organization' as source
FROM organization_holidays oh
JOIN holiday_types ht ON oh.holiday_type_id = ht.id
WHERE oh.organization_id = (
SELECT id FROM organizations WHERE user_id = $1
)
AND EXTRACT(YEAR FROM oh.date) = $2
AND EXTRACT(MONTH FROM oh.date) = $3
UNION ALL
SELECT ch.id, ch.name, ch.description, ch.date, ch.is_recurring,
'Public Holiday' as holiday_type_name, '#f37070' as color_code,
'country' as source
FROM country_holidays ch
JOIN organizations o ON ch.country_code = (
SELECT c.code FROM countries c WHERE c.id = o.country
)
WHERE o.user_id = $1
AND EXTRACT(YEAR FROM ch.date) = $2
AND EXTRACT(MONTH FROM ch.date) = $3
ORDER BY date;`;
const result = await db.query(q, [req.user?.owner_id, year, month]);
return res.status(200).send(new ServerResponse(true, result.rows));
}
@HandleExceptions()
public static async populateCountryHolidays(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
// Check if this organization has recently populated holidays (within last hour)
const recentPopulationCheck = `
SELECT COUNT(*) as count
FROM organization_holidays
WHERE organization_id = (SELECT id FROM organizations WHERE user_id = $1)
AND created_at > NOW() - INTERVAL '1 hour'
`;
const recentResult = await db.query(recentPopulationCheck, [req.user?.owner_id]);
const recentCount = parseInt(recentResult.rows[0]?.count || '0');
// If there are recent holidays added, skip population
if (recentCount > 10) {
return res.status(200).send(new ServerResponse(true, {
success: true,
message: "Holidays were recently populated, skipping to avoid duplicates",
total_populated: 0,
recently_populated: true
}));
}
const Holidays = require("date-holidays");
const countries = [
{ code: "US", name: "United States" },
{ code: "GB", name: "United Kingdom" },
{ code: "CA", name: "Canada" },
{ code: "AU", name: "Australia" },
{ code: "DE", name: "Germany" },
{ code: "FR", name: "France" },
{ code: "IT", name: "Italy" },
{ code: "ES", name: "Spain" },
{ code: "NL", name: "Netherlands" },
{ code: "BE", name: "Belgium" },
{ code: "CH", name: "Switzerland" },
{ code: "AT", name: "Austria" },
{ code: "SE", name: "Sweden" },
{ code: "NO", name: "Norway" },
{ code: "DK", name: "Denmark" },
{ code: "FI", name: "Finland" },
{ code: "PL", name: "Poland" },
{ code: "CZ", name: "Czech Republic" },
{ code: "HU", name: "Hungary" },
{ code: "RO", name: "Romania" },
{ code: "BG", name: "Bulgaria" },
{ code: "HR", name: "Croatia" },
{ code: "SI", name: "Slovenia" },
{ code: "SK", name: "Slovakia" },
{ code: "LT", name: "Lithuania" },
{ code: "LV", name: "Latvia" },
{ code: "EE", name: "Estonia" },
{ code: "IE", name: "Ireland" },
{ code: "PT", name: "Portugal" },
{ code: "GR", name: "Greece" },
{ code: "CY", name: "Cyprus" },
{ code: "MT", name: "Malta" },
{ code: "LU", name: "Luxembourg" },
{ code: "IS", name: "Iceland" },
{ code: "CN", name: "China" },
{ code: "JP", name: "Japan" },
{ code: "KR", name: "South Korea" },
{ code: "IN", name: "India" },
{ code: "BR", name: "Brazil" },
{ code: "AR", name: "Argentina" },
{ code: "MX", name: "Mexico" },
{ code: "ZA", name: "South Africa" },
{ code: "NZ", name: "New Zealand" },
{ code: "LK", name: "Sri Lanka" }
];
let totalPopulated = 0;
const errors = [];
for (const country of countries) {
try {
// Special handling for Sri Lanka
if (country.code === 'LK') {
// Import the holiday data provider
const { HolidayDataProvider } = require("../services/holiday-data-provider");
for (let year = 2020; year <= 2050; year++) {
const sriLankanHolidays = await HolidayDataProvider.getSriLankanHolidays(year);
for (const holiday of sriLankanHolidays) {
const query = `
INSERT INTO country_holidays (country_code, name, description, date, is_recurring)
VALUES ($1, $2, $3, $4, $5)
ON CONFLICT (country_code, name, date) DO NOTHING
`;
await db.query(query, [
'LK',
holiday.name,
holiday.description,
holiday.date,
holiday.is_recurring
]);
totalPopulated++;
}
}
} else {
// Use date-holidays for other countries
const hd = new Holidays(country.code);
for (let year = 2020; year <= 2050; year++) {
const holidays = hd.getHolidays(year);
for (const holiday of holidays) {
if (!holiday.date || typeof holiday.date !== "object") {
continue;
}
const dateStr = holiday.date.toISOString().split("T")[0];
const name = holiday.name || "Unknown Holiday";
const description = holiday.type || "Public Holiday";
const query = `
INSERT INTO country_holidays (country_code, name, description, date, is_recurring)
VALUES ($1, $2, $3, $4, $5)
ON CONFLICT (country_code, name, date) DO NOTHING
`;
await db.query(query, [
country.code,
name,
description,
dateStr,
true
]);
totalPopulated++;
}
}
}
} catch (error: any) {
errors.push(`${country.name}: ${error?.message || "Unknown error"}`);
}
}
const response = {
success: true,
message: `Successfully populated ${totalPopulated} holidays`,
total_populated: totalPopulated,
errors: errors.length > 0 ? errors : undefined
};
return res.status(200).send(new ServerResponse(true, response));
}
}

View File

@@ -137,10 +137,6 @@ export default class HomePageController extends WorklenzControllerBase {
WHERE category_id NOT IN (SELECT id
FROM sys_task_status_categories
WHERE is_done IS FALSE))
AND NOT EXISTS(SELECT project_id
FROM archived_projects
WHERE project_id = p.id
AND user_id = $2)
${groupByClosure}
ORDER BY t.end_date ASC`;
@@ -162,13 +158,9 @@ export default class HomePageController extends WorklenzControllerBase {
WHERE category_id NOT IN (SELECT id
FROM sys_task_status_categories
WHERE is_done IS FALSE))
AND NOT EXISTS(SELECT project_id
FROM archived_projects
WHERE project_id = p.id
AND user_id = $3)
${groupByClosure}`;
const result = await db.query(q, [teamId, userId, userId]);
const result = await db.query(q, [teamId, userId]);
const [row] = result.rows;
return row;
}

File diff suppressed because it is too large Load Diff

View File

@@ -322,7 +322,7 @@ export default class ProjectInsightsController extends WorklenzControllerBase {
(SELECT get_task_assignees(tasks.id)) AS assignees
FROM tasks
JOIN work_log ON work_log.task_id = tasks.id
WHERE project_id = $1 AND total_minutes <> 0 AND (total_minutes * 60) <> work_log.total_time_spent
WHERE project_id = $1
AND CASE
WHEN ($2 IS TRUE) THEN project_id IS NOT NULL
ELSE archived IS FALSE END

View File

@@ -9,7 +9,7 @@ import {getColor} from "../shared/utils";
import TeamMembersController from "./team-members-controller";
import {checkTeamSubscriptionStatus} from "../shared/paddle-utils";
import {updateUsers} from "../shared/paddle-requests";
import {statusExclude, TRIAL_MEMBER_LIMIT} from "../shared/constants";
import {statusExclude} from "../shared/constants";
import {NotificationsService} from "../services/notifications/notifications.service";
export default class ProjectMembersController extends WorklenzControllerBase {
@@ -118,17 +118,6 @@ export default class ProjectMembersController extends WorklenzControllerBase {
return res.status(200).send(new ServerResponse(false, null, "Maximum number of life time users reached."));
}
/**
* Checks trial user team member limit
*/
if (subscriptionData.subscription_status === "trialing") {
const currentTrialMembers = parseInt(subscriptionData.current_count) || 0;
if (currentTrialMembers + 1 > TRIAL_MEMBER_LIMIT) {
return res.status(200).send(new ServerResponse(false, null, `Trial users cannot exceed ${TRIAL_MEMBER_LIMIT} team members. Please upgrade to add more members.`));
}
}
// if (subscriptionData.status === "trialing") break;
if (!userExists && !subscriptionData.is_credit && !subscriptionData.is_custom && subscriptionData.subscription_status !== "trialing") {
// if (subscriptionData.subscription_status === "active") {

View File

@@ -1,292 +0,0 @@
import db from "../config/db";
import { IWorkLenzRequest } from "../interfaces/worklenz-request";
import { IWorkLenzResponse } from "../interfaces/worklenz-response";
import { ServerResponse } from "../models/server-response";
import HandleExceptions from "../decorators/handle-exceptions";
import WorklenzControllerBase from "./worklenz-controller-base";
export default class ProjectRateCardController extends WorklenzControllerBase {
// Insert a single role for a project
@HandleExceptions()
public static async createOne(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
const { project_id, job_title_id, rate, man_day_rate } = req.body;
if (!project_id || !job_title_id || typeof rate !== "number") {
return res.status(400).send(new ServerResponse(false, null, "Invalid input"));
}
// Handle both rate and man_day_rate fields
const columns = ["project_id", "job_title_id", "rate"];
const values = [project_id, job_title_id, rate];
if (typeof man_day_rate !== "undefined") {
columns.push("man_day_rate");
values.push(man_day_rate);
}
const q = `
INSERT INTO finance_project_rate_card_roles (${columns.join(", ")})
VALUES (${values.map((_, i) => `$${i + 1}`).join(", ")})
ON CONFLICT (project_id, job_title_id) DO UPDATE SET
rate = EXCLUDED.rate${typeof man_day_rate !== "undefined" ? ", man_day_rate = EXCLUDED.man_day_rate" : ""}
RETURNING *,
(SELECT name FROM job_titles jt WHERE jt.id = finance_project_rate_card_roles.job_title_id) AS jobtitle;
`;
const result = await db.query(q, values);
return res.status(200).send(new ServerResponse(true, result.rows[0]));
}
// Insert multiple roles for a project
@HandleExceptions()
public static async createMany(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
const { project_id, roles } = req.body;
if (!Array.isArray(roles) || !project_id) {
return res.status(400).send(new ServerResponse(false, null, "Invalid input"));
}
// Handle both rate and man_day_rate fields for each role
const columns = ["project_id", "job_title_id", "rate", "man_day_rate"];
const values = roles.map((role: any) => [
project_id,
role.job_title_id,
typeof role.rate !== "undefined" ? role.rate : 0,
typeof role.man_day_rate !== "undefined" ? role.man_day_rate : 0
]);
const q = `
INSERT INTO finance_project_rate_card_roles (${columns.join(", ")})
VALUES ${values.map((_, i) => `($${i * 4 + 1}, $${i * 4 + 2}, $${i * 4 + 3}, $${i * 4 + 4})`).join(",")}
ON CONFLICT (project_id, job_title_id) DO UPDATE SET
rate = EXCLUDED.rate,
man_day_rate = EXCLUDED.man_day_rate
RETURNING *,
(SELECT name FROM job_titles jt WHERE jt.id = finance_project_rate_card_roles.job_title_id) AS jobtitle;
`;
const flatValues = values.flat();
const result = await db.query(q, flatValues);
return res.status(200).send(new ServerResponse(true, result.rows));
}
// Get all roles for a project
@HandleExceptions()
public static async getByProjectId(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
const { project_id } = req.params;
const q = `
SELECT
fprr.*,
jt.name as jobtitle,
(
SELECT COALESCE(json_agg(pm.id), '[]'::json)
FROM project_members pm
WHERE pm.project_rate_card_role_id = fprr.id
) AS members
FROM finance_project_rate_card_roles fprr
LEFT JOIN job_titles jt ON fprr.job_title_id = jt.id
WHERE fprr.project_id = $1
ORDER BY fprr.created_at;
`;
const result = await db.query(q, [project_id]);
return res.status(200).send(new ServerResponse(true, result.rows));
}
// Get a single role by id
@HandleExceptions()
public static async getById(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
const { id } = req.params;
const q = `
SELECT
fprr.*,
jt.name as jobtitle,
(
SELECT COALESCE(json_agg(pm.id), '[]'::json)
FROM project_members pm
WHERE pm.project_rate_card_role_id = fprr.id
) AS members
FROM finance_project_rate_card_roles fprr
LEFT JOIN job_titles jt ON fprr.job_title_id = jt.id
WHERE fprr.id = $1;
`;
const result = await db.query(q, [id]);
return res.status(200).send(new ServerResponse(true, result.rows[0]));
}
// Update a single role by id
@HandleExceptions()
public static async updateById(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
const { id } = req.params;
const { job_title_id, rate, man_day_rate } = req.body;
let setClause = "job_title_id = $1, updated_at = NOW()";
const values = [job_title_id];
if (typeof man_day_rate !== "undefined") {
setClause += ", man_day_rate = $2";
values.push(man_day_rate);
} else {
setClause += ", rate = $2";
values.push(rate);
}
values.push(id);
const q = `
WITH updated AS (
UPDATE finance_project_rate_card_roles
SET ${setClause}
WHERE id = $3
RETURNING *
),
jobtitles AS (
SELECT u.*, jt.name AS jobtitle
FROM updated u
JOIN job_titles jt ON jt.id = u.job_title_id
),
members AS (
SELECT json_agg(pm.id) AS members, pm.project_rate_card_role_id
FROM project_members pm
WHERE pm.project_rate_card_role_id IN (SELECT id FROM jobtitles)
GROUP BY pm.project_rate_card_role_id
)
SELECT jt.*, m.members
FROM jobtitles jt
LEFT JOIN members m ON m.project_rate_card_role_id = jt.id;
`;
const result = await db.query(q, values);
return res.status(200).send(new ServerResponse(true, result.rows[0]));
}
// update project member rate for a project with members
@HandleExceptions()
public static async updateProjectMemberByProjectIdAndMemberId(
req: IWorkLenzRequest,
res: IWorkLenzResponse
): Promise<IWorkLenzResponse> {
const { project_id, id } = req.params;
const { project_rate_card_role_id } = req.body;
if (!project_id || !id || !project_rate_card_role_id) {
return res.status(400).send(new ServerResponse(false, null, "Missing values"));
}
try {
// Step 1: Check current role assignment
const checkQuery = `
SELECT project_rate_card_role_id
FROM project_members
WHERE id = $1 AND project_id = $2;
`;
const { rows: checkRows } = await db.query(checkQuery, [id, project_id]);
const currentRoleId = checkRows[0]?.project_rate_card_role_id;
if (currentRoleId !== null && currentRoleId !== project_rate_card_role_id) {
// Step 2: Fetch members with the requested role
const membersQuery = `
SELECT COALESCE(json_agg(id), '[]'::json) AS members
FROM project_members
WHERE project_id = $1 AND project_rate_card_role_id = $2;
`;
const { rows: memberRows } = await db.query(membersQuery, [project_id, project_rate_card_role_id]);
return res.status(200).send(
new ServerResponse(false, memberRows[0], "Already Assigned !")
);
}
// Step 3: Perform the update
const updateQuery = `
UPDATE project_members
SET project_rate_card_role_id = CASE
WHEN project_rate_card_role_id = $1 THEN NULL
ELSE $1
END
WHERE id = $2
AND project_id = $3
AND EXISTS (
SELECT 1
FROM finance_project_rate_card_roles
WHERE id = $1 AND project_id = $3
)
RETURNING project_rate_card_role_id;
`;
const { rows: updateRows } = await db.query(updateQuery, [project_rate_card_role_id, id, project_id]);
if (updateRows.length === 0) {
return res.status(200).send(new ServerResponse(true, [], "Project member not found or invalid project_rate_card_role_id"));
}
const updatedRoleId = updateRows[0].project_rate_card_role_id || project_rate_card_role_id;
// Step 4: Fetch updated members list
const membersQuery = `
SELECT COALESCE(json_agg(id), '[]'::json) AS members
FROM project_members
WHERE project_id = $1 AND project_rate_card_role_id = $2;
`;
const { rows: finalMembers } = await db.query(membersQuery, [project_id, updatedRoleId]);
return res.status(200).send(new ServerResponse(true, finalMembers[0]));
} catch (error) {
return res.status(500).send(new ServerResponse(false, null, "Internal server error"));
}
}
// Update all roles for a project (delete then insert)
@HandleExceptions()
public static async updateByProjectId(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
const { project_id, roles } = req.body;
if (!Array.isArray(roles) || !project_id) {
return res.status(400).send(new ServerResponse(false, null, "Invalid input"));
}
if (roles.length === 0) {
// If no roles provided, do nothing and return empty array
return res.status(200).send(new ServerResponse(true, []));
}
// Build upsert query for all roles
const columns = ["project_id", "job_title_id", "rate", "man_day_rate"];
const values = roles.map((role: any) => [
project_id,
role.job_title_id,
typeof role.rate !== "undefined" ? role.rate : null,
typeof role.man_day_rate !== "undefined" ? role.man_day_rate : null
]);
const q = `
WITH upserted AS (
INSERT INTO finance_project_rate_card_roles (${columns.join(", ")})
VALUES ${values.map((_, i) => `($${i * 4 + 1}, $${i * 4 + 2}, $${i * 4 + 3}, $${i * 4 + 4})`).join(",")}
ON CONFLICT (project_id, job_title_id)
DO UPDATE SET rate = EXCLUDED.rate, man_day_rate = EXCLUDED.man_day_rate, updated_at = NOW()
RETURNING *
),
jobtitles AS (
SELECT upr.*, jt.name AS jobtitle
FROM upserted upr
JOIN job_titles jt ON jt.id = upr.job_title_id
),
members AS (
SELECT json_agg(pm.id) AS members, pm.project_rate_card_role_id
FROM project_members pm
WHERE pm.project_rate_card_role_id IN (SELECT id FROM jobtitles)
GROUP BY pm.project_rate_card_role_id
)
SELECT jt.*, m.members
FROM jobtitles jt
LEFT JOIN members m ON m.project_rate_card_role_id = jt.id;
`;
const flatValues = values.flat();
const result = await db.query(q, flatValues);
return res.status(200).send(new ServerResponse(true, result.rows));
}
// Delete a single role by id
@HandleExceptions()
public static async deleteById(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
const { id } = req.params;
const q = `DELETE FROM finance_project_rate_card_roles WHERE id = $1 RETURNING *;`;
const result = await db.query(q, [id]);
return res.status(200).send(new ServerResponse(true, result.rows[0]));
}
// Delete all roles for a project
@HandleExceptions()
public static async deleteByProjectId(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
const { project_id } = req.params;
const q = `DELETE FROM finance_project_rate_card_roles WHERE project_id = $1 RETURNING *;`;
const result = await db.query(q, [project_id]);
return res.status(200).send(new ServerResponse(true, result.rows));
}
}

View File

@@ -71,7 +71,7 @@ export default class ProjectsController extends WorklenzControllerBase {
return res.status(200).send(new ServerResponse(false, [], `Sorry, the free plan cannot have more than ${projectsLimit} projects.`));
}
}
const q = `SELECT create_project($1) AS project`;
req.body.team_id = req.user?.team_id || null;
@@ -317,58 +317,65 @@ export default class ProjectsController extends WorklenzControllerBase {
@HandleExceptions()
public static async getMembersByProjectId(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
const {sortField, sortOrder, size, offset} = this.toPaginationOptions(req.query, "name");
const search = (req.query.search || "").toString().trim();
let searchFilter = "";
const params = [req.params.id, req.user?.team_id ?? null, size, offset];
if (search) {
searchFilter = `
AND (
(SELECT name FROM team_member_info_view WHERE team_member_info_view.team_member_id = tm.id) ILIKE '%' || $5 || '%'
OR (SELECT email FROM team_member_info_view WHERE team_member_info_view.team_member_id = tm.id) ILIKE '%' || $5 || '%'
)
`;
params.push(search);
}
const q = `
WITH filtered_members AS (
SELECT project_members.id,
team_member_id,
(SELECT name FROM team_member_info_view WHERE team_member_info_view.team_member_id = tm.id) AS name,
(SELECT email FROM team_member_info_view WHERE team_member_info_view.team_member_id = tm.id) AS email,
u.avatar_url,
(SELECT COUNT(*) FROM tasks WHERE archived IS FALSE AND project_id = project_members.project_id AND id IN (SELECT task_id FROM tasks_assignees WHERE tasks_assignees.project_member_id = project_members.id)) AS all_tasks_count,
(SELECT COUNT(*) FROM tasks WHERE archived IS FALSE AND project_id = project_members.project_id AND id IN (SELECT task_id FROM tasks_assignees WHERE tasks_assignees.project_member_id = project_members.id) AND status_id IN (SELECT id FROM task_statuses WHERE category_id = (SELECT id FROM sys_task_status_categories WHERE is_done IS TRUE))) AS completed_tasks_count,
EXISTS(SELECT email FROM email_invitations WHERE team_member_id = project_members.team_member_id AND email_invitations.team_id = $2) AS pending_invitation,
(SELECT project_access_levels.name FROM project_access_levels WHERE project_access_levels.id = project_members.project_access_level_id) AS access,
(SELECT name FROM job_titles WHERE id = tm.job_title_id) AS job_title
FROM project_members
INNER JOIN team_members tm ON project_members.team_member_id = tm.id
LEFT JOIN users u ON tm.user_id = u.id
WHERE project_id = $1
${search ? searchFilter : ""}
)
SELECT
(SELECT COUNT(*) FROM filtered_members) AS total,
(SELECT COALESCE(ARRAY_TO_JSON(ARRAY_AGG(ROW_TO_JSON(t))), '[]'::JSON)
FROM (
SELECT * FROM filtered_members
ORDER BY ${sortField} ${sortOrder}
LIMIT $3 OFFSET $4
) t
) AS data
SELECT ROW_TO_JSON(rec) AS members
FROM (SELECT COUNT(*) AS total,
(SELECT COALESCE(ARRAY_TO_JSON(ARRAY_AGG(ROW_TO_JSON(t))), '[]'::JSON)
FROM (SELECT project_members.id,
team_member_id,
(SELECT name
FROM team_member_info_view
WHERE team_member_info_view.team_member_id = tm.id),
(SELECT email
FROM team_member_info_view
WHERE team_member_info_view.team_member_id = tm.id) AS email,
u.avatar_url,
(SELECT COUNT(*)
FROM tasks
WHERE archived IS FALSE
AND project_id = project_members.project_id
AND id IN (SELECT task_id
FROM tasks_assignees
WHERE tasks_assignees.project_member_id = project_members.id)) AS all_tasks_count,
(SELECT COUNT(*)
FROM tasks
WHERE archived IS FALSE
AND project_id = project_members.project_id
AND id IN (SELECT task_id
FROM tasks_assignees
WHERE tasks_assignees.project_member_id = project_members.id)
AND status_id IN (SELECT id
FROM task_statuses
WHERE category_id = (SELECT id
FROM sys_task_status_categories
WHERE is_done IS TRUE))) AS completed_tasks_count,
EXISTS(SELECT email
FROM email_invitations
WHERE team_member_id = project_members.team_member_id
AND email_invitations.team_id = $2) AS pending_invitation,
(SELECT project_access_levels.name
FROM project_access_levels
WHERE project_access_levels.id = project_members.project_access_level_id) AS access,
(SELECT name FROM job_titles WHERE id = tm.job_title_id) AS job_title
FROM project_members
INNER JOIN team_members tm ON project_members.team_member_id = tm.id
LEFT JOIN users u ON tm.user_id = u.id
WHERE project_id = $1
ORDER BY ${sortField} ${sortOrder}
LIMIT $3 OFFSET $4) t) AS data
FROM project_members
WHERE project_id = $1) rec;
`;
const result = await db.query(q, params);
const result = await db.query(q, [req.params.id, req.user?.team_id ?? null, size, offset]);
const [data] = result.rows;
for (const member of data?.data || []) {
for (const member of data?.members.data || []) {
member.progress = member.all_tasks_count > 0
? ((member.completed_tasks_count / member.all_tasks_count) * 100).toFixed(0) : 0;
}
return res.status(200).send(new ServerResponse(true, data || this.paginatedDatasetDefaultStruct));
return res.status(200).send(new ServerResponse(true, data?.members || this.paginatedDatasetDefaultStruct));
}
@HandleExceptions()
@@ -388,8 +395,6 @@ export default class ProjectsController extends WorklenzControllerBase {
projects.folder_id,
projects.phase_label,
projects.category_id,
projects.currency,
projects.budget,
(projects.estimated_man_days) AS man_days,
(projects.estimated_working_days) AS working_days,
(projects.hours_per_day) AS hours_per_day,
@@ -403,9 +408,6 @@ export default class ProjectsController extends WorklenzControllerBase {
sps.color_code AS status_color,
sps.icon AS status_icon,
(SELECT name FROM clients WHERE id = projects.client_id) AS client_name,
projects.use_manual_progress,
projects.use_weighted_progress,
projects.use_time_progress,
(SELECT COALESCE(ROW_TO_JSON(pm), '{}'::JSON)
FROM (SELECT team_member_id AS id,
@@ -751,186 +753,4 @@ export default class ProjectsController extends WorklenzControllerBase {
}
@HandleExceptions()
public static async getGrouped(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
// Use qualified field name for projects to avoid ambiguity
const {searchQuery, sortField, sortOrder, size, offset} = this.toPaginationOptions(req.query, ["projects.name"]);
const groupBy = req.query.groupBy as string || "category";
const filterByMember = !req.user?.owner && !req.user?.is_admin ?
` AND is_member_of_project(projects.id, '${req.user?.id}', $1) ` : "";
const isFavorites = req.query.filter === "1" ? ` AND EXISTS(SELECT user_id FROM favorite_projects WHERE user_id = '${req.user?.id}' AND project_id = projects.id)` : "";
const isArchived = req.query.filter === "2"
? ` AND EXISTS(SELECT user_id FROM archived_projects WHERE user_id = '${req.user?.id}' AND project_id = projects.id)`
: ` AND NOT EXISTS(SELECT user_id FROM archived_projects WHERE user_id = '${req.user?.id}' AND project_id = projects.id)`;
const categories = this.getFilterByCategoryWhereClosure(req.query.categories as string);
const statuses = this.getFilterByStatusWhereClosure(req.query.statuses as string);
// Determine grouping field and join based on groupBy parameter
let groupField = "";
let groupName = "";
let groupColor = "";
let groupJoin = "";
let groupByFields = "";
let groupOrderBy = "";
switch (groupBy) {
case "client":
groupField = "COALESCE(projects.client_id::text, 'no-client')";
groupName = "COALESCE(clients.name, 'No Client')";
groupColor = "'#688'";
groupJoin = "LEFT JOIN clients ON projects.client_id = clients.id";
groupByFields = "projects.client_id, clients.name";
groupOrderBy = "COALESCE(clients.name, 'No Client')";
break;
case "status":
groupField = "COALESCE(projects.status_id::text, 'no-status')";
groupName = "COALESCE(sys_project_statuses.name, 'No Status')";
groupColor = "COALESCE(sys_project_statuses.color_code, '#888')";
groupJoin = "LEFT JOIN sys_project_statuses ON projects.status_id = sys_project_statuses.id";
groupByFields = "projects.status_id, sys_project_statuses.name, sys_project_statuses.color_code";
groupOrderBy = "COALESCE(sys_project_statuses.name, 'No Status')";
break;
case "category":
default:
groupField = "COALESCE(projects.category_id::text, 'uncategorized')";
groupName = "COALESCE(project_categories.name, 'Uncategorized')";
groupColor = "COALESCE(project_categories.color_code, '#888')";
groupJoin = "LEFT JOIN project_categories ON projects.category_id = project_categories.id";
groupByFields = "projects.category_id, project_categories.name, project_categories.color_code";
groupOrderBy = "COALESCE(project_categories.name, 'Uncategorized')";
}
// Ensure sortField is properly qualified for the inner project query
let qualifiedSortField = sortField;
if (Array.isArray(sortField)) {
qualifiedSortField = sortField[0]; // Take the first field if it's an array
}
// Replace "projects." with "p2." for the inner query
const innerSortField = qualifiedSortField.replace("projects.", "p2.");
const q = `
SELECT ROW_TO_JSON(rec) AS groups
FROM (
SELECT COUNT(DISTINCT ${groupField}) AS total_groups,
(SELECT COALESCE(ARRAY_TO_JSON(ARRAY_AGG(ROW_TO_JSON(group_data))), '[]'::JSON)
FROM (
SELECT ${groupField} AS group_key,
${groupName} AS group_name,
${groupColor} AS group_color,
COUNT(*) AS project_count,
(SELECT COALESCE(ARRAY_TO_JSON(ARRAY_AGG(ROW_TO_JSON(project_data))), '[]'::JSON)
FROM (
SELECT p2.id,
p2.name,
(SELECT sys_project_statuses.name FROM sys_project_statuses WHERE sys_project_statuses.id = p2.status_id) AS status,
(SELECT sys_project_statuses.color_code FROM sys_project_statuses WHERE sys_project_statuses.id = p2.status_id) AS status_color,
(SELECT sys_project_statuses.icon FROM sys_project_statuses WHERE sys_project_statuses.id = p2.status_id) AS status_icon,
EXISTS(SELECT user_id
FROM favorite_projects
WHERE user_id = '${req.user?.id}'
AND project_id = p2.id) AS favorite,
EXISTS(SELECT user_id
FROM archived_projects
WHERE user_id = '${req.user?.id}'
AND project_id = p2.id) AS archived,
p2.color_code,
p2.start_date,
p2.end_date,
p2.category_id,
(SELECT COUNT(*)
FROM tasks
WHERE archived IS FALSE
AND project_id = p2.id) AS all_tasks_count,
(SELECT COUNT(*)
FROM tasks
WHERE archived IS FALSE
AND project_id = p2.id
AND status_id IN (SELECT task_statuses.id
FROM task_statuses
WHERE task_statuses.project_id = p2.id
AND task_statuses.category_id IN
(SELECT sys_task_status_categories.id FROM sys_task_status_categories WHERE sys_task_status_categories.is_done IS TRUE))) AS completed_tasks_count,
(SELECT COUNT(*)
FROM project_members
WHERE project_members.project_id = p2.id) AS members_count,
(SELECT get_project_members(p2.id)) AS names,
(SELECT clients.name FROM clients WHERE clients.id = p2.client_id) AS client_name,
(SELECT users.name FROM users WHERE users.id = p2.owner_id) AS project_owner,
(SELECT project_categories.name FROM project_categories WHERE project_categories.id = p2.category_id) AS category_name,
(SELECT project_categories.color_code
FROM project_categories
WHERE project_categories.id = p2.category_id) AS category_color,
((SELECT project_members.team_member_id as team_member_id
FROM project_members
WHERE project_members.project_id = p2.id
AND project_members.project_access_level_id = (SELECT project_access_levels.id FROM project_access_levels WHERE project_access_levels.key = 'PROJECT_MANAGER'))) AS project_manager_team_member_id,
(SELECT project_members.default_view
FROM project_members
WHERE project_members.project_id = p2.id
AND project_members.team_member_id = '${req.user?.team_member_id}') AS team_member_default_view,
(SELECT CASE
WHEN ((SELECT MAX(tasks.updated_at)
FROM tasks
WHERE tasks.archived IS FALSE
AND tasks.project_id = p2.id) >
p2.updated_at)
THEN (SELECT MAX(tasks.updated_at)
FROM tasks
WHERE tasks.archived IS FALSE
AND tasks.project_id = p2.id)
ELSE p2.updated_at END) AS updated_at
FROM projects p2
${groupJoin.replace("projects.", "p2.")}
WHERE p2.team_id = $1
AND ${groupField.replace("projects.", "p2.")} = ${groupField}
${categories.replace("projects.", "p2.")}
${statuses.replace("projects.", "p2.")}
${isArchived.replace("projects.", "p2.")}
${isFavorites.replace("projects.", "p2.")}
${filterByMember.replace("projects.", "p2.")}
${searchQuery.replace("projects.", "p2.")}
ORDER BY ${innerSortField} ${sortOrder}
) project_data
) AS projects
FROM projects
${groupJoin}
WHERE projects.team_id = $1 ${categories} ${statuses} ${isArchived} ${isFavorites} ${filterByMember} ${searchQuery}
GROUP BY ${groupByFields}
ORDER BY ${groupOrderBy}
LIMIT $2 OFFSET $3
) group_data
) AS data
FROM projects
${groupJoin}
WHERE projects.team_id = $1 ${categories} ${statuses} ${isArchived} ${isFavorites} ${filterByMember} ${searchQuery}
) rec;
`;
const result = await db.query(q, [req.user?.team_id || null, size, offset]);
const [data] = result.rows;
// Process the grouped data
for (const group of data?.groups.data || []) {
for (const project of group.projects || []) {
project.progress = project.all_tasks_count > 0
? ((project.completed_tasks_count / project.all_tasks_count) * 100).toFixed(0) : 0;
project.updated_at_string = moment(project.updated_at).fromNow();
project.names = this.createTagList(project?.names);
project.names.map((a: any) => a.color_code = getColor(a.name));
if (project.project_manager_team_member_id) {
project.project_manager = {
id: project.project_manager_team_member_id
};
}
}
}
return res.status(200).send(new ServerResponse(true, data?.groups || { total_groups: 0, data: [] }));
}
}

View File

@@ -1,198 +0,0 @@
import { IWorkLenzRequest } from "../interfaces/worklenz-request";
import { IWorkLenzResponse } from "../interfaces/worklenz-response";
import db from "../config/db";
import { ServerResponse } from "../models/server-response";
import WorklenzControllerBase from "./worklenz-controller-base";
import HandleExceptions from "../decorators/handle-exceptions";
export default class RateCardController extends WorklenzControllerBase {
@HandleExceptions()
public static async create(
req: IWorkLenzRequest,
res: IWorkLenzResponse
): Promise<IWorkLenzResponse> {
const q = `
INSERT INTO finance_rate_cards (team_id, name)
VALUES ($1, $2)
RETURNING id, name, team_id, created_at, updated_at;
`;
const result = await db.query(q, [
req.user?.team_id || null,
req.body.name,
]);
const [data] = result.rows;
return res.status(200).send(new ServerResponse(true, data));
}
@HandleExceptions()
public static async get(
req: IWorkLenzRequest,
res: IWorkLenzResponse
): Promise<IWorkLenzResponse> {
const { searchQuery, sortField, sortOrder, size, offset } =
this.toPaginationOptions(req.query, "name");
const q = `
SELECT ROW_TO_JSON(rec) AS rate_cards
FROM (
SELECT COUNT(*) AS total,
(
SELECT COALESCE(ARRAY_TO_JSON(ARRAY_AGG(ROW_TO_JSON(t))), '[]'::JSON)
FROM (
SELECT id, name, team_id, currency, created_at, updated_at
FROM finance_rate_cards
WHERE team_id = $1 ${searchQuery}
ORDER BY ${sortField} ${sortOrder}
LIMIT $2 OFFSET $3
) t
) AS data
FROM finance_rate_cards
WHERE team_id = $1 ${searchQuery}
) rec;
`;
const result = await db.query(q, [req.user?.team_id || null, size, offset]);
const [data] = result.rows;
return res
.status(200)
.send(
new ServerResponse(
true,
data.rate_cards || this.paginatedDatasetDefaultStruct
)
);
}
@HandleExceptions()
public static async getById(
req: IWorkLenzRequest,
res: IWorkLenzResponse
): Promise<IWorkLenzResponse> {
// 1. Fetch the rate card
const q = `
SELECT id, name, team_id, currency, created_at, updated_at
FROM finance_rate_cards
WHERE id = $1 AND team_id = $2;
`;
const result = await db.query(q, [
req.params.id,
req.user?.team_id || null,
]);
const [data] = result.rows;
if (!data) {
return res
.status(404)
.send(new ServerResponse(false, null, "Rate card not found"));
}
// 2. Fetch job roles with job title names
const jobRolesQ = `
SELECT
rcr.job_title_id,
jt.name AS jobTitle,
rcr.rate,
rcr.man_day_rate,
rcr.rate_card_id
FROM finance_rate_card_roles rcr
LEFT JOIN job_titles jt ON rcr.job_title_id = jt.id
WHERE rcr.rate_card_id = $1
`;
const jobRolesResult = await db.query(jobRolesQ, [req.params.id]);
const jobRolesList = jobRolesResult.rows;
// 3. Return the rate card with jobRolesList
return res.status(200).send(
new ServerResponse(true, {
...data,
jobRolesList,
})
);
}
@HandleExceptions()
public static async update(
req: IWorkLenzRequest,
res: IWorkLenzResponse
): Promise<IWorkLenzResponse> {
// 1. Update the rate card
const updateRateCardQ = `
UPDATE finance_rate_cards
SET name = $3, currency = $4, updated_at = NOW()
WHERE id = $1 AND team_id = $2
RETURNING id, name, team_id, currency, created_at, updated_at;
`;
const result = await db.query(updateRateCardQ, [
req.params.id,
req.user?.team_id || null,
req.body.name,
req.body.currency,
]);
const [rateCardData] = result.rows;
// 2. Update job roles (delete old, insert new)
if (Array.isArray(req.body.jobRolesList)) {
// Delete existing roles for this rate card
await db.query(
`DELETE FROM finance_rate_card_roles WHERE rate_card_id = $1;`,
[req.params.id]
);
// Insert new roles
for (const role of req.body.jobRolesList) {
if (role.job_title_id) {
await db.query(
`INSERT INTO finance_rate_card_roles (rate_card_id, job_title_id, rate, man_day_rate)
VALUES ($1, $2, $3, $4);`,
[
req.params.id,
role.job_title_id,
role.rate ?? 0,
role.man_day_rate ?? 0,
]
);
}
}
}
// 3. Get jobRolesList with job title names
const jobRolesQ = `
SELECT
rcr.job_title_id,
jt.name AS jobTitle,
rcr.rate
FROM finance_rate_card_roles rcr
LEFT JOIN job_titles jt ON rcr.job_title_id = jt.id
WHERE rcr.rate_card_id = $1
`;
const jobRolesResult = await db.query(jobRolesQ, [req.params.id]);
const jobRolesList = jobRolesResult.rows;
// 4. Return the updated rate card with jobRolesList
return res.status(200).send(
new ServerResponse(true, {
...rateCardData,
jobRolesList,
})
);
}
@HandleExceptions()
public static async deleteById(
req: IWorkLenzRequest,
res: IWorkLenzResponse
): Promise<IWorkLenzResponse> {
const q = `
DELETE FROM finance_rate_cards
WHERE id = $1 AND team_id = $2
RETURNING id;
`;
const result = await db.query(q, [
req.params.id,
req.user?.team_id || null,
]);
return res
.status(200)
.send(new ServerResponse(true, result.rows.length > 0));
}
}

View File

@@ -1,179 +0,0 @@
// Example of updated getMemberTimeSheets method with timezone support
// This shows the key changes needed to handle timezones properly
import moment from "moment-timezone";
import db from "../../config/db";
import { IWorkLenzRequest } from "../../interfaces/worklenz-request";
import { IWorkLenzResponse } from "../../interfaces/worklenz-response";
import { ServerResponse } from "../../models/server-response";
import { DATE_RANGES } from "../../shared/constants";
export async function getMemberTimeSheets(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
const archived = req.query.archived === "true";
const teams = (req.body.teams || []) as string[];
const teamIds = teams.map(id => `'${id}'`).join(",");
const projects = (req.body.projects || []) as string[];
const projectIds = projects.map(p => `'${p}'`).join(",");
const {billable} = req.body;
// Get user timezone from request or database
const userTimezone = req.body.timezone || await getUserTimezone(req.user?.id || "");
if (!teamIds || !projectIds.length)
return res.status(200).send(new ServerResponse(true, { users: [], projects: [] }));
const { duration, date_range } = req.body;
// Calculate date range with timezone support
let startDate: moment.Moment;
let endDate: moment.Moment;
if (date_range && date_range.length === 2) {
// Convert user's local dates to their timezone's start/end of day
startDate = moment.tz(date_range[0], userTimezone).startOf("day");
endDate = moment.tz(date_range[1], userTimezone).endOf("day");
} else if (duration === DATE_RANGES.ALL_TIME) {
const minDateQuery = `SELECT MIN(COALESCE(start_date, created_at)) as min_date FROM projects WHERE id IN (${projectIds})`;
const minDateResult = await db.query(minDateQuery, []);
const minDate = minDateResult.rows[0]?.min_date;
startDate = minDate ? moment.tz(minDate, userTimezone) : moment.tz("2000-01-01", userTimezone);
endDate = moment.tz(userTimezone);
} else {
// Calculate ranges based on user's timezone
const now = moment.tz(userTimezone);
switch (duration) {
case DATE_RANGES.YESTERDAY:
startDate = now.clone().subtract(1, "day").startOf("day");
endDate = now.clone().subtract(1, "day").endOf("day");
break;
case DATE_RANGES.LAST_WEEK:
startDate = now.clone().subtract(1, "week").startOf("isoWeek");
endDate = now.clone().subtract(1, "week").endOf("isoWeek");
break;
case DATE_RANGES.LAST_MONTH:
startDate = now.clone().subtract(1, "month").startOf("month");
endDate = now.clone().subtract(1, "month").endOf("month");
break;
case DATE_RANGES.LAST_QUARTER:
startDate = now.clone().subtract(3, "months").startOf("day");
endDate = now.clone().endOf("day");
break;
default:
startDate = now.clone().startOf("day");
endDate = now.clone().endOf("day");
}
}
// Convert to UTC for database queries
const startUtc = startDate.utc().format("YYYY-MM-DD HH:mm:ss");
const endUtc = endDate.utc().format("YYYY-MM-DD HH:mm:ss");
// Calculate working days in user's timezone
const totalDays = endDate.diff(startDate, "days") + 1;
let workingDays = 0;
const current = startDate.clone();
while (current.isSameOrBefore(endDate, "day")) {
if (current.isoWeekday() >= 1 && current.isoWeekday() <= 5) {
workingDays++;
}
current.add(1, "day");
}
// Updated SQL query with proper timezone handling
const billableQuery = buildBillableQuery(billable);
const archivedClause = archived ? "" : `AND projects.id NOT IN (SELECT project_id FROM archived_projects WHERE project_id = projects.id AND user_id = '${req.user?.id}')`;
const q = `
WITH project_hours AS (
SELECT
id,
COALESCE(hours_per_day, 8) as hours_per_day
FROM projects
WHERE id IN (${projectIds})
),
total_working_hours AS (
SELECT
SUM(hours_per_day) * ${workingDays} as total_hours
FROM project_hours
)
SELECT
u.id,
u.email,
tm.name,
tm.color_code,
COALESCE(SUM(twl.time_spent), 0) as logged_time,
COALESCE(SUM(twl.time_spent), 0) / 3600.0 as value,
(SELECT total_hours FROM total_working_hours) as total_working_hours,
CASE
WHEN (SELECT total_hours FROM total_working_hours) > 0
THEN ROUND((COALESCE(SUM(twl.time_spent), 0) / 3600.0) / (SELECT total_hours FROM total_working_hours) * 100, 2)
ELSE 0
END as utilization_percent,
ROUND(COALESCE(SUM(twl.time_spent), 0) / 3600.0, 2) as utilized_hours,
ROUND(COALESCE(SUM(twl.time_spent), 0) / 3600.0 - (SELECT total_hours FROM total_working_hours), 2) as over_under_utilized_hours,
'${userTimezone}' as user_timezone,
'${startDate.format("YYYY-MM-DD")}' as report_start_date,
'${endDate.format("YYYY-MM-DD")}' as report_end_date
FROM team_members tm
LEFT JOIN users u ON tm.user_id = u.id
LEFT JOIN task_work_log twl ON twl.user_id = u.id
LEFT JOIN tasks t ON twl.task_id = t.id ${billableQuery}
LEFT JOIN projects p ON t.project_id = p.id
WHERE tm.team_id IN (${teamIds})
AND (
twl.id IS NULL
OR (
p.id IN (${projectIds})
AND twl.created_at >= '${startUtc}'::TIMESTAMP
AND twl.created_at <= '${endUtc}'::TIMESTAMP
${archivedClause}
)
)
GROUP BY u.id, u.email, tm.name, tm.color_code
ORDER BY logged_time DESC`;
const result = await db.query(q, []);
// Add timezone context to response
const response = {
data: result.rows,
timezone_info: {
user_timezone: userTimezone,
report_period: {
start: startDate.format("YYYY-MM-DD HH:mm:ss z"),
end: endDate.format("YYYY-MM-DD HH:mm:ss z"),
working_days: workingDays,
total_days: totalDays
}
}
};
return res.status(200).send(new ServerResponse(true, response));
}
async function getUserTimezone(userId: string): Promise<string> {
const q = `SELECT tz.name as timezone
FROM users u
JOIN timezones tz ON u.timezone_id = tz.id
WHERE u.id = $1`;
const result = await db.query(q, [userId]);
return result.rows[0]?.timezone || "UTC";
}
function buildBillableQuery(billable: { billable: boolean; nonBillable: boolean }): string {
if (!billable) return "";
const { billable: isBillable, nonBillable } = billable;
if (isBillable && nonBillable) {
return "";
} else if (isBillable) {
return " AND tasks.billable IS TRUE";
} else if (nonBillable) {
return " AND tasks.billable IS FALSE";
}
return "";
}

View File

@@ -15,25 +15,6 @@ enum IToggleOptions {
}
export default class ReportingAllocationController extends ReportingControllerBase {
// Helper method to build billable query with custom table alias
private static buildBillableQueryWithAlias(selectedStatuses: { billable: boolean; nonBillable: boolean }, tableAlias: string = 'tasks'): string {
const { billable, nonBillable } = selectedStatuses;
if (billable && nonBillable) {
// Both are enabled, no need to filter
return "";
} else if (billable && !nonBillable) {
// Only billable is enabled - show only billable tasks
return ` AND ${tableAlias}.billable IS TRUE`;
} else if (!billable && nonBillable) {
// Only non-billable is enabled - show only non-billable tasks
return ` AND ${tableAlias}.billable IS FALSE`;
} else {
// Neither selected - this shouldn't happen in normal UI flow
return "";
}
}
private static async getTimeLoggedByProjects(projects: string[], users: string[], key: string, dateRange: string[], archived = false, user_id = "", billable: { billable: boolean; nonBillable: boolean }): Promise<any> {
try {
const projectIds = projects.map(p => `'${p}'`).join(",");
@@ -96,8 +77,8 @@ export default class ReportingAllocationController extends ReportingControllerBa
sps.icon AS status_icon,
(SELECT COUNT(*)
FROM tasks
WHERE CASE WHEN ($1 IS TRUE) THEN project_id IS NOT NULL ELSE archived = FALSE END
AND project_id = projects.id ${billableQuery}) AS all_tasks_count,
WHERE CASE WHEN ($1 IS TRUE) THEN project_id IS NOT NULL ELSE archived = FALSE END ${billableQuery}
AND project_id = projects.id) AS all_tasks_count,
(SELECT COUNT(*)
FROM tasks
WHERE CASE WHEN ($1 IS TRUE) THEN project_id IS NOT NULL ELSE archived = FALSE END
@@ -113,11 +94,10 @@ export default class ReportingAllocationController extends ReportingControllerBa
SELECT name,
(SELECT COALESCE(SUM(time_spent), 0)
FROM task_work_log
LEFT JOIN tasks ON task_work_log.task_id = tasks.id
WHERE user_id = users.id
LEFT JOIN tasks ON task_work_log.task_id = tasks.id
WHERE user_id = users.id ${billableQuery}
AND CASE WHEN ($1 IS TRUE) THEN tasks.project_id IS NOT NULL ELSE tasks.archived = FALSE END
AND tasks.project_id = projects.id
${billableQuery}
${duration}) AS time_logged
FROM users
WHERE id IN (${userIds})
@@ -141,11 +121,10 @@ export default class ReportingAllocationController extends ReportingControllerBa
const q = `(SELECT id,
(SELECT COALESCE(SUM(time_spent), 0)
FROM task_work_log
LEFT JOIN tasks ON task_work_log.task_id = tasks.id
LEFT JOIN tasks ON task_work_log.task_id = tasks.id ${billableQuery}
WHERE user_id = users.id
AND CASE WHEN ($1 IS TRUE) THEN tasks.project_id IS NOT NULL ELSE tasks.archived = FALSE END
AND tasks.project_id IN (${projectIds})
${billableQuery}
${duration}) AS time_logged
FROM users
WHERE id IN (${userIds})
@@ -367,8 +346,6 @@ export default class ReportingAllocationController extends ReportingControllerBa
const projects = (req.body.projects || []) as string[];
const projectIds = projects.map(p => `'${p}'`).join(",");
const categories = (req.body.categories || []) as string[];
const noCategory = req.body.noCategory || false;
const billable = req.body.billable;
if (!teamIds || !projectIds.length)
@@ -384,33 +361,6 @@ export default class ReportingAllocationController extends ReportingControllerBa
const billableQuery = this.buildBillableQuery(billable);
// Prepare projects filter
let projectsFilter = "";
if (projectIds.length > 0) {
projectsFilter = `AND p.id IN (${projectIds})`;
} else {
// If no projects are selected, don't show any data
projectsFilter = `AND 1=0`; // This will match no rows
}
// Prepare categories filter - updated logic
let categoriesFilter = "";
if (categories.length > 0 && noCategory) {
// Both specific categories and "No Category" are selected
const categoryIds = categories.map(id => `'${id}'`).join(",");
categoriesFilter = `AND (p.category_id IS NULL OR p.category_id IN (${categoryIds}))`;
} else if (categories.length === 0 && noCategory) {
// Only "No Category" is selected
categoriesFilter = `AND p.category_id IS NULL`;
} else if (categories.length > 0 && !noCategory) {
// Only specific categories are selected
const categoryIds = categories.map(id => `'${id}'`).join(",");
categoriesFilter = `AND p.category_id IN (${categoryIds})`;
} else {
// categories.length === 0 && !noCategory - no categories selected, show nothing
categoriesFilter = `AND 1=0`; // This will match no rows
}
const q = `
SELECT p.id,
p.name,
@@ -418,15 +368,13 @@ export default class ReportingAllocationController extends ReportingControllerBa
SUM(total_minutes) AS estimated,
color_code
FROM projects p
LEFT JOIN tasks ON tasks.project_id = p.id
LEFT JOIN tasks ON tasks.project_id = p.id ${billableQuery}
LEFT JOIN task_work_log ON task_work_log.task_id = tasks.id
WHERE p.id IN (${projectIds}) ${durationClause} ${archivedClause} ${categoriesFilter} ${billableQuery}
WHERE p.id IN (${projectIds}) ${durationClause} ${archivedClause}
GROUP BY p.id, p.name
ORDER BY logged_time DESC;`;
const result = await db.query(q, []);
const utilization = (req.body.utilization || []) as string[];
const data = [];
for (const project of result.rows) {
@@ -453,416 +401,38 @@ export default class ReportingAllocationController extends ReportingControllerBa
const projects = (req.body.projects || []) as string[];
const projectIds = projects.map(p => `'${p}'`).join(",");
const categories = (req.body.categories || []) as string[];
const noCategory = req.body.noCategory || false;
const billable = req.body.billable;
if (!teamIds)
return res.status(200).send(new ServerResponse(true, { filteredRows: [], totals: { total_time_logs: "0", total_estimated_hours: "0", total_utilization: "0" } }));
if (!teamIds || !projectIds.length)
return res.status(200).send(new ServerResponse(true, { users: [], projects: [] }));
const { duration, date_range } = req.body;
// Calculate the date range (start and end)
let startDate: moment.Moment;
let endDate: moment.Moment;
if (date_range && date_range.length === 2) {
startDate = moment(date_range[0]);
endDate = moment(date_range[1]);
} else if (duration === DATE_RANGES.ALL_TIME) {
// Fetch the earliest start_date (or created_at if null) from selected projects
const minDateQuery = projectIds.length > 0
? `SELECT MIN(COALESCE(start_date, created_at)) as min_date FROM projects WHERE id IN (${projectIds})`
: `SELECT MIN(COALESCE(start_date, created_at)) as min_date FROM projects WHERE team_id IN (${teamIds})`;
const minDateResult = await db.query(minDateQuery, []);
const minDate = minDateResult.rows[0]?.min_date;
startDate = minDate ? moment(minDate) : moment('2000-01-01');
endDate = moment();
} else {
switch (duration) {
case DATE_RANGES.YESTERDAY:
startDate = moment().subtract(1, "day");
endDate = moment().subtract(1, "day");
break;
case DATE_RANGES.LAST_WEEK:
startDate = moment().subtract(1, "week").startOf("isoWeek");
endDate = moment().subtract(1, "week").endOf("isoWeek");
break;
case DATE_RANGES.LAST_MONTH:
startDate = moment().subtract(1, "month").startOf("month");
endDate = moment().subtract(1, "month").endOf("month");
break;
case DATE_RANGES.LAST_QUARTER:
startDate = moment().subtract(3, "months").startOf("quarter");
endDate = moment().subtract(1, "quarter").endOf("quarter");
break;
default:
startDate = moment().startOf("day");
endDate = moment().endOf("day");
}
}
// Get organization working days
const orgWorkingDaysQuery = `
SELECT monday, tuesday, wednesday, thursday, friday, saturday, sunday
FROM organization_working_days
WHERE organization_id IN (
SELECT t.organization_id
FROM teams t
WHERE t.id IN (${teamIds})
LIMIT 1
);
`;
const orgWorkingDaysResult = await db.query(orgWorkingDaysQuery, []);
const workingDaysConfig = orgWorkingDaysResult.rows[0] || {
monday: true,
tuesday: true,
wednesday: true,
thursday: true,
friday: true,
saturday: false,
sunday: false
};
// Get organization ID for holiday queries
const orgIdQuery = `SELECT t.organization_id FROM teams t WHERE t.id IN (${teamIds}) LIMIT 1`;
const orgIdResult = await db.query(orgIdQuery, []);
const organizationId = orgIdResult.rows[0]?.organization_id;
// Fetch organization holidays within the date range
const orgHolidaysQuery = `
SELECT date
FROM organization_holidays
WHERE organization_id = $1
AND date >= $2::date
AND date <= $3::date
`;
const orgHolidaysResult = await db.query(orgHolidaysQuery, [
organizationId,
startDate.format('YYYY-MM-DD'),
endDate.format('YYYY-MM-DD')
]);
// Fetch country/state holidays if auto-sync is enabled
let countryStateHolidays: any[] = [];
const holidaySettingsQuery = `
SELECT country_code, state_code, auto_sync_holidays
FROM organization_holiday_settings
WHERE organization_id = $1
`;
const holidaySettingsResult = await db.query(holidaySettingsQuery, [organizationId]);
const holidaySettings = holidaySettingsResult.rows[0];
if (holidaySettings?.auto_sync_holidays && holidaySettings.country_code) {
// Fetch country holidays
const countryHolidaysQuery = `
SELECT date
FROM country_holidays
WHERE country_code = $1
AND (
(is_recurring = false AND date >= $2::date AND date <= $3::date) OR
(is_recurring = true AND
EXTRACT(MONTH FROM date) || '-' || EXTRACT(DAY FROM date) IN (
SELECT EXTRACT(MONTH FROM d::date) || '-' || EXTRACT(DAY FROM d::date)
FROM generate_series($2::date, $3::date, '1 day'::interval) d
)
)
)
`;
const countryHolidaysResult = await db.query(countryHolidaysQuery, [
holidaySettings.country_code,
startDate.format('YYYY-MM-DD'),
endDate.format('YYYY-MM-DD')
]);
countryStateHolidays = countryStateHolidays.concat(countryHolidaysResult.rows);
// Fetch state holidays if state_code is set
if (holidaySettings.state_code) {
const stateHolidaysQuery = `
SELECT date
FROM state_holidays
WHERE country_code = $1 AND state_code = $2
AND (
(is_recurring = false AND date >= $3::date AND date <= $4::date) OR
(is_recurring = true AND
EXTRACT(MONTH FROM date) || '-' || EXTRACT(DAY FROM date) IN (
SELECT EXTRACT(MONTH FROM d::date) || '-' || EXTRACT(DAY FROM d::date)
FROM generate_series($3::date, $4::date, '1 day'::interval) d
)
)
)
`;
const stateHolidaysResult = await db.query(stateHolidaysQuery, [
holidaySettings.country_code,
holidaySettings.state_code,
startDate.format('YYYY-MM-DD'),
endDate.format('YYYY-MM-DD')
]);
countryStateHolidays = countryStateHolidays.concat(stateHolidaysResult.rows);
}
}
// Create a Set of holiday dates for efficient lookup
const holidayDates = new Set<string>();
// Add organization holidays
orgHolidaysResult.rows.forEach(row => {
holidayDates.add(moment(row.date).format('YYYY-MM-DD'));
});
// Add country/state holidays (handling recurring holidays)
countryStateHolidays.forEach(row => {
const holidayDate = moment(row.date);
if (row.is_recurring) {
// For recurring holidays, check each year in the date range
let checkDate = startDate.clone().month(holidayDate.month()).date(holidayDate.date());
if (checkDate.isBefore(startDate)) {
checkDate.add(1, 'year');
}
while (checkDate.isSameOrBefore(endDate)) {
if (checkDate.isSameOrAfter(startDate)) {
holidayDates.add(checkDate.format('YYYY-MM-DD'));
}
checkDate.add(1, 'year');
}
} else {
holidayDates.add(holidayDate.format('YYYY-MM-DD'));
}
});
// Count working days based on organization settings, excluding holidays
let workingDays = 0;
let current = startDate.clone();
while (current.isSameOrBefore(endDate, 'day')) {
const day = current.isoWeekday();
const currentDateStr = current.format('YYYY-MM-DD');
// Check if it's a working day AND not a holiday
if (
!holidayDates.has(currentDateStr) && (
(day === 1 && workingDaysConfig.monday) ||
(day === 2 && workingDaysConfig.tuesday) ||
(day === 3 && workingDaysConfig.wednesday) ||
(day === 4 && workingDaysConfig.thursday) ||
(day === 5 && workingDaysConfig.friday) ||
(day === 6 && workingDaysConfig.saturday) ||
(day === 7 && workingDaysConfig.sunday)
)
) {
workingDays++;
}
current.add(1, 'day');
}
// Get organization working hours
const orgWorkingHoursQuery = `SELECT hours_per_day FROM organizations WHERE id = (SELECT t.organization_id FROM teams t WHERE t.id IN (${teamIds}) LIMIT 1)`;
const orgWorkingHoursResult = await db.query(orgWorkingHoursQuery, []);
const orgWorkingHours = orgWorkingHoursResult.rows[0]?.hours_per_day || 8;
// Calculate total working hours with minimum baseline for non-working day scenarios
let totalWorkingHours = workingDays * orgWorkingHours;
let isNonWorkingPeriod = false;
// If no working days but there might be logged time, set minimum baseline
// This ensures that time logged on non-working days is treated as over-utilization
// Business Logic: If someone works on weekends/holidays when workingDays = 0,
// we use a minimal baseline (1 hour) so any logged time results in >100% utilization
if (totalWorkingHours === 0) {
totalWorkingHours = 1; // Minimal baseline to ensure over-utilization
isNonWorkingPeriod = true;
}
const durationClause = this.getDateRangeClause(duration || DATE_RANGES.LAST_WEEK, date_range);
const archivedClause = archived
? ""
: `AND p.id NOT IN (SELECT project_id FROM archived_projects WHERE project_id = p.id AND user_id = '${req.user?.id}') `;
const billableQuery = this.buildBillableQueryWithAlias(billable, 't');
const members = (req.body.members || []) as string[];
// Prepare members filter
let membersFilter = "";
if (members.length > 0) {
const memberIds = members.map(id => `'${id}'`).join(",");
membersFilter = `AND tmiv.team_member_id IN (${memberIds})`;
} else {
// If no members are selected, we should not show any data
// This is different from other filters where no selection means "show all"
// For members, no selection should mean "show none" to respect the UI filter state
membersFilter = `AND 1=0`; // This will match no rows
}
// Note: Members filter works differently - when no members are selected, show nothing
const billableQuery = this.buildBillableQuery(billable);
// Create custom duration clause for twl table alias
let customDurationClause = "";
if (date_range && date_range.length === 2) {
const start = moment(date_range[0]).format("YYYY-MM-DD");
const end = moment(date_range[1]).format("YYYY-MM-DD");
if (start === end) {
customDurationClause = `AND twl.created_at::DATE = '${start}'::DATE`;
} else {
customDurationClause = `AND twl.created_at::DATE >= '${start}'::DATE AND twl.created_at < '${end}'::DATE + INTERVAL '1 day'`;
}
} else {
const key = duration || DATE_RANGES.LAST_WEEK;
if (key === DATE_RANGES.YESTERDAY)
customDurationClause = "AND twl.created_at >= (CURRENT_DATE - INTERVAL '1 day')::DATE AND twl.created_at < CURRENT_DATE::DATE";
else if (key === DATE_RANGES.LAST_WEEK)
customDurationClause = "AND twl.created_at >= (CURRENT_DATE - INTERVAL '1 week')::DATE AND twl.created_at < CURRENT_DATE::DATE + INTERVAL '1 day'";
else if (key === DATE_RANGES.LAST_MONTH)
customDurationClause = "AND twl.created_at >= (CURRENT_DATE - INTERVAL '1 month')::DATE AND twl.created_at < CURRENT_DATE::DATE + INTERVAL '1 day'";
else if (key === DATE_RANGES.LAST_QUARTER)
customDurationClause = "AND twl.created_at >= (CURRENT_DATE - INTERVAL '3 months')::DATE AND twl.created_at < CURRENT_DATE::DATE + INTERVAL '1 day'";
}
// Prepare conditional filters for the subquery - only apply if selections are made
let conditionalProjectsFilter = "";
let conditionalCategoriesFilter = "";
// Only apply project filter if projects are actually selected
if (projectIds.length > 0) {
conditionalProjectsFilter = `AND p.id IN (${projectIds})`;
}
// Only apply category filter if categories are selected or noCategory is true
if (categories.length > 0 && noCategory) {
const categoryIds = categories.map(id => `'${id}'`).join(",");
conditionalCategoriesFilter = `AND (p.category_id IS NULL OR p.category_id IN (${categoryIds}))`;
} else if (categories.length === 0 && noCategory) {
conditionalCategoriesFilter = `AND p.category_id IS NULL`;
} else if (categories.length > 0 && !noCategory) {
const categoryIds = categories.map(id => `'${id}'`).join(",");
conditionalCategoriesFilter = `AND p.category_id IN (${categoryIds})`;
}
// If no categories and no noCategory, don't filter by category (show all)
// Check if all filters are unchecked (Clear All scenario) - return no data to avoid overwhelming UI
const hasProjectFilter = projectIds.length > 0;
const hasCategoryFilter = categories.length > 0 || noCategory;
const hasMemberFilter = members.length > 0;
// Note: We'll check utilization filter after the query since it's applied post-processing
if (!hasProjectFilter && !hasCategoryFilter && !hasMemberFilter) {
// Still need to check utilization filter, but we'll do a quick check
const utilization = (req.body.utilization || []) as string[];
const hasUtilizationFilter = utilization.length > 0;
if (!hasUtilizationFilter) {
return res.status(200).send(new ServerResponse(true, { filteredRows: [], totals: { total_time_logs: "0", total_estimated_hours: "0", total_utilization: "0" } }));
}
}
// Modified query to start from team members and calculate filtered time logs
// This query ensures ALL active team members are included, even if they have no logged time
const q = `
SELECT
tmiv.team_member_id,
tmiv.email,
tmiv.name,
COALESCE(
(SELECT SUM(twl.time_spent)
FROM task_work_log twl
LEFT JOIN tasks t ON t.id = twl.task_id
LEFT JOIN projects p ON p.id = t.project_id
WHERE twl.user_id = tmiv.user_id
${customDurationClause}
${conditionalProjectsFilter}
${conditionalCategoriesFilter}
${archivedClause}
${billableQuery}
AND p.team_id = tmiv.team_id
), 0
) AS logged_time
FROM team_member_info_view tmiv
WHERE tmiv.team_id IN (${teamIds})
AND tmiv.active = TRUE
${membersFilter}
GROUP BY tmiv.email, tmiv.name, tmiv.team_member_id, tmiv.user_id, tmiv.team_id
ORDER BY logged_time DESC;`;
SELECT tmiv.email, tmiv.name, SUM(time_spent) AS logged_time
FROM team_member_info_view tmiv
LEFT JOIN task_work_log ON task_work_log.user_id = tmiv.user_id
LEFT JOIN tasks ON tasks.id = task_work_log.task_id ${billableQuery}
LEFT JOIN projects p ON p.id = tasks.project_id AND p.team_id = tmiv.team_id
WHERE p.id IN (${projectIds})
${durationClause} ${archivedClause}
GROUP BY tmiv.email, tmiv.name
ORDER BY logged_time DESC;`;
const result = await db.query(q, []);
const utilization = (req.body.utilization || []) as string[];
// Precompute totalWorkingHours * 3600 for efficiency
const totalWorkingSeconds = totalWorkingHours * 3600;
// calculate utilization state
for (let i = 0, len = result.rows.length; i < len; i++) {
const member = result.rows[i];
const loggedSeconds = member.logged_time ? parseFloat(member.logged_time) : 0;
const utilizedHours = loggedSeconds / 3600;
// For individual members, use the same logic as total calculation
let memberWorkingHours;
let utilizationPercent;
if (isNonWorkingPeriod) {
// Non-working period: each member's expected working hours is 0
memberWorkingHours = 0;
// Any time logged during non-working period is overtime
utilizationPercent = loggedSeconds > 0 ? 100 : 0; // Show 100+ as numeric 100 for consistency
} else {
// Normal working period
memberWorkingHours = totalWorkingHours;
utilizationPercent = memberWorkingHours > 0 && loggedSeconds
? ((loggedSeconds / (memberWorkingHours * 3600)) * 100)
: 0;
}
const overUnder = utilizedHours - memberWorkingHours;
member.value = utilizedHours ? parseFloat(utilizedHours.toFixed(2)) : 0;
for (const member of result.rows) {
member.value = member.logged_time ? parseFloat(moment.duration(member.logged_time, "seconds").asHours().toFixed(2)) : 0;
member.color_code = getColor(member.name);
member.total_working_hours = memberWorkingHours;
member.utilization_percent = utilizationPercent.toFixed(2);
member.utilized_hours = utilizedHours.toFixed(2);
member.over_under_utilized_hours = overUnder.toFixed(2);
if (utilizationPercent < 90) {
member.utilization_state = 'under';
} else if (utilizationPercent <= 110) {
member.utilization_state = 'optimal';
} else {
member.utilization_state = 'over';
}
}
// Apply utilization filter
let filteredRows;
if (utilization.length > 0) {
// Filter to only show selected utilization states
filteredRows = result.rows.filter(member => utilization.includes(member.utilization_state));
} else {
// No utilization states selected
// If we reached here, it means at least one other filter was applied
// so we show all members (don't filter by utilization)
filteredRows = result.rows;
}
// Calculate totals
const total_time_logs = filteredRows.reduce((sum, member) => sum + parseFloat(member.logged_time || '0'), 0);
let total_estimated_hours;
let total_utilization;
if (isNonWorkingPeriod) {
// Non-working period: expected capacity is 0
total_estimated_hours = 0;
// Special handling for utilization on non-working days
total_utilization = total_time_logs > 0 ? "100+" : "0";
} else {
// Normal working period calculation
total_estimated_hours = totalWorkingHours * filteredRows.length;
total_utilization = total_time_logs > 0 && total_estimated_hours > 0
? ((total_time_logs / (total_estimated_hours * 3600)) * 100).toFixed(1)
: '0';
}
return res.status(200).send(new ServerResponse(true, {
filteredRows,
totals: {
total_time_logs: ((total_time_logs / 3600).toFixed(2)).toString(),
total_estimated_hours: total_estimated_hours.toString(),
total_utilization: total_utilization.toString(),
},
}));
return res.status(200).send(new ServerResponse(true, result.rows));
}
@HandleExceptions()
@@ -945,9 +515,6 @@ export default class ReportingAllocationController extends ReportingControllerBa
const projects = (req.body.projects || []) as string[];
const projectIds = projects.map(p => `'${p}'`).join(",");
const categories = (req.body.categories || []) as string[];
const noCategory = req.body.noCategory || false;
const { type, billable } = req.body;
if (!teamIds || !projectIds.length)
@@ -963,33 +530,6 @@ export default class ReportingAllocationController extends ReportingControllerBa
const billableQuery = this.buildBillableQuery(billable);
// Prepare projects filter
let projectsFilter = "";
if (projectIds.length > 0) {
projectsFilter = `AND p.id IN (${projectIds})`;
} else {
// If no projects are selected, don't show any data
projectsFilter = `AND 1=0`; // This will match no rows
}
// Prepare categories filter - updated logic
let categoriesFilter = "";
if (categories.length > 0 && noCategory) {
// Both specific categories and "No Category" are selected
const categoryIds = categories.map(id => `'${id}'`).join(",");
categoriesFilter = `AND (p.category_id IS NULL OR p.category_id IN (${categoryIds}))`;
} else if (categories.length === 0 && noCategory) {
// Only "No Category" is selected
categoriesFilter = `AND p.category_id IS NULL`;
} else if (categories.length > 0 && !noCategory) {
// Only specific categories are selected
const categoryIds = categories.map(id => `'${id}'`).join(",");
categoriesFilter = `AND p.category_id IN (${categoryIds})`;
} else {
// categories.length === 0 && !noCategory - no categories selected, show nothing
categoriesFilter = `AND 1=0`; // This will match no rows
}
const q = `
SELECT p.id,
p.name,
@@ -1003,9 +543,9 @@ export default class ReportingAllocationController extends ReportingControllerBa
WHERE project_id = p.id) AS estimated,
color_code
FROM projects p
LEFT JOIN tasks ON tasks.project_id = p.id
LEFT JOIN tasks ON tasks.project_id = p.id ${billableQuery}
LEFT JOIN task_work_log ON task_work_log.task_id = tasks.id
WHERE p.id IN (${projectIds}) ${durationClause} ${archivedClause} ${categoriesFilter} ${billableQuery}
WHERE p.id IN (${projectIds}) ${durationClause} ${archivedClause}
GROUP BY p.id, p.name
ORDER BY logged_time DESC;`;
const result = await db.query(q, []);
@@ -1031,4 +571,4 @@ export default class ReportingAllocationController extends ReportingControllerBa
return res.status(200).send(new ServerResponse(true, data));
}
}
}

View File

@@ -1,140 +0,0 @@
import WorklenzControllerBase from "../worklenz-controller-base";
import { IWorkLenzRequest } from "../../interfaces/worklenz-request";
import db from "../../config/db";
import moment from "moment-timezone";
import { DATE_RANGES } from "../../shared/constants";
export default abstract class ReportingControllerBaseWithTimezone extends WorklenzControllerBase {
/**
* Get the user's timezone from the database or request
* @param userId - The user ID
* @returns The user's timezone or 'UTC' as default
*/
protected static async getUserTimezone(userId: string): Promise<string> {
const q = `SELECT tz.name as timezone
FROM users u
JOIN timezones tz ON u.timezone_id = tz.id
WHERE u.id = $1`;
const result = await db.query(q, [userId]);
return result.rows[0]?.timezone || "UTC";
}
/**
* Generate date range clause with timezone support
* @param key - Date range key (e.g., YESTERDAY, LAST_WEEK)
* @param dateRange - Array of date strings
* @param userTimezone - User's timezone (e.g., 'America/New_York')
* @returns SQL clause for date filtering
*/
protected static getDateRangeClauseWithTimezone(key: string, dateRange: string[], userTimezone: string) {
// For custom date ranges
if (dateRange.length === 2) {
try {
// Handle different date formats that might come from frontend
let startDate, endDate;
// Try to parse the date - it might be a full JS Date string or ISO string
if (dateRange[0].includes("GMT") || dateRange[0].includes("(")) {
// Parse JavaScript Date toString() format
startDate = moment(new Date(dateRange[0]));
endDate = moment(new Date(dateRange[1]));
} else {
// Parse ISO format or other formats
startDate = moment(dateRange[0]);
endDate = moment(dateRange[1]);
}
// Convert to user's timezone and get start/end of day
const start = startDate.tz(userTimezone).startOf("day");
const end = endDate.tz(userTimezone).endOf("day");
// Convert to UTC for database comparison
const startUtc = start.utc().format("YYYY-MM-DD HH:mm:ss");
const endUtc = end.utc().format("YYYY-MM-DD HH:mm:ss");
if (start.isSame(end, "day")) {
// Single day selection
return `AND twl.created_at >= '${startUtc}'::TIMESTAMP AND twl.created_at <= '${endUtc}'::TIMESTAMP`;
}
return `AND twl.created_at >= '${startUtc}'::TIMESTAMP AND twl.created_at <= '${endUtc}'::TIMESTAMP`;
} catch (error) {
console.error("Error parsing date range:", error, { dateRange, userTimezone });
// Fallback to current date if parsing fails
const now = moment.tz(userTimezone);
const startUtc = now.clone().startOf("day").utc().format("YYYY-MM-DD HH:mm:ss");
const endUtc = now.clone().endOf("day").utc().format("YYYY-MM-DD HH:mm:ss");
return `AND twl.created_at >= '${startUtc}'::TIMESTAMP AND twl.created_at <= '${endUtc}'::TIMESTAMP`;
}
}
// For predefined ranges, calculate based on user's timezone
const now = moment.tz(userTimezone);
let startDate, endDate;
switch (key) {
case DATE_RANGES.YESTERDAY:
startDate = now.clone().subtract(1, "day").startOf("day");
endDate = now.clone().subtract(1, "day").endOf("day");
break;
case DATE_RANGES.LAST_WEEK:
startDate = now.clone().subtract(1, "week").startOf("week");
endDate = now.clone().subtract(1, "week").endOf("week");
break;
case DATE_RANGES.LAST_MONTH:
startDate = now.clone().subtract(1, "month").startOf("month");
endDate = now.clone().subtract(1, "month").endOf("month");
break;
case DATE_RANGES.LAST_QUARTER:
startDate = now.clone().subtract(3, "months").startOf("day");
endDate = now.clone().endOf("day");
break;
default:
return "";
}
if (startDate && endDate) {
const startUtc = startDate.utc().format("YYYY-MM-DD HH:mm:ss");
const endUtc = endDate.utc().format("YYYY-MM-DD HH:mm:ss");
return `AND twl.created_at >= '${startUtc}'::TIMESTAMP AND twl.created_at <= '${endUtc}'::TIMESTAMP`;
}
return "";
}
/**
* Format dates for display in user's timezone
* @param date - Date to format
* @param userTimezone - User's timezone
* @param format - Moment format string
* @returns Formatted date string
*/
protected static formatDateInTimezone(date: string | Date, userTimezone: string, format = "YYYY-MM-DD HH:mm:ss") {
return moment.tz(date, userTimezone).format(format);
}
/**
* Get working days count between two dates in user's timezone
* @param startDate - Start date
* @param endDate - End date
* @param userTimezone - User's timezone
* @returns Number of working days
*/
protected static getWorkingDaysInTimezone(startDate: string, endDate: string, userTimezone: string): number {
const start = moment.tz(startDate, userTimezone);
const end = moment.tz(endDate, userTimezone);
let workingDays = 0;
const current = start.clone();
while (current.isSameOrBefore(end, "day")) {
// Monday = 1, Friday = 5
if (current.isoWeekday() >= 1 && current.isoWeekday() <= 5) {
workingDays++;
}
current.add(1, "day");
}
return workingDays;
}
}

View File

@@ -6,69 +6,10 @@ import { IWorkLenzResponse } from "../../interfaces/worklenz-response";
import { ServerResponse } from "../../models/server-response";
import { DATE_RANGES, TASK_PRIORITY_COLOR_ALPHA } from "../../shared/constants";
import { formatDuration, getColor, int } from "../../shared/utils";
import ReportingControllerBaseWithTimezone from "./reporting-controller-base-with-timezone";
import ReportingControllerBase from "./reporting-controller-base";
import Excel from "exceljs";
export default class ReportingMembersController extends ReportingControllerBaseWithTimezone {
protected static getPercentage(n: number, total: number) {
return +(n ? (n / total) * 100 : 0).toFixed();
}
protected static getCurrentTeamId(req: IWorkLenzRequest): string | null {
return req.user?.team_id ?? null;
}
public static convertMinutesToHoursAndMinutes(totalMinutes: number) {
const hours = Math.floor(totalMinutes / 60);
const minutes = totalMinutes % 60;
return `${hours}h ${minutes}m`;
}
public static convertSecondsToHoursAndMinutes(seconds: number) {
const hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
return `${hours}h ${minutes}m`;
}
protected static formatEndDate(endDate: string) {
const end = moment(endDate).format("YYYY-MM-DD");
const fEndDate = moment(end);
return fEndDate;
}
protected static formatCurrentDate() {
const current = moment().format("YYYY-MM-DD");
const fCurrentDate = moment(current);
return fCurrentDate;
}
protected static getDaysLeft(endDate: string): number | null {
if (!endDate) return null;
const fCurrentDate = this.formatCurrentDate();
const fEndDate = this.formatEndDate(endDate);
return fEndDate.diff(fCurrentDate, "days");
}
protected static isOverdue(endDate: string): boolean {
if (!endDate) return false;
const fCurrentDate = this.formatCurrentDate();
const fEndDate = this.formatEndDate(endDate);
return fEndDate.isBefore(fCurrentDate);
}
protected static isToday(endDate: string): boolean {
if (!endDate) return false;
const fCurrentDate = this.formatCurrentDate();
const fEndDate = this.formatEndDate(endDate);
return fEndDate.isSame(fCurrentDate);
}
export default class ReportingMembersController extends ReportingControllerBase {
private static async getMembers(
teamId: string, searchQuery = "",
@@ -90,7 +31,6 @@ export default class ReportingMembersController extends ReportingControllerBaseW
const completedDurationClasue = this.completedDurationFilter(key, dateRange);
const overdueActivityLogsClause = this.getActivityLogsOverdue(key, dateRange);
const activityLogCreationFilter = this.getActivityLogsCreationClause(key, dateRange);
const timeLogDateRangeClause = this.getTimeLogDateRangeClause(key, dateRange);
const q = `SELECT COUNT(DISTINCT email) AS total,
(SELECT COALESCE(ARRAY_TO_JSON(ARRAY_AGG(ROW_TO_JSON(t))), '[]'::JSON)
@@ -160,27 +100,12 @@ export default class ReportingMembersController extends ReportingControllerBaseW
FROM tasks t
LEFT JOIN tasks_assignees ta ON t.id = ta.task_id
WHERE team_member_id = tmiv.team_member_id
AND is_doing((SELECT new_value FROM task_activity_logs tl WHERE tl.task_id = t.id AND tl.attribute_type = 'status' ${activityLogCreationFilter} ORDER BY tl.created_at DESC LIMIT 1)::UUID, t.project_id) ${archivedClause}) AS ongoing_by_activity_logs,
(SELECT COALESCE(SUM(twl.time_spent), 0)
FROM task_work_log twl
LEFT JOIN tasks t ON twl.task_id = t.id
WHERE twl.user_id = (SELECT user_id FROM team_members WHERE id = tmiv.team_member_id)
AND t.billable IS TRUE
AND t.project_id IN (SELECT id FROM projects WHERE team_id = $1)
${timeLogDateRangeClause}
${includeArchived ? "" : `AND t.project_id NOT IN (SELECT project_id FROM archived_projects WHERE project_id = t.project_id AND archived_projects.user_id = '${userId}')`}) AS billable_time,
(SELECT COALESCE(SUM(twl.time_spent), 0)
FROM task_work_log twl
LEFT JOIN tasks t ON twl.task_id = t.id
WHERE twl.user_id = (SELECT user_id FROM team_members WHERE id = tmiv.team_member_id)
AND t.billable IS FALSE
AND t.project_id IN (SELECT id FROM projects WHERE team_id = $1)
${timeLogDateRangeClause}
${includeArchived ? "" : `AND t.project_id NOT IN (SELECT project_id FROM archived_projects WHERE project_id = t.project_id AND archived_projects.user_id = '${userId}')`}) AS non_billable_time
AND is_doing((SELECT new_value FROM task_activity_logs tl WHERE tl.task_id = t.id AND tl.attribute_type = 'status' ${activityLogCreationFilter} ORDER BY tl.created_at DESC LIMIT 1)::UUID, t.project_id) ${archivedClause}) AS ongoing_by_activity_logs
FROM team_member_info_view tmiv
WHERE tmiv.team_id = $1 ${teamsClause}
AND tmiv.team_member_id IN (SELECT team_member_id
FROM project_members
WHERE project_id IN (SELECT id FROM projects WHERE projects.team_id = tmiv.team_id))
${searchQuery}
GROUP BY email, name, avatar_url, team_member_id, tmiv.team_id
ORDER BY last_user_activity DESC NULLS LAST
@@ -188,6 +113,9 @@ export default class ReportingMembersController extends ReportingControllerBaseW
${pagingClause}) t) AS members
FROM team_member_info_view tmiv
WHERE tmiv.team_id = $1 ${teamsClause}
AND tmiv.team_member_id IN (SELECT team_member_id
FROM project_members
WHERE project_id IN (SELECT id FROM projects WHERE projects.team_id = tmiv.team_id))
${searchQuery}`;
const result = await db.query(q, [teamId]);
const [data] = result.rows;
@@ -383,30 +311,6 @@ export default class ReportingMembersController extends ReportingControllerBaseW
return "";
}
protected static getTimeLogDateRangeClause(key: string, dateRange: string[]) {
if (dateRange.length === 2) {
const start = moment(dateRange[0]).format("YYYY-MM-DD");
const end = moment(dateRange[1]).format("YYYY-MM-DD");
if (start === end) {
return `AND twl.created_at::DATE = '${start}'::DATE`;
}
return `AND twl.created_at::DATE >= '${start}'::DATE AND twl.created_at < '${end}'::DATE + INTERVAL '1 day'`;
}
if (key === DATE_RANGES.YESTERDAY)
return `AND twl.created_at >= (CURRENT_DATE - INTERVAL '1 day')::DATE AND twl.created_at < CURRENT_DATE::DATE`;
if (key === DATE_RANGES.LAST_WEEK)
return `AND twl.created_at >= (CURRENT_DATE - INTERVAL '1 week')::DATE AND twl.created_at < CURRENT_DATE::DATE + INTERVAL '1 day'`;
if (key === DATE_RANGES.LAST_MONTH)
return `AND twl.created_at >= (CURRENT_DATE - INTERVAL '1 month')::DATE AND twl.created_at < CURRENT_DATE::DATE + INTERVAL '1 day'`;
if (key === DATE_RANGES.LAST_QUARTER)
return `AND twl.created_at >= (CURRENT_DATE - INTERVAL '3 months')::DATE AND twl.created_at < CURRENT_DATE::DATE + INTERVAL '1 day'`;
return "";
}
private static formatDuration(duration: moment.Duration) {
const empty = "0h 0m";
let format = "";
@@ -519,8 +423,6 @@ export default class ReportingMembersController extends ReportingControllerBaseW
{ header: "Overdue Tasks", key: "overdue_tasks", width: 20 },
{ header: "Completed Tasks", key: "completed_tasks", width: 20 },
{ header: "Ongoing Tasks", key: "ongoing_tasks", width: 20 },
{ header: "Billable Time (seconds)", key: "billable_time", width: 25 },
{ header: "Non-Billable Time (seconds)", key: "non_billable_time", width: 25 },
{ header: "Done Tasks(%)", key: "done_tasks", width: 20 },
{ header: "Doing Tasks(%)", key: "doing_tasks", width: 20 },
{ header: "Todo Tasks(%)", key: "todo_tasks", width: 20 }
@@ -528,14 +430,14 @@ export default class ReportingMembersController extends ReportingControllerBaseW
// set title
sheet.getCell("A1").value = `Members from ${teamName}`;
sheet.mergeCells("A1:M1");
sheet.mergeCells("A1:K1");
sheet.getCell("A1").alignment = { horizontal: "center" };
sheet.getCell("A1").style.fill = { type: "pattern", pattern: "solid", fgColor: { argb: "D9D9D9" } };
sheet.getCell("A1").font = { size: 16 };
// set export date
sheet.getCell("A2").value = `Exported on : ${exportDate}`;
sheet.mergeCells("A2:M2");
sheet.mergeCells("A2:K2");
sheet.getCell("A2").alignment = { horizontal: "center" };
sheet.getCell("A2").style.fill = { type: "pattern", pattern: "solid", fgColor: { argb: "F2F2F2" } };
sheet.getCell("A2").font = { size: 12 };
@@ -545,7 +447,7 @@ export default class ReportingMembersController extends ReportingControllerBaseW
sheet.mergeCells("A3:D3");
// set table headers
sheet.getRow(5).values = ["Member", "Email", "Tasks Assigned", "Overdue Tasks", "Completed Tasks", "Ongoing Tasks", "Billable Time (seconds)", "Non-Billable Time (seconds)", "Done Tasks(%)", "Doing Tasks(%)", "Todo Tasks(%)"];
sheet.getRow(5).values = ["Member", "Email", "Tasks Assigned", "Overdue Tasks", "Completed Tasks", "Ongoing Tasks", "Done Tasks(%)", "Doing Tasks(%)", "Todo Tasks(%)"];
sheet.getRow(5).font = { bold: true };
for (const member of result.members) {
@@ -556,8 +458,6 @@ export default class ReportingMembersController extends ReportingControllerBaseW
overdue_tasks: member.overdue,
completed_tasks: member.completed,
ongoing_tasks: member.ongoing,
billable_time: member.billable_time || 0,
non_billable_time: member.non_billable_time || 0,
done_tasks: member.completed,
doing_tasks: member.ongoing_by_activity_logs,
todo_tasks: member.todo_by_activity_logs
@@ -587,9 +487,7 @@ export default class ReportingMembersController extends ReportingControllerBaseW
dateRange = date_range.split(",");
}
// Get user timezone for proper date filtering
const userTimezone = await this.getUserTimezone(req.user?.id as string);
const durationClause = this.getDateRangeClauseWithTimezone(duration as string || DATE_RANGES.LAST_WEEK, dateRange, userTimezone);
const durationClause = ReportingMembersController.getDateRangeClauseMembers(duration as string || DATE_RANGES.LAST_WEEK, dateRange, "twl");
const minMaxDateClause = this.getMinMaxDates(duration as string || DATE_RANGES.LAST_WEEK, dateRange, "task_work_log");
const memberName = (req.query.member_name as string)?.trim() || null;
@@ -1140,9 +1038,7 @@ export default class ReportingMembersController extends ReportingControllerBaseW
public static async getMemberTimelogs(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
const { team_member_id, team_id, duration, date_range, archived, billable } = req.body;
// Get user timezone for proper date filtering
const userTimezone = await this.getUserTimezone(req.user?.id as string);
const durationClause = this.getDateRangeClauseWithTimezone(duration || DATE_RANGES.LAST_WEEK, date_range, userTimezone);
const durationClause = ReportingMembersController.getDateRangeClauseMembers(duration || DATE_RANGES.LAST_WEEK, date_range, "twl");
const minMaxDateClause = this.getMinMaxDates(duration || DATE_RANGES.LAST_WEEK, date_range, "task_work_log");
const billableQuery = this.buildBillableQuery(billable);
@@ -1334,8 +1230,8 @@ public static async getSingleMemberProjects(req: IWorkLenzRequest, res: IWorkLen
row.actual_time = int(row.actual_time);
row.estimated_time_string = this.convertMinutesToHoursAndMinutes(int(row.estimated_time));
row.actual_time_string = this.convertSecondsToHoursAndMinutes(int(row.actual_time));
row.days_left = this.getDaysLeft(row.end_date);
row.is_overdue = this.isOverdue(row.end_date);
row.days_left = ReportingControllerBase.getDaysLeft(row.end_date);
row.is_overdue = ReportingControllerBase.isOverdue(row.end_date);
if (row.days_left && row.is_overdue) {
row.days_left = row.days_left.toString().replace(/-/g, "");
}
@@ -1433,4 +1329,4 @@ public static async getSingleMemberProjects(req: IWorkLenzRequest, res: IWorkLen
}
}
}

View File

@@ -53,13 +53,13 @@ export default class ScheduleControllerV2 extends WorklenzControllerBase {
const [workingDays] = workingDaysResults.rows;
// get organization working hours
const getDataHoursq = `SELECT hours_per_day FROM organizations WHERE user_id = $1 GROUP BY id LIMIT 1;`;
const getDataHoursq = `SELECT working_hours FROM organizations WHERE user_id = $1 GROUP BY id LIMIT 1;`;
const workingHoursResults = await db.query(getDataHoursq, [req.user?.owner_id]);
const [workingHours] = workingHoursResults.rows;
return res.status(200).send(new ServerResponse(true, { workingDays: workingDays?.working_days, workingHours: workingHours?.hours_per_day }));
return res.status(200).send(new ServerResponse(true, { workingDays: workingDays?.working_days, workingHours: workingHours?.working_hours }));
}
@HandleExceptions()
@@ -74,13 +74,18 @@ export default class ScheduleControllerV2 extends WorklenzControllerBase {
.map(day => `${day.toLowerCase()} = ${workingDays.includes(day)}`)
.join(", ");
const updateQuery = `UPDATE public.organization_working_days
const updateQuery = `
UPDATE public.organization_working_days
SET ${setClause}, updated_at = CURRENT_TIMESTAMP
WHERE organization_id IN (SELECT id FROM organizations WHERE user_id = $1);`;
WHERE organization_id IN (
SELECT organization_id FROM organizations
WHERE user_id = $1
);
`;
await db.query(updateQuery, [req.user?.owner_id]);
const getDataHoursq = `UPDATE organizations SET hours_per_day = $1 WHERE user_id = $2;`;
const getDataHoursq = `UPDATE organizations SET working_hours = $1 WHERE user_id = $2;`;
await db.query(getDataHoursq, [workingHours, req.user?.owner_id]);

View File

@@ -1,201 +0,0 @@
import { IWorkLenzRequest } from "../interfaces/worklenz-request";
import { IWorkLenzResponse } from "../interfaces/worklenz-response";
import { ServerResponse } from "../models/server-response";
import WorklenzControllerBase from "./worklenz-controller-base";
import HandleExceptions from "../decorators/handle-exceptions";
import { ISurveySubmissionRequest } from "../interfaces/survey";
import db from "../config/db";
export default class SurveyController extends WorklenzControllerBase {
@HandleExceptions()
public static async getAccountSetupSurvey(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
const q = `
SELECT
s.id,
s.name,
s.description,
s.survey_type,
s.is_active,
COALESCE(
json_agg(
json_build_object(
'id', sq.id,
'survey_id', sq.survey_id,
'question_key', sq.question_key,
'question_type', sq.question_type,
'is_required', sq.is_required,
'sort_order', sq.sort_order,
'options', sq.options
) ORDER BY sq.sort_order
) FILTER (WHERE sq.id IS NOT NULL),
'[]'
) AS questions
FROM surveys s
LEFT JOIN survey_questions sq ON s.id = sq.survey_id
WHERE s.survey_type = 'account_setup' AND s.is_active = true
GROUP BY s.id, s.name, s.description, s.survey_type, s.is_active
LIMIT 1;
`;
const result = await db.query(q);
const [survey] = result.rows;
if (!survey) {
return res.status(200).send(new ServerResponse(false, null, "Account setup survey not found"));
}
return res.status(200).send(new ServerResponse(true, survey));
}
@HandleExceptions()
public static async submitSurveyResponse(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
const userId = req.user?.id;
const body = req.body as ISurveySubmissionRequest;
if (!userId) {
return res.status(200).send(new ServerResponse(false, null, "User not authenticated"));
}
if (!body.survey_id || !body.answers || !Array.isArray(body.answers)) {
return res.status(200).send(new ServerResponse(false, null, "Invalid survey submission data"));
}
// Check if user has already submitted a response for this survey
const existingResponseQuery = `
SELECT id FROM survey_responses
WHERE user_id = $1 AND survey_id = $2;
`;
const existingResult = await db.query(existingResponseQuery, [userId, body.survey_id]);
let responseId: string;
if (existingResult.rows.length > 0) {
// Update existing response
responseId = existingResult.rows[0].id;
const updateResponseQuery = `
UPDATE survey_responses
SET is_completed = true, completed_at = NOW(), updated_at = NOW()
WHERE id = $1;
`;
await db.query(updateResponseQuery, [responseId]);
// Delete existing answers
const deleteAnswersQuery = `DELETE FROM survey_answers WHERE response_id = $1;`;
await db.query(deleteAnswersQuery, [responseId]);
} else {
// Create new response
const createResponseQuery = `
INSERT INTO survey_responses (survey_id, user_id, is_completed, completed_at)
VALUES ($1, $2, true, NOW())
RETURNING id;
`;
const responseResult = await db.query(createResponseQuery, [body.survey_id, userId]);
responseId = responseResult.rows[0].id;
}
// Insert new answers
if (body.answers.length > 0) {
const answerValues: string[] = [];
const params: any[] = [];
body.answers.forEach((answer, index) => {
const baseIndex = index * 4;
answerValues.push(`($${baseIndex + 1}, $${baseIndex + 2}, $${baseIndex + 3}, $${baseIndex + 4})`);
params.push(
responseId,
answer.question_id,
answer.answer_text || null,
answer.answer_json ? JSON.stringify(answer.answer_json) : null
);
});
const insertAnswersQuery = `
INSERT INTO survey_answers (response_id, question_id, answer_text, answer_json)
VALUES ${answerValues.join(', ')};
`;
await db.query(insertAnswersQuery, params);
}
return res.status(200).send(new ServerResponse(true, { response_id: responseId }));
}
@HandleExceptions()
public static async getUserSurveyResponse(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
const userId = req.user?.id;
const surveyId = req.params.survey_id;
if (!userId) {
return res.status(200).send(new ServerResponse(false, null, "User not authenticated"));
}
const q = `
SELECT
sr.id,
sr.survey_id,
sr.user_id,
sr.is_completed,
sr.started_at,
sr.completed_at,
COALESCE(
json_agg(
json_build_object(
'question_id', sa.question_id,
'answer_text', sa.answer_text,
'answer_json', sa.answer_json
)
) FILTER (WHERE sa.id IS NOT NULL),
'[]'
) AS answers
FROM survey_responses sr
LEFT JOIN survey_answers sa ON sr.id = sa.response_id
WHERE sr.user_id = $1 AND sr.survey_id = $2
GROUP BY sr.id, sr.survey_id, sr.user_id, sr.is_completed, sr.started_at, sr.completed_at;
`;
const result = await db.query(q, [userId, surveyId]);
const [response] = result.rows;
if (!response) {
return res.status(200).send(new ServerResponse(false, null, "Survey response not found"));
}
return res.status(200).send(new ServerResponse(true, response));
}
@HandleExceptions()
public static async checkAccountSetupSurveyStatus(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
const userId = req.user?.id;
if (!userId) {
return res.status(200).send(new ServerResponse(false, null, "User not authenticated"));
}
const q = `
SELECT EXISTS(
SELECT 1
FROM survey_responses sr
INNER JOIN surveys s ON sr.survey_id = s.id
WHERE sr.user_id = $1
AND s.survey_type = 'account_setup'
AND sr.is_completed = true
) as is_completed,
(
SELECT sr.completed_at
FROM survey_responses sr
INNER JOIN surveys s ON sr.survey_id = s.id
WHERE sr.user_id = $1
AND s.survey_type = 'account_setup'
AND sr.is_completed = true
LIMIT 1
) as completed_at;
`;
const result = await db.query(q, [userId]);
const status = result.rows[0] || { is_completed: false, completed_at: null };
return res.status(200).send(new ServerResponse(true, status));
}
}

View File

@@ -16,23 +16,19 @@ export default class TaskPhasesController extends WorklenzControllerBase {
if (!req.query.id)
return res.status(400).send(new ServerResponse(false, null, "Invalid request"));
// Use custom name if provided, otherwise use default naming pattern
const phaseName = req.body.name?.trim() ||
`Untitled Phase (${(await db.query("SELECT COUNT(*) FROM project_phases WHERE project_id = $1", [req.query.id])).rows[0].count + 1})`;
const q = `
INSERT INTO project_phases (name, color_code, project_id, sort_index)
VALUES (
CONCAT('Untitled Phase (', (SELECT COUNT(*) FROM project_phases WHERE project_id = $2) + 1, ')'),
$1,
$2,
$3,
(SELECT COUNT(*) FROM project_phases WHERE project_id = $3) + 1)
(SELECT COUNT(*) FROM project_phases WHERE project_id = $2) + 1)
RETURNING id, name, color_code, sort_index;
`;
req.body.color_code = this.DEFAULT_PHASE_COLOR;
const result = await db.query(q, [phaseName, req.body.color_code, req.query.id]);
const result = await db.query(q, [req.body.color_code, req.query.id]);
const [data] = result.rows;
data.color_code = getColor(data.name) + TASK_STATUS_COLOR_ALPHA;

View File

@@ -134,25 +134,6 @@ export default class TaskStatusesController extends WorklenzControllerBase {
return res.status(200).send(new ServerResponse(true, data));
}
@HandleExceptions()
public static async updateCategory(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
const hasMoreCategories = await TaskStatusesController.hasMoreCategories(req.params.id, req.query.current_project_id as string);
if (!hasMoreCategories)
return res.status(200).send(new ServerResponse(false, null, existsErrorMessage).withTitle("Status category update failed!"));
const q = `
UPDATE task_statuses
SET category_id = $2
WHERE id = $1
AND project_id = $3
RETURNING (SELECT color_code FROM sys_task_status_categories WHERE id = task_statuses.category_id), (SELECT color_code_dark FROM sys_task_status_categories WHERE id = task_statuses.category_id);
`;
const result = await db.query(q, [req.params.id, req.body.category_id, req.query.current_project_id]);
const [data] = result.rows;
return res.status(200).send(new ServerResponse(true, data));
}
@HandleExceptions()
public static async updateStatusOrder(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
const q = `SELECT update_status_order($1);`;

View File

@@ -1,6 +1,6 @@
import WorklenzControllerBase from "./worklenz-controller-base";
import { getColor } from "../shared/utils";
import { PriorityColorCodes, TASK_PRIORITY_COLOR_ALPHA, TASK_STATUS_COLOR_ALPHA } from "../shared/constants";
import {getColor} from "../shared/utils";
import {PriorityColorCodes, TASK_PRIORITY_COLOR_ALPHA, TASK_STATUS_COLOR_ALPHA} from "../shared/constants";
import moment from "moment/moment";
export const GroupBy = {
@@ -16,7 +16,6 @@ export interface ITaskGroup {
start_date?: string;
end_date?: string;
color_code: string;
color_code_dark: string;
category_id: string | null;
old_category_id?: string;
todo_progress?: number;
@@ -33,46 +32,10 @@ export default class TasksControllerBase extends WorklenzControllerBase {
}
public static updateTaskViewModel(task: any) {
// For parent tasks (with subtasks), always use calculated progress from subtasks
if (task.sub_tasks_count > 0) {
// Ensure progress matches complete_ratio for consistency
task.progress = task.complete_ratio || 0;
// Important: Parent tasks should not have manual progress
// If they somehow do, reset it
if (task.manual_progress) {
task.manual_progress = false;
task.progress_value = null;
}
}
// For tasks without subtasks, respect manual progress if set
else if (task.manual_progress === true && task.progress_value !== null && task.progress_value !== undefined) {
// For manually set progress, use that value directly
task.progress = parseInt(task.progress_value);
task.complete_ratio = parseInt(task.progress_value);
}
// For tasks with no subtasks and no manual progress
else {
// Only calculate progress based on time if time-based progress is enabled for the project
if (task.project_use_time_progress && task.total_minutes_spent && task.total_minutes) {
// Cap the progress at 100% to prevent showing more than 100% progress
task.progress = Math.min(~~(task.total_minutes_spent / task.total_minutes * 100), 100);
} else {
// Default to 0% progress when time-based calculation is not enabled
task.progress = 0;
}
// Set complete_ratio to match progress
task.complete_ratio = task.progress;
}
// Ensure numeric values
task.progress = parseInt(task.progress) || 0;
task.complete_ratio = parseInt(task.complete_ratio) || 0;
task.progress = ~~(task.total_minutes_spent / task.total_minutes * 100);
task.overdue = task.total_minutes < task.total_minutes_spent;
task.time_spent = { hours: ~~(task.total_minutes_spent / 60), minutes: task.total_minutes_spent % 60 };
task.time_spent = {hours: ~~(task.total_minutes_spent / 60), minutes: task.total_minutes_spent % 60};
task.comments_count = Number(task.comments_count) ? +task.comments_count : 0;
task.attachments_count = Number(task.attachments_count) ? +task.attachments_count : 0;
@@ -110,9 +73,9 @@ export default class TasksControllerBase extends WorklenzControllerBase {
if (task.timer_start_time)
task.timer_start_time = moment(task.timer_start_time).valueOf();
// Set completed_count and total_tasks_count regardless of progress calculation method
const totalCompleted = (+task.completed_sub_tasks + +task.parent_task_completed) || 0;
const totalTasks = +task.sub_tasks_count || 0;
const totalTasks = +task.sub_tasks_count || 0; // if needed add +1 for parent
task.complete_ratio = TasksControllerBase.calculateTaskCompleteRatio(totalCompleted, totalTasks);
task.completed_count = totalCompleted;
task.total_tasks_count = totalTasks;

File diff suppressed because it is too large Load Diff

View File

@@ -13,7 +13,7 @@ import { SocketEvents } from "../socket.io/events";
import WorklenzControllerBase from "./worklenz-controller-base";
import HandleExceptions from "../decorators/handle-exceptions";
import { formatDuration, getColor } from "../shared/utils";
import { statusExclude, TEAM_MEMBER_TREE_MAP_COLOR_ALPHA, TRIAL_MEMBER_LIMIT } from "../shared/constants";
import { statusExclude, TEAM_MEMBER_TREE_MAP_COLOR_ALPHA } from "../shared/constants";
import { checkTeamSubscriptionStatus } from "../shared/paddle-utils";
import { updateUsers } from "../shared/paddle-requests";
import { NotificationsService } from "../services/notifications/notifications.service";
@@ -141,17 +141,6 @@ export default class TeamMembersController extends WorklenzControllerBase {
return res.status(200).send(new ServerResponse(false, null, "Cannot exceed the maximum number of life time users."));
}
/**
* Checks trial user team member limit
*/
if (subscriptionData.subscription_status === "trialing") {
const currentTrialMembers = parseInt(subscriptionData.current_count) || 0;
if (currentTrialMembers + incrementBy > TRIAL_MEMBER_LIMIT) {
return res.status(200).send(new ServerResponse(false, null, `Trial users cannot exceed ${TRIAL_MEMBER_LIMIT} team members. Please upgrade to add more members.`));
}
}
/**
* Checks subscription details and updates the user count if applicable.
* Sends a response if there is an issue with the subscription.
@@ -1092,18 +1081,6 @@ export default class TeamMembersController extends WorklenzControllerBase {
return res.status(200).send(new ServerResponse(false, "Please check your subscription status."));
}
/**
* Checks trial user team member limit
*/
if (subscriptionData.subscription_status === "trialing") {
const currentTrialMembers = parseInt(subscriptionData.current_count) || 0;
const emailsToAdd = req.body.emails?.length || 1;
if (currentTrialMembers + emailsToAdd > TRIAL_MEMBER_LIMIT) {
return res.status(200).send(new ServerResponse(false, null, `Trial users cannot exceed ${TRIAL_MEMBER_LIMIT} team members. Please upgrade to add more members.`));
}
}
// if (subscriptionData.status === "trialing") break;
if (!subscriptionData.is_credit && !subscriptionData.is_custom) {
if (subscriptionData.subscription_status === "active") {

View File

@@ -1,117 +0,0 @@
import moment from "moment";
import { IWorkLenzRequest } from "../interfaces/worklenz-request";
import { IWorkLenzResponse } from "../interfaces/worklenz-response";
import db from "../config/db";
import { ServerResponse } from "../models/server-response";
import WorklenzControllerBase from "./worklenz-controller-base";
import HandleExceptions from "../decorators/handle-exceptions";
import { formatDuration, formatLogText, getColor } from "../shared/utils";
interface IUserRecentTask {
task_id: string;
task_name: string;
project_id: string;
project_name: string;
last_activity_at: string;
activity_count: number;
project_color?: string;
task_status?: string;
status_color?: string;
}
interface IUserTimeLoggedTask {
task_id: string;
task_name: string;
project_id: string;
project_name: string;
total_time_logged: number;
total_time_logged_string: string;
last_logged_at: string;
logged_by_timer: boolean;
project_color?: string;
task_status?: string;
status_color?: string;
log_entries_count?: number;
estimated_time?: number;
}
export default class UserActivityLogsController extends WorklenzControllerBase {
@HandleExceptions()
public static async getRecentTasks(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
if (!req.user) {
return res.status(401).send(new ServerResponse(false, null, "Unauthorized"));
}
const { id: userId, team_id: teamId } = req.user;
const { offset = 0, limit = 10 } = req.query;
// Optimized query with better performance and team filtering
const q = `
SELECT DISTINCT tal.task_id, t.name AS task_name, tal.project_id, p.name AS project_name,
MAX(tal.created_at) AS last_activity_at,
COUNT(DISTINCT tal.id) AS activity_count,
p.color_code AS project_color,
(SELECT name FROM task_statuses WHERE id = t.status_id) AS task_status,
(SELECT color_code
FROM sys_task_status_categories
WHERE id = (SELECT category_id FROM task_statuses WHERE id = t.status_id)) AS status_color
FROM task_activity_logs tal
INNER JOIN tasks t ON tal.task_id = t.id AND t.archived = FALSE
INNER JOIN projects p ON tal.project_id = p.id AND p.team_id = $1
WHERE tal.user_id = $2
AND tal.created_at >= NOW() - INTERVAL '30 days'
GROUP BY tal.task_id, t.name, tal.project_id, p.name, p.color_code, t.status_id
ORDER BY MAX(tal.created_at) DESC
LIMIT $3 OFFSET $4;
`;
const result = await db.query(q, [teamId, userId, limit, offset]);
const tasks: IUserRecentTask[] = result.rows;
return res.status(200).send(new ServerResponse(true, tasks));
}
@HandleExceptions()
public static async getTimeLoggedTasks(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
if (!req.user) {
return res.status(401).send(new ServerResponse(false, null, "Unauthorized"));
}
const { id: userId, team_id: teamId } = req.user;
const { offset = 0, limit = 10 } = req.query;
// Optimized query with better performance, team filtering, and useful additional data
const q = `
SELECT twl.task_id, t.name AS task_name, t.project_id, p.name AS project_name,
SUM(twl.time_spent) AS total_time_logged,
MAX(twl.created_at) AS last_logged_at,
MAX(twl.logged_by_timer::int)::boolean AS logged_by_timer,
p.color_code AS project_color,
(SELECT name FROM task_statuses WHERE id = t.status_id) AS task_status,
(SELECT color_code
FROM sys_task_status_categories
WHERE id = (SELECT category_id FROM task_statuses WHERE id = t.status_id)) AS status_color,
COUNT(DISTINCT twl.id) AS log_entries_count,
(t.total_minutes * 60) AS estimated_time
FROM task_work_log twl
INNER JOIN tasks t ON twl.task_id = t.id AND t.archived = FALSE
INNER JOIN projects p ON t.project_id = p.id AND p.team_id = $1
WHERE twl.user_id = $2
AND twl.created_at >= NOW() - INTERVAL '90 days'
GROUP BY twl.task_id, t.name, t.project_id, p.name, p.color_code, t.status_id, t.total_minutes
HAVING SUM(twl.time_spent) > 0
ORDER BY MAX(twl.created_at) DESC
LIMIT $3 OFFSET $4;
`;
const result = await db.query(q, [teamId, userId, limit, offset]);
const tasks: IUserTimeLoggedTask[] = result.rows.map(task => ({
...task,
total_time_logged_string: formatDuration(moment.duration(task.total_time_logged, "seconds")),
}));
return res.status(200).send(new ServerResponse(true, tasks));
}
}

View File

@@ -34,24 +34,29 @@ export default abstract class WorklenzControllerBase {
const offset = queryParams.search ? 0 : (index - 1) * size;
const paging = queryParams.paging || "true";
// let s = "";
// if (typeof searchField === "string") {
// s = `${searchField} || ' ' || id::TEXT`;
// } else if (Array.isArray(searchField)) {
// s = searchField.join(" || ' ' || ");
// }
// const search = (queryParams.search as string || "").trim();
// const searchQuery = search ? `AND TO_TSVECTOR(${s}) @@ TO_TSQUERY('${toTsQuery(search)}')` : "";
const search = (queryParams.search as string || "").trim();
let s = "";
if (typeof searchField === "string") {
s = ` ${searchField} ILIKE '%${search}%'`;
} else if (Array.isArray(searchField)) {
s = searchField.map(index => ` ${index} ILIKE '%${search}%'`).join(" OR ");
}
let searchQuery = "";
if (search) {
// Properly escape single quotes to prevent SQL syntax errors
const escapedSearch = search.replace(/'/g, "''");
let s = "";
if (typeof searchField === "string") {
s = ` ${searchField} ILIKE '%${escapedSearch}%'`;
} else if (Array.isArray(searchField)) {
s = searchField.map(field => ` ${field} ILIKE '%${escapedSearch}%'`).join(" OR ");
}
if (s) {
searchQuery = isMemberFilter ? ` (${s}) AND ` : ` AND (${s}) `;
}
searchQuery = isMemberFilter ? ` (${s}) AND ` : ` AND (${s}) `;
}
// Sort

View File

@@ -1,11 +1,11 @@
import {startDailyDigestJob} from "./daily-digest-job";
import {startNotificationsJob} from "./notifications-job";
import {startProjectDigestJob} from "./project-digest-job";
import {startRecurringTasksJob} from "./recurring-tasks";
import { startRecurringTasksJob } from "./recurring-tasks";
export function startCronJobs() {
startNotificationsJob();
startDailyDigestJob();
startProjectDigestJob();
if (process.env.ENABLE_RECURRING_JOBS === "true") startRecurringTasksJob();
// startRecurringTasksJob();
}

View File

@@ -7,90 +7,12 @@ import TasksController from "../controllers/tasks-controller";
// At 11:00+00 (4.30pm+530) on every day-of-month if it's on every day-of-week from Monday through Friday.
// const TIME = "0 11 */1 * 1-5";
const TIME = process.env.RECURRING_JOBS_INTERVAL || "0 11 */1 * 1-5";
const TIME = "*/2 * * * *";
const TIME_FORMAT = "YYYY-MM-DD";
// const TIME = "0 0 * * *"; // Runs at midnight every day
const log = (value: any) => console.log("recurring-task-cron-job:", value);
// Define future limits for different schedule types
// More conservative limits to prevent task list clutter
const FUTURE_LIMITS = {
daily: moment.duration(3, "days"),
weekly: moment.duration(1, "week"),
monthly: moment.duration(1, "month"),
every_x_days: (interval: number) => moment.duration(interval, "days"),
every_x_weeks: (interval: number) => moment.duration(interval, "weeks"),
every_x_months: (interval: number) => moment.duration(interval, "months")
};
// Helper function to get the future limit based on schedule type
function getFutureLimit(scheduleType: string, interval?: number): moment.Duration {
switch (scheduleType) {
case "daily":
return FUTURE_LIMITS.daily;
case "weekly":
return FUTURE_LIMITS.weekly;
case "monthly":
return FUTURE_LIMITS.monthly;
case "every_x_days":
return FUTURE_LIMITS.every_x_days(interval || 1);
case "every_x_weeks":
return FUTURE_LIMITS.every_x_weeks(interval || 1);
case "every_x_months":
return FUTURE_LIMITS.every_x_months(interval || 1);
default:
return moment.duration(3, "days"); // Default to 3 days
}
}
// Helper function to batch create tasks
async function createBatchTasks(template: ITaskTemplate & IRecurringSchedule, endDates: moment.Moment[]) {
const createdTasks = [];
for (const nextEndDate of endDates) {
const existingTaskQuery = `
SELECT id FROM tasks
WHERE schedule_id = $1 AND end_date::DATE = $2::DATE;
`;
const existingTaskResult = await db.query(existingTaskQuery, [template.schedule_id, nextEndDate.format(TIME_FORMAT)]);
if (existingTaskResult.rows.length === 0) {
const createTaskQuery = `SELECT create_quick_task($1::json) as task;`;
const taskData = {
name: template.name,
priority_id: template.priority_id,
project_id: template.project_id,
reporter_id: template.reporter_id,
status_id: template.status_id || null,
end_date: nextEndDate.format(TIME_FORMAT),
schedule_id: template.schedule_id
};
const createTaskResult = await db.query(createTaskQuery, [JSON.stringify(taskData)]);
const createdTask = createTaskResult.rows[0].task;
if (createdTask) {
createdTasks.push(createdTask);
for (const assignee of template.assignees) {
await TasksController.createTaskBulkAssignees(assignee.team_member_id, template.project_id, createdTask.id, assignee.assigned_by);
}
for (const label of template.labels) {
const q = `SELECT add_or_remove_task_label($1, $2) AS labels;`;
await db.query(q, [createdTask.id, label.label_id]);
}
console.log(`Created task for template ${template.name} with end date ${nextEndDate.format(TIME_FORMAT)}`);
}
} else {
console.log(`Skipped creating task for template ${template.name} with end date ${nextEndDate.format(TIME_FORMAT)} - task already exists`);
}
}
return createdTasks;
}
async function onRecurringTaskJobTick() {
try {
log("(cron) Recurring tasks job started.");
@@ -111,44 +33,65 @@ async function onRecurringTaskJobTick() {
? moment(template.last_task_end_date)
: moment(template.created_at);
// Calculate future limit based on schedule type
const futureLimit = moment(template.last_checked_at || template.created_at)
.add(getFutureLimit(
template.schedule_type,
template.interval_days || template.interval_weeks || template.interval_months || 1
));
const futureLimit = moment(template.last_checked_at || template.created_at).add(1, "week");
let nextEndDate = calculateNextEndDate(template, lastTaskEndDate);
const endDatesToCreate: moment.Moment[] = [];
// Find all future occurrences within the limit
while (nextEndDate.isSameOrBefore(futureLimit)) {
if (nextEndDate.isAfter(now)) {
endDatesToCreate.push(moment(nextEndDate));
}
// Find the next future occurrence
while (nextEndDate.isSameOrBefore(now)) {
nextEndDate = calculateNextEndDate(template, nextEndDate);
}
// Batch create tasks for all future dates
if (endDatesToCreate.length > 0) {
const createdTasks = await createBatchTasks(template, endDatesToCreate);
createdTaskCount += createdTasks.length;
// Update the last_checked_at in the schedule
const updateScheduleQuery = `
UPDATE task_recurring_schedules
SET last_checked_at = $1::DATE,
last_created_task_end_date = $2
WHERE id = $3;
// Only create a task if it's within the future limit
if (nextEndDate.isSameOrBefore(futureLimit)) {
const existingTaskQuery = `
SELECT id FROM tasks
WHERE schedule_id = $1 AND end_date::DATE = $2::DATE;
`;
await db.query(updateScheduleQuery, [
moment().format(TIME_FORMAT),
endDatesToCreate[endDatesToCreate.length - 1].format(TIME_FORMAT),
template.schedule_id
]);
const existingTaskResult = await db.query(existingTaskQuery, [template.schedule_id, nextEndDate.format(TIME_FORMAT)]);
if (existingTaskResult.rows.length === 0) {
const createTaskQuery = `SELECT create_quick_task($1::json) as task;`;
const taskData = {
name: template.name,
priority_id: template.priority_id,
project_id: template.project_id,
reporter_id: template.reporter_id,
status_id: template.status_id || null,
end_date: nextEndDate.format(TIME_FORMAT),
schedule_id: template.schedule_id
};
const createTaskResult = await db.query(createTaskQuery, [JSON.stringify(taskData)]);
const createdTask = createTaskResult.rows[0].task;
if (createdTask) {
createdTaskCount++;
for (const assignee of template.assignees) {
await TasksController.createTaskBulkAssignees(assignee.team_member_id, template.project_id, createdTask.id, assignee.assigned_by);
}
for (const label of template.labels) {
const q = `SELECT add_or_remove_task_label($1, $2) AS labels;`;
await db.query(q, [createdTask.id, label.label_id]);
}
console.log(`Created task for template ${template.name} with end date ${nextEndDate.format(TIME_FORMAT)}`);
}
} else {
console.log(`Skipped creating task for template ${template.name} with end date ${nextEndDate.format(TIME_FORMAT)} - task already exists`);
}
} else {
console.log(`No tasks created for template ${template.name} - next occurrence is beyond the future limit`);
console.log(`No task created for template ${template.name} - next occurrence is beyond the future limit`);
}
// Update the last_checked_at in the schedule
const updateScheduleQuery = `
UPDATE task_recurring_schedules
SET last_checked_at = $1::DATE, last_created_task_end_date = $2
WHERE id = $3;
`;
await db.query(updateScheduleQuery, [moment(template.last_checked_at || template.created_at).add(1, "day").format(TIME_FORMAT), nextEndDate.format(TIME_FORMAT), template.schedule_id]);
}
log(`(cron) Recurring tasks job ended with ${createdTaskCount} new tasks created.`);

View File

@@ -1,219 +0,0 @@
{
"_metadata": {
"description": "Sri Lankan Public Holidays Data",
"last_updated": "2025-01-31",
"sources": {
"2025": "Based on official government sources and existing verified data",
"note": "All dates should be verified against official sources before use"
},
"official_sources": [
"Central Bank of Sri Lanka - Holiday Circulars",
"Department of Meteorology - Astrological calculations",
"Ministry of Public Administration - Official gazette",
"Buddhist and Pali University - Poya day calculations",
"All Ceylon Jamiyyatul Ulama - Islamic calendar",
"Hindu Cultural Centre - Hindu calendar"
],
"verification_process": "Each year should be verified against current official publications before adding to production systems"
},
"2025": [
{
"name": "Duruthu Full Moon Poya Day",
"date": "2025-01-13",
"type": "Poya",
"description": "Commemorates the first visit of Buddha to Sri Lanka",
"is_recurring": false
},
{
"name": "Navam Full Moon Poya Day",
"date": "2025-02-12",
"type": "Poya",
"description": "Commemorates the appointment of Sariputta and Moggallana as Buddha's chief disciples",
"is_recurring": false
},
{
"name": "Independence Day",
"date": "2025-02-04",
"type": "Public",
"description": "Commemorates the independence of Sri Lanka from British rule in 1948",
"is_recurring": true
},
{
"name": "Medin Full Moon Poya Day",
"date": "2025-03-14",
"type": "Poya",
"description": "Commemorates Buddha's first visit to his father's palace after enlightenment",
"is_recurring": false
},
{
"name": "Eid al-Fitr",
"date": "2025-03-31",
"type": "Public",
"description": "Festival marking the end of Ramadan",
"is_recurring": false
},
{
"name": "Bak Full Moon Poya Day",
"date": "2025-04-12",
"type": "Poya",
"description": "Commemorates Buddha's second visit to Sri Lanka",
"is_recurring": false
},
{
"name": "Sinhala and Tamil New Year Day",
"date": "2025-04-13",
"type": "Public",
"description": "Traditional New Year celebrated by Sinhalese and Tamil communities",
"is_recurring": true
},
{
"name": "Day after Sinhala and Tamil New Year",
"date": "2025-04-14",
"type": "Public",
"description": "Second day of traditional New Year celebrations",
"is_recurring": true
},
{
"name": "Good Friday",
"date": "2025-04-18",
"type": "Public",
"description": "Christian commemoration of the crucifixion of Jesus Christ",
"is_recurring": false
},
{
"name": "May Day",
"date": "2025-05-01",
"type": "Public",
"description": "International Workers' Day",
"is_recurring": true
},
{
"name": "Vesak Full Moon Poya Day",
"date": "2025-05-12",
"type": "Poya",
"description": "Most sacred day for Buddhists - commemorates birth, enlightenment and passing of Buddha",
"is_recurring": false
},
{
"name": "Day after Vesak Full Moon Poya Day",
"date": "2025-05-13",
"type": "Public",
"description": "Additional day for Vesak celebrations",
"is_recurring": false
},
{
"name": "Eid al-Adha",
"date": "2025-06-07",
"type": "Public",
"description": "Islamic festival of sacrifice",
"is_recurring": false
},
{
"name": "Poson Full Moon Poya Day",
"date": "2025-06-11",
"type": "Poya",
"description": "Commemorates the introduction of Buddhism to Sri Lanka by Arahat Mahinda",
"is_recurring": false
},
{
"name": "Esala Full Moon Poya Day",
"date": "2025-07-10",
"type": "Poya",
"description": "Commemorates Buddha's first sermon and the arrival of the Sacred Tooth Relic",
"is_recurring": false
},
{
"name": "Nikini Full Moon Poya Day",
"date": "2025-08-09",
"type": "Poya",
"description": "Commemorates the first Buddhist council",
"is_recurring": false
},
{
"name": "Binara Full Moon Poya Day",
"date": "2025-09-07",
"type": "Poya",
"description": "Commemorates Buddha's visit to heaven to preach to his mother",
"is_recurring": false
},
{
"name": "Vap Full Moon Poya Day",
"date": "2025-10-07",
"type": "Poya",
"description": "Marks the end of Buddhist Lent and Buddha's return from heaven",
"is_recurring": false
},
{
"name": "Deepavali",
"date": "2025-10-20",
"type": "Public",
"description": "Hindu Festival of Lights",
"is_recurring": false
},
{
"name": "Il Full Moon Poya Day",
"date": "2025-11-05",
"type": "Poya",
"description": "Commemorates Buddha's ordination of sixty disciples",
"is_recurring": false
},
{
"name": "Unduvap Full Moon Poya Day",
"date": "2025-12-04",
"type": "Poya",
"description": "Commemorates the arrival of Sanghamitta Theri with the Sacred Bo sapling",
"is_recurring": false
},
{
"name": "Christmas Day",
"date": "2025-12-25",
"type": "Public",
"description": "Christian celebration of the birth of Jesus Christ",
"is_recurring": true
}
],
"fixed_holidays": [
{
"name": "Independence Day",
"month": 2,
"day": 4,
"type": "Public",
"description": "Commemorates the independence of Sri Lanka from British rule in 1948"
},
{
"name": "May Day",
"month": 5,
"day": 1,
"type": "Public",
"description": "International Workers' Day"
},
{
"name": "Christmas Day",
"month": 12,
"day": 25,
"type": "Public",
"description": "Christian celebration of the birth of Jesus Christ"
}
],
"variable_holidays_info": {
"sinhala_tamil_new_year": {
"description": "Sinhala and Tamil New Year dates vary based on astrological calculations. Common patterns:",
"common_dates": [
{ "pattern": "April 12-13", "years": "Some years" },
{ "pattern": "April 13-14", "years": "Most common" },
{ "pattern": "April 14-15", "years": "Occasional" }
],
"note": "These dates should be verified annually from official sources like the Department of Meteorology or astrological authorities"
},
"poya_days": {
"description": "Full moon Poya days follow the lunar calendar and change each year",
"note": "Dates should be obtained from Buddhist calendar or astronomical calculations"
},
"religious_holidays": {
"eid_fitr": "Based on Islamic lunar calendar - varies each year",
"eid_adha": "Based on Islamic lunar calendar - varies each year",
"good_friday": "Based on Easter calculation - varies each year",
"deepavali": "Based on Hindu lunar calendar - varies each year"
}
}
}

View File

@@ -1,170 +0,0 @@
# Sri Lankan Holiday Annual Update Process
## Overview
This document outlines the process for annually updating Sri Lankan holiday data to ensure accurate utilization calculations.
## Data Sources & Verification
### Official Government Sources
1. **Central Bank of Sri Lanka**
- Holiday circulars (usually published in December for the next year)
- Website: [cbsl.gov.lk](https://www.cbsl.gov.lk)
2. **Department of Meteorology**
- Astrological calculations for Sinhala & Tamil New Year
- Website: [meteo.gov.lk](http://www.meteo.gov.lk)
3. **Ministry of Public Administration**
- Official gazette notifications
- Public holiday declarations
### Religious Authorities
1. **Buddhist Calendar**
- Buddhist and Pali University of Sri Lanka
- Major temples (Malwatte, Asgiriya)
2. **Islamic Calendar**
- All Ceylon Jamiyyatul Ulama (ACJU)
- Colombo Grand Mosque
3. **Hindu Calendar**
- Hindu Cultural Centre
- Tamil cultural organizations
## Annual Update Workflow
### 1. Preparation (October - November)
```bash
# Check current data status
node update-sri-lankan-holidays.js --list
node update-sri-lankan-holidays.js --validate
```
### 2. Research Phase (November - December)
For the upcoming year (e.g., 2026):
1. **Fixed Holidays** ✅ Already handled
- Independence Day (Feb 4)
- May Day (May 1)
- Christmas Day (Dec 25)
2. **Variable Holidays** ⚠️ Require verification
- **Sinhala & Tamil New Year**: Check Department of Meteorology
- **Poya Days**: Check Buddhist calendar/temples
- **Good Friday**: Calculate from Easter
- **Eid al-Fitr & Eid al-Adha**: Check Islamic calendar
- **Deepavali**: Check Hindu calendar
### 3. Data Collection Template
```bash
# Generate template for the new year
node update-sri-lankan-holidays.js --poya-template 2026
```
This will output a template like:
```json
{
"name": "Duruthu Full Moon Poya Day",
"date": "2026-??-??",
"type": "Poya",
"description": "Commemorates the first visit of Buddha to Sri Lanka",
"is_recurring": false
}
```
### 4. Research Checklist
#### Sinhala & Tamil New Year
- [ ] Check Department of Meteorology announcements
- [ ] Verify with astrological authorities
- [ ] Confirm if dates are April 12-13, 13-14, or 14-15
#### Poya Days (12 per year)
- [ ] Get Buddhist calendar for the year
- [ ] Verify with temples or Buddhist authorities
- [ ] Double-check lunar calendar calculations
#### Religious Holidays
- [ ] **Good Friday**: Calculate based on Easter
- [ ] **Eid al-Fitr**: Check Islamic calendar/ACJU
- [ ] **Eid al-Adha**: Check Islamic calendar/ACJU
- [ ] **Deepavali**: Check Hindu calendar/cultural centers
### 5. Data Entry
1. Edit `src/data/sri-lankan-holidays.json`
2. Add new year section with verified dates
3. Update metadata with sources used
### 6. Validation & Testing
```bash
# Validate the new data
node update-sri-lankan-holidays.js --validate
# Generate SQL for database
node update-sri-lankan-holidays.js --generate-sql 2026
```
### 7. Database Update
1. Create new migration file with the generated SQL
2. Test in development environment
3. Deploy to production
### 8. Documentation
- Update metadata in JSON file
- Document sources used
- Note any special circumstances or date changes
## Emergency Updates
If holidays are announced late or changed:
1. **Quick JSON Update**:
```bash
# Edit the JSON file directly
# Add the new/changed holiday
```
2. **Database Hotfix**:
```sql
INSERT INTO country_holidays (country_code, name, description, date, is_recurring)
VALUES ('LK', 'Emergency Holiday', 'Description', 'YYYY-MM-DD', false)
ON CONFLICT (country_code, name, date) DO NOTHING;
```
3. **Notify Users**: Consider adding a notification system for holiday changes
## Quality Assurance
### Pre-Release Checklist
- [ ] All 12 Poya days included for the year
- [ ] Sinhala & Tamil New Year dates verified
- [ ] Religious holidays cross-checked with multiple sources
- [ ] No duplicate dates
- [ ] JSON format validation passes
- [ ] Database migration tested
### Post-Release Monitoring
- [ ] Monitor utilization calculations for anomalies
- [ ] Check user feedback for missed holidays
- [ ] Verify against actual government announcements
## Automation Opportunities
Future improvements could include:
1. **API Integration**: Connect to reliable holiday APIs
2. **Web Scraping**: Automated monitoring of official websites
3. **Notification System**: Alert when new holidays are announced
4. **Validation Service**: Cross-check against multiple sources
## Contact Information
For questions about the holiday update process:
- Technical issues: Development team
- Holiday verification: Sri Lankan team members
- Religious holidays: Local community contacts
## Version History
- **v1.0** (2025-01-31): Initial process documentation
- **2025 Data**: Verified and included
- **2026+ Data**: Pending official source verification

View File

@@ -1,54 +0,0 @@
export interface IHolidayType {
id: string;
name: string;
description?: string;
color_code: string;
created_at: string;
updated_at: string;
}
export interface IOrganizationHoliday {
id: string;
organization_id: string;
holiday_type_id: string;
name: string;
description?: string;
date: string;
is_recurring: boolean;
created_at: string;
updated_at: string;
holiday_type?: IHolidayType;
}
export interface ICountryHoliday {
id: string;
country_code: string;
name: string;
description?: string;
date: string;
is_recurring: boolean;
created_at: string;
updated_at: string;
}
export interface ICreateHolidayRequest {
name: string;
description?: string;
date: string;
holiday_type_id: string;
is_recurring?: boolean;
}
export interface IUpdateHolidayRequest {
id: string;
name?: string;
description?: string;
date?: string;
holiday_type_id?: string;
is_recurring?: boolean;
}
export interface IImportCountryHolidaysRequest {
country_code: string;
year?: number;
}

View File

@@ -1,37 +0,0 @@
export interface ISurveyQuestion {
id: string;
survey_id: string;
question_key: string;
question_type: 'single_choice' | 'multiple_choice' | 'text';
is_required: boolean;
sort_order: number;
options?: string[];
}
export interface ISurvey {
id: string;
name: string;
description?: string;
survey_type: 'account_setup' | 'onboarding' | 'feedback';
is_active: boolean;
questions?: ISurveyQuestion[];
}
export interface ISurveyAnswer {
question_id: string;
answer_text?: string;
answer_json?: string[];
}
export interface ISurveyResponse {
id?: string;
survey_id: string;
user_id?: string;
is_completed: boolean;
answers: ISurveyAnswer[];
}
export interface ISurveySubmissionRequest {
survey_id: string;
answers: ISurveyAnswer[];
}

View File

@@ -1,53 +0,0 @@
import { NextFunction } from "express";
import { IWorkLenzRequest } from "../../interfaces/worklenz-request";
import { IWorkLenzResponse } from "../../interfaces/worklenz-response";
import { ServerResponse } from "../../models/server-response";
import { ISurveySubmissionRequest } from "../../interfaces/survey";
export default function surveySubmissionValidator(req: IWorkLenzRequest, res: IWorkLenzResponse, next: NextFunction): IWorkLenzResponse | void {
const body = req.body as ISurveySubmissionRequest;
if (!body) {
return res.status(200).send(new ServerResponse(false, null, "Request body is required"));
}
if (!body.survey_id || typeof body.survey_id !== 'string') {
return res.status(200).send(new ServerResponse(false, null, "Survey ID is required and must be a string"));
}
if (!body.answers || !Array.isArray(body.answers)) {
return res.status(200).send(new ServerResponse(false, null, "Answers are required and must be an array"));
}
// Validate each answer
for (let i = 0; i < body.answers.length; i++) {
const answer = body.answers[i];
if (!answer.question_id || typeof answer.question_id !== 'string') {
return res.status(200).send(new ServerResponse(false, null, `Answer ${i + 1}: Question ID is required and must be a string`));
}
// answer_text and answer_json are both optional - users can submit empty answers
// Validate answer_text if provided
if (answer.answer_text && typeof answer.answer_text !== 'string') {
return res.status(200).send(new ServerResponse(false, null, `Answer ${i + 1}: answer_text must be a string`));
}
// Validate answer_json if provided
if (answer.answer_json && !Array.isArray(answer.answer_json)) {
return res.status(200).send(new ServerResponse(false, null, `Answer ${i + 1}: answer_json must be an array`));
}
// Validate answer_json items are strings
if (answer.answer_json) {
for (let j = 0; j < answer.answer_json.length; j++) {
if (typeof answer.answer_json[j] !== 'string') {
return res.status(200).send(new ServerResponse(false, null, `Answer ${i + 1}: answer_json items must be strings`));
}
}
}
}
return next();
}

View File

@@ -3,16 +3,13 @@ import { Strategy as LocalStrategy } from "passport-local";
import { log_error } from "../../shared/utils";
import db from "../../config/db";
import { Request } from "express";
import { ERROR_KEY, SUCCESS_KEY } from "./passport-constants";
async function handleLogin(req: Request, email: string, password: string, done: any) {
// Clear any existing flash messages
(req.session as any).flash = {};
console.log("Login attempt for:", email);
if (!email || !password) {
const errorMsg = "Please enter both email and password";
req.flash(ERROR_KEY, errorMsg);
return done(null, false);
console.log("Missing credentials");
return done(null, false, { message: "Please enter both email and password" });
}
try {
@@ -22,27 +19,23 @@ async function handleLogin(req: Request, email: string, password: string, done:
AND google_id IS NULL
AND is_deleted IS FALSE;`;
const result = await db.query(q, [email]);
console.log("User query result count:", result.rowCount);
const [data] = result.rows;
if (!data?.password) {
const errorMsg = "No account found with this email";
req.flash(ERROR_KEY, errorMsg);
return done(null, false);
console.log("No account found");
return done(null, false, { message: "No account found with this email" });
}
const passwordMatch = bcrypt.compareSync(password, data.password);
console.log("Password match:", passwordMatch);
if (passwordMatch && email === data.email) {
delete data.password;
const successMsg = "User successfully logged in";
req.flash(SUCCESS_KEY, successMsg);
return done(null, data);
return done(null, data, {message: "User successfully logged in"});
}
const errorMsg = "Incorrect email or password";
req.flash(ERROR_KEY, errorMsg);
return done(null, false);
return done(null, false, { message: "Incorrect email or password" });
} catch (error) {
console.error("Login error:", error);
log_error(error, req.body);

View File

@@ -1,4 +0,0 @@
{
"doesNotExistText": "Na vjen keq, faqja që kërkoni nuk ekziston.",
"backHomeButton": "Kthehu në Faqen Kryesore"
}

View File

@@ -1,31 +0,0 @@
{
"continue": "Vazhdo",
"setupYourAccount": "Konfiguro Llogarinë Tënde në Worklenz.",
"organizationStepTitle": "Emërtoni Organizatën Tuaj",
"organizationStepLabel": "Zgjidhni një emër për llogarinë tuaj në Worklenz.",
"projectStepTitle": "Krijoni projektin tuaj të parë",
"projectStepLabel": "Në cilin projekt po punoni aktualisht?",
"projectStepPlaceholder": "p.sh. Plani i Marketingut",
"tasksStepTitle": "Krijoni detyrat tuaja të para",
"tasksStepLabel": "Shkruani disa detyra që do të kryeni në",
"tasksStepAddAnother": "Shto një tjetër",
"emailPlaceholder": "Adresa email",
"invalidEmail": "Ju lutemi vendosni një adresë email të vlefshme",
"or": "ose",
"templateButton": "Importo nga shablloni",
"goBack": "Kthehu Mbrapa",
"cancel": "Anulo",
"create": "Krijo",
"templateDrawerTitle": "Zgjidh nga shabllonet",
"step3InputLabel": "Fto me email",
"addAnother": "Shto një tjetër",
"skipForNow": "Kalo tani për tani",
"formTitle": "Krijoni detyrën tuaj të parë.",
"step3Title": "Fto ekipin tënd të punojë me",
"maxMembers": " (Mund të ftoni deri në 5 anëtarë)",
"maxTasks": " (Mund të krijoni deri në 5 detyra)"
}

View File

@@ -1,113 +0,0 @@
{
"title": "Faturimet",
"currentBill": "Fatura Aktuale",
"configuration": "Konfigurimi",
"currentPlanDetails": "Detajet e Planit Aktual",
"upgradePlan": "Përmirëso Planin",
"cardBodyText01": "Provë falas",
"cardBodyText02": "(Plani juaj i provës skadon në 1 muaj 19 ditë)",
"redeemCode": "Kodi i Zbritjes",
"accountStorage": "Depozita e Llogarisë",
"used": "Përdorur:",
"remaining": "E mbetur:",
"charges": "Tarifat",
"tooltip": "Tarifat për ciklin aktual të faturimit",
"description": "Përshkrimi",
"billingPeriod": "Periudha e Faturimit",
"billStatus": "Statusi i Faturës",
"perUserValue": "Vlera për Përdorues",
"users": "Përdoruesit",
"amount": "Shuma",
"invoices": "Faturat",
"transactionId": "ID e Transaksionit",
"transactionDate": "Data e Transaksionit",
"paymentMethod": "Metoda e Pagesës",
"status": "Statusi",
"ltdUsers": "Mund të shtoni deri në {{ltd_users}} përdorues.",
"totalSeats": "Vende totale",
"availableSeats": "Vende të disponueshme",
"addMoreSeats": "Shto më shumë vende",
"drawerTitle": "Kodi i Zbritjes",
"label": "Kodi i Zbritjes",
"drawerPlaceholder": "Vendosni kodin tuaj të zbritjes",
"redeemSubmit": "Paraqit",
"modalTitle": "Zgjidhni planin më të mirë për ekipin tuaj",
"seatLabel": "Numri i vendeve",
"freePlan": "Plan Falas",
"startup": "Startup",
"business": "Biznes",
"tag": "Më i Popullarizuar",
"enterprise": "Ndërmarrje",
"freeSubtitle": "falas përgjithmonë",
"freeUsers": "Më e mira për përdorim personal",
"freeText01": "100MB depozitë",
"freeText02": "3 projekte",
"freeText03": "5 anëtarë të ekipit",
"startupSubtitle": "ÇMIM I RASTËSISHËM / muaj",
"startupUsers": "Deri në 15 përdorues",
"startupText01": "25GB depozitë",
"startupText02": "Projekte të pakufizuara aktive",
"startupText03": "Orar",
"startupText04": "Raportim",
"startupText05": "Abonohu në projekte",
"businessSubtitle": "përdorues / muaj",
"businessUsers": "16 - 200 përdorues",
"enterpriseUsers": "200 - 500+ përdorues",
"footerTitle": "Ju lutemi na jepni një numër kontakti që mund të përdorim për t'ju kontaktuar.",
"footerLabel": "Numri i Kontaktit",
"footerButton": "Na kontaktoni",
"redeemCodePlaceHolder": "Vendosni kodin tuaj të zbritjes",
"submit": "Paraqit",
"trialPlan": "Provë Falas",
"trialExpireDate": "E vlefshme deri më {{trial_expire_date}}",
"trialExpired": "Provat tuaja falas skaduan {{trial_expire_string}}",
"trialInProgress": "Provat tuaja falas skadojnë {{trial_expire_string}}",
"required": "Kjo fushë është e detyrueshme",
"invalidCode": "Kod i pavlefshëm",
"selectPlan": "Zgjidhni planin më të mirë për ekipin tuaj",
"changeSubscriptionPlan": "Ndryshoni planin tuaj të abonimit",
"noOfSeats": "Numri i vendeve",
"annualPlan": "Pro - Vjetor",
"monthlyPlan": "Pro - Mujor",
"freeForever": "Falas Përgjithmonë",
"bestForPersonalUse": "Më e mira për përdorim personal",
"storage": "Depozitë",
"projects": "Projekte",
"teamMembers": "Anëtarët e Ekipit",
"unlimitedTeamMembers": "Anëtarë të pakufizuar të ekipit",
"unlimitedActiveProjects": "Projekte të pakufizuara aktive",
"schedule": "Orar",
"reporting": "Raportim",
"subscribeToProjects": "Abonohu në projekte",
"billedAnnually": "Faturuar çdo vit",
"billedMonthly": "Faturuar çdo muaj",
"pausePlan": "Pauzë Planin",
"resumePlan": "Rifillo Planin",
"changePlan": "Ndrysho Planin",
"cancelPlan": "Anulo Planin",
"perMonthPerUser": "për përdorues/muaj",
"viewInvoice": "Shiko Faturën",
"switchToFreePlan": "Kalo në Planin Falas",
"expirestoday": "sot",
"expirestomorrow": "nesër",
"expiredDaysAgo": "{{days}} ditë më parë",
"continueWith": "Vazhdo me {{plan}}",
"changeToPlan": "Ndrysho në {{plan}}"
}

View File

@@ -1,8 +0,0 @@
{
"overview": "Përmbledhje",
"name": "Emri i Organizatës",
"owner": "Pronari i Organizatës",
"admins": "Administruesit e Organizatës",
"contactNumber": "Shto Numrin e Kontaktit",
"edit": "Redakto"
}

View File

@@ -1,12 +0,0 @@
{
"membersCount": "Numri i Anëtarëve",
"createdAt": "Krijuar më",
"projectName": "Emri i Projektit",
"teamName": "Emri i Ekipit",
"refreshProjects": "Rifresko Projektet",
"searchPlaceholder": "Kërkoni sipas emrit të projektit",
"deleteProject": "Jeni i sigurt që dëshironi të fshini këtë projekt?",
"confirm": "Konfirmo",
"cancel": "Anulo",
"delete": "Fshi Projektin"
}

View File

@@ -1,8 +0,0 @@
{
"overview": "Përmbledhje",
"users": "Përdoruesit",
"teams": "Ekipet",
"billing": "Faturimi",
"projects": "Projektet",
"adminCenter": "Qendra Administrative"
}

View File

@@ -1,33 +0,0 @@
{
"title": "Ekipet",
"subtitle": "ekipet",
"tooltip": "Rifresko ekipet",
"placeholder": "Kërko sipas emrit",
"addTeam": "Shto Ekip",
"team": "Ekipi",
"membersCount": "Numri i Anëtarëve",
"members": "Anëtarët",
"drawerTitle": "Krijo Ekip të Ri",
"label": "Emri i Ekipit",
"drawerPlaceholder": "Emri",
"create": "Krijo",
"delete": "Fshi",
"settings": "Cilësimet",
"popTitle": "Jeni i sigurt?",
"message": "Ju lutemi shkruani një Emër",
"teamSettings": "Cilësimet e Ekipit",
"teamName": "Emri i Ekipit",
"teamDescription": "Përshkrimi i Ekipit",
"teamMembers": "Anëtarët e Ekipit",
"teamMembersCount": "Numri i Anëtarëve të Ekipit",
"teamMembersPlaceholder": "Kërko sipas emrit",
"addMember": "Shto Anëtar",
"add": "Shto",
"update": "Përditëso",
"teamNamePlaceholder": "Emri i ekipit",
"user": "Përdoruesi",
"role": "Roli",
"owner": "Pronari",
"admin": "Administruesi",
"member": "Anëtari"
}

View File

@@ -1,9 +0,0 @@
{
"title": "Përdoruesit",
"subTitle": "përdoruesit",
"placeholder": "Kërko sipas emrit",
"user": "Përdoruesi",
"email": "Email",
"lastActivity": "Aktiviteti i Fundit",
"refresh": "Rifresko përdoruesit"
}

View File

@@ -1,34 +0,0 @@
{
"name": "Emri",
"client": "Klienti",
"category": "Kategoria",
"status": "Statusi",
"tasksProgress": "Përparimi i Detyrave",
"updated_at": "E Përditësuar së Fundi",
"members": "Anëtarët",
"setting": "Cilësimet",
"projects": "Projektet",
"refreshProjects": "Rifresko projektet",
"all": "Të gjitha",
"favorites": "Të preferuarit",
"archived": "E arkivuar",
"placeholder": "Kërko sipas emrit",
"archive": "Arkivo",
"unarchive": "Çarkivo",
"archiveConfirm": "Jeni i sigurt që dëshironi të arkivoni këtë projekt?",
"unarchiveConfirm": "Jeni i sigurt që dëshironi të çarkivoni këtë projekt?",
"yes": "Po",
"no": "Jo",
"clickToFilter": "Kliko për të filtruar sipas",
"noProjects": "Nuk u gjetën projekte",
"addToFavourites": "Shto te të preferuarit",
"list": "Lista",
"group": "Grupi",
"listView": "Pamja e Listës",
"groupView": "Pamja e Grupit",
"groupBy": {
"category": "Kategoria",
"client": "Klienti"
},
"noPermission": "Nuk keni leje për të kryer këtë veprim"
}

View File

@@ -1,5 +0,0 @@
{
"loggingOut": "Po dilni...",
"authenticating": "Po autentikoheni...",
"gettingThingsReady": "Po përgatiten gjërat për ju..."
}

View File

@@ -1,12 +0,0 @@
{
"headerDescription": "Rivendosni fjalëkalimin tuaj",
"emailLabel": "Email",
"emailPlaceholder": "Vendosni email-in tuaj",
"emailRequired": "Ju lutemi vendosni Email-in tuaj!",
"resetPasswordButton": "Rivendos Fjalëkalimin",
"returnToLoginButton": "Kthehu te Hyrja",
"passwordResetSuccessMessage": "Një lidhje për rivendosjen e fjalëkalimit është dërguar në email-in tuaj.",
"orText": "OSE",
"successTitle": "U dërguan udhëzimet për rivendosje!",
"successMessage": "Informacioni për rivendosje është dërguar në email-in tuaj. Ju lutemi kontrolloni email-in."
}

View File

@@ -1,27 +0,0 @@
{
"headerDescription": "Hyni në llogarinë tuaj",
"emailLabel": "Email",
"emailPlaceholder": "Vendosni email-in tuaj",
"emailRequired": "Ju lutemi vendosni Email-in tuaj!",
"passwordLabel": "Fjalëkalimi",
"passwordPlaceholder": "Vendosni fjalëkalimin",
"passwordRequired": "Ju lutemi vendosni Fjalëkalimin!",
"rememberMe": "Më mbaj mend",
"loginButton": "Hyr",
"signupButton": "Regjistrohu",
"forgotPasswordButton": "Keni harruar fjalëkalimin?",
"signInWithGoogleButton": "Hyr me Google",
"dontHaveAccountText": "Nuk keni llogari?",
"orText": "OSE",
"successMessage": "Jeni futur me sukses!",
"loginError": "Hyrja dështoi",
"googleLoginError": "Hyrja përmes Google dështoi",
"validationMessages": {
"email": "Ju lutemi vendosni një adresë email të vlefshme",
"password": "Fjalëkalimi duhet të jetë së paku 8 karaktere"
},
"errorMessages": {
"loginErrorTitle": "Hyrja dështoi",
"loginErrorMessage": "Ju lutemi kontrolloni email-in dhe fjalëkalimin dhe provoni përsëri"
}
}

View File

@@ -1,29 +0,0 @@
{
"headerDescription": "Regjistrohuni për të filluar",
"nameLabel": "Emri i Plotë",
"namePlaceholder": "Shkruani emrin tuaj të plotë",
"nameRequired": "Ju lutemi shkruani emrin tuaj të plotë!",
"nameMinCharacterRequired": "Emri duhet të jetë së paku 4 karaktere!",
"emailLabel": "Email",
"emailPlaceholder": "Shkruani email-in tuaj",
"emailRequired": "Ju lutemi shkruani Email-in tuaj!",
"passwordLabel": "Fjalëkalimi",
"passwordPlaceholder": "Krijoni një fjalëkalim",
"passwordRequired": "Ju lutemi krijoni një Fjalëkalim!",
"passwordMinCharacterRequired": "Fjalëkalimi duhet të jetë së paku 8 karaktere!",
"passwordPatternRequired": "Fjalëkalimi nuk plotëson kërkesat!",
"strongPasswordPlaceholder": "Vendosni një fjalëkalim më të fortë",
"passwordValidationAltText": "Fjalëkalimi duhet të përmbajë së paku 8 karaktere me shkronja të mëdha dhe të vogla, një numër dhe një simbol.",
"signupSuccessMessage": "Jeni regjistruar me sukses!",
"privacyPolicyLink": "Politika e Privatësisë",
"termsOfUseLink": "Kushtet e Përdorimit",
"bySigningUpText": "Duke u regjistruar, ju pranoni",
"andText": "dhe",
"signupButton": "Regjistrohu",
"signInWithGoogleButton": "Hyr me Google",
"alreadyHaveAccountText": "Keni tashmë një llogari?",
"loginButton": "Hyr",
"orText": "OSE",
"reCAPTCHAVerificationError": "Gabim në Verifikimin e reCAPTCHA",
"reCAPTCHAVerificationErrorMessage": "Nuk mundëm të verifikojmë reCAPTCHA-n tuaj. Ju lutemi provoni përsëri."
}

View File

@@ -1,14 +0,0 @@
{
"title": "Verifikoni Email-in për Rivendosje",
"description": "Vendosni fjalëkalimin tuaj të ri",
"placeholder": "Vendosni fjalëkalimin tuaj të ri",
"confirmPasswordPlaceholder": "Konfirmoni fjalëkalimin e ri",
"passwordHint": "Të paktën 8 karaktere, me shkronja të mëdha dhe të vogla, një numër dhe një simbol.",
"resetPasswordButton": "Rivendos fjalëkalimin",
"orText": "Ose",
"resendResetEmail": "Dërgo përsëri email-in e rivendosjes",
"passwordRequired": "Ju lutemi vendosni fjalëkalimin e ri",
"returnToLoginButton": "Kthehu te Hyrja",
"confirmPasswordRequired": "Ju lutemi konfirmoni fjalëkalimin e ri",
"passwordMismatch": "Fjalëkalimet nuk përputhen"
}

View File

@@ -1,9 +0,0 @@
{
"login-success": "Hyrja u krye me sukses!",
"login-failed": "Hyrja dështoi. Ju lutemi kontrolloni kredencialet dhe provoni përsëri.",
"signup-success": "Regjistrimi u krye me sukses! Mirë se erdhët.",
"signup-failed": "Regjistrimi dështoi. Ju lutemi sigurohuni që të gjitha fushat e nevojshme janë plotësuar dhe provoni përsëri.",
"reconnecting": "Jeni shkëputur nga serveri.",
"connection-lost": "Lidhja me serverin dështoi. Ju lutemi kontrolloni lidhjen tuaj me internet.",
"connection-restored": "U lidhët me serverin me sukses"
}

View File

@@ -1,13 +0,0 @@
{
"formTitle": "Krijoni projektin tuaj të parë",
"inputLabel": "Në cilin projekt po punoni aktualisht?",
"or": "ose",
"templateButton": "Importo nga shablloni",
"createFromTemplate": "Krijo nga shablloni",
"goBack": "Kthehu Mbrapa",
"continue": "Vazhdo",
"cancel": "Anulo",
"create": "Krijo",
"templateDrawerTitle": "Zgjidh nga shabllonet",
"createProject": "Krijo Projekt"
}

View File

@@ -1,7 +0,0 @@
{
"formTitle": "Krijo detyrën tënde të parë.",
"inputLabel": "Shkruaj disa detyra që do të kryesh në",
"addAnother": "Shto një tjetër",
"goBack": "Kthehu mbrapa",
"continue": "Vazhdo"
}

View File

@@ -1,46 +0,0 @@
{
"todoList": {
"title": "Lista e Detyrave",
"refreshTasks": "Rifresko detyrat",
"addTask": "+ Shto Detyrë",
"noTasks": "Asnjë detyrë",
"pressEnter": "Shtyp",
"toCreate": "për të krijuar.",
"markAsDone": "Shëno si të përfunduar"
},
"projects": {
"title": "Projektet",
"refreshProjects": "Rifresko projektet",
"noRecentProjects": "Aktualisht nuk jeni caktuar në asnjë projekt.",
"noFavouriteProjects": "Asnjë projekt i shënuar si i preferuar.",
"recent": "Të Fundit",
"favourites": "Të Preferuarat"
},
"tasks": {
"assignedToMe": "Më janë caktuar",
"assignedByMe": "I kam caktuar",
"all": "Të Gjitha",
"today": "Sot",
"upcoming": "Ardhj",
"overdue": "Të vonuara",
"noDueDate": "Pa afat",
"noTasks": "Asnjë detyrë për të shfaqur.",
"addTask": "+ Shto detyrë",
"name": "Emri",
"project": "Projekti",
"status": "Statusi",
"dueDate": "Afati",
"dueDatePlaceholder": "Cakto Afatin",
"tomorrow": "Nesër",
"nextWeek": "Javën e Ardhshme",
"nextMonth": "Muajin e Ardhshëm",
"projectRequired": "Ju lutemi zgjidhni një projekt",
"pressTabToSelectDueDateAndProject": "Shtyp Tab për të zgjedhur afatin dhe projektin",
"dueOn": "Detyrat me afat më",
"taskRequired": "Ju lutemi shtoni një detyrë",
"list": "Listë",
"calendar": "Kalendar",
"tasks": "Detyrat",
"refresh": "Rifresko"
}
}

Some files were not shown because too many files have changed in this diff Show More