From 3990b944080d8824279fd68bde0972507e8b8a77 Mon Sep 17 00:00:00 2001 From: "Rahul R." Date: Mon, 23 Sep 2024 23:33:16 +0530 Subject: [PATCH] [Fix] Adjust Pending Time Logs Entries (#8240) * wip: working on start/stop adjust entries * fix: start/stop timer methods improvement --- packages/contracts/src/timesheet.model.ts | 10 +- packages/core/src/core/utils.ts | 35 +- .../core/src/employee/employee.service.ts | 60 ++-- .../schedule-time-log-entries.handler.ts | 242 +++++++------ .../schedule-time-log-entries.command.ts | 8 +- .../time-tracking/time-log/time-log.entity.ts | 9 +- .../timer/dto/start-timer.dto.ts | 74 ++-- .../time-tracking/timer/dto/stop-timer.dto.ts | 7 +- .../src/time-tracking/timer/timer.service.ts | 339 ++++++++++-------- .../time-tracker/time-tracker.service.ts | 35 +- 10 files changed, 447 insertions(+), 372 deletions(-) diff --git a/packages/contracts/src/timesheet.model.ts b/packages/contracts/src/timesheet.model.ts index 1096b6a7181..2d79dda174c 100644 --- a/packages/contracts/src/timesheet.model.ts +++ b/packages/contracts/src/timesheet.model.ts @@ -7,7 +7,7 @@ import { } from './organization-projects.model'; import { IEmployee, IEmployeeFindInput, IEmployeeEntityInput } from './employee.model'; import { ITask } from './task.model'; -import { ITag } from './tag.model'; +import { ITag, ITaggable } from './tag.model'; import { IPaginationInput } from './core.model'; import { ReportGroupByFilter } from './report.model'; import { IUser } from './user.model'; @@ -98,7 +98,7 @@ export interface IDateRange { export interface ITimeLog extends IBasePerTenantAndOrganizationEntityModel, IRelationalOrganizationProject, - IRelationalOrganizationTeam { + IRelationalOrganizationTeam, ITaggable { employee: IEmployee; employeeId: ID; timesheet?: ITimesheet; @@ -113,14 +113,12 @@ export interface ITimeLog source?: TimeLogSourceEnum; startedAt?: Date; stoppedAt?: Date; - /** Edited At* */ editedAt?: Date; - logType: TimeLogType; + logType?: TimeLogType; description?: string; reason?: string; duration: number; - isBillable: boolean; - tags?: string[]; + isBillable?: boolean; isRunning?: boolean; isEdited?: boolean; } diff --git a/packages/core/src/core/utils.ts b/packages/core/src/core/utils.ts index d2fa3836a4e..a4165910c90 100644 --- a/packages/core/src/core/utils.ts +++ b/packages/core/src/core/utils.ts @@ -306,33 +306,28 @@ export function freshTimestamp(): Date { } /** - * Function that performs the date range validation + * Validates the date range between startedAt and stoppedAt. * - * @param startedAt - * @param stoppedAt - * @returns + * @param startedAt The start date of the range. + * @param stoppedAt The end date of the range. + * @throws BadRequestException if the dates are invalid or if the stoppedAt date is before the startedAt date. */ export function validateDateRange(startedAt: Date, stoppedAt: Date): void { - try { - const start = moment(startedAt); - const end = moment(stoppedAt); + const start = moment(startedAt); + const end = moment(stoppedAt); - console.log('------ Stopped Timer ------', start.toDate(), end.toDate()); + console.log('------ Stopped Timer ------', start.toDate(), end.toDate()); - if (!start.isValid() || !end.isValid()) { - throw 'Started and Stopped date must be valid date.'; - // If either start or end date is invalid, return without throwing an exception - } + if (!start.isValid() || !end.isValid()) { + throw new BadRequestException('Started and Stopped date must be valid dates.'); + } - if (end.isBefore(start)) { - throw 'Stopped date must be greater than started date.'; - } - } catch (error) { - // If any error occurs during date validation, throw a BadRequestException - throw new BadRequestException(error); + if (end.isBefore(start)) { + throw new BadRequestException('Stopped date must be greater than the started date.'); } } + /** * Function that returns intersection of 2 arrays * @param arr1 Array 1 @@ -474,8 +469,8 @@ export const flatten = (input: any): any => { const newKey = Array.isArray(value) ? key : nestedKeys.length > 0 - ? `${key}.${nestedKeys.join('.')}` - : key; + ? `${key}.${nestedKeys.join('.')}` + : key; return acc.concat(newKey); } }, []) || [] diff --git a/packages/core/src/employee/employee.service.ts b/packages/core/src/employee/employee.service.ts index 09fe0839785..65d2b1f5826 100644 --- a/packages/core/src/employee/employee.service.ts +++ b/packages/core/src/employee/employee.service.ts @@ -3,10 +3,10 @@ import { Brackets, FindManyOptions, FindOneOptions, In, SelectQueryBuilder, Wher import * as moment from 'moment'; import { IBasePerTenantAndOrganizationEntityModel, + ID, IDateRangePicker, IEmployee, IFindMembersInput, - IOrganization, IPagination, PermissionsEnum } from '@gauzy/contracts'; @@ -97,7 +97,7 @@ export class EmployeeService extends TenantAwareCrudService { * @param userIds An array of user IDs. * @returns A promise resolving to an array of employees. */ - async findEmployeesByUserIds(userIds: string[]): Promise { + async findEmployeesByUserIds(userIds: ID[]): Promise { try { // Get the tenant ID from the current request context const tenantId = RequestContext.currentTenantId(); @@ -137,7 +137,7 @@ export class EmployeeService extends TenantAwareCrudService { * @param userId The ID of the user. * @returns The employeeId or null if not found or in case of an error. */ - async findEmployeeIdByUserId(userId: string): Promise { + async findEmployeeIdByUserId(userId: ID): Promise { try { const tenantId = RequestContext.currentTenantId(); // Construct the where clause based on whether tenantId is available @@ -172,29 +172,35 @@ export class EmployeeService extends TenantAwareCrudService { * @param userId The ID of the user to find. * @returns A Promise resolving to the employee if found, otherwise null. */ - async findOneByUserId(userId: string, options?: FindOneOptions): Promise { + async findOneByUserId(userId: ID, options?: FindOneOptions): Promise { try { + // Retrieve the tenant ID from the current context const tenantId = RequestContext.currentTenantId(); - // Construct the where clause based on whether tenantId is available + // Define the base where clause const whereClause = { userId, + ...(tenantId && { tenantId }), // Include tenantId if available isActive: true, isArchived: false, - ...(tenantId && { tenantId }) // Include tenantId if available }; - const queryOptions = options ? { ...options } : {}; + + // Merge the existing where conditions in options, if any + const queryOptions: FindOneOptions = { + ...options, + where: { + ...whereClause, + ...(options?.where || {}) // Merge with existing where options if present + } + }; switch (this.ormType) { case MultiORMEnum.MikroORM: - const { mikroOptions } = parseTypeORMFindToMikroOrm(options as FindManyOptions); - const item = await this.mikroOrmRepository.findOne(whereClause, mikroOptions); + const { where, mikroOptions } = parseTypeORMFindToMikroOrm(queryOptions as FindManyOptions); + const item = await this.mikroOrmRepository.findOne(where, mikroOptions); return this.serialize(item as Employee); case MultiORMEnum.TypeORM: - return this.typeOrmRepository.findOne({ - where: whereClause, - ...queryOptions - }); + return this.typeOrmRepository.findOne(queryOptions); default: throw new Error(`Not implemented for ${this.ormType}`); } @@ -208,7 +214,7 @@ export class EmployeeService extends TenantAwareCrudService { * Retrieves all active employees with their associated user and organization details. * @returns A Promise that resolves to an array of active employees. */ - public async findAllActive(): Promise { + public async findAllActive(): Promise { try { return await super.find({ where: { isActive: true, isArchived: false }, @@ -232,7 +238,7 @@ export class EmployeeService extends TenantAwareCrudService { * @returns */ async findWorkingEmployees( - organizationId: IOrganization['id'], + organizationId: ID, forRange: IDateRangePicker | any, withUser: boolean = false ): Promise> { @@ -497,17 +503,19 @@ export class EmployeeService extends TenantAwareCrudService { * Softly delete an employee by ID, with organization and tenant constraints. * * @param employeeId - ID of the employee to delete. - * @param options - Contains organizationId and possibly other per-tenant information. + * @param params - Contains organizationId and possibly other per-tenant information. * @returns - UpdateResult or DeleteResult depending on the ORM type. */ async softRemovedById( - employeeId: IEmployee['id'], - options: IBasePerTenantAndOrganizationEntityModel + employeeId: ID, + params: IBasePerTenantAndOrganizationEntityModel ): Promise { try { - const { organizationId } = options; + // Obtain the organization ID from the provided parameters + const organizationId = params.organizationId; + // Obtain tenant ID from the current request context - const tenantId = RequestContext.currentTenantId() || options.tenantId; + const tenantId = RequestContext.currentTenantId() || params.tenantId; // Perform the soft delete operation return await super.softRemove(employeeId, { @@ -527,18 +535,20 @@ export class EmployeeService extends TenantAwareCrudService { * and tenant ID to ensure that the correct employee is restored. * * @param employeeId The ID of the employee to restore. - * @param options Additional context parameters, including organization ID and tenant ID. + * @param params Additional context parameters, including organization ID and tenant ID. * @returns The restored Employee entity. * @throws BadRequestException if the employee cannot be restored or if an error occurs. */ async softRecoverById( - employeeId: IEmployee['id'], - options: IBasePerTenantAndOrganizationEntityModel + employeeId: ID, + params: IBasePerTenantAndOrganizationEntityModel ): Promise { try { - const { organizationId } = options; + // Obtain the organization ID from the provided parameters + const organizationId = params.organizationId; + // Obtain the tenant ID from the current request context or the provided options - const tenantId = RequestContext.currentTenantId() || options.tenantId; + const tenantId = RequestContext.currentTenantId() || params.tenantId; // Perform the soft recovery operation using the ID, organization ID, and tenant ID return await super.softRecover(employeeId, { diff --git a/packages/core/src/time-tracking/time-log/commands/handlers/schedule-time-log-entries.handler.ts b/packages/core/src/time-tracking/time-log/commands/handlers/schedule-time-log-entries.handler.ts index e252dcdfe21..dafe2070356 100644 --- a/packages/core/src/time-tracking/time-log/commands/handlers/schedule-time-log-entries.handler.ts +++ b/packages/core/src/time-tracking/time-log/commands/handlers/schedule-time-log-entries.handler.ts @@ -1,42 +1,78 @@ import { ICommandHandler, CommandHandler } from '@nestjs/cqrs'; import { Brackets, SelectQueryBuilder, WhereExpressionBuilder } from 'typeorm'; import * as moment from 'moment'; -import { isEmpty, isNotEmpty } from '@gauzy/common'; -import { ITimeLog } from '@gauzy/contracts'; +import { isEmpty } from '@gauzy/common'; +import { ID, ITimeLog } from '@gauzy/contracts'; import { prepareSQLQuery as p } from './../../../../database/database.helper'; import { TimeLog } from './../../time-log.entity'; import { ScheduleTimeLogEntriesCommand } from '../schedule-time-log-entries.command'; -import { RequestContext } from './../../../../core/context'; import { TypeOrmTimeLogRepository } from '../../repository/type-orm-time-log.repository'; @CommandHandler(ScheduleTimeLogEntriesCommand) export class ScheduleTimeLogEntriesHandler implements ICommandHandler { - constructor(private readonly typeOrmTimeLogRepository: TypeOrmTimeLogRepository) {} + constructor(readonly typeOrmTimeLogRepository: TypeOrmTimeLogRepository) { } /** - * Schedule TimeLog Entries + * Executes the scheduling of TimeLog entries based on the given command parameters. + * This function is responsible for identifying any pending time logs for a specific tenant, organization, + * and optionally an employee, and then processing each entry to ensure they are accurately tracked and updated. * - * @param command - * @returns + * The function first retrieves all pending TimeLog entries that match the given criteria, + * then iterates through each of them to perform necessary adjustments such as stopping timers, + * updating durations, and correcting the 'stoppedAt' timestamps based on the employee's activities. + * + * @param command The command containing the details needed to identify the pending TimeLog entries, + * including `tenantId`, `organizationId`, and optionally `employeeId`. + * + * @returns A Promise that resolves when all pending TimeLog entries have been processed and updated. */ public async execute(command: ScheduleTimeLogEntriesCommand): Promise { - const { timeLog } = command; - let timeLogs: ITimeLog[] = []; + const { tenantId, organizationId, employeeId } = command; + + // Retrieve all pending TimeLog entries based on the provided tenantId, organizationId, and employeeId (if available) + const timeLogs = await this.getPendingTimeLogs( + tenantId, + organizationId, + employeeId + ); + + // Iterate through each pending time log entry to process and update them as necessary + for await (const timeLog of timeLogs) { + await this.processTimeLogEntry(timeLog); + } + } - // Query the timeLogs - const query = this.typeOrmTimeLogRepository.createQueryBuilder('time_log'); - query.setFindOptions({ + /** + * Retrieve pending TimeLog entries based on the given criteria. + * + * @param tenantId + * @param organizationId + * @param employeeId + * @returns A list of pending time logs + */ + private async getPendingTimeLogs( + tenantId: ID, + organizationId: ID, + employeeId?: ID + ): Promise { + // Construct the query with find options + const query = this.typeOrmTimeLogRepository.createQueryBuilder('time_log').setFindOptions({ relations: { timeSlots: true } }); - if (timeLog) { - // Get the tenantId - const tenantId = RequestContext.currentTenantId() || timeLog.tenantId; + // Define the main query structure + query.where((qb: SelectQueryBuilder) => { + const andWhere = new Brackets((web: WhereExpressionBuilder) => { + web.andWhere(p(`"${qb.alias}"."stoppedAt" IS NOT NULL`)); + web.andWhere(p(`"${qb.alias}"."isRunning" = :isRunning`), { isRunning: true }); + }); - // Get the organizationId - const { organizationId, employeeId } = timeLog; + const orWhere = new Brackets((web: WhereExpressionBuilder) => { + web.andWhere(p(`"${qb.alias}"."stoppedAt" IS NULL`)); + }); - query.where((qb: SelectQueryBuilder) => { + // Apply filtering based on employeeId and organizationId + if (!!employeeId && !!organizationId) { qb.andWhere( new Brackets((web: WhereExpressionBuilder) => { web.andWhere(p(`"${qb.alias}"."employeeId" = :employeeId`), { employeeId }); @@ -44,97 +80,101 @@ export class ScheduleTimeLogEntriesHandler implements ICommandHandler { - web.andWhere( - new Brackets((web: WhereExpressionBuilder) => { - web.andWhere(p(`"${qb.alias}"."stoppedAt" IS NOT NULL`)); - web.andWhere(p(`"${qb.alias}"."isRunning" = :isRunning`), { isRunning: true }); - }) - ); - web.orWhere( - new Brackets((web: WhereExpressionBuilder) => { - web.andWhere(p(`"${qb.alias}"."stoppedAt" IS NULL`)); - }) - ); - }) - ); - console.log('Schedule Time Log Query For Tenant Organization Entries', qb.getQueryAndParameters()); - }); + } + + qb.andWhere(new Brackets((web: WhereExpressionBuilder) => { + web.andWhere(andWhere); + web.orWhere(orWhere); + })); + }); + + console.log( + `Schedule Time Log Query For ${employeeId ? 'Tenant Organization' : 'All'} Entries`, + query.getQueryAndParameters() + ); + + return await query.getMany(); + } + + /** + * Process a single TimeLog entry, adjusting its duration and stopping it if necessary. + * + * @param timeLog The time log entry to process + */ + private async processTimeLogEntry(timeLog: ITimeLog): Promise { + const { timeSlots } = timeLog; + + // Calculate the minutes difference since the time log started + const minutes = moment().diff(moment.utc(timeLog.startedAt), 'minutes'); + + // Handle cases where there are no time slots + if (isEmpty(timeSlots)) { + // If the minutes difference is greater than 10, update the stoppedAt date + if (minutes > 10) { + await this.updateStoppedAtUsingStartedAt(timeLog); + } } else { - query.where((qb: SelectQueryBuilder) => { - qb.andWhere( - new Brackets((web: WhereExpressionBuilder) => { - web.andWhere(p(`"${qb.alias}"."stoppedAt" IS NOT NULL`)); - web.andWhere(p(`"${qb.alias}"."isRunning" = :isRunning`), { isRunning: true }); - }) - ); - qb.orWhere( - new Brackets((web: WhereExpressionBuilder) => { - web.andWhere(p(`"${qb.alias}"."stoppedAt" IS NULL`)); - }) - ); - console.log('Schedule Time Log Query For All Entries', query.getQueryAndParameters()); - }); + // Handle cases where there are time slots + await this.updateStoppedAtUsingTimeSlots(timeLog, timeSlots); } - // Get all pending TimeLog entries - timeLogs = await query.getMany(); + // Stop the pending time log entry + await this.stopTimeLog(timeLog); + } - // Loop through all the timeLogs - for await (const timeLog of timeLogs) { - const { timeSlots } = timeLog; - - // Calculate the minutes difference - const minutes = moment().diff(moment.utc(timeLog.startedAt), 'minutes'); - - // Handle case where there are no time slots - if (isEmpty(timeLog.timeSlots)) { - // If the minutes difference is greater than 10, update the stoppedAt date - if (minutes > 10) { - console.log('Schedule Time Log Entry Updated StoppedAt Using StartedAt', timeLog.startedAt); - - // Calculate the stoppedAt date - const stoppedAt = moment.utc(timeLog.startedAt).add(10, 'seconds').toDate(); - - // Calculate the stoppedAt date - await this.typeOrmTimeLogRepository.save({ - id: timeLog.id, - stoppedAt - }); - } - } - // Handle case where there are time slots - else if (isNotEmpty(timeLog.timeSlots)) { - // Calculate the duration - const duration = timeSlots.reduce((sum, { duration }) => sum + duration, 0); - - // Calculate the stoppedAt date - const stoppedAt = moment.utc(timeLog.startedAt).add(duration, 'seconds').toDate(); - - // Calculate the minutes difference - const minutes = moment.utc().diff(moment.utc(stoppedAt), 'minutes'); - - console.log('Schedule Time Log Entry Updated StoppedAt Using StoppedAt', stoppedAt); - - // If the minutes difference is greater than 10, update the stoppedAt date - if (minutes > 10) { - await this.typeOrmTimeLogRepository.save({ - id: timeLog.id, - stoppedAt - }); - } - } - /** - * Stop previous pending timer anyway. - * If we have any pending TimeLog entry - */ + /** + * Update the stoppedAt field using the startedAt value for a time log. + * + * @param timeLog The time log entry to update + */ + private async updateStoppedAtUsingStartedAt(timeLog: ITimeLog): Promise { + // Calculate the stoppedAt date + const stoppedAt = moment.utc(timeLog.startedAt).add(10, 'seconds').toDate(); + + // Update the stoppedAt field in the database + await this.typeOrmTimeLogRepository.save({ + id: timeLog.id, + stoppedAt + }); + console.log('Schedule Time Log Entry Updated StoppedAt Using StartedAt', timeLog.startedAt); + } + + /** + * Update the stoppedAt field using the total duration from the time slots for a time log. + * + * @param timeLog The time log entry to update + * @param timeSlots The time slots associated with the time log + */ + private async updateStoppedAtUsingTimeSlots(timeLog: ITimeLog, timeSlots: any[]): Promise { + // Calculate the duration + const duration = timeSlots.reduce((sum, { duration }) => sum + duration, 0); + + // Calculate the stoppedAt date + const stoppedAt = moment.utc(timeLog.startedAt).add(duration, 'seconds').toDate(); + + // Calculate the minutes difference + const minutes = moment.utc().diff(moment.utc(stoppedAt), 'minutes'); + + // Update the stoppedAt field in the database + if (minutes > 10) { await this.typeOrmTimeLogRepository.save({ id: timeLog.id, - isRunning: false + stoppedAt }); - console.log('Schedule Time Log Entry Updated Entry', timeLog); + console.log('Schedule Time Log Entry Updated StoppedAt Using StoppedAt', stoppedAt); } } + + /** + * Mark the time log as not running (stopped) in the database. + * + * @param timeLog The time log entry to stop + */ + private async stopTimeLog(timeLog: ITimeLog): Promise { + await this.typeOrmTimeLogRepository.save({ + id: timeLog.id, + isRunning: false + }); + } } diff --git a/packages/core/src/time-tracking/time-log/commands/schedule-time-log-entries.command.ts b/packages/core/src/time-tracking/time-log/commands/schedule-time-log-entries.command.ts index 650295cd6da..6e34d265beb 100644 --- a/packages/core/src/time-tracking/time-log/commands/schedule-time-log-entries.command.ts +++ b/packages/core/src/time-tracking/time-log/commands/schedule-time-log-entries.command.ts @@ -1,8 +1,12 @@ import { ICommand } from '@nestjs/cqrs'; -import { ITimeLog } from '@gauzy/contracts'; +import { ID } from '@gauzy/contracts'; export class ScheduleTimeLogEntriesCommand implements ICommand { static readonly type = 'Adjust [TimeLog] Entries'; - constructor(public readonly timeLog?: ITimeLog) {} + constructor( + public readonly employeeId?: ID, + public readonly organizationId?: ID, + public readonly tenantId?: ID + ) {} } diff --git a/packages/core/src/time-tracking/time-log/time-log.entity.ts b/packages/core/src/time-tracking/time-log/time-log.entity.ts index bf7b08e1d8e..5b667d2ce36 100644 --- a/packages/core/src/time-tracking/time-log/time-log.entity.ts +++ b/packages/core/src/time-tracking/time-log/time-log.entity.ts @@ -64,7 +64,7 @@ export class TimeLog extends TenantOrganizationBaseEntity implements ITimeLog { @IsEnum(TimeLogType) @ColumnIndex() @MultiORMColumn({ default: TimeLogType.TRACKED }) - logType: TimeLogType; + logType?: TimeLogType; @ApiProperty({ type: () => String, enum: TimeLogSourceEnum, default: TimeLogSourceEnum.WEB_TIMER }) @IsEnum(TimeLogSourceEnum) @@ -92,7 +92,7 @@ export class TimeLog extends TenantOrganizationBaseEntity implements ITimeLog { @IsBoolean() @ColumnIndex() @MultiORMColumn({ default: false }) - isBillable: boolean; + isBillable?: boolean; @ApiPropertyOptional({ type: () => Boolean }) @IsOptional() @@ -101,7 +101,10 @@ export class TimeLog extends TenantOrganizationBaseEntity implements ITimeLog { @MultiORMColumn({ nullable: true }) isRunning?: boolean; - @ApiPropertyOptional({ type: () => String }) + /** + * Version of the sources (Desktop/Web/Extension/Mobile) timer + */ + @ApiPropertyOptional({ type: () => String, example: '1.0.1' }) @IsOptional() @IsString() @ColumnIndex() diff --git a/packages/core/src/time-tracking/timer/dto/start-timer.dto.ts b/packages/core/src/time-tracking/timer/dto/start-timer.dto.ts index 9bb3d5e4e70..74e726dd000 100644 --- a/packages/core/src/time-tracking/timer/dto/start-timer.dto.ts +++ b/packages/core/src/time-tracking/timer/dto/start-timer.dto.ts @@ -1,53 +1,21 @@ -import { IOrganizationContact, IOrganizationProject, IOrganizationTeam, ITask, ITimerToggleInput, TimeLogSourceEnum, TimeLogType } from "@gauzy/contracts"; -import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger"; -import { IsBoolean, IsEnum, IsOptional, IsString, IsUUID } from "class-validator"; -import { TenantOrganizationBaseDTO } from "./../../../core/dto"; - -export class StartTimerDTO extends TenantOrganizationBaseDTO implements ITimerToggleInput { - - @ApiProperty({ type: () => String, enum: TimeLogType }) - @IsEnum(TimeLogType) - readonly logType: TimeLogType; - - @ApiProperty({ type: () => String, enum: TimeLogSourceEnum }) - @IsEnum(TimeLogSourceEnum) - readonly source: TimeLogSourceEnum; - - @ApiPropertyOptional({ type: () => Boolean }) - @IsOptional() - @IsBoolean() - readonly isBillable: boolean; - - @ApiPropertyOptional({ type: () => String }) - @IsOptional() - @IsString() - readonly description: string; - - @ApiPropertyOptional({ type: () => String }) - @IsOptional() - @IsUUID() - readonly projectId: IOrganizationProject['id']; - - @ApiPropertyOptional({ type: () => String }) - @IsOptional() - @IsUUID() - readonly taskId: ITask['id']; - - @ApiPropertyOptional({ type: () => String }) - @IsOptional() - @IsUUID() - readonly organizationContactId: IOrganizationContact['id']; - - @ApiPropertyOptional({ type: () => String }) - @IsOptional() - @IsUUID() - readonly organizationTeamId: IOrganizationTeam['id']; - - /** - * Version of the sources (Desktop/Web/Browser/Mobile) timer - */ - @ApiPropertyOptional({ type: () => String, example: '1.0.1' }) - @IsOptional() - @IsString() - readonly version: string; -} +import { IntersectionType, PickType } from '@nestjs/swagger'; +import { ITimerToggleInput } from '@gauzy/contracts'; +import { TenantOrganizationBaseDTO } from './../../../core/dto'; +import { TimeLog } from '../../time-log/time-log.entity'; + +export class StartTimerDTO + extends IntersectionType( + TenantOrganizationBaseDTO, + PickType(TimeLog, [ + 'source', + 'logType', + 'isBillable', + 'description', + 'version', + 'organizationTeamId', + 'organizationContactId', + 'projectId', + 'taskId' + ] as const) + ) + implements ITimerToggleInput {} diff --git a/packages/core/src/time-tracking/timer/dto/stop-timer.dto.ts b/packages/core/src/time-tracking/timer/dto/stop-timer.dto.ts index 35bce139678..219231605b2 100644 --- a/packages/core/src/time-tracking/timer/dto/stop-timer.dto.ts +++ b/packages/core/src/time-tracking/timer/dto/stop-timer.dto.ts @@ -2,7 +2,6 @@ import { ITimerToggleInput } from '@gauzy/contracts'; import { IntersectionType, PartialType, PickType } from '@nestjs/swagger'; import { StartTimerDTO } from './start-timer.dto'; -export class StopTimerDTO extends IntersectionType( - StartTimerDTO, - PartialType(PickType(StartTimerDTO, ['source', 'logType'] as const)) -) implements ITimerToggleInput { } +export class StopTimerDTO + extends IntersectionType(StartTimerDTO, PartialType(PickType(StartTimerDTO, ['source', 'logType'] as const))) + implements ITimerToggleInput {} diff --git a/packages/core/src/time-tracking/timer/timer.service.ts b/packages/core/src/time-tracking/timer/timer.service.ts index bdb7b59d4a3..93d7183e30d 100644 --- a/packages/core/src/time-tracking/timer/timer.service.ts +++ b/packages/core/src/time-tracking/timer/timer.service.ts @@ -13,7 +13,8 @@ import { PermissionsEnum, ITimeSlot, IEmployee, - IEmployeeFindInput + IEmployeeFindInput, + ID } from '@gauzy/contracts'; import { isNotEmpty } from '@gauzy/common'; import { TimeLog } from '../../core/entities/internal'; @@ -28,6 +29,7 @@ import { validateDateRange } from '../../core/utils'; import { prepareSQLQuery as p } from '../../database/database.helper'; +import { EmployeeService } from '../../employee/employee.service'; import { DeleteTimeSpanCommand, IGetConflictTimeLogCommand, @@ -51,8 +53,9 @@ export class TimerService { readonly mikroOrmTimeLogRepository: MikroOrmTimeLogRepository, readonly typeOrmEmployeeRepository: TypeOrmEmployeeRepository, readonly mikroOrmEmployeeRepository: MikroOrmEmployeeRepository, - readonly commandBus: CommandBus - ) {} + private readonly _employeeService: EmployeeService, + private readonly _commandBus: CommandBus + ) { } /** * Fetches an employee based on the provided query. @@ -185,77 +188,66 @@ export class TimerService { } /** - * Start time tracking + * Start time tracking for an employee. * - * @param request - * @returns + * @param request The timer toggle input details. + * @returns A Promise resolving to the created ITimeLog entry. */ async startTimer(request: ITimerToggleInput): Promise { console.log( '----------------------------------Started Timer Date----------------------------------', moment.utc(request.startedAt).toDate() ); - const { organizationId, source, logType } = request; - - /** - * If source or logType is not found in the request, reject the request. - */ - const c1 = Object.values(TimeLogSourceEnum).includes(source); - const c2 = Object.values(TimeLogType).includes(logType); - - if (!c1 || !c2) { - throw new BadRequestException(); - } - const userId = RequestContext.currentUserId(); + // Retrieve the tenant ID from the current context or the provided one in the request const tenantId = RequestContext.currentTenantId() || request.tenantId; - const employee = await this.typeOrmEmployeeRepository.findOneBy({ - userId, - tenantId - }); - if (!employee) { - throw new NotFoundException("We couldn't find the employee you were looking for."); + // Destructure the necessary parameters from the request + const { + source, + logType, + projectId, + taskId, + organizationContactId, + organizationTeamId, + description, + isBillable, + version + } = request; + + // Retrieve the employee information + const employee = await this.findEmployee(); + + // Throw an exception if the employee is not found or tracking is disabled + if (!employee.isTrackingEnabled) { + throw new ForbiddenException(`The time tracking functionality has been disabled for you.`); } - const { id: employeeId } = employee; - const lastLog = await this.getLastRunningLog(request); - - console.log('Start Timer Time Log', { request, lastLog }); + // Get the employee ID + const { id: employeeId, organizationId } = employee; - if (lastLog) { - /** - * If you want to start timer, but employee timer is already started. - * So, we have to first update stop timer entry in database, then create start timer entry. - * It will manage to create proper entires in database - */ - console.log('Schedule Time Log Entries Command', lastLog); - await this.commandBus.execute(new ScheduleTimeLogEntriesCommand(lastLog)); - } + try { + // Retrieve any existing running logs for the employee + const logs = await this.getLastRunningLogs(); - await this.typeOrmEmployeeRepository.update( - { id: employeeId }, - { - isOnline: true, // Employee status (Online/Offline) - isTrackingTime: true // Employee time tracking status + // If there are existing running logs, stop them before starting a new one + if (logs.length > 0) { + await this.stopPreviousRunningTimers(employeeId, organizationId, tenantId); } - ); - - // Get the request parameters - const { projectId, taskId, organizationContactId, organizationTeamId } = request; - const { description, isBillable, version } = request; + } catch (error) { + console.error('Error while getting last running logs', error); + } - // Get the current date - const now = moment.utc().toDate(); - const startedAt = request.startedAt ? moment.utc(request.startedAt).toDate() : now; + // Determine the start date and time in UTC + const startedAt = request.startedAt ? moment.utc(request.startedAt).toDate() : moment.utc().toDate(); - // Create the timeLog - return await this.commandBus.execute( + // Create a new time log entry using the command bus + const timeLog = await this._commandBus.execute( new TimeLogCreateCommand({ organizationId, tenantId, employeeId, - startedAt: startedAt, + startedAt, stoppedAt: startedAt, duration: 0, source: source || TimeLogSourceEnum.WEB_TIMER, @@ -270,13 +262,22 @@ export class TimerService { isRunning: true }) ); + + // Update the employee's tracking status to reflect they are now tracking time + await this._employeeService.update(employeeId, { + isOnline: true, + isTrackingTime: true + }); + + // Return the newly created time log entry + return timeLog; } /** - * Stop time tracking + * Stop time tracking for the current employee. * - * @param request - * @returns + * @param request The input data for stopping the timer. + * @returns A Promise resolving to the updated ITimeLog entry. */ async stopTimer(request: ITimerToggleInput): Promise { console.log( @@ -284,58 +285,32 @@ export class TimerService { moment.utc(request.stoppedAt).toDate() ); - // Get the user ID - const userId = RequestContext.currentUserId(); - // Get the tenant ID + // Retrieve tenant ID const tenantId = RequestContext.currentTenantId() || request.tenantId; - // Get the employee - const employee = await this.typeOrmEmployeeRepository.findOneBy({ - userId, - tenantId - }); - if (!employee) { - throw new NotFoundException("We couldn't find the employee you were looking for."); - } + // Fetch the employee details + const employee = await this.findEmployee(); - // Get the employee ID - const { id: employeeId } = employee; - - // Update the employee - await this.typeOrmEmployeeRepository.update( - { id: employeeId }, - { - isOnline: false, // Employee status (Online/Offline) - isTrackingTime: false // Employee time tracking status - } - ); + // Check if time tracking is enabled for the employee + if (!employee.isTrackingEnabled) { + throw new ForbiddenException('The time tracking functionality has been disabled for you.'); + } - let lastLog = await this.getLastRunningLog(request); + // Retrieve the last running log or start a new timer if none exist + let lastLog = await this.getLastRunningLog(); if (!lastLog) { - /** - * If you want to stop timer, but employee timer is already stopped. - * So, we have to first create start timer entry in database, then update stop timer entry. - * It will manage to create proper entires in database - */ + console.log('No running log found. Starting a new timer before stopping it.'); lastLog = await this.startTimer(request); } - // Get the organization ID - const organizationId = request.organizationTeamId || employee.organizationId; - - // Get the current date and set the initial stoppedAt date - const now = moment.utc().toDate(); - let stoppedAt = moment.utc(request.stoppedAt ?? now).toDate(); + const organizationId = employee.organizationId ?? lastLog.organizationId; + const stoppedAt = moment.utc(request.stoppedAt ?? moment.utc()).toDate(); - /** Function that performs the date range validation */ - try { - validateDateRange(lastLog.startedAt, stoppedAt); - } catch (error) { - throw new BadRequestException(error); - } + // Validate the date range + validateDateRange(lastLog.startedAt, stoppedAt); - // Update the lastLog - lastLog = await this.commandBus.execute( + // Update the time log entry to mark it as stopped + lastLog = await this._commandBus.execute( new TimeLogUpdateCommand( { stoppedAt, @@ -347,9 +322,33 @@ export class TimerService { ); console.log('Stop Timer Time Log', { lastLog }); + // Update the employee's tracking status + await this._employeeService.update(employee.id, { + isOnline: false, // Employee status (Online/Offline) + isTrackingTime: false // Employee time tracking status + }); + + // Handle conflicting time logs + await this.handleConflictingTimeLogs(lastLog, tenantId, organizationId); + + return lastLog; + } + + /** + * Handles any conflicting time logs that overlap with the current time log entry. + * + * @param lastLog The last running time log entry. + * @param tenantId The tenant ID. + * @param organizationId The organization ID. + */ + private async handleConflictingTimeLogs( + lastLog: ITimeLog, + tenantId: ID, + organizationId: ID + ): Promise { try { - // Get conflicts time logs - const conflicts = await this.commandBus.execute( + // Retrieve conflicting time logs + const conflicts = await this._commandBus.execute( new IGetConflictTimeLogCommand({ ignoreId: lastLog.id, startDate: lastLog.startedAt, @@ -360,37 +359,34 @@ export class TimerService { }) ); - console.log('Get Conflicts Time Logs', conflicts, { + console.log('Conflicting Time Logs:', conflicts, { ignoreId: lastLog.id, startDate: lastLog.startedAt, endDate: lastLog.stoppedAt, employeeId: lastLog.employeeId, - organizationId: request.organizationId || lastLog.organizationId, + organizationId: organizationId || lastLog.organizationId, tenantId }); - // If there are conflicts, delete them if (isNotEmpty(conflicts)) { const times: IDateRange = { start: new Date(lastLog.startedAt), end: new Date(lastLog.stoppedAt) }; - // Delete conflicts + // Delete conflicting time slots await Promise.all( conflicts.flatMap((timeLog: ITimeLog) => { const { timeSlots = [] } = timeLog; return timeSlots.map((timeSlot: ITimeSlot) => - this.commandBus.execute(new DeleteTimeSpanCommand(times, timeLog, timeSlot)) + this._commandBus.execute(new DeleteTimeSpanCommand(times, timeLog, timeSlot)) ); }) ); } } catch (error) { - console.error('Error while deleting time span during conflicts timelogs', error); + console.error('Error while handling conflicts in time logs:', error); } - - return lastLog; } /** @@ -400,7 +396,7 @@ export class TimerService { * @returns */ async toggleTimeLog(request: ITimerToggleInput): Promise { - const lastLog = await this.getLastRunningLog(request); + const lastLog = await this.getLastRunningLog(); if (!lastLog) { return this.startTimer(request); } else { @@ -409,51 +405,104 @@ export class TimerService { } /** - * Get employee last running timer + * Stops all previous running timers for the specified employee. * - * @param request - * @returns + * @param employeeId - The ID of the employee whose timers need to be stopped + * @param organizationId - The ID of the organization to which the employee belongs + * @param tenantId - The ID of the tenant context */ - private async getLastRunningLog(request: ITimerToggleInput): Promise { - const userId = RequestContext.currentUserId(); - const tenantId = RequestContext.currentTenantId(); + async stopPreviousRunningTimers(employeeId: ID, organizationId: ID, tenantId: ID): Promise { + try { + // Execute the ScheduleTimeLogEntriesCommand to stop all previous running timers + await this._commandBus.execute(new ScheduleTimeLogEntriesCommand( + employeeId, + organizationId, + tenantId + )); + } catch (error) { + // Log the error or handle it appropriately + console.log('Failed to stop previous running timers:', error); + } + } - // Replace 'Employee' with your actual Employee entity type - const employee = await this.typeOrmEmployeeRepository.findOne({ - where: { userId, tenantId }, - relations: { user: true } - }); + /** + * Retrieves the current employee record based on the user and tenant context. + * + * @returns The employee record if found. + * @throws NotFoundException if the employee record is not found. + */ + async findEmployee(): Promise { + const userId = RequestContext.currentUserId(); // Get the current user ID + const tenantId = RequestContext.currentTenantId(); // Get the current tenant ID + + // Fetch the employee record using userId and tenantId + const employee = await this._employeeService.findOneByUserId(userId, { where: { tenantId } }); - // If employee is not found, throw a NotFoundException if (!employee) { - throw new NotFoundException("We couldn't find the employee you were looking for."); + throw new NotFoundException('Employee record not found. Please verify your details and try again.'); } - // Employee time tracking status - if (!employee.isTrackingEnabled) { - throw new ForbiddenException(`The time tracking functionality has been disabled for you.`); - } + return employee; + } - // Get the employee ID - const { id: employeeId } = employee; - // Get the organization ID - const organizationId = request.organizationTeamId || employee.organizationId; + /** + * Get the last running log or all pending running logs for the current employee + * + * @param fetchAll - Set to `true` to fetch all pending logs, otherwise fetch the last running log + * @returns A single time log if `fetchAll` is `false`, or an array of time logs if `fetchAll` is `true` + */ + private async getRunningLogs(fetchAll: boolean = false): Promise { + const tenantId = RequestContext.currentTenantId(); // Retrieve the tenant ID from the current context - // Return the last running log - return await this.typeOrmTimeLogRepository.findOne({ - where: { - stoppedAt: Not(IsNull()), - employeeId, - tenantId, - organizationId, - isRunning: true - }, - order: { - startedAt: 'DESC', - createdAt: 'DESC' - } - }); + // Extract employeeId and organizationId + const { id: employeeId, organizationId } = await this.findEmployee(); + + // Define common query conditions + const whereClause = { + employeeId, + tenantId, + organizationId, + isRunning: true, + stoppedAt: Not(IsNull()), // Logs should have a non-null `stoppedAt` + }; + + // Determine whether to fetch a single log or multiple logs + return fetchAll + ? await this.typeOrmTimeLogRepository.find({ + where: whereClause, + order: { startedAt: 'DESC', createdAt: 'DESC' } + }) + : await this.typeOrmTimeLogRepository.findOne({ + where: whereClause, + order: { startedAt: 'DESC', createdAt: 'DESC' } + }); + } + + /** + * Get the employee's last running timer log + * + * @returns The last running ITimeLog entry for the current employee + */ + private async getLastRunningLog(): Promise { + // Retrieve the last running log by using the `getRunningLogs` method with `fetchAll` set to false + const lastRunningLog = await this.getRunningLogs(); + + // Ensure that the returned log is of type ITimeLog + return lastRunningLog as ITimeLog; + } + + /** + * Get all pending running logs for the current employee + * + * @returns An array of pending time logs + */ + private async getLastRunningLogs(): Promise { + // Retrieve the last running log by using the `getRunningLogs` method with `fetchAll` set to false + const logs = await this.getRunningLogs(true); + + // Ensure that the returned logs are of type ITimeLog[] + return logs as ITimeLog[]; } /** @@ -467,7 +516,7 @@ export class TimerService { const { organizationId, organizationTeamId, source } = request; // Define the array to store employeeIds - let employeeIds: string[] = []; + let employeeIds: ID[] = []; const permissions = [PermissionsEnum.CHANGE_SELECTED_EMPLOYEE, PermissionsEnum.ORG_MEMBER_LAST_LOG_VIEW]; diff --git a/packages/ui-core/core/src/lib/services/time-tracker/time-tracker.service.ts b/packages/ui-core/core/src/lib/services/time-tracker/time-tracker.service.ts index ed1bfbfe0b3..a3341352ac9 100644 --- a/packages/ui-core/core/src/lib/services/time-tracker/time-tracker.service.ts +++ b/packages/ui-core/core/src/lib/services/time-tracker/time-tracker.service.ts @@ -17,13 +17,17 @@ import { TimeLogSourceEnum, ITimerStatusInput } from '@gauzy/contracts'; -import { toLocal, toParams, toUTC } from '@gauzy/ui-core/common'; -import { API_PREFIX, BACKGROUND_SYNC_INTERVAL } from '@gauzy/ui-core/common'; +import { API_PREFIX, BACKGROUND_SYNC_INTERVAL, toLocal, toParams, toUTC } from '@gauzy/ui-core/common'; import { Store as AppStore } from '../store/store.service'; import { ITimerSynced } from './interfaces'; +/** + * Creates and returns the initial state for the timer. + * + * @returns The initial TimerState object. + */ export function createInitialTimerState(): TimerState { - let timerConfig = { + const defaultTimerConfig = { isBillable: true, organizationId: null, tenantId: null, @@ -33,19 +37,24 @@ export function createInitialTimerState(): TimerState { description: '', logType: TimeLogType.TRACKED, source: TimeLogSourceEnum.WEB_TIMER, - tags: [], startedAt: null, stoppedAt: null }; - try { - const config = localStorage.getItem('timerConfig'); - if (config) { - timerConfig = { - ...timerConfig, - ...JSON.parse(config) - }; + + // Retrieve and parse the stored timer configuration, if available + const storedConfig = (() => { + try { + return JSON.parse(localStorage.getItem('timerConfig') || '{}'); + } catch (error) { + console.error('Error parsing timerConfig from localStorage:', error); + return {}; } - } catch (error) {} + })(); + + // Merge default and stored configurations + const timerConfig = { ...defaultTimerConfig, ...storedConfig }; + + // Return the complete initial TimerState return { showTimerWindow: false, duration: 0, @@ -56,6 +65,7 @@ export function createInitialTimerState(): TimerState { } as TimerState; } + @Injectable({ providedIn: 'root' }) @StoreConfig({ name: 'timer' }) export class TimerStore extends Store { @@ -354,7 +364,6 @@ export class TimeTrackerService implements OnDestroy { organizationContactId: this.timerSynced.lastLog.organizationContactId, description: this.timerSynced.lastLog.description, source: this.timerSynced.source, - tags: this.timerSynced.lastLog.tags, startedAt: this.timerSynced.startedAt, stoppedAt: this.timerSynced.stoppedAt };