Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[Feat] Find tasks by start and due dates filters #8542

Merged
merged 13 commits into from
Nov 21, 2024
Merged
16 changes: 14 additions & 2 deletions packages/contracts/src/task.model.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
import { IBasePerTenantAndOrganizationEntityModel, IBaseRelationsEntityModel, ID } from './base-entity.model';
import { IEmployee } from './employee.model';
import { IEmployee, IEmployeeEntityInput } from './employee.model';
import { IInvoiceItem } from './invoice-item.model';
import { IRelationalOrganizationProject } from './organization-projects.model';
import {
IOrganizationSprint,
IRelationalOrganizationSprint,
IOrganizationSprintTaskHistory
} from './organization-sprint.model';
import { IOrganizationTeam } from './organization-team.model';
import { IOrganizationTeam, IRelationalOrganizationTeam } from './organization-team.model';
import { ITag } from './tag.model';
import { IUser } from './user.model';
import { ITaskStatus, TaskStatusEnum } from './task-status.model';
Expand Down Expand Up @@ -104,3 +104,15 @@ export interface IGetTasksByViewFilters extends IBasePerTenantAndOrganizationEnt
// Relations
relations?: string[];
}

export interface ITaskDateFilterInput
extends IBasePerTenantAndOrganizationEntityModel,
Pick<ITask, 'projectId' | 'organizationSprintId' | 'creatorId'>,
IEmployeeEntityInput,
IRelationalOrganizationTeam,
Pick<IGetTasksByViewFilters, 'relations'> {
startDateFrom?: Date;
startDateTo?: Date;
dueDateFrom?: Date;
dueDateTo?: Date;
}
1 change: 1 addition & 0 deletions packages/core/src/core/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,3 +10,4 @@ export * from './decorators';
export * from './dto';
export * from './orm-type';
export * from './plugin-common.module';
export * from './util';
47 changes: 47 additions & 0 deletions packages/core/src/core/util/find-by-date-between.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import { BadRequestException } from '@nestjs/common';
import { SelectQueryBuilder } from 'typeorm';

type AllowedFields = 'startDate' | 'dueDate' | 'endDate' | 'createdAt';

function isValidField(field: string): field is AllowedFields {
return ['startDate', 'dueDate', 'endDate', 'createdAt'].includes(field);
}

/**
* Adds a date range condition to a TypeORM query builder
* @param query - The TypeORM SelectQueryBuilder instance
* @param field - The date field to filter on
* @param from - Start date (inclusive, UTC)
* @param to - End date (inclusive, UTC)
* @param p - Optional transform function for the query string
* @returns Modified query builder instance
* @throws a BadRequestException if from date is after to date
*/
export function addBetween<T>(
query: SelectQueryBuilder<T>,
field: string,
from?: Date,
to?: Date,
p?: (queryStr: string) => string
): SelectQueryBuilder<T> {
GloireMutaliko21 marked this conversation as resolved.
Show resolved Hide resolved
if (!isValidField(field)) {
throw new BadRequestException(`Invalid field name: ${field}`);
}

if (from || to) {
if (from && to && from > to) {
throw new BadRequestException('"From" date must not be after "to" date');
}
// Convert dates to UTC for consistent comparison
const utcFrom = from?.toISOString();
const utcTo = to?.toISOString();
if (from && to) {
query.andWhere(p(`"${query.alias}"."${field}" BETWEEN :from AND :to`), { from: utcFrom, to: utcTo });
} else if (from) {
query.andWhere(p(`"${query.alias}"."${field}" >= :from`), { from: utcFrom });
} else if (to) {
query.andWhere(p(`"${query.alias}"."${field}" <= :to`), { to: utcTo });
}
}
return query;
}
GloireMutaliko21 marked this conversation as resolved.
Show resolved Hide resolved
2 changes: 2 additions & 0 deletions packages/core/src/core/util/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export * from './find-by-date-between';
export * from './object-utils';
40 changes: 40 additions & 0 deletions packages/core/src/tasks/dto/get-task-by-date-filter.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { ApiPropertyOptional } from '@nestjs/swagger';
import { IsDate, IsOptional, ValidateIf } from 'class-validator';
import { Type } from 'class-transformer';
import { ITaskDateFilterInput } from '@gauzy/contracts';
import { TenantOrganizationBaseDTO } from '../../core/dto';
import { IsBeforeDate } from './../../shared/validators';

export class TaskDateFilterInputDTO extends TenantOrganizationBaseDTO implements ITaskDateFilterInput {
@ApiPropertyOptional({ type: () => Date })
@Type(() => Date)
@IsOptional()
@IsDate()
@IsBeforeDate(TaskDateFilterInputDTO, (it) => it.startDateTo, {
message: 'Start date from must be before the start date to'
})
startDateFrom?: Date;

@ApiPropertyOptional({ type: () => Date })
@Type(() => Date)
@IsOptional()
@IsDate()
@ValidateIf((o) => o.startDateFrom != null)
startDateTo?: Date;
GloireMutaliko21 marked this conversation as resolved.
Show resolved Hide resolved

@ApiPropertyOptional({ type: () => Date })
@Type(() => Date)
@IsOptional()
@IsDate()
@IsBeforeDate(TaskDateFilterInputDTO, (it) => it.dueDateTo, {
message: 'Due date from must be before the due date to'
})
dueDateFrom?: Date;

@ApiPropertyOptional({ type: () => Date })
@Type(() => Date)
@IsOptional()
@IsDate()
@ValidateIf((o) => o.dueDateFrom != null)
dueDateTo?: Date;
}
1 change: 1 addition & 0 deletions packages/core/src/tasks/dto/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,4 @@ export * from './create-task.dto';
export * from './task-max-number-query.dto';
export * from './update-task.dto';
export * from './get-task-by-id.dto';
export * from './get-task-by-date-filter.dto';
18 changes: 17 additions & 1 deletion packages/core/src/tasks/task.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ import { CrudController, PaginationParams } from './../core/crud';
import { Task } from './task.entity';
import { TaskService } from './task.service';
import { TaskCreateCommand, TaskUpdateCommand } from './commands';
import { CreateTaskDTO, GetTaskByIdDTO, TaskMaxNumberQueryDTO, UpdateTaskDTO } from './dto';
import { CreateTaskDTO, GetTaskByIdDTO, TaskDateFilterInputDTO, TaskMaxNumberQueryDTO, UpdateTaskDTO } from './dto';

@ApiTags('Tasks')
@UseGuards(TenantPermissionGuard, PermissionGuard)
Expand Down Expand Up @@ -146,6 +146,22 @@ export class TaskController extends CrudController<Task> {
return this.taskService.findModuleTasks(params);
}

/**
* Retrieves tasks based on the provided date filters for startDate and dueDate.
*
* @function getTasksByDateFilters
* @param {TaskDateFilterInputDTO} params - The DTO containing the date filters for the tasks.
*/
@ApiOperation({ summary: 'Get tasks by start and due dates filters.' })
@ApiResponse({ status: HttpStatus.OK, description: 'Tasks retrieved successfully.' })
@ApiResponse({ status: HttpStatus.NOT_FOUND, description: 'No records found.' })
@Permissions(PermissionsEnum.ALL_ORG_VIEW, PermissionsEnum.ORG_TASK_VIEW)
@Get('/filter-by-date')
@UseValidationPipe({ transform: true })
async getTasksByDateFilters(@Query() params: TaskDateFilterInputDTO): Promise<IPagination<ITask>> {
rahul-rocket marked this conversation as resolved.
Show resolved Hide resolved
return this.taskService.getTasksByDateFilters(params);
}

/**
* GET view tasks
*
Expand Down
95 changes: 94 additions & 1 deletion packages/core/src/tasks/task.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,11 +22,13 @@ import {
ITask,
ITaskUpdateInput,
PermissionsEnum,
ActionTypeEnum
ActionTypeEnum,
ITaskDateFilterInput
} from '@gauzy/contracts';
import { isEmpty, isNotEmpty } from '@gauzy/common';
import { isPostgres, isSqlite } from '@gauzy/config';
import { PaginationParams, TenantAwareCrudService } from './../core/crud';
import { addBetween } from './../core/util';
import { RequestContext } from '../core/context';
import { TaskViewService } from './views/view.service';
import { ActivityLogService } from '../activity-log/activity-log.service';
Expand Down Expand Up @@ -837,4 +839,95 @@ export class TaskService extends TenantAwareCrudService<Task> {
throw new HttpException({ message: error?.message, error }, HttpStatus.BAD_REQUEST);
}
}

/**
* Retrieves tasks based on the provided date filters for startDate and dueDate.
*
* @function getTasksByDateFilters
* @param {ITaskDateFilterInput} params - The query params containing the date filters for the tasks.
*
* @returns {Promise<IPagination<ITask>>} A promise that resolves to an paginated tasks filtered by the provided dates.
*
* @throws {Error} Will throw an error if there is a problem with the database query.
*/
async getTasksByDateFilters(params: ITaskDateFilterInput): Promise<IPagination<ITask>> {
const tenantId = RequestContext.currentTenantId() || params.tenantId;

try {
const {
startDateFrom,
startDateTo,
dueDateFrom,
dueDateTo,
creatorId,
organizationId,
employeeId,
projectId,
organizationTeamId,
organizationSprintId,
relations
} = params;
GloireMutaliko21 marked this conversation as resolved.
Show resolved Hide resolved

let query = this.typeOrmRepository.createQueryBuilder(this.tableName);

query.andWhere(
new Brackets((qb: WhereExpressionBuilder) => {
qb.andWhere(p(`"${query.alias}"."tenantId" = :tenantId`), { tenantId });
qb.andWhere(p(`"${query.alias}"."organizationId" = :organizationId`), { organizationId });
})
);

// Apply the filters on startDate and dueDate
query = addBetween<Task>(query, 'startDate', startDateFrom, startDateTo, p);
query = addBetween<Task>(query, 'dueDate', dueDateFrom, dueDateTo, p);

GloireMutaliko21 marked this conversation as resolved.
Show resolved Hide resolved
// Add Optional additional filters by
query.andWhere(
new Brackets((web: WhereExpressionBuilder) => {
if (isNotEmpty(creatorId)) {
web.andWhere(p(`"${query.alias}"."creatorId" = :creatorId`), { creatorId });
}
if (isNotEmpty(employeeId)) {
query.leftJoin(`${query.alias}.members`, 'members');
web.andWhere((qb: SelectQueryBuilder<Task>) => {
const subQuery = qb.subQuery();
subQuery.select(p('"task_employee"."taskId"')).from(p('task_employee'), p('task_employee'));
subQuery.andWhere(p('"task_employee"."employeeId" = :employeeId'), { employeeId });
return p(`"task_members"."taskId" IN (${subQuery.distinct(true).getQuery()})`);
});
}
if (isNotEmpty(organizationTeamId)) {
query.leftJoin(`${query.alias}.teams`, 'teams');
web.andWhere((qb: SelectQueryBuilder<Task>) => {
const subQuery = qb.subQuery();
subQuery.select(p('"task_team"."taskId"')).from(p('task_team'), p('task_team'));
subQuery.andWhere(p('"task_team"."organizationTeamId" = :organizationTeamId'), {
organizationTeamId
});
return p(`"task_teams"."taskId" IN (${subQuery.distinct(true).getQuery()})`);
});
}
if (isNotEmpty(projectId)) {
web.andWhere(p(`"${query.alias}"."projectId" = :projectId`), { projectId });
}
if (isNotEmpty(organizationSprintId)) {
web.andWhere(p(`"${query.alias}"."organizationSprintId" = :organizationSprintId`), {
organizationSprintId
});
}
})
);

// Check if relations were provided and include them
query.setFindOptions({
...(relations ? { relations } : {})
});
GloireMutaliko21 marked this conversation as resolved.
Show resolved Hide resolved

const [items, total] = await query.getManyAndCount();

return { items, total };
} catch (error) {
throw new BadRequestException(error);
}
}
}
Loading