diff --git a/.cspell.json b/.cspell.json index 19e7be763f2..222308faa1b 100644 --- a/.cspell.json +++ b/.cspell.json @@ -12,6 +12,8 @@ "Alexi", "alish", "Alish", + "Rahul", + "rahul", "apicivo", "apicw", "apidemo", diff --git a/apps/gauzy/src/app/pages/employees/activity/screenshot/screenshot/screenshot.component.ts b/apps/gauzy/src/app/pages/employees/activity/screenshot/screenshot/screenshot.component.ts index 387fcc6b6ca..7b3442caa1c 100644 --- a/apps/gauzy/src/app/pages/employees/activity/screenshot/screenshot/screenshot.component.ts +++ b/apps/gauzy/src/app/pages/employees/activity/screenshot/screenshot/screenshot.component.ts @@ -10,16 +10,17 @@ import moment from 'moment-timezone'; import { UntilDestroy, untilDestroyed } from '@ngneat/until-destroy'; import { NbDialogService } from '@nebular/theme'; import { TranslateService } from '@ngx-translate/core'; -import { DateRangePickerBuilderService, Store, TimesheetFilterService, TimesheetService } from '@gauzy/ui-core/core'; import { ITimeLogFilters, ITimeSlot, IGetTimeSlotInput, IScreenshotMap, IScreenshot, - PermissionsEnum + PermissionsEnum, + ID } from '@gauzy/contracts'; import { isEmpty, distinctUntilChange, isNotEmpty, toTimezone } from '@gauzy/ui-core/common'; +import { DateRangePickerBuilderService, Store, TimesheetFilterService, TimesheetService } from '@gauzy/ui-core/core'; import { BaseSelectorFilterComponent, DeleteConfirmationComponent, @@ -188,7 +189,7 @@ export class ScreenshotComponent extends BaseSelectorFilterComponent implements * * @param slotId The ID of the time slot to toggle selection for. */ - toggleSelect(slotId?: string): void { + toggleSelect(slotId?: ID): void { if (slotId) { // Toggle the selection state of the time slot identified by slotId this.selectedIds[slotId] = !this.selectedIds[slotId]; @@ -252,11 +253,17 @@ export class ScreenshotComponent extends BaseSelectorFilterComponent implements const ids = Object.keys(this.selectedIds).filter((key) => this.selectedIds[key]); // Construct request object with organization ID - const { id: organizationId } = this.organization; - const request = { ids, organizationId }; + const { id: organizationId, tenantId } = this.organization; + + // Call the deleteTimeSlots API with forceDelete set to true + const api$ = this._timesheetService.deleteTimeSlots({ + ids, + organizationId, + tenantId + }); // Convert the promise to an observable and handle deletion - return from(this._timesheetService.deleteTimeSlots(request)).pipe( + return from(api$).pipe( tap(() => this._deleteScreenshotGallery(ids)), tap(() => this.screenshots$.next(true)) ); @@ -266,10 +273,6 @@ export class ScreenshotComponent extends BaseSelectorFilterComponent implements .subscribe(); } - ngOnDestroy(): void { - this._galleryService.clearGallery(); - } - /** * Groups time slots by hour and prepares data for display. * Also generates screenshot URLs and calculates employee work on the same time slots. @@ -344,7 +347,7 @@ export class ScreenshotComponent extends BaseSelectorFilterComponent implements * * @param timeSlotIds An array of time slot IDs whose screenshots should be removed from the gallery. */ - private _deleteScreenshotGallery(timeSlotIds: string[]) { + private _deleteScreenshotGallery(timeSlotIds: ID[]) { if (isNotEmpty(this.originalTimeSlots)) { // Extract all screenshots from time slots that match the provided time slot IDs const screenshotsToRemove = this.originalTimeSlots @@ -361,4 +364,8 @@ export class ScreenshotComponent extends BaseSelectorFilterComponent implements this._galleryService.removeGalleryItems(screenshotsToRemove); } } + + ngOnDestroy(): void { + this._galleryService.clearGallery(); + } } diff --git a/apps/gauzy/src/app/pages/employees/edit-employee/edit-employee-profile/edit-employee-projects/edit-employee-projects.component.ts b/apps/gauzy/src/app/pages/employees/edit-employee/edit-employee-profile/edit-employee-projects/edit-employee-projects.component.ts index b72163f9c20..667e1235d4e 100644 --- a/apps/gauzy/src/app/pages/employees/edit-employee/edit-employee-profile/edit-employee-projects/edit-employee-projects.component.ts +++ b/apps/gauzy/src/app/pages/employees/edit-employee/edit-employee-profile/edit-employee-projects/edit-employee-projects.component.ts @@ -11,7 +11,13 @@ import { PermissionsEnum } from '@gauzy/contracts'; import { distinctUntilChange } from '@gauzy/ui-core/common'; -import { EmployeeStore, OrganizationProjectsService, Store, ToastrService } from '@gauzy/ui-core/core'; +import { + EmployeeStore, + ErrorHandlingService, + OrganizationProjectsService, + Store, + ToastrService +} from '@gauzy/ui-core/core'; import { TranslationBaseComponent } from '@gauzy/ui-core/i18n'; @UntilDestroy({ checkProperties: true }) @@ -45,11 +51,12 @@ export class EditEmployeeProjectsComponent extends TranslationBaseComponent impl public organization: IOrganization; constructor( - private readonly organizationProjectsService: OrganizationProjectsService, - private readonly toastrService: ToastrService, - private readonly employeeStore: EmployeeStore, public readonly translateService: TranslateService, - private readonly store: Store + private readonly _organizationProjectsService: OrganizationProjectsService, + private readonly _toastrService: ToastrService, + private readonly _employeeStore: EmployeeStore, + private readonly _store: Store, + private readonly _errorHandlingService: ErrorHandlingService ) { super(translateService); } @@ -61,8 +68,8 @@ export class EditEmployeeProjectsComponent extends TranslationBaseComponent impl untilDestroyed(this) ) .subscribe(); - const storeOrganization$ = this.store.selectedOrganization$; - const storeEmployee$ = this.employeeStore.selectedEmployee$; + const storeOrganization$ = this._store.selectedOrganization$; + const storeEmployee$ = this._employeeStore.selectedEmployee$; combineLatest([storeOrganization$, storeEmployee$]) .pipe( distinctUntilChange(), @@ -77,79 +84,129 @@ export class EditEmployeeProjectsComponent extends TranslationBaseComponent impl .subscribe(); } - ngOnDestroy(): void {} + /** + * Submits the form to update the employee's project association. + * + * If the `member` exists in the input, the method will either update or remove the employee's project assignment + * and provide feedback through a success or error toastr notification. + * + * @param input The input data containing information about the employee and the project. + * @param removed A flag indicating whether the employee was removed from or added to the project. + */ + async submitForm(input: IEditEntityByMemberInput, removed: boolean): Promise { + if (!this.organization || !input.member) { + return; + } + + const { id: organizationId, tenantId } = this.organization; - async submitForm(formInput: IEditEntityByMemberInput, removed: boolean) { try { - if (formInput.member) { - await this.organizationProjectsService.updateByEmployee(formInput); - this.loadProjects(); - this.toastrService.success( - removed ? 'TOASTR.MESSAGE.EMPLOYEE_PROJECT_REMOVED' : 'TOASTR.MESSAGE.EMPLOYEE_PROJECT_ADDED' - ); - } + // Update the employee's project assignment + await this._organizationProjectsService.updateByEmployee({ + addedProjectIds: input.addedEntityIds, + removedProjectIds: input.removedEntityIds, + member: input.member, + organizationId, + tenantId + }); + + // Show success message based on the action performed (added or removed) + const message = removed + ? 'TOASTR.MESSAGE.EMPLOYEE_PROJECT_REMOVED' + : 'TOASTR.MESSAGE.EMPLOYEE_PROJECT_ADDED'; + this._toastrService.success(message); } catch (error) { - this.toastrService.danger('TOASTR.MESSAGE.EMPLOYEE_EDIT_ERROR'); + // Show error message in case of failure + this._toastrService.danger('TOASTR.MESSAGE.EMPLOYEE_EDIT_ERROR'); + } finally { + // Notify subscribers that the operation is complete + this.subject$.next(true); } } /** - * Load organization & employee assigned projects + * Loads organization and employee assigned projects. + * + * This method loads the projects assigned to the selected employee and all organization projects, + * then filters out the employee's assigned projects from the full list of organization projects. */ - private async loadProjects() { + private async loadProjects(): Promise { + // Load employee projects and all organization projects await this.loadSelectedEmployeeProjects(); + + // Get all organization projects const organizationProjects = await this.getOrganizationProjects(); + // Filter out employee's assigned projects from the organization projects list this.organizationProjects = organizationProjects.filter( - (item: IOrganizationProject) => - !this.employeeProjects.some((project: IOrganizationProject) => project.id === item.id) + (orgProject: IOrganizationProject) => + !this.employeeProjects.some((empProject: IOrganizationProject) => empProject.id === orgProject.id) ); } /** - * Get selected employee projects + * Fetches projects assigned to the selected employee. + * + * This method loads the projects associated with the selected employee if the user has the necessary permissions + * and the organization is available. * - * @returns + * @returns A Promise that resolves once the employee projects are loaded. */ - private async loadSelectedEmployeeProjects() { + private async loadSelectedEmployeeProjects(): Promise { if ( !this.organization || - !this.store.hasAnyPermission(PermissionsEnum.ALL_ORG_VIEW, PermissionsEnum.ORG_PROJECT_VIEW) + !this._store.hasAnyPermission(PermissionsEnum.ALL_ORG_VIEW, PermissionsEnum.ORG_PROJECT_VIEW) ) { return; } - const { tenantId } = this.store.user; - const { id: organizationId } = this.organization; + const { id: organizationId, tenantId } = this.organization; const { id: selectedEmployeeId } = this.selectedEmployee; - this.employeeProjects = await this.organizationProjectsService.getAllByEmployee(selectedEmployeeId, { - organizationId, - tenantId - }); + try { + // Fetch and assign employee projects to the component property + this.employeeProjects = await this._organizationProjectsService.getAllByEmployee(selectedEmployeeId, { + organizationId, + tenantId + }); + } catch (error) { + console.error('Error loading selected employee projects:', error); + this._errorHandlingService.handleError(error); + } } /** - * Get organization projects + * Fetches all projects within the organization. * - * @returns + * This method retrieves all projects in the organization if the user has the required permissions + * and the organization is available. + * + * @returns A Promise that resolves to an array of organization projects. */ private async getOrganizationProjects(): Promise { if ( !this.organization || - !this.store.hasAnyPermission(PermissionsEnum.ALL_ORG_VIEW, PermissionsEnum.ORG_PROJECT_VIEW) + !this._store.hasAnyPermission(PermissionsEnum.ALL_ORG_VIEW, PermissionsEnum.ORG_PROJECT_VIEW) ) { - return; + return []; } - const { tenantId } = this.store.user; - const { id: organizationId } = this.organization; + const { id: organizationId, tenantId } = this.organization; - return ( - await this.organizationProjectsService.getAll([], { + try { + // Fetch and return all organization projects + const result = await this._organizationProjectsService.getAll([], { organizationId, tenantId - }) - ).items; + }); + return result.items; + } catch (error) { + console.error('Error fetching organization projects:', error); + // Handle errors + this._errorHandlingService.handleError(error); + return []; + } } + + ngOnDestroy(): void {} } diff --git a/apps/gauzy/src/app/pages/employees/timesheet/daily/daily/daily.component.html b/apps/gauzy/src/app/pages/employees/timesheet/daily/daily/daily.component.html index 4ad7eb3f841..ddb272ac95c 100644 --- a/apps/gauzy/src/app/pages/employees/timesheet/daily/daily/daily.component.html +++ b/apps/gauzy/src/app/pages/employees/timesheet/daily/daily/daily.component.html @@ -137,13 +137,6 @@ {{ log.duration | durationFormat }}
-
- {{ - log.startedAt - | utcToTimezone : filters?.timeZone - | date : 'MMMM d, yyyy' - }} -
{{ log.startedAt diff --git a/apps/gauzy/src/app/pages/projects/components/project-list/list.component.ts b/apps/gauzy/src/app/pages/projects/components/project-list/list.component.ts index ceeeebc9e9b..397323020e7 100644 --- a/apps/gauzy/src/app/pages/projects/components/project-list/list.component.ts +++ b/apps/gauzy/src/app/pages/projects/components/project-list/list.component.ts @@ -32,6 +32,7 @@ import { DateViewComponent, DeleteConfirmationComponent, EmployeesMergedTeamsComponent, + EmployeeWithLinksComponent, PaginationFilterBaseComponent, ProjectOrganizationComponent, ProjectOrganizationEmployeesComponent, @@ -247,6 +248,7 @@ export class ProjectListComponent extends PaginationFilterBaseComponent implemen resultMap: (project: IOrganizationProject) => { return Object.assign({}, project, { ...this.privatePublicProjectMapper(project), + managers: project.members.filter((member) => member.isManager).map((item) => item.employee), employeesMergedTeams: [ project.members.map((member: IOrganizationProjectEmployee) => member.employee) ] @@ -417,6 +419,17 @@ export class ProjectListComponent extends PaginationFilterBaseComponent implemen instance.value = cell.getValue(); } }, + managers: { + title: this.getTranslation('ORGANIZATIONS_PAGE.EDIT.TEAMS_PAGE.MANAGERS'), + type: 'custom', + isFilterable: false, + renderComponent: EmployeeWithLinksComponent, + componentInitFunction: (instance: EmployeeWithLinksComponent, cell: Cell) => { + instance.rowData = cell.getRow().getData(); + instance.value = cell.getRawValue(); + } + }, + employeesMergedTeams: { title: this.getTranslation('ORGANIZATIONS_PAGE.EDIT.MEMBERS'), type: 'custom', diff --git a/apps/gauzy/src/assets/images/avatars/rahul.png b/apps/gauzy/src/assets/images/avatars/rahul.png new file mode 100644 index 00000000000..de035cd440b Binary files /dev/null and b/apps/gauzy/src/assets/images/avatars/rahul.png differ diff --git a/packages/contracts/src/employee.model.ts b/packages/contracts/src/employee.model.ts index fea497de79a..bf4bab5e5fe 100644 --- a/packages/contracts/src/employee.model.ts +++ b/packages/contracts/src/employee.model.ts @@ -26,6 +26,7 @@ import { IOrganizationProjectModule } from './organization-project-module.model' import { CurrenciesEnum } from './currency.model'; import { IFavorite } from './favorite.model'; import { IComment } from './comment.model'; +import { IOrganizationSprint } from './organization-sprint.model'; export interface IFindMembersInput extends IBasePerTenantAndOrganizationEntityModel { organizationTeamId: ID; @@ -79,6 +80,7 @@ export interface IEmployee extends IBasePerTenantAndOrganizationEntityModel, ITa timesheets?: ITimesheet[]; tasks?: ITask[]; modules?: IOrganizationProjectModule[]; + sprints?: IOrganizationSprint[]; assignedComments?: IComment[]; timeSlots?: ITimeSlot[]; contact?: IContact; @@ -280,3 +282,8 @@ export interface IEmployeeStoreState { export interface IEmployeeUpdateProfileStatus extends IBasePerTenantAndOrganizationEntityModel { readonly isActive: boolean; } + +export interface IMemberEntityBased extends IBasePerTenantAndOrganizationEntityModel { + memberIds?: ID[]; // Members of the given entity + managerIds?: ID[]; // Managers of the given entity +} diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts index 6e24c420870..ecde9c94cf2 100644 --- a/packages/contracts/src/index.ts +++ b/packages/contracts/src/index.ts @@ -120,6 +120,7 @@ export * from './task-related-issue-type.model'; export * from './task-size.model'; export * from './task-status.model'; export * from './task-version.model'; +export * from './task-view.model'; export * from './task.model'; export * from './daily-plan.model'; export * from './tenant.model'; diff --git a/packages/contracts/src/organization-projects.model.ts b/packages/contracts/src/organization-projects.model.ts index a96211c39b7..f3453439d3d 100644 --- a/packages/contracts/src/organization-projects.model.ts +++ b/packages/contracts/src/organization-projects.model.ts @@ -1,4 +1,4 @@ -import { IEmployee, IEmployeeEntityInput } from './employee.model'; +import { IEmployee, IEmployeeEntityInput, IMemberEntityBased } from './employee.model'; import { IRelationalOrganizationContact } from './organization-contact.model'; import { ITaggable } from './tag.model'; import { ITask } from './task.model'; @@ -85,10 +85,7 @@ export interface IOrganizationProjectsFindInput organizationTeamId?: ID; } -export interface IOrganizationProjectCreateInput extends IOrganizationProjectBase { - memberIds?: ID[]; // Manager of the organization project - managerIds?: ID[]; // Manager of the organization project -} +export interface IOrganizationProjectCreateInput extends IOrganizationProjectBase, IMemberEntityBased {} export interface IOrganizationProjectUpdateInput extends IOrganizationProjectCreateInput {} @@ -122,5 +119,5 @@ export enum OrganizationProjectBudgetTypeEnum { export interface IOrganizationProjectEditByEmployeeInput extends IBasePerTenantAndOrganizationEntityModel { addedProjectIds?: ID[]; removedProjectIds?: ID[]; - member: IOrganizationProjectEmployee; + member: IEmployee; } diff --git a/packages/contracts/src/organization-sprint.model.ts b/packages/contracts/src/organization-sprint.model.ts index 083147fbfcc..ee0fc74d9dd 100644 --- a/packages/contracts/src/organization-sprint.model.ts +++ b/packages/contracts/src/organization-sprint.model.ts @@ -1,21 +1,46 @@ import { IOrganizationProjectModule } from './organization-project-module.model'; -import { IBasePerTenantAndOrganizationEntityModel } from './base-entity.model'; +import { IBasePerTenantAndOrganizationEntityModel, ID } from './base-entity.model'; import { IOrganizationProject } from './organization-projects.model'; import { ITask } from './task.model'; +import { IEmployeeEntityInput, IMemberEntityBased } from './employee.model'; +import { IRelationalRole } from './role.model'; +import { JsonData } from './activity-log.model'; +import { IUser } from './user.model'; -export interface IOrganizationSprint extends IBasePerTenantAndOrganizationEntityModel { - name: string; - projectId: string; +// Base interface with optional properties +export interface IRelationalOrganizationSprint { + organizationSprint?: IOrganizationSprint; + organizationSprintId?: ID; +} + +export interface IOrganizationSprintBase extends IBasePerTenantAndOrganizationEntityModel { + name?: string; goal?: string; - length: number; // Duration of Sprint. Default value - 7 (days) + length?: number; // Duration of Sprint. Default value - 7 (days) startDate?: Date; endDate?: Date; + status?: OrganizationSprintStatusEnum; dayStart?: number; // Enum ((Sunday-Saturday) => (0-7)) + sprintProgress?: JsonData; // Stores the current state and metrics of the sprint's progress project?: IOrganizationProject; + projectId?: ID; tasks?: ITask[]; + members?: IOrganizationSprintEmployee[]; modules?: IOrganizationProjectModule[]; + taskSprints?: IOrganizationSprintTask[]; + fromSprintTaskHistories?: IOrganizationSprintTaskHistory[]; + toSprintTaskHistories?: IOrganizationSprintTaskHistory[]; } +export interface IOrganizationSprint extends IOrganizationSprintBase { + name: string; + length: number; + project: IOrganizationProject; + projectId: ID; +} + +export interface IOrganizationSprintCreateInput extends IOrganizationSprintBase, IMemberEntityBased {} + export enum SprintStartDayEnum { SUNDAY = 1, MONDAY = 2, @@ -26,14 +51,41 @@ export enum SprintStartDayEnum { SATURDAY = 7 } -export interface IOrganizationSprintUpdateInput { - name: string; - goal?: string; - length: number; - startDate?: Date; - endDate?: Date; - dayStart?: number; - project?: IOrganizationProject; - isActive?: boolean; - tasks?: ITask[]; +export enum OrganizationSprintStatusEnum { + ACTIVE = 'active', + COMPLETED = 'completed', + DRAFT = 'draft', + UPCOMING = 'upcoming' +} + +export interface IOrganizationSprintUpdateInput extends IOrganizationSprintCreateInput {} + +export interface IOrganizationSprintEmployee + extends IBasePerTenantAndOrganizationEntityModel, + IEmployeeEntityInput, + IRelationalRole { + organizationSprint: IOrganizationSprint; + organizationSprintId: ID; + isManager?: boolean; + assignedAt?: Date; +} + +export interface IOrganizationSprintTask extends IBasePerTenantAndOrganizationEntityModel { + organizationSprint: IOrganizationSprint; + organizationSprintId: ID; + task?: ITask; + taskId: ID; + totalWorkedHours?: number; +} + +export interface IOrganizationSprintTaskHistory extends IBasePerTenantAndOrganizationEntityModel { + reason?: string; + task?: ITask; + taskId?: ID; + fromSprint?: IOrganizationSprint; + fromSprintId?: ID; + toSprint?: IOrganizationSprint; + toSprintId?: ID; + movedBy?: IUser; + movedById?: ID; } diff --git a/packages/contracts/src/organization-team.model.ts b/packages/contracts/src/organization-team.model.ts index 1d49272cc4d..cb38cd48228 100644 --- a/packages/contracts/src/organization-team.model.ts +++ b/packages/contracts/src/organization-team.model.ts @@ -1,4 +1,4 @@ -import { IEmployeeEntityInput } from './employee.model'; +import { IEmployeeEntityInput, IMemberEntityBased } from './employee.model'; import { IBasePerTenantAndOrganizationEntityModel, ID } from './base-entity.model'; import { IOrganizationTeamEmployee } from './organization-team-employee-model'; import { ITag } from './tag.model'; @@ -38,7 +38,10 @@ export interface IOrganizationTeamFindInput extends IBasePerTenantAndOrganizatio members?: IOrganizationTeamEmployee; } -export interface IOrganizationTeamCreateInput extends IBasePerTenantAndOrganizationEntityModel, IRelationalImageAsset { +export interface IOrganizationTeamCreateInput + extends IBasePerTenantAndOrganizationEntityModel, + IRelationalImageAsset, + IMemberEntityBased { name: string; emoji?: string; teamSize?: string; @@ -49,8 +52,6 @@ export interface IOrganizationTeamCreateInput extends IBasePerTenantAndOrganizat requirePlanToTrack?: boolean; public?: boolean; profile_link?: string; - memberIds?: ID[]; - managerIds?: ID[]; tags?: ITag[]; projects?: IOrganizationProject[]; } diff --git a/packages/contracts/src/screenshot.model.ts b/packages/contracts/src/screenshot.model.ts index 3d0f080ebbc..3fec57225bb 100644 --- a/packages/contracts/src/screenshot.model.ts +++ b/packages/contracts/src/screenshot.model.ts @@ -1,6 +1,6 @@ import { IBasePerTenantAndOrganizationEntityModel, ID } from './base-entity.model'; import { FileStorageProvider } from './file-provider'; -import { ITimeSlot } from './timesheet.model'; +import { IDeleteEntity, ITimeSlot } from './timesheet.model'; import { IRelationalUser } from './user.model'; export interface IScreenshot extends IBasePerTenantAndOrganizationEntityModel, IRelationalUser { @@ -36,3 +36,9 @@ export interface IScreenshotCreateInput extends IBasePerTenantAndOrganizationEnt thumb?: string; recordedAt: Date | string; } + +/** + * Interface for deleting time slots. + * Includes an array of time slot IDs to be deleted. + */ +export interface IDeleteScreenshot extends IDeleteEntity {} diff --git a/packages/contracts/src/task-view.model.ts b/packages/contracts/src/task-view.model.ts new file mode 100644 index 00000000000..dc2e781bf31 --- /dev/null +++ b/packages/contracts/src/task-view.model.ts @@ -0,0 +1,37 @@ +import { IBasePerTenantAndOrganizationEntityModel } from './base-entity.model'; +import { IRelationalOrganizationTeam } from './organization-team.model'; +import { IRelationalOrganizationProject } from './organization-projects.model'; +import { IRelationalOrganizationProjectModule } from './organization-project-module.model'; +import { IRelationalOrganizationSprint } from './organization-sprint.model'; +import { JsonData } from './activity-log.model'; + +export interface ITaskViewBase + extends IBasePerTenantAndOrganizationEntityModel, + IRelationalOrganizationTeam, + IRelationalOrganizationProject, + IRelationalOrganizationProjectModule, + IRelationalOrganizationSprint { + name?: string; + description?: string; + visibilityLevel?: VisibilityLevelEnum; + queryParams?: JsonData; + filterOptions?: JsonData; + displayOptions?: JsonData; + properties?: Record; + isLocked?: boolean; +} + +export interface ITaskView extends ITaskViewBase { + name: string; // Ensure name is always defined +} + +export interface ITaskViewCreateInput extends ITaskViewBase {} + +export interface ITaskViewUpdateInput extends ITaskViewCreateInput {} + +export enum VisibilityLevelEnum { + MODULE_AND_SPRINT = 0, + TEAM_AND_PROJECT = 1, + ORGANIZATION = 2, + WORKSPACE = 3 +} diff --git a/packages/contracts/src/task.model.ts b/packages/contracts/src/task.model.ts index b8bb62e2b48..99a78b26edc 100644 --- a/packages/contracts/src/task.model.ts +++ b/packages/contracts/src/task.model.ts @@ -2,7 +2,11 @@ import { IBasePerTenantAndOrganizationEntityModel, IBaseRelationsEntityModel, ID import { IEmployee } from './employee.model'; import { IInvoiceItem } from './invoice-item.model'; import { IRelationalOrganizationProject } from './organization-projects.model'; -import { IOrganizationSprint } from './organization-sprint.model'; +import { + IOrganizationSprint, + IRelationalOrganizationSprint, + IOrganizationSprintTaskHistory +} from './organization-sprint.model'; import { IOrganizationTeam } from './organization-team.model'; import { ITag } from './tag.model'; import { IUser } from './user.model'; @@ -11,7 +15,10 @@ import { ITaskPriority, TaskPriorityEnum } from './task-priority.model'; import { ITaskSize, TaskSizeEnum } from './task-size.model'; import { IOrganizationProjectModule } from './organization-project-module.model'; -export interface ITask extends IBasePerTenantAndOrganizationEntityModel, IRelationalOrganizationProject { +export interface ITask + extends IBasePerTenantAndOrganizationEntityModel, + IRelationalOrganizationProject, + IRelationalOrganizationSprint { title: string; number?: number; public?: boolean; @@ -27,8 +34,8 @@ export interface ITask extends IBasePerTenantAndOrganizationEntityModel, IRelati invoiceItems?: IInvoiceItem[]; teams?: IOrganizationTeam[]; modules?: IOrganizationProjectModule[]; - organizationSprint?: IOrganizationSprint; - organizationSprintId?: ID; + taskSprints?: IOrganizationSprint[]; + taskSprintHistories?: IOrganizationSprintTaskHistory[]; creator?: IUser; creatorId?: ID; isDraft?: boolean; // Define if task is still draft (E.g : Task description not completed yet) @@ -66,6 +73,7 @@ export type ITaskCreateInput = ITask; export interface ITaskUpdateInput extends ITaskCreateInput { id?: string; + taskSprintMoveReason?: string; } export interface IGetTaskById { diff --git a/packages/contracts/src/timesheet.model.ts b/packages/contracts/src/timesheet.model.ts index 0be638b7df2..64b9622b946 100644 --- a/packages/contracts/src/timesheet.model.ts +++ b/packages/contracts/src/timesheet.model.ts @@ -37,8 +37,8 @@ export interface ITimesheet extends IBasePerTenantAndOrganizationEntityModel { } export interface ITimesheetCreateInput extends IBasePerTenantAndOrganizationEntityModel { - employeeId: string; - approvedById?: string; + employeeId: ID; + approvedById?: ID; duration: number; keyboard: number; mouse: number; @@ -53,8 +53,8 @@ export interface ITimesheetCreateInput extends IBasePerTenantAndOrganizationEnti } export interface ITimeSheetFindInput extends IBasePerTenantAndOrganizationEntityModel { - employeeId: string; - approvedById?: string; + employeeId: ID; + approvedById?: ID; employee: IEmployeeFindInput; isBilled?: boolean; status?: string; @@ -73,12 +73,12 @@ export enum TimesheetStatus { } export interface IUpdateTimesheetStatusInput extends IBasePerTenantAndOrganizationEntityModel { - ids: string | string[]; + ids: ID | ID[]; status?: TimesheetStatus; } export interface ISubmitTimesheetInput extends IBasePerTenantAndOrganizationEntityModel { - ids: string | string[]; + ids: ID | ID[]; status: 'submit' | 'unsubmit'; } @@ -86,9 +86,9 @@ export interface IGetTimesheetInput extends IBasePerTenantAndOrganizationEntityM onlyMe?: boolean; startDate?: Date | string; endDate?: Date | string; - projectIds?: string[]; - clientId?: string[]; - employeeIds?: string[]; + projectIds?: ID[]; + clientId?: ID[]; + employeeIds?: ID[]; } export interface IDateRange { @@ -98,7 +98,8 @@ export interface IDateRange { export interface ITimeLog extends IBasePerTenantAndOrganizationEntityModel, IRelationalOrganizationProject, - IRelationalOrganizationTeam, ITaggable { + IRelationalOrganizationTeam, + ITaggable { employee: IEmployee; employeeId: ID; timesheet?: ITimesheet; @@ -124,10 +125,10 @@ export interface ITimeLog } export interface ITimeLogCreateInput extends IBasePerTenantAndOrganizationEntityModel { - employeeId: string; - timesheetId?: string; - taskId?: string; - projectId: string; + employeeId: ID; + timesheetId?: ID; + taskId?: ID; + projectId: ID; startedAt?: Date; stoppedAt?: Date; logType: TimeLogType; @@ -138,7 +139,7 @@ export interface ITimeLogCreateInput extends IBasePerTenantAndOrganizationEntity } export interface ITimeSlotCreateInput extends IBasePerTenantAndOrganizationEntityModel { - employeeId: string; + employeeId: ID; duration: number; keyboard: number; mouse: number; @@ -175,19 +176,19 @@ export interface ITimeLogFilters extends IBasePerTenantAndOrganizationEntityMode startDate?: Date | string; endDate?: Date | string; isCustomDate?: boolean; - projectIds?: string[]; - teamIds?: string[]; - employeeIds?: string[]; + employeeIds?: ID[]; + projectIds?: ID[]; + teamIds?: ID[]; + taskIds?: ID[]; logType?: TimeLogType[]; source?: TimeLogSourceEnum[]; activityLevel?: { start: number; end: number; }; - taskIds?: string[]; defaultRange?: boolean; unitOfTime?: any; - categoryId?: string; + categoryId?: ID; timeZone?: string; timeFormat?: TimeFormatEnum; } @@ -199,14 +200,14 @@ export interface ITimeLogTodayFilters extends IBasePerTenantAndOrganizationEntit export interface ITimeSlot extends IBasePerTenantAndOrganizationEntityModel { [x: string]: any; - employeeId: string; + employeeId: ID; employee?: IEmployee; activities?: IActivity[]; screenshots?: IScreenshot[]; timeLogs?: ITimeLog[]; timeSlotMinutes?: ITimeSlotMinute[]; project?: IOrganizationProject; - projectId?: string; + projectId?: ID; duration?: number; keyboard?: number; mouse?: number; @@ -223,13 +224,13 @@ export interface ITimeSlot extends IBasePerTenantAndOrganizationEntityModel { export interface ITimeSlotTimeLogs extends IBasePerTenantAndOrganizationEntityModel { timeLogs: ITimeLog[]; timeSlots: ITimeSlot[]; - timeLogId: string; - timeSlotId: string; + timeLogId: ID; + timeSlotId: ID; } export interface ITimeSlotMinute extends IBasePerTenantAndOrganizationEntityModel { timeSlot?: ITimeSlot; - timeSlotId?: string; + timeSlotId?: ID; keyboard?: number; mouse?: number; datetime?: Date; @@ -239,20 +240,19 @@ export interface IActivity extends IBasePerTenantAndOrganizationEntityModel { title: string; description?: string; employee?: IEmployee; - employeeId?: string; + employeeId?: ID; timeSlot?: ITimeSlot; - timeSlotId?: string; + timeSlotId?: ID; project?: IOrganizationProject; - projectId?: string; + projectId?: ID; task?: ITask; - taskId?: string; + taskId?: ID; metaData?: string | IURLMetaData; date: string; time: string; duration?: number; type?: string; source?: string; - id?: string; activityTimestamp?: string; recordedAt?: Date; } @@ -261,7 +261,7 @@ export interface IDailyActivity { [x: string]: any; sessions?: number; duration?: number; - employeeId?: string; + employeeId?: ID; date?: string; title?: string; description?: string; @@ -270,15 +270,15 @@ export interface IDailyActivity { } export interface ICreateActivityInput extends IBasePerTenantAndOrganizationEntityModel { - employeeId?: string; - projectId?: string; + employeeId?: ID; + projectId?: ID; + timeSlotId?: ID; duration?: number; keyboard?: number; mouse?: number; overall?: number; startedAt?: Date; stoppedAt?: Date; - timeSlotId?: string; type: string; title: string; data?: string; @@ -302,7 +302,7 @@ export interface ITimerStatusInput IEmployeeEntityInput, IRelationalOrganizationTeam { source?: TimeLogSourceEnum; - employeeIds?: string[]; + employeeIds?: ID[]; } export interface ITimerStatus { @@ -329,9 +329,9 @@ export interface ITimerPosition { export interface ITimerToggleInput extends IBasePerTenantAndOrganizationEntityModel, Pick { - projectId?: string; - taskId?: string; - organizationContactId?: string; + projectId?: ID; + taskId?: ID; + organizationContactId?: ID; description?: string; logType?: TimeLogType; source?: TimeLogSourceEnum; @@ -344,11 +344,10 @@ export interface ITimerToggleInput } export interface IManualTimeInput extends IBasePerTenantAndOrganizationEntityModel { - id?: string; - employeeId?: string; - projectId?: string; - taskId?: string; - organizationContactId?: string; + employeeId?: ID; + projectId?: ID; + taskId?: ID; + organizationContactId?: ID; logType?: TimeLogType; description?: string; reason?: string; @@ -361,7 +360,7 @@ export interface IManualTimeInput extends IBasePerTenantAndOrganizationEntityMod export interface IGetTimeLogInput extends ITimeLogFilters, IBaseRelationsEntityModel { onlyMe?: boolean; - timesheetId?: string; + timesheetId?: ID; } export interface IGetTimeLogReportInput extends IGetTimeLogInput { @@ -370,10 +369,10 @@ export interface IGetTimeLogReportInput extends IGetTimeLogInput { } export interface IGetTimeLogConflictInput extends IBasePerTenantAndOrganizationEntityModel, IBaseRelationsEntityModel { - ignoreId?: string | string[]; + ignoreId?: ID | ID[]; startDate: string | Date; endDate: string | Date; - employeeId: string; + employeeId: ID; } export interface IGetTimeSlotInput extends ITimeLogFilters, IBaseRelationsEntityModel { @@ -387,8 +386,8 @@ export interface IGetActivitiesInput extends ITimeLogFilters, IPaginationInput, } export interface IBulkActivitiesInput extends IBasePerTenantAndOrganizationEntityModel { - employeeId: string; - projectId?: string; + employeeId: ID; + projectId?: ID; activities: IActivity[]; } @@ -397,7 +396,7 @@ export interface IReportDayGroupByDate { logs: { project: IOrganizationProject; employeeLogs: { - task: ITask; + tasks: ITask[]; employee: IEmployee; sum: number; activity: number; @@ -419,7 +418,7 @@ export interface IReportDayGroupByDate { logs: { project: IOrganizationProject; employeeLogs: { - task: ITask; + tasks: ITask[]; employee: IEmployee; sum: number; activity: number; @@ -435,7 +434,7 @@ export interface IReportDayGroupByEmployee { sum: number; activity: number; project: IOrganizationProject; - task: ITask; + tasks: ITask[]; }[]; }[]; } @@ -445,7 +444,7 @@ export interface IReportDayGroupByProject { logs: { date: string; employeeLogs: { - task: ITask; + tasks: ITask[]; employee: IEmployee; sum: number; activity: number; @@ -460,7 +459,7 @@ export interface IReportDayGroupByClient { logs: { date: string; employeeLogs: { - task: ITask; + tasks: ITask[]; employee: IEmployee; sum: number; activity: number; @@ -507,11 +506,26 @@ export interface IClientBudgetLimitReport { remainingBudget?: number; } -export interface IDeleteTimeSlot extends IBasePerTenantAndOrganizationEntityModel { - ids: string[]; +/** + * Base interface for delete operations that include forceDelete flag + * and extend the tenant and organization properties. + */ +export interface IDeleteEntity extends IBasePerTenantAndOrganizationEntityModel { + forceDelete?: boolean; +} + +/** + * Interface for deleting time slots. + * Includes an array of time slot IDs to be deleted. + */ +export interface IDeleteTimeSlot extends IDeleteEntity { + ids: ID[]; } -export interface IDeleteTimeLog extends IBasePerTenantAndOrganizationEntityModel { - logIds: string[]; - forceDelete: boolean; +/** + * Interface for deleting time logs. + * Includes an array of log IDs to be deleted. + */ +export interface IDeleteTimeLog extends IDeleteEntity { + logIds: ID[]; } diff --git a/packages/core/src/activity-log/activity-log.helper.ts b/packages/core/src/activity-log/activity-log.helper.ts index 9389d2eb0da..260e53724b0 100644 --- a/packages/core/src/activity-log/activity-log.helper.ts +++ b/packages/core/src/activity-log/activity-log.helper.ts @@ -1,9 +1,9 @@ -import { ActionTypeEnum, ActivityLogEntityEnum } from "@gauzy/contracts"; +import { ActionTypeEnum, ActivityLogEntityEnum, IActivityLogUpdatedValues } from '@gauzy/contracts'; const ActivityTemplates = { [ActionTypeEnum.Created]: `{action} a new {entity} called "{entityName}"`, [ActionTypeEnum.Updated]: `{action} {entity} "{entityName}"`, - [ActionTypeEnum.Deleted]: `{action} {entity} "{entityName}"`, + [ActionTypeEnum.Deleted]: `{action} {entity} "{entityName}"` }; /** @@ -31,7 +31,26 @@ export function generateActivityLogDescription( case 'entityName': return entityName; default: - return ''; + return ''; } }); } + +export function activityLogUpdatedFieldsAndValues(original: T, updated: Partial) { + const updatedFields: string[] = []; + const previousValues: IActivityLogUpdatedValues[] = []; + const updatedValues: IActivityLogUpdatedValues[] = []; + + for (const key of Object.keys(updated)) { + if (original[key] !== updated[key]) { + // Add updated field + updatedFields.push(key); + + // Add old and new values + previousValues.push({ [key]: original[key] }); + updatedValues.push({ [key]: updated[key] }); + } + } + + return { updatedFields, previousValues, updatedValues }; +} diff --git a/packages/core/src/app.module.ts b/packages/core/src/app.module.ts index 13e22b0170e..018703b0ca3 100644 --- a/packages/core/src/app.module.ts +++ b/packages/core/src/app.module.ts @@ -148,6 +148,7 @@ import { CommentModule } from './comment/comment.module'; import { StatsModule } from './stats/stats.module'; // Global Stats Module import { ReactionModule } from './reaction/reaction.module'; import { ActivityLogModule } from './activity-log/activity-log.module'; +import { TaskViewModule } from './tasks/views/view.module'; const { unleashConfig } = environment; @@ -445,7 +446,8 @@ if (environment.THROTTLE_ENABLED) { StatsModule, // Global Stats Module ReactionModule, CommentModule, - ActivityLogModule + ActivityLogModule, + TaskViewModule // Task views Module ], controllers: [AppController], providers: [ diff --git a/packages/core/src/core/context/request-context.middleware.ts b/packages/core/src/core/context/request-context.middleware.ts index 2132b51b0e8..21e5b098783 100644 --- a/packages/core/src/core/context/request-context.middleware.ts +++ b/packages/core/src/core/context/request-context.middleware.ts @@ -1,10 +1,12 @@ import { Injectable, NestMiddleware } from '@nestjs/common'; +import { ClsService } from 'nestjs-cls'; import { Request, Response, NextFunction } from 'express'; import { RequestContext } from './request-context'; -import { ClsService } from 'nestjs-cls'; @Injectable() export class RequestContextMiddleware implements NestMiddleware { + readonly logging: boolean = true; + constructor(private clsService: ClsService) {} use(req: Request, res: Response, next: NextFunction) { @@ -12,15 +14,21 @@ export class RequestContextMiddleware implements NestMiddleware { const context = new RequestContext({ req, res }); this.clsService.set(RequestContext.name, context); + // Get the full URL of the request const fullUrl = `${req.protocol}://${req.get('host')}${req.originalUrl}`; - console.log(`Context ${context.id}. Request URL: ${fullUrl} started...`); + + if (this.logging) { + console.log(`Context ${context.id}. Request URL: ${fullUrl} started...`); + } // Capture the original res.end const originalEnd = res.end.bind(res); // Override res.end res.end = (...args: any[]): Response => { - console.log(`Context ${context.id}. Request to ${fullUrl} completed.`); + if (this.logging) { + console.log(`Context ${context.id}. Request to ${fullUrl} completed.`); + } // Call the original res.end and return its result return originalEnd(...args); diff --git a/packages/core/src/core/dto/index.ts b/packages/core/src/core/dto/index.ts index 7d1f8c6ee94..22f2b489825 100644 --- a/packages/core/src/core/dto/index.ts +++ b/packages/core/src/core/dto/index.ts @@ -1,3 +1,4 @@ export * from './tenant-base.dto'; export * from './tenant-organization-base.dto'; export * from './translate-base-dto'; +export * from './member-entity-based.dto'; diff --git a/packages/core/src/core/dto/member-entity-based.dto.ts b/packages/core/src/core/dto/member-entity-based.dto.ts new file mode 100644 index 00000000000..98807a9326d --- /dev/null +++ b/packages/core/src/core/dto/member-entity-based.dto.ts @@ -0,0 +1,24 @@ +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { IsArray, IsOptional, IsUUID } from 'class-validator'; +import { ID, IMemberEntityBased } from '@gauzy/contracts'; +import { TenantOrganizationBaseDTO } from './tenant-organization-base.dto'; + +export class MemberEntityBasedDTO extends TenantOrganizationBaseDTO implements IMemberEntityBased { + /** + * Array of member UUIDs. + */ + @ApiPropertyOptional({ type: Array }) + @IsOptional() + @IsArray() + @IsUUID('all', { each: true }) + memberIds?: ID[] = []; + + /** + * Array of manager UUIDs. + */ + @ApiPropertyOptional({ type: Array }) + @IsOptional() + @IsArray() + @IsUUID('all', { each: true }) + managerIds?: ID[] = []; +} diff --git a/packages/core/src/core/entities/index.ts b/packages/core/src/core/entities/index.ts index 6759156bf65..ba8e388d44f 100644 --- a/packages/core/src/core/entities/index.ts +++ b/packages/core/src/core/entities/index.ts @@ -83,6 +83,9 @@ import { OrganizationProjectEmployee, OrganizationProjectModule, OrganizationRecurringExpense, + OrganizationSprintEmployee, + OrganizationSprintTask, + OrganizationSprintTaskHistory, OrganizationSprint, OrganizationTaskSetting, OrganizationTeam, @@ -127,6 +130,7 @@ import { TaskSize, TaskStatus, TaskVersion, + TaskView, Tenant, TenantSetting, TimeLog, @@ -227,6 +231,9 @@ export const coreEntities = [ OrganizationProjectEmployee, OrganizationProjectModule, OrganizationRecurringExpense, + OrganizationSprintEmployee, + OrganizationSprintTask, + OrganizationSprintTaskHistory, OrganizationSprint, OrganizationTaskSetting, OrganizationTeam, @@ -271,6 +278,7 @@ export const coreEntities = [ TaskSize, TaskStatus, TaskVersion, + TaskView, Tenant, TenantSetting, TimeLog, diff --git a/packages/core/src/core/entities/internal.ts b/packages/core/src/core/entities/internal.ts index 07e0b09c37d..3b2c28a2067 100644 --- a/packages/core/src/core/entities/internal.ts +++ b/packages/core/src/core/entities/internal.ts @@ -87,6 +87,9 @@ export * from '../../organization-project/organization-project-employee.entity'; export * from '../../organization-project-module/organization-project-module.entity'; export * from '../../organization-recurring-expense/organization-recurring-expense.entity'; export * from '../../organization-sprint/organization-sprint.entity'; +export * from '../../organization-sprint/organization-sprint-employee.entity'; +export * from '../../organization-sprint/organization-sprint-task.entity'; +export * from '../../organization-sprint/organization-sprint-task-history.entity'; export * from '../../organization-task-setting/organization-task-setting.entity'; export * from '../../organization-team-employee/organization-team-employee.entity'; export * from '../../organization-team-join-request/organization-team-join-request.entity'; @@ -131,6 +134,7 @@ export * from '../../tasks/sizes/size.entity'; export * from '../../tasks/statuses/status.entity'; export * from '../../tasks/task.entity'; export * from '../../tasks/versions/version.entity'; +export * from '../../tasks/views/view.entity'; export * from '../../tenant/tenant-setting/tenant-setting.entity'; export * from '../../tenant/tenant.entity'; export * from '../../time-off-policy/time-off-policy.entity'; diff --git a/packages/core/src/core/entities/subscribers/base-entity-event.subscriber.ts b/packages/core/src/core/entities/subscribers/base-entity-event.subscriber.ts index 338d00aee62..43bfe0e7608 100644 --- a/packages/core/src/core/entities/subscribers/base-entity-event.subscriber.ts +++ b/packages/core/src/core/entities/subscribers/base-entity-event.subscriber.ts @@ -1,119 +1,114 @@ import { EntityName } from '@mikro-orm/core'; -import { EntityEventSubscriber } from './entity-event.subsciber'; +import { EntityEventSubscriber } from './entity-event.subscriber'; import { IEntityEventSubscriber, MultiOrmEntityManager } from './entity-event-subscriber.types'; /** * An abstract class that provides a base implementation for IEntityEventSubscriber. * This class can be extended to create specific event subscribers for different entities. */ -export abstract class BaseEntityEventSubscriber extends EntityEventSubscriber implements IEntityEventSubscriber { +export abstract class BaseEntityEventSubscriber + extends EntityEventSubscriber + implements IEntityEventSubscriber +{ + /** + * An optional method that can be implemented by subclasses. + * It should return either a constructor function (a class) or a string + * representing the name of the entity to which this subscriber will listen. + * The default implementation returns undefined. + * + * @returns {Function | string | undefined} The entity class or its name, or undefined. + */ + listenTo(): Function | string | undefined { + return; + } - /** - * An optional method that can be implemented by subclasses. - * It should return either a constructor function (a class) or a string - * representing the name of the entity to which this subscriber will listen. - * The default implementation returns undefined. - * - * @returns {Function | string | undefined} The entity class or its name, or undefined. - */ - listenTo(): Function | string | undefined { - return; - } + /** + * Returns the array of entities this subscriber is subscribed to. + * If listenTo is not defined, it returns an empty array. + * + * @returns {EntityName[]} An array containing the entities to which this subscriber listens. + */ + getSubscribedEntities(): EntityName[] { + if (this.listenTo()) { + return [this.listenTo()]; + } + return []; + } - /** - * Returns the array of entities this subscriber is subscribed to. - * If listenTo is not defined, it returns an empty array. - * - * @returns {EntityName[]} An array containing the entities to which this subscriber listens. - */ - getSubscribedEntities(): EntityName[] { - if (this.listenTo()) { - return [this.listenTo()]; - } - return []; - } + /** + * Called before a new entity is persisted. Override in subclasses to define custom pre-creation logic. + * + * @param entity The entity that is about to be created. + * @param em The optional EntityManager, which can be either from TypeORM or MikroORM. + * @returns {Promise} + */ + async beforeEntityCreate(entity: Entity, em?: MultiOrmEntityManager): Promise { + // Default empty implementation + } - /** - * Called before a new entity is persisted. Override in subclasses to define custom pre-creation logic. - * - * @param entity The entity that is about to be created. - * @param em The optional EntityManager, which can be either from TypeORM or MikroORM. - * @returns {Promise} - */ - async beforeEntityCreate( - entity: Entity, - em?: MultiOrmEntityManager - ): Promise { - // Default empty implementation - } + /** + * Invoked before an entity update. Use in subclasses for specific update preparation. + * + * @param entity The entity being updated. + * @param em The optional EntityManager, which can be either from TypeORM or MikroORM. + * @returns {Promise} + */ + async beforeEntityUpdate(entity: Entity, em?: MultiOrmEntityManager): Promise { + // Default empty implementation + } - /** - * Invoked before an entity update. Use in subclasses for specific update preparation. - * - * @param entity The entity being updated. - * @param em The optional EntityManager, which can be either from TypeORM or MikroORM. - * @returns {Promise} - */ - async beforeEntityUpdate( - entity: Entity, - em?: MultiOrmEntityManager - ): Promise { - // Default empty implementation - } + /** + * Invoked after an entity update. Use in subclasses for specific update preparation. + * + * @param entity The entity being updated. + * @param em The optional EntityManager, which can be either from TypeORM or MikroORM. + * @returns {Promise} + */ + async afterEntityUpdate(entity: Entity, em?: MultiOrmEntityManager): Promise { + // Default empty implementation + } - /** - * Invoked after an entity update. Use in subclasses for specific update preparation. - * - * @param entity The entity being updated. - * @param em The optional EntityManager, which can be either from TypeORM or MikroORM. - * @returns {Promise} - */ - async afterEntityUpdate( - entity: Entity, - em?: MultiOrmEntityManager - ): Promise { - // Default empty implementation - } + /** + * Executed after an entity is created. Subclasses can override for post-creation actions. + * + * @param entity The newly created entity. + * @param em The optional EntityManager, which can be either from TypeORM or MikroORM. + * @returns {Promise} + */ + async afterEntityCreate(entity: Entity, em?: MultiOrmEntityManager): Promise { + // Default empty implementation + } - /** - * Executed after an entity is created. Subclasses can override for post-creation actions. - * - * @param entity The newly created entity. - * @param em The optional EntityManager, which can be either from TypeORM or MikroORM. - * @returns {Promise} - */ - async afterEntityCreate( - entity: Entity, - em?: MultiOrmEntityManager - ): Promise { - // Default empty implementation - } + /** + * Called following the loading of an entity. Ideal for post-load processing in subclasses. + * + * @param entity The entity that was loaded. + * @param em The optional EntityManager, which can be either from TypeORM or MikroORM. + * @returns {Promise} + */ + async afterEntityLoad(entity: Entity, em?: MultiOrmEntityManager): Promise { + // Default empty implementation + } - /** - * Called following the loading of an entity. Ideal for post-load processing in subclasses. - * - * @param entity The entity that was loaded. - * @param em The optional EntityManager, which can be either from TypeORM or MikroORM. - * @returns {Promise} - */ - async afterEntityLoad( - entity: Entity, - em?: MultiOrmEntityManager - ): Promise { - // Default empty implementation - } + /** + * Called following the deletion of an entity. Ideal for post-deletion processing in subclasses. + * + * @param entity The entity that was deleted. + * @param em The optional EntityManager, which can be either from TypeORM or MikroORM. + * @returns {Promise} + */ + async afterEntityDelete(entity: Entity, em?: MultiOrmEntityManager): Promise { + // Default empty implementation + } - /** - * Called following the deletion of an entity. Ideal for post-deletion processing in subclasses. - * - * @param entity The entity that was deleted. - * @param em The optional EntityManager, which can be either from TypeORM or MikroORM. - * @returns {Promise} - */ - async afterEntityDelete( - entity: Entity, - em?: MultiOrmEntityManager - ): Promise { - // Default empty implementation - } + /** + * Called following the soft removal of an entity. Ideal for post-deletion processing in subclasses. + * + * @param entity The entity that was soft removed. + * @param em The optional EntityManager, which can be either from TypeORM or MikroORM. + * @returns {Promise} + */ + async afterEntitySoftRemove(entity: Entity, em?: MultiOrmEntityManager): Promise { + // Default empty implementation + } } diff --git a/packages/core/src/core/entities/subscribers/entity-event-subscriber.types.ts b/packages/core/src/core/entities/subscribers/entity-event-subscriber.types.ts index 00ed930e176..456abf7316b 100644 --- a/packages/core/src/core/entities/subscribers/entity-event-subscriber.types.ts +++ b/packages/core/src/core/entities/subscribers/entity-event-subscriber.types.ts @@ -73,4 +73,13 @@ export interface IEntityEventSubscriber { * @returns {Promise} */ afterEntityDelete(entity: Entity, em?: MultiOrmEntityManager): Promise; + + /** + * Optional method that is called after an entity is soft removed. + * Implement this method to define specific logic to be executed after an entity soft removal event. + * + * @param entity The entity that has been soft removed. + * @param em An optional entity manager which can be either from TypeORM or MikroORM, used for further database operations if needed. + */ + afterEntitySoftRemove(entity: Entity, em?: MultiOrmEntityManager): Promise; } diff --git a/packages/core/src/core/entities/subscribers/entity-event.subsciber.ts b/packages/core/src/core/entities/subscribers/entity-event.subsciber.ts deleted file mode 100644 index 79669c7293d..00000000000 --- a/packages/core/src/core/entities/subscribers/entity-event.subsciber.ts +++ /dev/null @@ -1,295 +0,0 @@ -import { - EventArgs, - EventSubscriber as MikroEntitySubscriberInterface -} from '@mikro-orm/core'; -import { - InsertEvent, - LoadEvent, - RemoveEvent, - EntitySubscriberInterface as TypeOrmEntitySubscriberInterface, - UpdateEvent -} from 'typeorm'; -import { - MultiORM, - MultiORMEnum, - getORMType -} from '../../../core/utils'; -import { MultiOrmEntityManager } from './entity-event-subscriber.types'; - -// Get the type of the Object-Relational Mapping (ORM) used in the application. -const ormType: MultiORM = getORMType(); - -/** - * Implements event handling for entity creation. - * This class should be extended or integrated into your ORM event subscriber. - */ -export abstract class EntityEventSubscriber implements MikroEntitySubscriberInterface, TypeOrmEntitySubscriberInterface { - - /** - * Invoked when an entity is loaded in TypeORM. - * - * @param entity The loaded entity. - * @param event The load event details, if available. - * @returns {void | Promise} Can perform asynchronous operations. - */ - async afterLoad(entity: Entity, event?: LoadEvent): Promise { - try { - if (entity) { - await this.afterEntityLoad(entity, event.manager); - } - } catch (error) { - console.error("EntityEventSubscriber: Error in afterLoad:", error); - } - } - - /** - * Invoked when an entity is loaded in MikroORM. - * - * @param args The event arguments provided by MikroORM. - * @returns {void | Promise} Can perform asynchronous operations. - */ - async onLoad(args: EventArgs): Promise { - try { - if (args.entity) { - await this.afterEntityLoad(args.entity, args.em); - } - } catch (error) { - console.error("EntityEventSubscriber: Error in onLoad:", error); - } - } - - /** - * Abstract method for processing after an entity is loaded. Implement in subclasses for custom behavior. - * - * @param entity The entity that has been loaded. - * @param em The EntityManager, which can be either from TypeORM or MikroORM. - * @returns {Promise} - */ - protected abstract afterEntityLoad( - entity: Entity, - em?: MultiOrmEntityManager - ): Promise; - - /** - * Handles the event before an entity is created in MikroORM. - * - * @param args The event arguments provided by MikroORM. - * @returns {Promise} - Can perform asynchronous operations. - */ - async beforeCreate(args: EventArgs): Promise { - try { - if (args.entity) { - await this.beforeEntityCreate(args.entity, args.em); - } - } catch (error) { - console.error("EntityEventSubscriber: Error in beforeCreate:", error); - } - } - - /** - * Handles the event before an entity is inserted in TypeORM. - * - * @param event The insert event provided by TypeORM. - * @returns {Promise} - Can perform asynchronous operations. - */ - async beforeInsert(event: InsertEvent): Promise { - try { - if (event.entity) { - await this.beforeEntityCreate(event.entity, event.manager); - } - } catch (error) { - console.error("EntityEventSubscriber: Error in beforeInsert:", error); - } - } - - /** - * Abstract method for pre-creation logic of an entity. Implement in subclasses for custom actions. - * - * @param entity The entity that is about to be updated. - * @param em The EntityManager, which can be either from TypeORM or MikroORM. - * @returns {Promise} - */ - protected abstract beforeEntityCreate( - entity: Entity, - em?: MultiOrmEntityManager - ): Promise; - - /** - * Handles the event after an entity has been created in MikroORM. - * - * @param args - The event arguments provided by MikroORM. - * @returns {Promise} - Can perform asynchronous operations. - */ - async afterCreate(args: EventArgs): Promise { - try { - if (args.entity) { - await this.afterEntityCreate(args.entity, args.em); - } - } catch (error) { - console.error("EntityEventSubscriber: Error in afterCreate:", error); - } - } - - /** - * Handles the event after an entity has been inserted in TypeORM. - * - * @param event - The insert event provided by TypeORM. - * @returns {Promise} - Can perform asynchronous operations. - */ - async afterInsert(event: InsertEvent): Promise { - try { - if (event.entity) { - await this.afterEntityCreate(event.entity, event.manager); - } - } catch (error) { - console.error("EntityEventSubscriber: Error in afterInsert:", error); - } - } - - /** - * Abstract method for post-creation actions on an entity. Override in subclasses to define behavior. - * - * @param entity The entity that is about to be created. - * @param em The EntityManager, which can be either from TypeORM or MikroORM. - * @returns {Promise} - */ - protected abstract afterEntityCreate( - entity: Entity, - em?: MultiOrmEntityManager - ): Promise; - - /** - * Handles the 'before update' event for both MikroORM and TypeORM entities. It determines the - * type of ORM being used and appropriately casts the event to either EventArgs or UpdateEvent. - * - * @param event The event object which can be either EventArgs from MikroORM or UpdateEvent from TypeORM. - * @returns {Promise} A promise that resolves when the pre-update process is complete. Any errors during processing are caught and logged. - */ - async beforeUpdate(event: EventArgs | UpdateEvent): Promise { - try { - let entity: Entity; - let entityManager: MultiOrmEntityManager; - - switch (ormType) { - case MultiORMEnum.MikroORM: - entity = (event as EventArgs).entity; - entityManager = (event as EventArgs).em; - break; - case MultiORMEnum.TypeORM: - entity = (event as UpdateEvent).entity as Entity; - entityManager = (event as UpdateEvent).manager; - break; - default: - throw new Error(`Unsupported ORM type: ${ormType}`); - } - - if (entity) { - await this.beforeEntityUpdate(entity, entityManager); - } - } catch (error) { - console.error("EntityEventSubscriber: Error in beforeUpdate:", error); - } - } - - /** - * Abstract method for actions before updating an entity. Override in subclasses for specific logic. - * - * @param entity The entity that is about to be updated. - * @param em The EntityManager, which can be either from TypeORM or MikroORM. - * @returns {Promise} - */ - protected abstract beforeEntityUpdate( - entity: Entity, - em?: MultiOrmEntityManager - ): Promise; - - /** - * Handles the 'after update' event for both MikroORM and TypeORM entities. It determines the - * type of ORM being used and appropriately casts the event to either EventArgs or UpdateEvent. - * - * @param event - * @returns {Promise} A promise that resolves when the post-update process is complete. Any errors during processing are caught and logged. - */ - async afterUpdate(event: EventArgs | UpdateEvent): Promise { - try { - let entity: Entity; - let entityManager: MultiOrmEntityManager; - - switch (ormType) { - case MultiORMEnum.MikroORM: - entity = (event as EventArgs).entity; - entityManager = (event as EventArgs).em; - break; - case MultiORMEnum.TypeORM: - entity = (event as UpdateEvent).entity as Entity; - entityManager = (event as UpdateEvent).manager; - break; - default: - throw new Error(`Unsupported ORM type: ${ormType}`); - } - - if (entity) { - await this.afterEntityUpdate(entity, entityManager); - } - } catch (error) { - console.error("EntityEventSubscriber: Error in afterUpdate:", error); - } - } - - /** - * Abstract method for actions after updating an entity. Override in subclasses for specific logic. - * - * @param entity The entity that is about to be updated. - * @param em The EntityManager, which can be either from TypeORM or MikroORM. - * @returns {Promise} - */ - protected abstract afterEntityUpdate( - entity: Entity, - em?: MultiOrmEntityManager - ): Promise; - - /** - * Invoked when an entity is deleted in MikroORM. - * - * @param args The details of the delete event, including the deleted entity. - * @returns {void | Promise} Can perform asynchronous operations. - */ - async afterDelete(event: EventArgs): Promise { - try { - if (event.entity) { - await this.afterEntityDelete(event.entity, event.em); - } - } catch (error) { - console.error("EntityEventSubscriber: Error in afterDelete:", error); - } - } - - /** - * Invoked when an entity is removed in TypeORM. - * - * @param event The remove event details, including the removed entity. - * @returns {Promise} Can perform asynchronous operations. - */ - async afterRemove(event: RemoveEvent): Promise { - try { - if (event.entity && event.entityId) { - event.entity['id'] = event.entityId; - await this.afterEntityDelete(event.entity, event.manager); - } - } catch (error) { - console.error("EntityEventSubscriber: Error in afterRemove:", error); - } - } - - /** - * Abstract method for processing after an entity is deleted. Implement in subclasses for custom behavior. - * - * @param entity The entity that has been deleted. - * @param em The EntityManager, which can be either from TypeORM or MikroORM. - * @returns {Promise} - */ - protected abstract afterEntityDelete( - entity: Entity, - em?: MultiOrmEntityManager - ): Promise; -} diff --git a/packages/core/src/core/entities/subscribers/entity-event.subscriber.ts b/packages/core/src/core/entities/subscribers/entity-event.subscriber.ts new file mode 100644 index 00000000000..604f405f989 --- /dev/null +++ b/packages/core/src/core/entities/subscribers/entity-event.subscriber.ts @@ -0,0 +1,296 @@ +import { EventArgs, EventSubscriber as MikroEntitySubscriberInterface } from '@mikro-orm/core'; +import { + InsertEvent, + LoadEvent, + RemoveEvent, + EntitySubscriberInterface as TypeOrmEntitySubscriberInterface, + UpdateEvent +} from 'typeorm'; +import { MultiORM, MultiORMEnum, getORMType } from '../../utils'; +import { MultiOrmEntityManager } from './entity-event-subscriber.types'; + +// Get the type of the Object-Relational Mapping (ORM) used in the application. +const ormType: MultiORM = getORMType(); + +/** + * Implements event handling for entity creation. + * This class should be extended or integrated into your ORM event subscriber. + */ +export abstract class EntityEventSubscriber + implements MikroEntitySubscriberInterface, TypeOrmEntitySubscriberInterface +{ + /** + * Invoked when an entity is loaded in TypeORM. + * + * @param entity The loaded entity. + * @param event The load event details, if available. + * @returns {void | Promise} Can perform asynchronous operations. + */ + async afterLoad(entity: Entity, event?: LoadEvent): Promise { + try { + if (entity) { + await this.afterEntityLoad(entity, event.manager); + } + } catch (error) { + console.error('EntityEventSubscriber: Error in afterLoad:', error); + } + } + + /** + * Invoked when an entity is loaded in MikroORM. + * + * @param args The event arguments provided by MikroORM. + * @returns {void | Promise} Can perform asynchronous operations. + */ + async onLoad(args: EventArgs): Promise { + try { + if (args.entity) { + await this.afterEntityLoad(args.entity, args.em); + } + } catch (error) { + console.error('EntityEventSubscriber: Error in onLoad:', error); + } + } + + /** + * Abstract method for processing after an entity is loaded. Implement in subclasses for custom behavior. + * + * @param entity The entity that has been loaded. + * @param em The EntityManager, which can be either from TypeORM or MikroORM. + * @returns {Promise} + */ + protected abstract afterEntityLoad(entity: Entity, em?: MultiOrmEntityManager): Promise; + + /** + * Handles the event before an entity is created in MikroORM. + * + * @param args The event arguments provided by MikroORM. + * @returns {Promise} - Can perform asynchronous operations. + */ + async beforeCreate(args: EventArgs): Promise { + try { + if (args.entity) { + await this.beforeEntityCreate(args.entity, args.em); + } + } catch (error) { + console.error('EntityEventSubscriber: Error in beforeCreate:', error); + } + } + + /** + * Handles the event before an entity is inserted in TypeORM. + * + * @param event The insert event provided by TypeORM. + * @returns {Promise} - Can perform asynchronous operations. + */ + async beforeInsert(event: InsertEvent): Promise { + try { + if (event.entity) { + await this.beforeEntityCreate(event.entity, event.manager); + } + } catch (error) { + console.error('EntityEventSubscriber: Error in beforeInsert:', error); + } + } + + /** + * Abstract method for pre-creation logic of an entity. Implement in subclasses for custom actions. + * + * @param entity The entity that is about to be updated. + * @param em The EntityManager, which can be either from TypeORM or MikroORM. + * @returns {Promise} + */ + protected abstract beforeEntityCreate(entity: Entity, em?: MultiOrmEntityManager): Promise; + + /** + * Handles the event after an entity has been created in MikroORM. + * + * @param args - The event arguments provided by MikroORM. + * @returns {Promise} - Can perform asynchronous operations. + */ + async afterCreate(args: EventArgs): Promise { + try { + if (args.entity) { + await this.afterEntityCreate(args.entity, args.em); + } + } catch (error) { + console.error('EntityEventSubscriber: Error in afterCreate:', error); + } + } + + /** + * Handles the event after an entity has been inserted in TypeORM. + * + * @param event - The insert event provided by TypeORM. + * @returns {Promise} - Can perform asynchronous operations. + */ + async afterInsert(event: InsertEvent): Promise { + try { + if (event.entity) { + await this.afterEntityCreate(event.entity, event.manager); + } + } catch (error) { + console.error('EntityEventSubscriber: Error in afterInsert:', error); + } + } + + /** + * Abstract method for post-creation actions on an entity. Override in subclasses to define behavior. + * + * @param entity The entity that is about to be created. + * @param em The EntityManager, which can be either from TypeORM or MikroORM. + * @returns {Promise} + */ + protected abstract afterEntityCreate(entity: Entity, em?: MultiOrmEntityManager): Promise; + + /** + * Handles the 'before update' event for both MikroORM and TypeORM entities. It determines the + * type of ORM being used and appropriately casts the event to either EventArgs or UpdateEvent. + * + * @param event The event object which can be either EventArgs from MikroORM or UpdateEvent from TypeORM. + * @returns {Promise} A promise that resolves when the pre-update process is complete. Any errors during processing are caught and logged. + */ + async beforeUpdate(event: EventArgs | UpdateEvent): Promise { + try { + let entity: Entity; + let entityManager: MultiOrmEntityManager; + + switch (ormType) { + case MultiORMEnum.MikroORM: + entity = (event as EventArgs).entity; + entityManager = (event as EventArgs).em; + break; + case MultiORMEnum.TypeORM: + entity = (event as UpdateEvent).entity as Entity; + entityManager = (event as UpdateEvent).manager; + break; + default: + throw new Error(`Unsupported ORM type: ${ormType}`); + } + + if (entity) { + await this.beforeEntityUpdate(entity, entityManager); + } + } catch (error) { + console.error('EntityEventSubscriber: Error in beforeUpdate:', error); + } + } + + /** + * Abstract method for actions before updating an entity. Override in subclasses for specific logic. + * + * @param entity The entity that is about to be updated. + * @param em The EntityManager, which can be either from TypeORM or MikroORM. + * @returns {Promise} + */ + protected abstract beforeEntityUpdate(entity: Entity, em?: MultiOrmEntityManager): Promise; + + /** + * Handles the 'after update' event for both MikroORM and TypeORM entities. It determines the + * type of ORM being used and appropriately casts the event to either EventArgs or UpdateEvent. + * + * @param event + * @returns {Promise} A promise that resolves when the post-update process is complete. Any errors during processing are caught and logged. + */ + async afterUpdate(event: EventArgs | UpdateEvent): Promise { + try { + let entity: Entity; + let entityManager: MultiOrmEntityManager; + + switch (ormType) { + case MultiORMEnum.MikroORM: + entity = (event as EventArgs).entity; + entityManager = (event as EventArgs).em; + break; + case MultiORMEnum.TypeORM: + entity = (event as UpdateEvent).entity as Entity; + entityManager = (event as UpdateEvent).manager; + break; + default: + throw new Error(`Unsupported ORM type: ${ormType}`); + } + + if (entity) { + await this.afterEntityUpdate(entity, entityManager); + } + } catch (error) { + console.error('EntityEventSubscriber: Error in afterUpdate:', error); + } + } + + /** + * Abstract method for actions after updating an entity. Override in subclasses for specific logic. + * + * @param entity The entity that is about to be updated. + * @param em The EntityManager, which can be either from TypeORM or MikroORM. + * @returns {Promise} + */ + protected abstract afterEntityUpdate(entity: Entity, em?: MultiOrmEntityManager): Promise; + + /** + * Invoked when an entity is deleted in MikroORM. + * + * @param args The details of the delete event, including the deleted entity. + * @returns {void | Promise} Can perform asynchronous operations. + */ + async afterDelete(event: EventArgs): Promise { + try { + if (event.entity) { + await this.afterEntityDelete(event.entity, event.em); + } + } catch (error) { + console.error('EntityEventSubscriber: Error in afterDelete:', error); + } + } + + /** + * Invoked when an entity is removed in TypeORM. + * + * @param event The remove event details, including the removed entity. + * @returns {Promise} Can perform asynchronous operations. + */ + async afterRemove(event: RemoveEvent): Promise { + try { + if (event.entity && event.entityId) { + event.entity['id'] = event.entityId; + await this.afterEntityDelete(event.entity, event.manager); + } + } catch (error) { + console.error('EntityEventSubscriber: Error in afterRemove:', error); + } + } + + /** + * Abstract method for processing after an entity is deleted. Implement in subclasses for custom behavior. + * + * @param entity The entity that has been deleted. + * @param em The EntityManager, which can be either from TypeORM or MikroORM. + * @returns {Promise} + */ + protected abstract afterEntityDelete(entity: Entity, em?: MultiOrmEntityManager): Promise; + + /** + * Called after entity is soft removed from the database. + * + * @param event The remove event details, including the removed entity. + * @returns {Promise} Can perform asynchronous operations. + */ + async afterSoftRemove(event: RemoveEvent): Promise { + try { + if (event.entity && event.entityId) { + await this.afterEntitySoftRemove(event.entity, event.manager); + } + } catch (error) { + console.error('EntityEventSubscriber: Error in afterSoftRemove:', error); + } + } + + /** + * Abstract method for processing after an entity is soft removed. Implement in subclasses for custom behavior. + * + * @param entity The entity that has been soft removed. + * @param em The EntityManager, which can be either from TypeORM or MikroORM. + * + */ + protected abstract afterEntitySoftRemove(entity: Entity, em?: MultiOrmEntityManager): Promise; +} diff --git a/packages/core/src/core/file-storage/tenant-settings.middleware.ts b/packages/core/src/core/file-storage/tenant-settings.middleware.ts index ddeeb567d4d..b991bac28d7 100644 --- a/packages/core/src/core/file-storage/tenant-settings.middleware.ts +++ b/packages/core/src/core/file-storage/tenant-settings.middleware.ts @@ -7,12 +7,12 @@ import { CACHE_MANAGER } from '@nestjs/cache-manager'; @Injectable() export class TenantSettingsMiddleware implements NestMiddleware { - private logging = true; + private logging = false; constructor( @Inject(CACHE_MANAGER) private cacheManager: Cache, private readonly tenantSettingService: TenantSettingService - ) { } + ) {} /** * @@ -43,7 +43,10 @@ export class TenantSettingsMiddleware implements NestMiddleware { if (!tenantSettings) { if (this.logging) { - console.log('Tenant settings NOT loaded from Cache for tenantId: %s', decodedToken.tenantId); + console.log( + 'Tenant settings NOT loaded from Cache for tenantId: %s', + decodedToken.tenantId + ); } // Fetch tenant settings based on the decoded tenantId @@ -54,11 +57,14 @@ export class TenantSettingsMiddleware implements NestMiddleware { }); if (tenantSettings) { - const ttl = 5 * 60 * 1000 // 5 min caching period for Tenants Settings + const ttl = 5 * 60 * 1000; // 5 min caching period for Tenants Settings await this.cacheManager.set(cacheKey, tenantSettings, ttl); if (this.logging) { - console.log('Tenant settings loaded from DB and stored in Cache for tenantId: %s', decodedToken.tenantId); + console.log( + 'Tenant settings loaded from DB and stored in Cache for tenantId: %s', + decodedToken.tenantId + ); } } } else { diff --git a/packages/core/src/database/migrations/1727954184608-AlterTokenColumnTypeToTextInPasswordResetTable.ts b/packages/core/src/database/migrations/1727954184608-AlterTokenColumnTypeToTextInPasswordResetTable.ts index b8f602a5180..69767aba691 100644 --- a/packages/core/src/database/migrations/1727954184608-AlterTokenColumnTypeToTextInPasswordResetTable.ts +++ b/packages/core/src/database/migrations/1727954184608-AlterTokenColumnTypeToTextInPasswordResetTable.ts @@ -169,8 +169,14 @@ export class AlterTokenColumnTypeToTextInPasswordResetTable1727954184608 impleme * @param queryRunner */ public async mysqlUpQueryRunner(queryRunner: QueryRunner): Promise { - // Alter the column type to `text` without dropping the column - await queryRunner.query(`ALTER TABLE \`password_reset\` MODIFY \`token\` text NOT NULL`); + // Drop the original index without the key length restriction + await queryRunner.query(`DROP INDEX \`IDX_36e929b98372d961bb63bd4b4e\` ON \`password_reset\``); + // Alter the `token` column to `TEXT` and modify the index with a length + await queryRunner.query(`ALTER TABLE \`password_reset\` MODIFY \`token\` TEXT NOT NULL`); + // Recreate the original index without the key length restriction + await queryRunner.query( + `CREATE INDEX \`IDX_36e929b98372d961bb63bd4b4e\` ON \`password_reset\` (\`token\`(255))` + ); } /** @@ -179,7 +185,10 @@ export class AlterTokenColumnTypeToTextInPasswordResetTable1727954184608 impleme * @param queryRunner */ public async mysqlDownQueryRunner(queryRunner: QueryRunner): Promise { - // Revert the column type back to `varchar(255)` without dropping the column - await queryRunner.query(`ALTER TABLE \`password_reset\` MODIFY \`token\` varchar(255) NOT NULL`); + await queryRunner.query(`DROP INDEX \`IDX_36e929b98372d961bb63bd4b4e\` ON \`password_reset\``); + // Revert the `token` column back to `VARCHAR(255)` + await queryRunner.query(`ALTER TABLE \`password_reset\` MODIFY \`token\` VARCHAR(255) NOT NULL`); + // Recreate the original index without the key length restriction + await queryRunner.query(`CREATE INDEX \`IDX_36e929b98372d961bb63bd4b4e\` ON \`password_reset\` (\`token\`)`); } } diff --git a/packages/core/src/database/migrations/1728301971790-MigrateOrganizationSprintsRelatedEntities.ts b/packages/core/src/database/migrations/1728301971790-MigrateOrganizationSprintsRelatedEntities.ts new file mode 100644 index 00000000000..b2eadc4c0b7 --- /dev/null +++ b/packages/core/src/database/migrations/1728301971790-MigrateOrganizationSprintsRelatedEntities.ts @@ -0,0 +1,876 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; +import { yellow } from 'chalk'; +import { DatabaseTypeEnum } from '@gauzy/config'; + +export class MigrateOrganizationSprintsRelatedEntities1728301971790 implements MigrationInterface { + name = 'MigrateOrganizationSprintsRelatedEntities1728301971790'; + + /** + * Up Migration + * + * @param queryRunner + */ + public async up(queryRunner: QueryRunner): Promise { + console.log(yellow(this.name + ' start running!')); + + switch (queryRunner.connection.options.type) { + case DatabaseTypeEnum.sqlite: + case DatabaseTypeEnum.betterSqlite3: + await this.sqliteUpQueryRunner(queryRunner); + break; + case DatabaseTypeEnum.postgres: + await this.postgresUpQueryRunner(queryRunner); + break; + case DatabaseTypeEnum.mysql: + await this.mysqlUpQueryRunner(queryRunner); + break; + default: + throw Error(`Unsupported database: ${queryRunner.connection.options.type}`); + } + } + + /** + * Down Migration + * + * @param queryRunner + */ + public async down(queryRunner: QueryRunner): Promise { + switch (queryRunner.connection.options.type) { + case DatabaseTypeEnum.sqlite: + case DatabaseTypeEnum.betterSqlite3: + await this.sqliteDownQueryRunner(queryRunner); + break; + case DatabaseTypeEnum.postgres: + await this.postgresDownQueryRunner(queryRunner); + break; + case DatabaseTypeEnum.mysql: + await this.mysqlDownQueryRunner(queryRunner); + break; + default: + throw Error(`Unsupported database: ${queryRunner.connection.options.type}`); + } + } + + /** + * PostgresDB Up Migration + * + * @param queryRunner + */ + public async postgresUpQueryRunner(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `CREATE TABLE "organization_sprint_employee" ("deletedAt" TIMESTAMP, "id" uuid NOT NULL DEFAULT gen_random_uuid(), "createdAt" TIMESTAMP NOT NULL DEFAULT now(), "updatedAt" TIMESTAMP NOT NULL DEFAULT now(), "isActive" boolean DEFAULT true, "isArchived" boolean DEFAULT false, "archivedAt" TIMESTAMP, "tenantId" uuid, "organizationId" uuid, "isManager" boolean DEFAULT false, "assignedAt" TIMESTAMP, "organizationSprintId" uuid NOT NULL, "employeeId" uuid NOT NULL, "roleId" uuid, CONSTRAINT "PK_b31f2517142a7bf8b5c863a5f72" PRIMARY KEY ("id"))` + ); + await queryRunner.query( + `CREATE INDEX "IDX_fa0d179e320c8680cc32a41d70" ON "organization_sprint_employee" ("isActive") ` + ); + await queryRunner.query( + `CREATE INDEX "IDX_62b4c285d16b0759ae29791dd5" ON "organization_sprint_employee" ("isArchived") ` + ); + await queryRunner.query( + `CREATE INDEX "IDX_30d8b0ec5e73c133f3e3d9afef" ON "organization_sprint_employee" ("tenantId") ` + ); + await queryRunner.query( + `CREATE INDEX "IDX_2e74f97c99716d6a0a892ec6de" ON "organization_sprint_employee" ("organizationId") ` + ); + await queryRunner.query( + `CREATE INDEX "IDX_953b6df10eaabf11f5762bbee0" ON "organization_sprint_employee" ("isManager") ` + ); + await queryRunner.query( + `CREATE INDEX "IDX_fbcc76b45f43996c20936f229c" ON "organization_sprint_employee" ("assignedAt") ` + ); + await queryRunner.query( + `CREATE INDEX "IDX_68ea808c69795593450737c992" ON "organization_sprint_employee" ("organizationSprintId") ` + ); + await queryRunner.query( + `CREATE INDEX "IDX_e5ec88b5022e0d71ac88d876de" ON "organization_sprint_employee" ("employeeId") ` + ); + await queryRunner.query( + `CREATE INDEX "IDX_f516713776cfc5662cdbf2f3c4" ON "organization_sprint_employee" ("roleId") ` + ); + await queryRunner.query( + `CREATE TABLE "organization_sprint_task" ("deletedAt" TIMESTAMP, "id" uuid NOT NULL DEFAULT gen_random_uuid(), "createdAt" TIMESTAMP NOT NULL DEFAULT now(), "updatedAt" TIMESTAMP NOT NULL DEFAULT now(), "isActive" boolean DEFAULT true, "isArchived" boolean DEFAULT false, "archivedAt" TIMESTAMP, "tenantId" uuid, "organizationId" uuid, "totalWorkedHours" integer, "organizationSprintId" uuid NOT NULL, "taskId" uuid NOT NULL, CONSTRAINT "PK_7baee3cc404e521605aaf5a74d2" PRIMARY KEY ("id"))` + ); + await queryRunner.query( + `CREATE INDEX "IDX_2ceba05d5de64b36d361af6a34" ON "organization_sprint_task" ("isActive") ` + ); + await queryRunner.query( + `CREATE INDEX "IDX_b0a8b958a5716e73467c1937ec" ON "organization_sprint_task" ("isArchived") ` + ); + await queryRunner.query( + `CREATE INDEX "IDX_be3cb56b953b535835ad868391" ON "organization_sprint_task" ("tenantId") ` + ); + await queryRunner.query( + `CREATE INDEX "IDX_6a84b0cec9f10178a027f20098" ON "organization_sprint_task" ("organizationId") ` + ); + await queryRunner.query( + `CREATE INDEX "IDX_434665081d927127495623ad27" ON "organization_sprint_task" ("totalWorkedHours") ` + ); + await queryRunner.query( + `CREATE INDEX "IDX_889c9fd5c577a89f5f30facde4" ON "organization_sprint_task" ("organizationSprintId") ` + ); + await queryRunner.query( + `CREATE INDEX "IDX_e50cfbf82eec3f0b1d004a5c6e" ON "organization_sprint_task" ("taskId") ` + ); + await queryRunner.query( + `CREATE TABLE "organization_sprint_task_history" ("deletedAt" TIMESTAMP, "id" uuid NOT NULL DEFAULT gen_random_uuid(), "createdAt" TIMESTAMP NOT NULL DEFAULT now(), "updatedAt" TIMESTAMP NOT NULL DEFAULT now(), "isActive" boolean DEFAULT true, "isArchived" boolean DEFAULT false, "archivedAt" TIMESTAMP, "tenantId" uuid, "organizationId" uuid, "reason" text, "taskId" uuid NOT NULL, "fromSprintId" uuid NOT NULL, "toSprintId" uuid NOT NULL, "movedById" uuid, CONSTRAINT "PK_372b66962438094dc3c6ab926b5" PRIMARY KEY ("id"))` + ); + await queryRunner.query( + `CREATE INDEX "IDX_92db6225d2efc127ded3bdb5f1" ON "organization_sprint_task_history" ("isActive") ` + ); + await queryRunner.query( + `CREATE INDEX "IDX_8cde33e0a580277a2c1ed36a6b" ON "organization_sprint_task_history" ("isArchived") ` + ); + await queryRunner.query( + `CREATE INDEX "IDX_d5203d63179a7baf703e29a628" ON "organization_sprint_task_history" ("tenantId") ` + ); + await queryRunner.query( + `CREATE INDEX "IDX_8f686ac0104c90e95ef10f6c22" ON "organization_sprint_task_history" ("organizationId") ` + ); + await queryRunner.query( + `CREATE INDEX "IDX_ca809b0756488e63bc88918950" ON "organization_sprint_task_history" ("reason") ` + ); + await queryRunner.query( + `CREATE INDEX "IDX_3030e5dca58343e09ae1af0108" ON "organization_sprint_task_history" ("taskId") ` + ); + await queryRunner.query( + `CREATE INDEX "IDX_555c5952e67b2e93d4c7067f72" ON "organization_sprint_task_history" ("fromSprintId") ` + ); + await queryRunner.query( + `CREATE INDEX "IDX_da46a676d25c64bb06fc4536b3" ON "organization_sprint_task_history" ("toSprintId") ` + ); + await queryRunner.query( + `CREATE INDEX "IDX_7723913546575c33ab09ecfd50" ON "organization_sprint_task_history" ("movedById") ` + ); + await queryRunner.query(`ALTER TABLE "organization_sprint" ADD "status" character varying`); + await queryRunner.query(`ALTER TABLE "organization_sprint" ADD "sprintProgress" jsonb`); + await queryRunner.query(`CREATE INDEX "IDX_1cbe898fb849e4cffbddb60a87" ON "organization_sprint" ("status") `); + await queryRunner.query( + `ALTER TABLE "organization_sprint_employee" ADD CONSTRAINT "FK_30d8b0ec5e73c133f3e3d9afef1" FOREIGN KEY ("tenantId") REFERENCES "tenant"("id") ON DELETE CASCADE ON UPDATE NO ACTION` + ); + await queryRunner.query( + `ALTER TABLE "organization_sprint_employee" ADD CONSTRAINT "FK_2e74f97c99716d6a0a892ec6de7" FOREIGN KEY ("organizationId") REFERENCES "organization"("id") ON DELETE CASCADE ON UPDATE CASCADE` + ); + await queryRunner.query( + `ALTER TABLE "organization_sprint_employee" ADD CONSTRAINT "FK_68ea808c69795593450737c9923" FOREIGN KEY ("organizationSprintId") REFERENCES "organization_sprint"("id") ON DELETE CASCADE ON UPDATE NO ACTION` + ); + await queryRunner.query( + `ALTER TABLE "organization_sprint_employee" ADD CONSTRAINT "FK_e5ec88b5022e0d71ac88d876de2" FOREIGN KEY ("employeeId") REFERENCES "employee"("id") ON DELETE CASCADE ON UPDATE NO ACTION` + ); + await queryRunner.query( + `ALTER TABLE "organization_sprint_employee" ADD CONSTRAINT "FK_f516713776cfc5662cdbf2f3c4b" FOREIGN KEY ("roleId") REFERENCES "role"("id") ON DELETE CASCADE ON UPDATE NO ACTION` + ); + await queryRunner.query( + `ALTER TABLE "organization_sprint_task" ADD CONSTRAINT "FK_be3cb56b953b535835ad8683916" FOREIGN KEY ("tenantId") REFERENCES "tenant"("id") ON DELETE CASCADE ON UPDATE NO ACTION` + ); + await queryRunner.query( + `ALTER TABLE "organization_sprint_task" ADD CONSTRAINT "FK_6a84b0cec9f10178a027f200981" FOREIGN KEY ("organizationId") REFERENCES "organization"("id") ON DELETE CASCADE ON UPDATE CASCADE` + ); + await queryRunner.query( + `ALTER TABLE "organization_sprint_task" ADD CONSTRAINT "FK_889c9fd5c577a89f5f30facde42" FOREIGN KEY ("organizationSprintId") REFERENCES "organization_sprint"("id") ON DELETE CASCADE ON UPDATE NO ACTION` + ); + await queryRunner.query( + `ALTER TABLE "organization_sprint_task" ADD CONSTRAINT "FK_e50cfbf82eec3f0b1d004a5c6e8" FOREIGN KEY ("taskId") REFERENCES "task"("id") ON DELETE CASCADE ON UPDATE NO ACTION` + ); + await queryRunner.query( + `ALTER TABLE "organization_sprint_task_history" ADD CONSTRAINT "FK_d5203d63179a7baf703e29a628c" FOREIGN KEY ("tenantId") REFERENCES "tenant"("id") ON DELETE CASCADE ON UPDATE NO ACTION` + ); + await queryRunner.query( + `ALTER TABLE "organization_sprint_task_history" ADD CONSTRAINT "FK_8f686ac0104c90e95ef10f6c229" FOREIGN KEY ("organizationId") REFERENCES "organization"("id") ON DELETE CASCADE ON UPDATE CASCADE` + ); + await queryRunner.query( + `ALTER TABLE "organization_sprint_task_history" ADD CONSTRAINT "FK_3030e5dca58343e09ae1af01082" FOREIGN KEY ("taskId") REFERENCES "task"("id") ON DELETE CASCADE ON UPDATE NO ACTION` + ); + await queryRunner.query( + `ALTER TABLE "organization_sprint_task_history" ADD CONSTRAINT "FK_555c5952e67b2e93d4c7067f72d" FOREIGN KEY ("fromSprintId") REFERENCES "organization_sprint"("id") ON DELETE CASCADE ON UPDATE NO ACTION` + ); + await queryRunner.query( + `ALTER TABLE "organization_sprint_task_history" ADD CONSTRAINT "FK_da46a676d25c64bb06fc4536b34" FOREIGN KEY ("toSprintId") REFERENCES "organization_sprint"("id") ON DELETE CASCADE ON UPDATE NO ACTION` + ); + await queryRunner.query( + `ALTER TABLE "organization_sprint_task_history" ADD CONSTRAINT "FK_7723913546575c33ab09ecfd508" FOREIGN KEY ("movedById") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE NO ACTION` + ); + } + + /** + * PostgresDB Down Migration + * + * @param queryRunner + */ + public async postgresDownQueryRunner(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "organization_sprint_task_history" DROP CONSTRAINT "FK_7723913546575c33ab09ecfd508"` + ); + await queryRunner.query( + `ALTER TABLE "organization_sprint_task_history" DROP CONSTRAINT "FK_da46a676d25c64bb06fc4536b34"` + ); + await queryRunner.query( + `ALTER TABLE "organization_sprint_task_history" DROP CONSTRAINT "FK_555c5952e67b2e93d4c7067f72d"` + ); + await queryRunner.query( + `ALTER TABLE "organization_sprint_task_history" DROP CONSTRAINT "FK_3030e5dca58343e09ae1af01082"` + ); + await queryRunner.query( + `ALTER TABLE "organization_sprint_task_history" DROP CONSTRAINT "FK_8f686ac0104c90e95ef10f6c229"` + ); + await queryRunner.query( + `ALTER TABLE "organization_sprint_task_history" DROP CONSTRAINT "FK_d5203d63179a7baf703e29a628c"` + ); + await queryRunner.query( + `ALTER TABLE "organization_sprint_task" DROP CONSTRAINT "FK_e50cfbf82eec3f0b1d004a5c6e8"` + ); + await queryRunner.query( + `ALTER TABLE "organization_sprint_task" DROP CONSTRAINT "FK_889c9fd5c577a89f5f30facde42"` + ); + await queryRunner.query( + `ALTER TABLE "organization_sprint_task" DROP CONSTRAINT "FK_6a84b0cec9f10178a027f200981"` + ); + await queryRunner.query( + `ALTER TABLE "organization_sprint_task" DROP CONSTRAINT "FK_be3cb56b953b535835ad8683916"` + ); + await queryRunner.query( + `ALTER TABLE "organization_sprint_employee" DROP CONSTRAINT "FK_f516713776cfc5662cdbf2f3c4b"` + ); + await queryRunner.query( + `ALTER TABLE "organization_sprint_employee" DROP CONSTRAINT "FK_e5ec88b5022e0d71ac88d876de2"` + ); + await queryRunner.query( + `ALTER TABLE "organization_sprint_employee" DROP CONSTRAINT "FK_68ea808c69795593450737c9923"` + ); + await queryRunner.query( + `ALTER TABLE "organization_sprint_employee" DROP CONSTRAINT "FK_2e74f97c99716d6a0a892ec6de7"` + ); + await queryRunner.query( + `ALTER TABLE "organization_sprint_employee" DROP CONSTRAINT "FK_30d8b0ec5e73c133f3e3d9afef1"` + ); + await queryRunner.query(`DROP INDEX "public"."IDX_1cbe898fb849e4cffbddb60a87"`); + await queryRunner.query(`ALTER TABLE "organization_sprint" DROP COLUMN "sprintProgress"`); + await queryRunner.query(`ALTER TABLE "organization_sprint" DROP COLUMN "status"`); + await queryRunner.query(`DROP INDEX "public"."IDX_7723913546575c33ab09ecfd50"`); + await queryRunner.query(`DROP INDEX "public"."IDX_da46a676d25c64bb06fc4536b3"`); + await queryRunner.query(`DROP INDEX "public"."IDX_555c5952e67b2e93d4c7067f72"`); + await queryRunner.query(`DROP INDEX "public"."IDX_3030e5dca58343e09ae1af0108"`); + await queryRunner.query(`DROP INDEX "public"."IDX_ca809b0756488e63bc88918950"`); + await queryRunner.query(`DROP INDEX "public"."IDX_8f686ac0104c90e95ef10f6c22"`); + await queryRunner.query(`DROP INDEX "public"."IDX_d5203d63179a7baf703e29a628"`); + await queryRunner.query(`DROP INDEX "public"."IDX_8cde33e0a580277a2c1ed36a6b"`); + await queryRunner.query(`DROP INDEX "public"."IDX_92db6225d2efc127ded3bdb5f1"`); + await queryRunner.query(`DROP TABLE "organization_sprint_task_history"`); + await queryRunner.query(`DROP INDEX "public"."IDX_e50cfbf82eec3f0b1d004a5c6e"`); + await queryRunner.query(`DROP INDEX "public"."IDX_889c9fd5c577a89f5f30facde4"`); + await queryRunner.query(`DROP INDEX "public"."IDX_434665081d927127495623ad27"`); + await queryRunner.query(`DROP INDEX "public"."IDX_6a84b0cec9f10178a027f20098"`); + await queryRunner.query(`DROP INDEX "public"."IDX_be3cb56b953b535835ad868391"`); + await queryRunner.query(`DROP INDEX "public"."IDX_b0a8b958a5716e73467c1937ec"`); + await queryRunner.query(`DROP INDEX "public"."IDX_2ceba05d5de64b36d361af6a34"`); + await queryRunner.query(`DROP TABLE "organization_sprint_task"`); + await queryRunner.query(`DROP INDEX "public"."IDX_f516713776cfc5662cdbf2f3c4"`); + await queryRunner.query(`DROP INDEX "public"."IDX_e5ec88b5022e0d71ac88d876de"`); + await queryRunner.query(`DROP INDEX "public"."IDX_68ea808c69795593450737c992"`); + await queryRunner.query(`DROP INDEX "public"."IDX_fbcc76b45f43996c20936f229c"`); + await queryRunner.query(`DROP INDEX "public"."IDX_953b6df10eaabf11f5762bbee0"`); + await queryRunner.query(`DROP INDEX "public"."IDX_2e74f97c99716d6a0a892ec6de"`); + await queryRunner.query(`DROP INDEX "public"."IDX_30d8b0ec5e73c133f3e3d9afef"`); + await queryRunner.query(`DROP INDEX "public"."IDX_62b4c285d16b0759ae29791dd5"`); + await queryRunner.query(`DROP INDEX "public"."IDX_fa0d179e320c8680cc32a41d70"`); + await queryRunner.query(`DROP TABLE "organization_sprint_employee"`); + } + + /** + * SqliteDB and BetterSQlite3DB Up Migration + * + * @param queryRunner + */ + public async sqliteUpQueryRunner(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `CREATE TABLE "organization_sprint_employee" ("deletedAt" datetime, "id" varchar PRIMARY KEY NOT NULL, "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "isActive" boolean DEFAULT (1), "isArchived" boolean DEFAULT (0), "archivedAt" datetime, "tenantId" varchar, "organizationId" varchar, "isManager" boolean DEFAULT (0), "assignedAt" datetime, "organizationSprintId" varchar NOT NULL, "employeeId" varchar NOT NULL, "roleId" varchar)` + ); + await queryRunner.query( + `CREATE INDEX "IDX_fa0d179e320c8680cc32a41d70" ON "organization_sprint_employee" ("isActive") ` + ); + await queryRunner.query( + `CREATE INDEX "IDX_62b4c285d16b0759ae29791dd5" ON "organization_sprint_employee" ("isArchived") ` + ); + await queryRunner.query( + `CREATE INDEX "IDX_30d8b0ec5e73c133f3e3d9afef" ON "organization_sprint_employee" ("tenantId") ` + ); + await queryRunner.query( + `CREATE INDEX "IDX_2e74f97c99716d6a0a892ec6de" ON "organization_sprint_employee" ("organizationId") ` + ); + await queryRunner.query( + `CREATE INDEX "IDX_953b6df10eaabf11f5762bbee0" ON "organization_sprint_employee" ("isManager") ` + ); + await queryRunner.query( + `CREATE INDEX "IDX_fbcc76b45f43996c20936f229c" ON "organization_sprint_employee" ("assignedAt") ` + ); + await queryRunner.query( + `CREATE INDEX "IDX_68ea808c69795593450737c992" ON "organization_sprint_employee" ("organizationSprintId") ` + ); + await queryRunner.query( + `CREATE INDEX "IDX_e5ec88b5022e0d71ac88d876de" ON "organization_sprint_employee" ("employeeId") ` + ); + await queryRunner.query( + `CREATE INDEX "IDX_f516713776cfc5662cdbf2f3c4" ON "organization_sprint_employee" ("roleId") ` + ); + await queryRunner.query( + `CREATE TABLE "organization_sprint_task" ("deletedAt" datetime, "id" varchar PRIMARY KEY NOT NULL, "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "isActive" boolean DEFAULT (1), "isArchived" boolean DEFAULT (0), "archivedAt" datetime, "tenantId" varchar, "organizationId" varchar, "totalWorkedHours" integer, "organizationSprintId" varchar NOT NULL, "taskId" varchar NOT NULL)` + ); + await queryRunner.query( + `CREATE INDEX "IDX_2ceba05d5de64b36d361af6a34" ON "organization_sprint_task" ("isActive") ` + ); + await queryRunner.query( + `CREATE INDEX "IDX_b0a8b958a5716e73467c1937ec" ON "organization_sprint_task" ("isArchived") ` + ); + await queryRunner.query( + `CREATE INDEX "IDX_be3cb56b953b535835ad868391" ON "organization_sprint_task" ("tenantId") ` + ); + await queryRunner.query( + `CREATE INDEX "IDX_6a84b0cec9f10178a027f20098" ON "organization_sprint_task" ("organizationId") ` + ); + await queryRunner.query( + `CREATE INDEX "IDX_434665081d927127495623ad27" ON "organization_sprint_task" ("totalWorkedHours") ` + ); + await queryRunner.query( + `CREATE INDEX "IDX_889c9fd5c577a89f5f30facde4" ON "organization_sprint_task" ("organizationSprintId") ` + ); + await queryRunner.query( + `CREATE INDEX "IDX_e50cfbf82eec3f0b1d004a5c6e" ON "organization_sprint_task" ("taskId") ` + ); + await queryRunner.query( + `CREATE TABLE "organization_sprint_task_history" ("deletedAt" datetime, "id" varchar PRIMARY KEY NOT NULL, "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "isActive" boolean DEFAULT (1), "isArchived" boolean DEFAULT (0), "archivedAt" datetime, "tenantId" varchar, "organizationId" varchar, "reason" text, "taskId" varchar NOT NULL, "fromSprintId" varchar NOT NULL, "toSprintId" varchar NOT NULL, "movedById" varchar)` + ); + await queryRunner.query( + `CREATE INDEX "IDX_92db6225d2efc127ded3bdb5f1" ON "organization_sprint_task_history" ("isActive") ` + ); + await queryRunner.query( + `CREATE INDEX "IDX_8cde33e0a580277a2c1ed36a6b" ON "organization_sprint_task_history" ("isArchived") ` + ); + await queryRunner.query( + `CREATE INDEX "IDX_d5203d63179a7baf703e29a628" ON "organization_sprint_task_history" ("tenantId") ` + ); + await queryRunner.query( + `CREATE INDEX "IDX_8f686ac0104c90e95ef10f6c22" ON "organization_sprint_task_history" ("organizationId") ` + ); + await queryRunner.query( + `CREATE INDEX "IDX_ca809b0756488e63bc88918950" ON "organization_sprint_task_history" ("reason") ` + ); + await queryRunner.query( + `CREATE INDEX "IDX_3030e5dca58343e09ae1af0108" ON "organization_sprint_task_history" ("taskId") ` + ); + await queryRunner.query( + `CREATE INDEX "IDX_555c5952e67b2e93d4c7067f72" ON "organization_sprint_task_history" ("fromSprintId") ` + ); + await queryRunner.query( + `CREATE INDEX "IDX_da46a676d25c64bb06fc4536b3" ON "organization_sprint_task_history" ("toSprintId") ` + ); + await queryRunner.query( + `CREATE INDEX "IDX_7723913546575c33ab09ecfd50" ON "organization_sprint_task_history" ("movedById") ` + ); + await queryRunner.query(`DROP INDEX "IDX_76e53f9609ca05477d50980743"`); + await queryRunner.query(`DROP INDEX "IDX_5596b4fa7fb2ceb0955580becd"`); + await queryRunner.query(`DROP INDEX "IDX_f57ad03c4e471bd8530494ea63"`); + await queryRunner.query(`DROP INDEX "IDX_8a1fe8afb3aa672bae5993fbe7"`); + await queryRunner.query( + `CREATE TABLE "temporary_organization_sprint" ("id" varchar PRIMARY KEY NOT NULL, "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "tenantId" varchar, "organizationId" varchar, "name" varchar NOT NULL, "projectId" varchar NOT NULL, "goal" varchar, "length" integer NOT NULL DEFAULT (7), "startDate" datetime, "endDate" datetime, "dayStart" integer, "isActive" boolean DEFAULT (1), "isArchived" boolean DEFAULT (0), "deletedAt" datetime, "archivedAt" datetime, "status" varchar, "sprintProgress" text, CONSTRAINT "FK_f57ad03c4e471bd8530494ea63d" FOREIGN KEY ("tenantId") REFERENCES "tenant" ("id") ON DELETE CASCADE ON UPDATE NO ACTION, CONSTRAINT "FK_8a1fe8afb3aa672bae5993fbe7d" FOREIGN KEY ("organizationId") REFERENCES "organization" ("id") ON DELETE CASCADE ON UPDATE CASCADE, CONSTRAINT "FK_a140b7e30ff3455551a0fd599fb" FOREIGN KEY ("projectId") REFERENCES "organization_project" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)` + ); + await queryRunner.query( + `INSERT INTO "temporary_organization_sprint"("id", "createdAt", "updatedAt", "tenantId", "organizationId", "name", "projectId", "goal", "length", "startDate", "endDate", "dayStart", "isActive", "isArchived", "deletedAt", "archivedAt") SELECT "id", "createdAt", "updatedAt", "tenantId", "organizationId", "name", "projectId", "goal", "length", "startDate", "endDate", "dayStart", "isActive", "isArchived", "deletedAt", "archivedAt" FROM "organization_sprint"` + ); + await queryRunner.query(`DROP TABLE "organization_sprint"`); + await queryRunner.query(`ALTER TABLE "temporary_organization_sprint" RENAME TO "organization_sprint"`); + await queryRunner.query( + `CREATE INDEX "IDX_76e53f9609ca05477d50980743" ON "organization_sprint" ("isArchived") ` + ); + await queryRunner.query(`CREATE INDEX "IDX_5596b4fa7fb2ceb0955580becd" ON "organization_sprint" ("isActive") `); + await queryRunner.query(`CREATE INDEX "IDX_f57ad03c4e471bd8530494ea63" ON "organization_sprint" ("tenantId") `); + await queryRunner.query( + `CREATE INDEX "IDX_8a1fe8afb3aa672bae5993fbe7" ON "organization_sprint" ("organizationId") ` + ); + await queryRunner.query(`CREATE INDEX "IDX_1cbe898fb849e4cffbddb60a87" ON "organization_sprint" ("status") `); + await queryRunner.query(`DROP INDEX "IDX_fa0d179e320c8680cc32a41d70"`); + await queryRunner.query(`DROP INDEX "IDX_62b4c285d16b0759ae29791dd5"`); + await queryRunner.query(`DROP INDEX "IDX_30d8b0ec5e73c133f3e3d9afef"`); + await queryRunner.query(`DROP INDEX "IDX_2e74f97c99716d6a0a892ec6de"`); + await queryRunner.query(`DROP INDEX "IDX_953b6df10eaabf11f5762bbee0"`); + await queryRunner.query(`DROP INDEX "IDX_fbcc76b45f43996c20936f229c"`); + await queryRunner.query(`DROP INDEX "IDX_68ea808c69795593450737c992"`); + await queryRunner.query(`DROP INDEX "IDX_e5ec88b5022e0d71ac88d876de"`); + await queryRunner.query(`DROP INDEX "IDX_f516713776cfc5662cdbf2f3c4"`); + await queryRunner.query( + `CREATE TABLE "temporary_organization_sprint_employee" ("deletedAt" datetime, "id" varchar PRIMARY KEY NOT NULL, "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "isActive" boolean DEFAULT (1), "isArchived" boolean DEFAULT (0), "archivedAt" datetime, "tenantId" varchar, "organizationId" varchar, "isManager" boolean DEFAULT (0), "assignedAt" datetime, "organizationSprintId" varchar NOT NULL, "employeeId" varchar NOT NULL, "roleId" varchar, CONSTRAINT "FK_30d8b0ec5e73c133f3e3d9afef1" FOREIGN KEY ("tenantId") REFERENCES "tenant" ("id") ON DELETE CASCADE ON UPDATE NO ACTION, CONSTRAINT "FK_2e74f97c99716d6a0a892ec6de7" FOREIGN KEY ("organizationId") REFERENCES "organization" ("id") ON DELETE CASCADE ON UPDATE CASCADE, CONSTRAINT "FK_68ea808c69795593450737c9923" FOREIGN KEY ("organizationSprintId") REFERENCES "organization_sprint" ("id") ON DELETE CASCADE ON UPDATE NO ACTION, CONSTRAINT "FK_e5ec88b5022e0d71ac88d876de2" FOREIGN KEY ("employeeId") REFERENCES "employee" ("id") ON DELETE CASCADE ON UPDATE NO ACTION, CONSTRAINT "FK_f516713776cfc5662cdbf2f3c4b" FOREIGN KEY ("roleId") REFERENCES "role" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)` + ); + await queryRunner.query( + `INSERT INTO "temporary_organization_sprint_employee"("deletedAt", "id", "createdAt", "updatedAt", "isActive", "isArchived", "archivedAt", "tenantId", "organizationId", "isManager", "assignedAt", "organizationSprintId", "employeeId", "roleId") SELECT "deletedAt", "id", "createdAt", "updatedAt", "isActive", "isArchived", "archivedAt", "tenantId", "organizationId", "isManager", "assignedAt", "organizationSprintId", "employeeId", "roleId" FROM "organization_sprint_employee"` + ); + await queryRunner.query(`DROP TABLE "organization_sprint_employee"`); + await queryRunner.query( + `ALTER TABLE "temporary_organization_sprint_employee" RENAME TO "organization_sprint_employee"` + ); + await queryRunner.query( + `CREATE INDEX "IDX_fa0d179e320c8680cc32a41d70" ON "organization_sprint_employee" ("isActive") ` + ); + await queryRunner.query( + `CREATE INDEX "IDX_62b4c285d16b0759ae29791dd5" ON "organization_sprint_employee" ("isArchived") ` + ); + await queryRunner.query( + `CREATE INDEX "IDX_30d8b0ec5e73c133f3e3d9afef" ON "organization_sprint_employee" ("tenantId") ` + ); + await queryRunner.query( + `CREATE INDEX "IDX_2e74f97c99716d6a0a892ec6de" ON "organization_sprint_employee" ("organizationId") ` + ); + await queryRunner.query( + `CREATE INDEX "IDX_953b6df10eaabf11f5762bbee0" ON "organization_sprint_employee" ("isManager") ` + ); + await queryRunner.query( + `CREATE INDEX "IDX_fbcc76b45f43996c20936f229c" ON "organization_sprint_employee" ("assignedAt") ` + ); + await queryRunner.query( + `CREATE INDEX "IDX_68ea808c69795593450737c992" ON "organization_sprint_employee" ("organizationSprintId") ` + ); + await queryRunner.query( + `CREATE INDEX "IDX_e5ec88b5022e0d71ac88d876de" ON "organization_sprint_employee" ("employeeId") ` + ); + await queryRunner.query( + `CREATE INDEX "IDX_f516713776cfc5662cdbf2f3c4" ON "organization_sprint_employee" ("roleId") ` + ); + await queryRunner.query(`DROP INDEX "IDX_2ceba05d5de64b36d361af6a34"`); + await queryRunner.query(`DROP INDEX "IDX_b0a8b958a5716e73467c1937ec"`); + await queryRunner.query(`DROP INDEX "IDX_be3cb56b953b535835ad868391"`); + await queryRunner.query(`DROP INDEX "IDX_6a84b0cec9f10178a027f20098"`); + await queryRunner.query(`DROP INDEX "IDX_434665081d927127495623ad27"`); + await queryRunner.query(`DROP INDEX "IDX_889c9fd5c577a89f5f30facde4"`); + await queryRunner.query(`DROP INDEX "IDX_e50cfbf82eec3f0b1d004a5c6e"`); + await queryRunner.query( + `CREATE TABLE "temporary_organization_sprint_task" ("deletedAt" datetime, "id" varchar PRIMARY KEY NOT NULL, "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "isActive" boolean DEFAULT (1), "isArchived" boolean DEFAULT (0), "archivedAt" datetime, "tenantId" varchar, "organizationId" varchar, "totalWorkedHours" integer, "organizationSprintId" varchar NOT NULL, "taskId" varchar NOT NULL, CONSTRAINT "FK_be3cb56b953b535835ad8683916" FOREIGN KEY ("tenantId") REFERENCES "tenant" ("id") ON DELETE CASCADE ON UPDATE NO ACTION, CONSTRAINT "FK_6a84b0cec9f10178a027f200981" FOREIGN KEY ("organizationId") REFERENCES "organization" ("id") ON DELETE CASCADE ON UPDATE CASCADE, CONSTRAINT "FK_889c9fd5c577a89f5f30facde42" FOREIGN KEY ("organizationSprintId") REFERENCES "organization_sprint" ("id") ON DELETE CASCADE ON UPDATE NO ACTION, CONSTRAINT "FK_e50cfbf82eec3f0b1d004a5c6e8" FOREIGN KEY ("taskId") REFERENCES "task" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)` + ); + await queryRunner.query( + `INSERT INTO "temporary_organization_sprint_task"("deletedAt", "id", "createdAt", "updatedAt", "isActive", "isArchived", "archivedAt", "tenantId", "organizationId", "totalWorkedHours", "organizationSprintId", "taskId") SELECT "deletedAt", "id", "createdAt", "updatedAt", "isActive", "isArchived", "archivedAt", "tenantId", "organizationId", "totalWorkedHours", "organizationSprintId", "taskId" FROM "organization_sprint_task"` + ); + await queryRunner.query(`DROP TABLE "organization_sprint_task"`); + await queryRunner.query( + `ALTER TABLE "temporary_organization_sprint_task" RENAME TO "organization_sprint_task"` + ); + await queryRunner.query( + `CREATE INDEX "IDX_2ceba05d5de64b36d361af6a34" ON "organization_sprint_task" ("isActive") ` + ); + await queryRunner.query( + `CREATE INDEX "IDX_b0a8b958a5716e73467c1937ec" ON "organization_sprint_task" ("isArchived") ` + ); + await queryRunner.query( + `CREATE INDEX "IDX_be3cb56b953b535835ad868391" ON "organization_sprint_task" ("tenantId") ` + ); + await queryRunner.query( + `CREATE INDEX "IDX_6a84b0cec9f10178a027f20098" ON "organization_sprint_task" ("organizationId") ` + ); + await queryRunner.query( + `CREATE INDEX "IDX_434665081d927127495623ad27" ON "organization_sprint_task" ("totalWorkedHours") ` + ); + await queryRunner.query( + `CREATE INDEX "IDX_889c9fd5c577a89f5f30facde4" ON "organization_sprint_task" ("organizationSprintId") ` + ); + await queryRunner.query( + `CREATE INDEX "IDX_e50cfbf82eec3f0b1d004a5c6e" ON "organization_sprint_task" ("taskId") ` + ); + await queryRunner.query(`DROP INDEX "IDX_92db6225d2efc127ded3bdb5f1"`); + await queryRunner.query(`DROP INDEX "IDX_8cde33e0a580277a2c1ed36a6b"`); + await queryRunner.query(`DROP INDEX "IDX_d5203d63179a7baf703e29a628"`); + await queryRunner.query(`DROP INDEX "IDX_8f686ac0104c90e95ef10f6c22"`); + await queryRunner.query(`DROP INDEX "IDX_ca809b0756488e63bc88918950"`); + await queryRunner.query(`DROP INDEX "IDX_3030e5dca58343e09ae1af0108"`); + await queryRunner.query(`DROP INDEX "IDX_555c5952e67b2e93d4c7067f72"`); + await queryRunner.query(`DROP INDEX "IDX_da46a676d25c64bb06fc4536b3"`); + await queryRunner.query(`DROP INDEX "IDX_7723913546575c33ab09ecfd50"`); + await queryRunner.query( + `CREATE TABLE "temporary_organization_sprint_task_history" ("deletedAt" datetime, "id" varchar PRIMARY KEY NOT NULL, "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "isActive" boolean DEFAULT (1), "isArchived" boolean DEFAULT (0), "archivedAt" datetime, "tenantId" varchar, "organizationId" varchar, "reason" text, "taskId" varchar NOT NULL, "fromSprintId" varchar NOT NULL, "toSprintId" varchar NOT NULL, "movedById" varchar, CONSTRAINT "FK_d5203d63179a7baf703e29a628c" FOREIGN KEY ("tenantId") REFERENCES "tenant" ("id") ON DELETE CASCADE ON UPDATE NO ACTION, CONSTRAINT "FK_8f686ac0104c90e95ef10f6c229" FOREIGN KEY ("organizationId") REFERENCES "organization" ("id") ON DELETE CASCADE ON UPDATE CASCADE, CONSTRAINT "FK_3030e5dca58343e09ae1af01082" FOREIGN KEY ("taskId") REFERENCES "task" ("id") ON DELETE CASCADE ON UPDATE NO ACTION, CONSTRAINT "FK_555c5952e67b2e93d4c7067f72d" FOREIGN KEY ("fromSprintId") REFERENCES "organization_sprint" ("id") ON DELETE CASCADE ON UPDATE NO ACTION, CONSTRAINT "FK_da46a676d25c64bb06fc4536b34" FOREIGN KEY ("toSprintId") REFERENCES "organization_sprint" ("id") ON DELETE CASCADE ON UPDATE NO ACTION, CONSTRAINT "FK_7723913546575c33ab09ecfd508" FOREIGN KEY ("movedById") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)` + ); + await queryRunner.query( + `INSERT INTO "temporary_organization_sprint_task_history"("deletedAt", "id", "createdAt", "updatedAt", "isActive", "isArchived", "archivedAt", "tenantId", "organizationId", "reason", "taskId", "fromSprintId", "toSprintId", "movedById") SELECT "deletedAt", "id", "createdAt", "updatedAt", "isActive", "isArchived", "archivedAt", "tenantId", "organizationId", "reason", "taskId", "fromSprintId", "toSprintId", "movedById" FROM "organization_sprint_task_history"` + ); + await queryRunner.query(`DROP TABLE "organization_sprint_task_history"`); + await queryRunner.query( + `ALTER TABLE "temporary_organization_sprint_task_history" RENAME TO "organization_sprint_task_history"` + ); + await queryRunner.query( + `CREATE INDEX "IDX_92db6225d2efc127ded3bdb5f1" ON "organization_sprint_task_history" ("isActive") ` + ); + await queryRunner.query( + `CREATE INDEX "IDX_8cde33e0a580277a2c1ed36a6b" ON "organization_sprint_task_history" ("isArchived") ` + ); + await queryRunner.query( + `CREATE INDEX "IDX_d5203d63179a7baf703e29a628" ON "organization_sprint_task_history" ("tenantId") ` + ); + await queryRunner.query( + `CREATE INDEX "IDX_8f686ac0104c90e95ef10f6c22" ON "organization_sprint_task_history" ("organizationId") ` + ); + await queryRunner.query( + `CREATE INDEX "IDX_ca809b0756488e63bc88918950" ON "organization_sprint_task_history" ("reason") ` + ); + await queryRunner.query( + `CREATE INDEX "IDX_3030e5dca58343e09ae1af0108" ON "organization_sprint_task_history" ("taskId") ` + ); + await queryRunner.query( + `CREATE INDEX "IDX_555c5952e67b2e93d4c7067f72" ON "organization_sprint_task_history" ("fromSprintId") ` + ); + await queryRunner.query( + `CREATE INDEX "IDX_da46a676d25c64bb06fc4536b3" ON "organization_sprint_task_history" ("toSprintId") ` + ); + await queryRunner.query( + `CREATE INDEX "IDX_7723913546575c33ab09ecfd50" ON "organization_sprint_task_history" ("movedById") ` + ); + } + + /** + * SqliteDB and BetterSQlite3DB Down Migration + * + * @param queryRunner + */ + public async sqliteDownQueryRunner(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP INDEX "IDX_7723913546575c33ab09ecfd50"`); + await queryRunner.query(`DROP INDEX "IDX_da46a676d25c64bb06fc4536b3"`); + await queryRunner.query(`DROP INDEX "IDX_555c5952e67b2e93d4c7067f72"`); + await queryRunner.query(`DROP INDEX "IDX_3030e5dca58343e09ae1af0108"`); + await queryRunner.query(`DROP INDEX "IDX_ca809b0756488e63bc88918950"`); + await queryRunner.query(`DROP INDEX "IDX_8f686ac0104c90e95ef10f6c22"`); + await queryRunner.query(`DROP INDEX "IDX_d5203d63179a7baf703e29a628"`); + await queryRunner.query(`DROP INDEX "IDX_8cde33e0a580277a2c1ed36a6b"`); + await queryRunner.query(`DROP INDEX "IDX_92db6225d2efc127ded3bdb5f1"`); + await queryRunner.query( + `ALTER TABLE "organization_sprint_task_history" RENAME TO "temporary_organization_sprint_task_history"` + ); + await queryRunner.query( + `CREATE TABLE "organization_sprint_task_history" ("deletedAt" datetime, "id" varchar PRIMARY KEY NOT NULL, "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "isActive" boolean DEFAULT (1), "isArchived" boolean DEFAULT (0), "archivedAt" datetime, "tenantId" varchar, "organizationId" varchar, "reason" text, "taskId" varchar NOT NULL, "fromSprintId" varchar NOT NULL, "toSprintId" varchar NOT NULL, "movedById" varchar)` + ); + await queryRunner.query( + `INSERT INTO "organization_sprint_task_history"("deletedAt", "id", "createdAt", "updatedAt", "isActive", "isArchived", "archivedAt", "tenantId", "organizationId", "reason", "taskId", "fromSprintId", "toSprintId", "movedById") SELECT "deletedAt", "id", "createdAt", "updatedAt", "isActive", "isArchived", "archivedAt", "tenantId", "organizationId", "reason", "taskId", "fromSprintId", "toSprintId", "movedById" FROM "temporary_organization_sprint_task_history"` + ); + await queryRunner.query(`DROP TABLE "temporary_organization_sprint_task_history"`); + await queryRunner.query( + `CREATE INDEX "IDX_7723913546575c33ab09ecfd50" ON "organization_sprint_task_history" ("movedById") ` + ); + await queryRunner.query( + `CREATE INDEX "IDX_da46a676d25c64bb06fc4536b3" ON "organization_sprint_task_history" ("toSprintId") ` + ); + await queryRunner.query( + `CREATE INDEX "IDX_555c5952e67b2e93d4c7067f72" ON "organization_sprint_task_history" ("fromSprintId") ` + ); + await queryRunner.query( + `CREATE INDEX "IDX_3030e5dca58343e09ae1af0108" ON "organization_sprint_task_history" ("taskId") ` + ); + await queryRunner.query( + `CREATE INDEX "IDX_ca809b0756488e63bc88918950" ON "organization_sprint_task_history" ("reason") ` + ); + await queryRunner.query( + `CREATE INDEX "IDX_8f686ac0104c90e95ef10f6c22" ON "organization_sprint_task_history" ("organizationId") ` + ); + await queryRunner.query( + `CREATE INDEX "IDX_d5203d63179a7baf703e29a628" ON "organization_sprint_task_history" ("tenantId") ` + ); + await queryRunner.query( + `CREATE INDEX "IDX_8cde33e0a580277a2c1ed36a6b" ON "organization_sprint_task_history" ("isArchived") ` + ); + await queryRunner.query( + `CREATE INDEX "IDX_92db6225d2efc127ded3bdb5f1" ON "organization_sprint_task_history" ("isActive") ` + ); + await queryRunner.query(`DROP INDEX "IDX_e50cfbf82eec3f0b1d004a5c6e"`); + await queryRunner.query(`DROP INDEX "IDX_889c9fd5c577a89f5f30facde4"`); + await queryRunner.query(`DROP INDEX "IDX_434665081d927127495623ad27"`); + await queryRunner.query(`DROP INDEX "IDX_6a84b0cec9f10178a027f20098"`); + await queryRunner.query(`DROP INDEX "IDX_be3cb56b953b535835ad868391"`); + await queryRunner.query(`DROP INDEX "IDX_b0a8b958a5716e73467c1937ec"`); + await queryRunner.query(`DROP INDEX "IDX_2ceba05d5de64b36d361af6a34"`); + await queryRunner.query( + `ALTER TABLE "organization_sprint_task" RENAME TO "temporary_organization_sprint_task"` + ); + await queryRunner.query( + `CREATE TABLE "organization_sprint_task" ("deletedAt" datetime, "id" varchar PRIMARY KEY NOT NULL, "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "isActive" boolean DEFAULT (1), "isArchived" boolean DEFAULT (0), "archivedAt" datetime, "tenantId" varchar, "organizationId" varchar, "totalWorkedHours" integer, "organizationSprintId" varchar NOT NULL, "taskId" varchar NOT NULL)` + ); + await queryRunner.query( + `INSERT INTO "organization_sprint_task"("deletedAt", "id", "createdAt", "updatedAt", "isActive", "isArchived", "archivedAt", "tenantId", "organizationId", "totalWorkedHours", "organizationSprintId", "taskId") SELECT "deletedAt", "id", "createdAt", "updatedAt", "isActive", "isArchived", "archivedAt", "tenantId", "organizationId", "totalWorkedHours", "organizationSprintId", "taskId" FROM "temporary_organization_sprint_task"` + ); + await queryRunner.query(`DROP TABLE "temporary_organization_sprint_task"`); + await queryRunner.query( + `CREATE INDEX "IDX_e50cfbf82eec3f0b1d004a5c6e" ON "organization_sprint_task" ("taskId") ` + ); + await queryRunner.query( + `CREATE INDEX "IDX_889c9fd5c577a89f5f30facde4" ON "organization_sprint_task" ("organizationSprintId") ` + ); + await queryRunner.query( + `CREATE INDEX "IDX_434665081d927127495623ad27" ON "organization_sprint_task" ("totalWorkedHours") ` + ); + await queryRunner.query( + `CREATE INDEX "IDX_6a84b0cec9f10178a027f20098" ON "organization_sprint_task" ("organizationId") ` + ); + await queryRunner.query( + `CREATE INDEX "IDX_be3cb56b953b535835ad868391" ON "organization_sprint_task" ("tenantId") ` + ); + await queryRunner.query( + `CREATE INDEX "IDX_b0a8b958a5716e73467c1937ec" ON "organization_sprint_task" ("isArchived") ` + ); + await queryRunner.query( + `CREATE INDEX "IDX_2ceba05d5de64b36d361af6a34" ON "organization_sprint_task" ("isActive") ` + ); + await queryRunner.query(`DROP INDEX "IDX_f516713776cfc5662cdbf2f3c4"`); + await queryRunner.query(`DROP INDEX "IDX_e5ec88b5022e0d71ac88d876de"`); + await queryRunner.query(`DROP INDEX "IDX_68ea808c69795593450737c992"`); + await queryRunner.query(`DROP INDEX "IDX_fbcc76b45f43996c20936f229c"`); + await queryRunner.query(`DROP INDEX "IDX_953b6df10eaabf11f5762bbee0"`); + await queryRunner.query(`DROP INDEX "IDX_2e74f97c99716d6a0a892ec6de"`); + await queryRunner.query(`DROP INDEX "IDX_30d8b0ec5e73c133f3e3d9afef"`); + await queryRunner.query(`DROP INDEX "IDX_62b4c285d16b0759ae29791dd5"`); + await queryRunner.query(`DROP INDEX "IDX_fa0d179e320c8680cc32a41d70"`); + await queryRunner.query( + `ALTER TABLE "organization_sprint_employee" RENAME TO "temporary_organization_sprint_employee"` + ); + await queryRunner.query( + `CREATE TABLE "organization_sprint_employee" ("deletedAt" datetime, "id" varchar PRIMARY KEY NOT NULL, "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "isActive" boolean DEFAULT (1), "isArchived" boolean DEFAULT (0), "archivedAt" datetime, "tenantId" varchar, "organizationId" varchar, "isManager" boolean DEFAULT (0), "assignedAt" datetime, "organizationSprintId" varchar NOT NULL, "employeeId" varchar NOT NULL, "roleId" varchar)` + ); + await queryRunner.query( + `INSERT INTO "organization_sprint_employee"("deletedAt", "id", "createdAt", "updatedAt", "isActive", "isArchived", "archivedAt", "tenantId", "organizationId", "isManager", "assignedAt", "organizationSprintId", "employeeId", "roleId") SELECT "deletedAt", "id", "createdAt", "updatedAt", "isActive", "isArchived", "archivedAt", "tenantId", "organizationId", "isManager", "assignedAt", "organizationSprintId", "employeeId", "roleId" FROM "temporary_organization_sprint_employee"` + ); + await queryRunner.query(`DROP TABLE "temporary_organization_sprint_employee"`); + await queryRunner.query( + `CREATE INDEX "IDX_f516713776cfc5662cdbf2f3c4" ON "organization_sprint_employee" ("roleId") ` + ); + await queryRunner.query( + `CREATE INDEX "IDX_e5ec88b5022e0d71ac88d876de" ON "organization_sprint_employee" ("employeeId") ` + ); + await queryRunner.query( + `CREATE INDEX "IDX_68ea808c69795593450737c992" ON "organization_sprint_employee" ("organizationSprintId") ` + ); + await queryRunner.query( + `CREATE INDEX "IDX_fbcc76b45f43996c20936f229c" ON "organization_sprint_employee" ("assignedAt") ` + ); + await queryRunner.query( + `CREATE INDEX "IDX_953b6df10eaabf11f5762bbee0" ON "organization_sprint_employee" ("isManager") ` + ); + await queryRunner.query( + `CREATE INDEX "IDX_2e74f97c99716d6a0a892ec6de" ON "organization_sprint_employee" ("organizationId") ` + ); + await queryRunner.query( + `CREATE INDEX "IDX_30d8b0ec5e73c133f3e3d9afef" ON "organization_sprint_employee" ("tenantId") ` + ); + await queryRunner.query( + `CREATE INDEX "IDX_62b4c285d16b0759ae29791dd5" ON "organization_sprint_employee" ("isArchived") ` + ); + await queryRunner.query( + `CREATE INDEX "IDX_fa0d179e320c8680cc32a41d70" ON "organization_sprint_employee" ("isActive") ` + ); + await queryRunner.query(`DROP INDEX "IDX_1cbe898fb849e4cffbddb60a87"`); + await queryRunner.query(`DROP INDEX "IDX_8a1fe8afb3aa672bae5993fbe7"`); + await queryRunner.query(`DROP INDEX "IDX_f57ad03c4e471bd8530494ea63"`); + await queryRunner.query(`DROP INDEX "IDX_5596b4fa7fb2ceb0955580becd"`); + await queryRunner.query(`DROP INDEX "IDX_76e53f9609ca05477d50980743"`); + await queryRunner.query(`ALTER TABLE "organization_sprint" RENAME TO "temporary_organization_sprint"`); + await queryRunner.query( + `CREATE TABLE "organization_sprint" ("id" varchar PRIMARY KEY NOT NULL, "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "tenantId" varchar, "organizationId" varchar, "name" varchar NOT NULL, "projectId" varchar NOT NULL, "goal" varchar, "length" integer NOT NULL DEFAULT (7), "startDate" datetime, "endDate" datetime, "dayStart" integer, "isActive" boolean DEFAULT (1), "isArchived" boolean DEFAULT (0), "deletedAt" datetime, "archivedAt" datetime, CONSTRAINT "FK_f57ad03c4e471bd8530494ea63d" FOREIGN KEY ("tenantId") REFERENCES "tenant" ("id") ON DELETE CASCADE ON UPDATE NO ACTION, CONSTRAINT "FK_8a1fe8afb3aa672bae5993fbe7d" FOREIGN KEY ("organizationId") REFERENCES "organization" ("id") ON DELETE CASCADE ON UPDATE CASCADE, CONSTRAINT "FK_a140b7e30ff3455551a0fd599fb" FOREIGN KEY ("projectId") REFERENCES "organization_project" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)` + ); + await queryRunner.query( + `INSERT INTO "organization_sprint"("id", "createdAt", "updatedAt", "tenantId", "organizationId", "name", "projectId", "goal", "length", "startDate", "endDate", "dayStart", "isActive", "isArchived", "deletedAt", "archivedAt") SELECT "id", "createdAt", "updatedAt", "tenantId", "organizationId", "name", "projectId", "goal", "length", "startDate", "endDate", "dayStart", "isActive", "isArchived", "deletedAt", "archivedAt" FROM "temporary_organization_sprint"` + ); + await queryRunner.query(`DROP TABLE "temporary_organization_sprint"`); + await queryRunner.query( + `CREATE INDEX "IDX_8a1fe8afb3aa672bae5993fbe7" ON "organization_sprint" ("organizationId") ` + ); + await queryRunner.query(`CREATE INDEX "IDX_f57ad03c4e471bd8530494ea63" ON "organization_sprint" ("tenantId") `); + await queryRunner.query(`CREATE INDEX "IDX_5596b4fa7fb2ceb0955580becd" ON "organization_sprint" ("isActive") `); + await queryRunner.query( + `CREATE INDEX "IDX_76e53f9609ca05477d50980743" ON "organization_sprint" ("isArchived") ` + ); + await queryRunner.query(`DROP INDEX "IDX_7723913546575c33ab09ecfd50"`); + await queryRunner.query(`DROP INDEX "IDX_da46a676d25c64bb06fc4536b3"`); + await queryRunner.query(`DROP INDEX "IDX_555c5952e67b2e93d4c7067f72"`); + await queryRunner.query(`DROP INDEX "IDX_3030e5dca58343e09ae1af0108"`); + await queryRunner.query(`DROP INDEX "IDX_ca809b0756488e63bc88918950"`); + await queryRunner.query(`DROP INDEX "IDX_8f686ac0104c90e95ef10f6c22"`); + await queryRunner.query(`DROP INDEX "IDX_d5203d63179a7baf703e29a628"`); + await queryRunner.query(`DROP INDEX "IDX_8cde33e0a580277a2c1ed36a6b"`); + await queryRunner.query(`DROP INDEX "IDX_92db6225d2efc127ded3bdb5f1"`); + await queryRunner.query(`DROP TABLE "organization_sprint_task_history"`); + await queryRunner.query(`DROP INDEX "IDX_e50cfbf82eec3f0b1d004a5c6e"`); + await queryRunner.query(`DROP INDEX "IDX_889c9fd5c577a89f5f30facde4"`); + await queryRunner.query(`DROP INDEX "IDX_434665081d927127495623ad27"`); + await queryRunner.query(`DROP INDEX "IDX_6a84b0cec9f10178a027f20098"`); + await queryRunner.query(`DROP INDEX "IDX_be3cb56b953b535835ad868391"`); + await queryRunner.query(`DROP INDEX "IDX_b0a8b958a5716e73467c1937ec"`); + await queryRunner.query(`DROP INDEX "IDX_2ceba05d5de64b36d361af6a34"`); + await queryRunner.query(`DROP TABLE "organization_sprint_task"`); + await queryRunner.query(`DROP INDEX "IDX_f516713776cfc5662cdbf2f3c4"`); + await queryRunner.query(`DROP INDEX "IDX_e5ec88b5022e0d71ac88d876de"`); + await queryRunner.query(`DROP INDEX "IDX_68ea808c69795593450737c992"`); + await queryRunner.query(`DROP INDEX "IDX_fbcc76b45f43996c20936f229c"`); + await queryRunner.query(`DROP INDEX "IDX_953b6df10eaabf11f5762bbee0"`); + await queryRunner.query(`DROP INDEX "IDX_2e74f97c99716d6a0a892ec6de"`); + await queryRunner.query(`DROP INDEX "IDX_30d8b0ec5e73c133f3e3d9afef"`); + await queryRunner.query(`DROP INDEX "IDX_62b4c285d16b0759ae29791dd5"`); + await queryRunner.query(`DROP INDEX "IDX_fa0d179e320c8680cc32a41d70"`); + await queryRunner.query(`DROP TABLE "organization_sprint_employee"`); + } + + /** + * MySQL Up Migration + * + * @param queryRunner + */ + public async mysqlUpQueryRunner(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `CREATE TABLE \`organization_sprint_employee\` (\`deletedAt\` datetime(6) NULL, \`id\` varchar(36) NOT NULL, \`createdAt\` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), \`updatedAt\` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6), \`isActive\` tinyint NULL DEFAULT 1, \`isArchived\` tinyint NULL DEFAULT 0, \`archivedAt\` datetime NULL, \`tenantId\` varchar(255) NULL, \`organizationId\` varchar(255) NULL, \`isManager\` tinyint NULL DEFAULT 0, \`assignedAt\` datetime NULL, \`organizationSprintId\` varchar(255) NOT NULL, \`employeeId\` varchar(255) NOT NULL, \`roleId\` varchar(255) NULL, INDEX \`IDX_fa0d179e320c8680cc32a41d70\` (\`isActive\`), INDEX \`IDX_62b4c285d16b0759ae29791dd5\` (\`isArchived\`), INDEX \`IDX_30d8b0ec5e73c133f3e3d9afef\` (\`tenantId\`), INDEX \`IDX_2e74f97c99716d6a0a892ec6de\` (\`organizationId\`), INDEX \`IDX_953b6df10eaabf11f5762bbee0\` (\`isManager\`), INDEX \`IDX_fbcc76b45f43996c20936f229c\` (\`assignedAt\`), INDEX \`IDX_68ea808c69795593450737c992\` (\`organizationSprintId\`), INDEX \`IDX_e5ec88b5022e0d71ac88d876de\` (\`employeeId\`), INDEX \`IDX_f516713776cfc5662cdbf2f3c4\` (\`roleId\`), PRIMARY KEY (\`id\`)) ENGINE=InnoDB` + ); + await queryRunner.query( + `CREATE TABLE \`organization_sprint_task\` (\`deletedAt\` datetime(6) NULL, \`id\` varchar(36) NOT NULL, \`createdAt\` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), \`updatedAt\` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6), \`isActive\` tinyint NULL DEFAULT 1, \`isArchived\` tinyint NULL DEFAULT 0, \`archivedAt\` datetime NULL, \`tenantId\` varchar(255) NULL, \`organizationId\` varchar(255) NULL, \`totalWorkedHours\` int NULL, \`organizationSprintId\` varchar(255) NOT NULL, \`taskId\` varchar(255) NOT NULL, INDEX \`IDX_2ceba05d5de64b36d361af6a34\` (\`isActive\`), INDEX \`IDX_b0a8b958a5716e73467c1937ec\` (\`isArchived\`), INDEX \`IDX_be3cb56b953b535835ad868391\` (\`tenantId\`), INDEX \`IDX_6a84b0cec9f10178a027f20098\` (\`organizationId\`), INDEX \`IDX_434665081d927127495623ad27\` (\`totalWorkedHours\`), INDEX \`IDX_889c9fd5c577a89f5f30facde4\` (\`organizationSprintId\`), INDEX \`IDX_e50cfbf82eec3f0b1d004a5c6e\` (\`taskId\`), PRIMARY KEY (\`id\`)) ENGINE=InnoDB` + ); + await queryRunner.query( + `CREATE TABLE \`organization_sprint_task_history\` (\`deletedAt\` datetime(6) NULL, \`id\` varchar(36) NOT NULL, \`createdAt\` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), \`updatedAt\` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6), \`isActive\` tinyint NULL DEFAULT 1, \`isArchived\` tinyint NULL DEFAULT 0, \`archivedAt\` datetime NULL, \`tenantId\` varchar(255) NULL, \`organizationId\` varchar(255) NULL, \`reason\` text NULL, \`taskId\` varchar(255) NOT NULL, \`fromSprintId\` varchar(255) NOT NULL, \`toSprintId\` varchar(255) NOT NULL, \`movedById\` varchar(255) NULL, INDEX \`IDX_92db6225d2efc127ded3bdb5f1\` (\`isActive\`), INDEX \`IDX_8cde33e0a580277a2c1ed36a6b\` (\`isArchived\`), INDEX \`IDX_d5203d63179a7baf703e29a628\` (\`tenantId\`), INDEX \`IDX_8f686ac0104c90e95ef10f6c22\` (\`organizationId\`), INDEX \`IDX_ca809b0756488e63bc88918950\` (\`reason\`(255)), INDEX \`IDX_3030e5dca58343e09ae1af0108\` (\`taskId\`), INDEX \`IDX_555c5952e67b2e93d4c7067f72\` (\`fromSprintId\`), INDEX \`IDX_da46a676d25c64bb06fc4536b3\` (\`toSprintId\`), INDEX \`IDX_7723913546575c33ab09ecfd50\` (\`movedById\`), PRIMARY KEY (\`id\`)) ENGINE=InnoDB` + ); + await queryRunner.query(`ALTER TABLE \`organization_sprint\` ADD \`status\` varchar(255) NULL`); + await queryRunner.query(`ALTER TABLE \`organization_sprint\` ADD \`sprintProgress\` json NULL`); + await queryRunner.query( + `CREATE INDEX \`IDX_1cbe898fb849e4cffbddb60a87\` ON \`organization_sprint\` (\`status\`)` + ); + await queryRunner.query( + `ALTER TABLE \`organization_sprint_employee\` ADD CONSTRAINT \`FK_30d8b0ec5e73c133f3e3d9afef1\` FOREIGN KEY (\`tenantId\`) REFERENCES \`tenant\`(\`id\`) ON DELETE CASCADE ON UPDATE NO ACTION` + ); + await queryRunner.query( + `ALTER TABLE \`organization_sprint_employee\` ADD CONSTRAINT \`FK_2e74f97c99716d6a0a892ec6de7\` FOREIGN KEY (\`organizationId\`) REFERENCES \`organization\`(\`id\`) ON DELETE CASCADE ON UPDATE CASCADE` + ); + await queryRunner.query( + `ALTER TABLE \`organization_sprint_employee\` ADD CONSTRAINT \`FK_68ea808c69795593450737c9923\` FOREIGN KEY (\`organizationSprintId\`) REFERENCES \`organization_sprint\`(\`id\`) ON DELETE CASCADE ON UPDATE NO ACTION` + ); + await queryRunner.query( + `ALTER TABLE \`organization_sprint_employee\` ADD CONSTRAINT \`FK_e5ec88b5022e0d71ac88d876de2\` FOREIGN KEY (\`employeeId\`) REFERENCES \`employee\`(\`id\`) ON DELETE CASCADE ON UPDATE NO ACTION` + ); + await queryRunner.query( + `ALTER TABLE \`organization_sprint_employee\` ADD CONSTRAINT \`FK_f516713776cfc5662cdbf2f3c4b\` FOREIGN KEY (\`roleId\`) REFERENCES \`role\`(\`id\`) ON DELETE CASCADE ON UPDATE NO ACTION` + ); + await queryRunner.query( + `ALTER TABLE \`organization_sprint_task\` ADD CONSTRAINT \`FK_be3cb56b953b535835ad8683916\` FOREIGN KEY (\`tenantId\`) REFERENCES \`tenant\`(\`id\`) ON DELETE CASCADE ON UPDATE NO ACTION` + ); + await queryRunner.query( + `ALTER TABLE \`organization_sprint_task\` ADD CONSTRAINT \`FK_6a84b0cec9f10178a027f200981\` FOREIGN KEY (\`organizationId\`) REFERENCES \`organization\`(\`id\`) ON DELETE CASCADE ON UPDATE CASCADE` + ); + await queryRunner.query( + `ALTER TABLE \`organization_sprint_task\` ADD CONSTRAINT \`FK_889c9fd5c577a89f5f30facde42\` FOREIGN KEY (\`organizationSprintId\`) REFERENCES \`organization_sprint\`(\`id\`) ON DELETE CASCADE ON UPDATE NO ACTION` + ); + await queryRunner.query( + `ALTER TABLE \`organization_sprint_task\` ADD CONSTRAINT \`FK_e50cfbf82eec3f0b1d004a5c6e8\` FOREIGN KEY (\`taskId\`) REFERENCES \`task\`(\`id\`) ON DELETE CASCADE ON UPDATE NO ACTION` + ); + await queryRunner.query( + `ALTER TABLE \`organization_sprint_task_history\` ADD CONSTRAINT \`FK_d5203d63179a7baf703e29a628c\` FOREIGN KEY (\`tenantId\`) REFERENCES \`tenant\`(\`id\`) ON DELETE CASCADE ON UPDATE NO ACTION` + ); + await queryRunner.query( + `ALTER TABLE \`organization_sprint_task_history\` ADD CONSTRAINT \`FK_8f686ac0104c90e95ef10f6c229\` FOREIGN KEY (\`organizationId\`) REFERENCES \`organization\`(\`id\`) ON DELETE CASCADE ON UPDATE CASCADE` + ); + await queryRunner.query( + `ALTER TABLE \`organization_sprint_task_history\` ADD CONSTRAINT \`FK_3030e5dca58343e09ae1af01082\` FOREIGN KEY (\`taskId\`) REFERENCES \`task\`(\`id\`) ON DELETE CASCADE ON UPDATE NO ACTION` + ); + await queryRunner.query( + `ALTER TABLE \`organization_sprint_task_history\` ADD CONSTRAINT \`FK_555c5952e67b2e93d4c7067f72d\` FOREIGN KEY (\`fromSprintId\`) REFERENCES \`organization_sprint\`(\`id\`) ON DELETE CASCADE ON UPDATE NO ACTION` + ); + await queryRunner.query( + `ALTER TABLE \`organization_sprint_task_history\` ADD CONSTRAINT \`FK_da46a676d25c64bb06fc4536b34\` FOREIGN KEY (\`toSprintId\`) REFERENCES \`organization_sprint\`(\`id\`) ON DELETE CASCADE ON UPDATE NO ACTION` + ); + await queryRunner.query( + `ALTER TABLE \`organization_sprint_task_history\` ADD CONSTRAINT \`FK_7723913546575c33ab09ecfd508\` FOREIGN KEY (\`movedById\`) REFERENCES \`user\`(\`id\`) ON DELETE CASCADE ON UPDATE NO ACTION` + ); + } + + /** + * MySQL Down Migration + * + * @param queryRunner + */ + public async mysqlDownQueryRunner(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE \`organization_sprint_task_history\` DROP FOREIGN KEY \`FK_7723913546575c33ab09ecfd508\`` + ); + await queryRunner.query( + `ALTER TABLE \`organization_sprint_task_history\` DROP FOREIGN KEY \`FK_da46a676d25c64bb06fc4536b34\`` + ); + await queryRunner.query( + `ALTER TABLE \`organization_sprint_task_history\` DROP FOREIGN KEY \`FK_555c5952e67b2e93d4c7067f72d\`` + ); + await queryRunner.query( + `ALTER TABLE \`organization_sprint_task_history\` DROP FOREIGN KEY \`FK_3030e5dca58343e09ae1af01082\`` + ); + await queryRunner.query( + `ALTER TABLE \`organization_sprint_task_history\` DROP FOREIGN KEY \`FK_8f686ac0104c90e95ef10f6c229\`` + ); + await queryRunner.query( + `ALTER TABLE \`organization_sprint_task_history\` DROP FOREIGN KEY \`FK_d5203d63179a7baf703e29a628c\`` + ); + await queryRunner.query( + `ALTER TABLE \`organization_sprint_task\` DROP FOREIGN KEY \`FK_e50cfbf82eec3f0b1d004a5c6e8\`` + ); + await queryRunner.query( + `ALTER TABLE \`organization_sprint_task\` DROP FOREIGN KEY \`FK_889c9fd5c577a89f5f30facde42\`` + ); + await queryRunner.query( + `ALTER TABLE \`organization_sprint_task\` DROP FOREIGN KEY \`FK_6a84b0cec9f10178a027f200981\`` + ); + await queryRunner.query( + `ALTER TABLE \`organization_sprint_task\` DROP FOREIGN KEY \`FK_be3cb56b953b535835ad8683916\`` + ); + await queryRunner.query( + `ALTER TABLE \`organization_sprint_employee\` DROP FOREIGN KEY \`FK_f516713776cfc5662cdbf2f3c4b\`` + ); + await queryRunner.query( + `ALTER TABLE \`organization_sprint_employee\` DROP FOREIGN KEY \`FK_e5ec88b5022e0d71ac88d876de2\`` + ); + await queryRunner.query( + `ALTER TABLE \`organization_sprint_employee\` DROP FOREIGN KEY \`FK_68ea808c69795593450737c9923\`` + ); + await queryRunner.query( + `ALTER TABLE \`organization_sprint_employee\` DROP FOREIGN KEY \`FK_2e74f97c99716d6a0a892ec6de7\`` + ); + await queryRunner.query( + `ALTER TABLE \`organization_sprint_employee\` DROP FOREIGN KEY \`FK_30d8b0ec5e73c133f3e3d9afef1\`` + ); + await queryRunner.query(`DROP INDEX \`IDX_1cbe898fb849e4cffbddb60a87\` ON \`organization_sprint\``); + await queryRunner.query(`ALTER TABLE \`organization_sprint\` DROP COLUMN \`sprintProgress\``); + await queryRunner.query(`ALTER TABLE \`organization_sprint\` DROP COLUMN \`status\``); + await queryRunner.query( + `DROP INDEX \`IDX_7723913546575c33ab09ecfd50\` ON \`organization_sprint_task_history\`` + ); + await queryRunner.query( + `DROP INDEX \`IDX_da46a676d25c64bb06fc4536b3\` ON \`organization_sprint_task_history\`` + ); + await queryRunner.query( + `DROP INDEX \`IDX_555c5952e67b2e93d4c7067f72\` ON \`organization_sprint_task_history\`` + ); + await queryRunner.query( + `DROP INDEX \`IDX_3030e5dca58343e09ae1af0108\` ON \`organization_sprint_task_history\`` + ); + await queryRunner.query( + `DROP INDEX \`IDX_ca809b0756488e63bc88918950\` ON \`organization_sprint_task_history\`` + ); + await queryRunner.query( + `DROP INDEX \`IDX_8f686ac0104c90e95ef10f6c22\` ON \`organization_sprint_task_history\`` + ); + await queryRunner.query( + `DROP INDEX \`IDX_d5203d63179a7baf703e29a628\` ON \`organization_sprint_task_history\`` + ); + await queryRunner.query( + `DROP INDEX \`IDX_8cde33e0a580277a2c1ed36a6b\` ON \`organization_sprint_task_history\`` + ); + await queryRunner.query( + `DROP INDEX \`IDX_92db6225d2efc127ded3bdb5f1\` ON \`organization_sprint_task_history\`` + ); + await queryRunner.query(`DROP TABLE \`organization_sprint_task_history\``); + await queryRunner.query(`DROP INDEX \`IDX_e50cfbf82eec3f0b1d004a5c6e\` ON \`organization_sprint_task\``); + await queryRunner.query(`DROP INDEX \`IDX_889c9fd5c577a89f5f30facde4\` ON \`organization_sprint_task\``); + await queryRunner.query(`DROP INDEX \`IDX_434665081d927127495623ad27\` ON \`organization_sprint_task\``); + await queryRunner.query(`DROP INDEX \`IDX_6a84b0cec9f10178a027f20098\` ON \`organization_sprint_task\``); + await queryRunner.query(`DROP INDEX \`IDX_be3cb56b953b535835ad868391\` ON \`organization_sprint_task\``); + await queryRunner.query(`DROP INDEX \`IDX_b0a8b958a5716e73467c1937ec\` ON \`organization_sprint_task\``); + await queryRunner.query(`DROP INDEX \`IDX_2ceba05d5de64b36d361af6a34\` ON \`organization_sprint_task\``); + await queryRunner.query(`DROP TABLE \`organization_sprint_task\``); + await queryRunner.query(`DROP INDEX \`IDX_f516713776cfc5662cdbf2f3c4\` ON \`organization_sprint_employee\``); + await queryRunner.query(`DROP INDEX \`IDX_e5ec88b5022e0d71ac88d876de\` ON \`organization_sprint_employee\``); + await queryRunner.query(`DROP INDEX \`IDX_68ea808c69795593450737c992\` ON \`organization_sprint_employee\``); + await queryRunner.query(`DROP INDEX \`IDX_fbcc76b45f43996c20936f229c\` ON \`organization_sprint_employee\``); + await queryRunner.query(`DROP INDEX \`IDX_953b6df10eaabf11f5762bbee0\` ON \`organization_sprint_employee\``); + await queryRunner.query(`DROP INDEX \`IDX_2e74f97c99716d6a0a892ec6de\` ON \`organization_sprint_employee\``); + await queryRunner.query(`DROP INDEX \`IDX_30d8b0ec5e73c133f3e3d9afef\` ON \`organization_sprint_employee\``); + await queryRunner.query(`DROP INDEX \`IDX_62b4c285d16b0759ae29791dd5\` ON \`organization_sprint_employee\``); + await queryRunner.query(`DROP INDEX \`IDX_fa0d179e320c8680cc32a41d70\` ON \`organization_sprint_employee\``); + await queryRunner.query(`DROP TABLE \`organization_sprint_employee\``); + } +} diff --git a/packages/core/src/database/migrations/1728461410740-CreateTableIssueView.ts b/packages/core/src/database/migrations/1728461410740-CreateTableIssueView.ts new file mode 100644 index 00000000000..917c0eaebf0 --- /dev/null +++ b/packages/core/src/database/migrations/1728461410740-CreateTableIssueView.ts @@ -0,0 +1,275 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; +import { yellow } from 'chalk'; +import { DatabaseTypeEnum } from '@gauzy/config'; + +export class CreateTableIssueView1728461410740 implements MigrationInterface { + name = 'CreateTableIssueView1728461410740'; + + /** + * Up Migration + * + * @param queryRunner + */ + public async up(queryRunner: QueryRunner): Promise { + console.log(yellow(this.name + ' start running!')); + + switch (queryRunner.connection.options.type) { + case DatabaseTypeEnum.sqlite: + case DatabaseTypeEnum.betterSqlite3: + await this.sqliteUpQueryRunner(queryRunner); + break; + case DatabaseTypeEnum.postgres: + await this.postgresUpQueryRunner(queryRunner); + break; + case DatabaseTypeEnum.mysql: + await this.mysqlUpQueryRunner(queryRunner); + break; + default: + throw Error(`Unsupported database: ${queryRunner.connection.options.type}`); + } + } + + /** + * Down Migration + * + * @param queryRunner + */ + public async down(queryRunner: QueryRunner): Promise { + switch (queryRunner.connection.options.type) { + case DatabaseTypeEnum.sqlite: + case DatabaseTypeEnum.betterSqlite3: + await this.sqliteDownQueryRunner(queryRunner); + break; + case DatabaseTypeEnum.postgres: + await this.postgresDownQueryRunner(queryRunner); + break; + case DatabaseTypeEnum.mysql: + await this.mysqlDownQueryRunner(queryRunner); + break; + default: + throw Error(`Unsupported database: ${queryRunner.connection.options.type}`); + } + } + + /** + * PostgresDB Up Migration + * + * @param queryRunner + */ + public async postgresUpQueryRunner(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `CREATE TABLE "task_view" ("deletedAt" TIMESTAMP, "id" uuid NOT NULL DEFAULT gen_random_uuid(), "createdAt" TIMESTAMP NOT NULL DEFAULT now(), "updatedAt" TIMESTAMP NOT NULL DEFAULT now(), "isActive" boolean DEFAULT true, "isArchived" boolean DEFAULT false, "archivedAt" TIMESTAMP, "tenantId" uuid, "organizationId" uuid, "name" character varying NOT NULL, "description" text, "visibilityLevel" integer, "queryParams" jsonb, "filterOptions" jsonb, "displayOptions" jsonb, "properties" jsonb, "isLocked" boolean NOT NULL DEFAULT false, "projectId" uuid, "organizationTeamId" uuid, "projectModuleId" uuid, "organizationSprintId" uuid, CONSTRAINT "PK_f4c3a51cd56250a117c9bbb3af6" PRIMARY KEY ("id"))` + ); + await queryRunner.query(`CREATE INDEX "IDX_38bcdf0455ac5ab5a925a015ab" ON "task_view" ("isActive") `); + await queryRunner.query(`CREATE INDEX "IDX_7c4f8a4d5b859c23c42ab5f984" ON "task_view" ("isArchived") `); + await queryRunner.query(`CREATE INDEX "IDX_ee94e92fccbbf8898221cb4eb5" ON "task_view" ("tenantId") `); + await queryRunner.query(`CREATE INDEX "IDX_c1c6e1c8d7c7971e234a768419" ON "task_view" ("organizationId") `); + await queryRunner.query(`CREATE INDEX "IDX_cf779d00641c3bd276a5a7e4df" ON "task_view" ("name") `); + await queryRunner.query(`CREATE INDEX "IDX_d4c182a7adfffa1a57315e8bfc" ON "task_view" ("visibilityLevel") `); + await queryRunner.query(`CREATE INDEX "IDX_1e5e43bbd58c370c538dac0b17" ON "task_view" ("projectId") `); + await queryRunner.query(`CREATE INDEX "IDX_3c1eb880f298e646d43736e911" ON "task_view" ("organizationTeamId") `); + await queryRunner.query(`CREATE INDEX "IDX_e58e58a3fd113bf4b336c90997" ON "task_view" ("projectModuleId") `); + await queryRunner.query( + `CREATE INDEX "IDX_4814ca7712537f79bed938d9a1" ON "task_view" ("organizationSprintId") ` + ); + await queryRunner.query( + `ALTER TABLE "task_view" ADD CONSTRAINT "FK_ee94e92fccbbf8898221cb4eb53" FOREIGN KEY ("tenantId") REFERENCES "tenant"("id") ON DELETE CASCADE ON UPDATE NO ACTION` + ); + await queryRunner.query( + `ALTER TABLE "task_view" ADD CONSTRAINT "FK_c1c6e1c8d7c7971e234a768419c" FOREIGN KEY ("organizationId") REFERENCES "organization"("id") ON DELETE CASCADE ON UPDATE CASCADE` + ); + await queryRunner.query( + `ALTER TABLE "task_view" ADD CONSTRAINT "FK_1e5e43bbd58c370c538dac0b17c" FOREIGN KEY ("projectId") REFERENCES "organization_project"("id") ON DELETE CASCADE ON UPDATE NO ACTION` + ); + await queryRunner.query( + `ALTER TABLE "task_view" ADD CONSTRAINT "FK_3c1eb880f298e646d43736e911a" FOREIGN KEY ("organizationTeamId") REFERENCES "organization_team"("id") ON DELETE CASCADE ON UPDATE NO ACTION` + ); + await queryRunner.query( + `ALTER TABLE "task_view" ADD CONSTRAINT "FK_e58e58a3fd113bf4b336c90997b" FOREIGN KEY ("projectModuleId") REFERENCES "organization_project_module"("id") ON DELETE CASCADE ON UPDATE NO ACTION` + ); + await queryRunner.query( + `ALTER TABLE "task_view" ADD CONSTRAINT "FK_4814ca7712537f79bed938d9a15" FOREIGN KEY ("organizationSprintId") REFERENCES "organization_sprint"("id") ON DELETE CASCADE ON UPDATE NO ACTION` + ); + } + + /** + * PostgresDB Down Migration + * + * @param queryRunner + */ + public async postgresDownQueryRunner(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "task_view" DROP CONSTRAINT "FK_4814ca7712537f79bed938d9a15"`); + await queryRunner.query(`ALTER TABLE "task_view" DROP CONSTRAINT "FK_e58e58a3fd113bf4b336c90997b"`); + await queryRunner.query(`ALTER TABLE "task_view" DROP CONSTRAINT "FK_3c1eb880f298e646d43736e911a"`); + await queryRunner.query(`ALTER TABLE "task_view" DROP CONSTRAINT "FK_1e5e43bbd58c370c538dac0b17c"`); + await queryRunner.query(`ALTER TABLE "task_view" DROP CONSTRAINT "FK_c1c6e1c8d7c7971e234a768419c"`); + await queryRunner.query(`ALTER TABLE "task_view" DROP CONSTRAINT "FK_ee94e92fccbbf8898221cb4eb53"`); + await queryRunner.query(`DROP INDEX "public"."IDX_4814ca7712537f79bed938d9a1"`); + await queryRunner.query(`DROP INDEX "public"."IDX_e58e58a3fd113bf4b336c90997"`); + await queryRunner.query(`DROP INDEX "public"."IDX_3c1eb880f298e646d43736e911"`); + await queryRunner.query(`DROP INDEX "public"."IDX_1e5e43bbd58c370c538dac0b17"`); + await queryRunner.query(`DROP INDEX "public"."IDX_d4c182a7adfffa1a57315e8bfc"`); + await queryRunner.query(`DROP INDEX "public"."IDX_cf779d00641c3bd276a5a7e4df"`); + await queryRunner.query(`DROP INDEX "public"."IDX_c1c6e1c8d7c7971e234a768419"`); + await queryRunner.query(`DROP INDEX "public"."IDX_ee94e92fccbbf8898221cb4eb5"`); + await queryRunner.query(`DROP INDEX "public"."IDX_7c4f8a4d5b859c23c42ab5f984"`); + await queryRunner.query(`DROP INDEX "public"."IDX_38bcdf0455ac5ab5a925a015ab"`); + await queryRunner.query(`DROP TABLE "task_view"`); + } + + /** + * SqliteDB and BetterSQlite3DB Up Migration + * + * @param queryRunner + */ + public async sqliteUpQueryRunner(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `CREATE TABLE "task_view" ("deletedAt" datetime, "id" varchar PRIMARY KEY NOT NULL, "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "isActive" boolean DEFAULT (1), "isArchived" boolean DEFAULT (0), "archivedAt" datetime, "tenantId" varchar, "organizationId" varchar, "name" varchar NOT NULL, "description" text, "visibilityLevel" integer, "queryParams" text, "filterOptions" text, "displayOptions" text, "properties" text, "isLocked" boolean NOT NULL DEFAULT (0), "projectId" varchar, "organizationTeamId" varchar, "projectModuleId" varchar, "organizationSprintId" varchar)` + ); + await queryRunner.query(`CREATE INDEX "IDX_38bcdf0455ac5ab5a925a015ab" ON "task_view" ("isActive") `); + await queryRunner.query(`CREATE INDEX "IDX_7c4f8a4d5b859c23c42ab5f984" ON "task_view" ("isArchived") `); + await queryRunner.query(`CREATE INDEX "IDX_ee94e92fccbbf8898221cb4eb5" ON "task_view" ("tenantId") `); + await queryRunner.query(`CREATE INDEX "IDX_c1c6e1c8d7c7971e234a768419" ON "task_view" ("organizationId") `); + await queryRunner.query(`CREATE INDEX "IDX_cf779d00641c3bd276a5a7e4df" ON "task_view" ("name") `); + await queryRunner.query(`CREATE INDEX "IDX_d4c182a7adfffa1a57315e8bfc" ON "task_view" ("visibilityLevel") `); + await queryRunner.query(`CREATE INDEX "IDX_1e5e43bbd58c370c538dac0b17" ON "task_view" ("projectId") `); + await queryRunner.query(`CREATE INDEX "IDX_3c1eb880f298e646d43736e911" ON "task_view" ("organizationTeamId") `); + await queryRunner.query(`CREATE INDEX "IDX_e58e58a3fd113bf4b336c90997" ON "task_view" ("projectModuleId") `); + await queryRunner.query( + `CREATE INDEX "IDX_4814ca7712537f79bed938d9a1" ON "task_view" ("organizationSprintId") ` + ); + await queryRunner.query(`DROP INDEX "IDX_38bcdf0455ac5ab5a925a015ab"`); + await queryRunner.query(`DROP INDEX "IDX_7c4f8a4d5b859c23c42ab5f984"`); + await queryRunner.query(`DROP INDEX "IDX_ee94e92fccbbf8898221cb4eb5"`); + await queryRunner.query(`DROP INDEX "IDX_c1c6e1c8d7c7971e234a768419"`); + await queryRunner.query(`DROP INDEX "IDX_cf779d00641c3bd276a5a7e4df"`); + await queryRunner.query(`DROP INDEX "IDX_d4c182a7adfffa1a57315e8bfc"`); + await queryRunner.query(`DROP INDEX "IDX_1e5e43bbd58c370c538dac0b17"`); + await queryRunner.query(`DROP INDEX "IDX_3c1eb880f298e646d43736e911"`); + await queryRunner.query(`DROP INDEX "IDX_e58e58a3fd113bf4b336c90997"`); + await queryRunner.query(`DROP INDEX "IDX_4814ca7712537f79bed938d9a1"`); + await queryRunner.query( + `CREATE TABLE "temporary_task_view" ("deletedAt" datetime, "id" varchar PRIMARY KEY NOT NULL, "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "isActive" boolean DEFAULT (1), "isArchived" boolean DEFAULT (0), "archivedAt" datetime, "tenantId" varchar, "organizationId" varchar, "name" varchar NOT NULL, "description" text, "visibilityLevel" integer, "queryParams" text, "filterOptions" text, "displayOptions" text, "properties" text, "isLocked" boolean NOT NULL DEFAULT (0), "projectId" varchar, "organizationTeamId" varchar, "projectModuleId" varchar, "organizationSprintId" varchar, CONSTRAINT "FK_ee94e92fccbbf8898221cb4eb53" FOREIGN KEY ("tenantId") REFERENCES "tenant" ("id") ON DELETE CASCADE ON UPDATE NO ACTION, CONSTRAINT "FK_c1c6e1c8d7c7971e234a768419c" FOREIGN KEY ("organizationId") REFERENCES "organization" ("id") ON DELETE CASCADE ON UPDATE CASCADE, CONSTRAINT "FK_1e5e43bbd58c370c538dac0b17c" FOREIGN KEY ("projectId") REFERENCES "organization_project" ("id") ON DELETE CASCADE ON UPDATE NO ACTION, CONSTRAINT "FK_3c1eb880f298e646d43736e911a" FOREIGN KEY ("organizationTeamId") REFERENCES "organization_team" ("id") ON DELETE CASCADE ON UPDATE NO ACTION, CONSTRAINT "FK_e58e58a3fd113bf4b336c90997b" FOREIGN KEY ("projectModuleId") REFERENCES "organization_project_module" ("id") ON DELETE CASCADE ON UPDATE NO ACTION, CONSTRAINT "FK_4814ca7712537f79bed938d9a15" FOREIGN KEY ("organizationSprintId") REFERENCES "organization_sprint" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)` + ); + await queryRunner.query( + `INSERT INTO "temporary_task_view"("deletedAt", "id", "createdAt", "updatedAt", "isActive", "isArchived", "archivedAt", "tenantId", "organizationId", "name", "description", "visibilityLevel", "queryParams", "filterOptions", "displayOptions", "properties", "isLocked", "projectId", "organizationTeamId", "projectModuleId", "organizationSprintId") SELECT "deletedAt", "id", "createdAt", "updatedAt", "isActive", "isArchived", "archivedAt", "tenantId", "organizationId", "name", "description", "visibilityLevel", "queryParams", "filterOptions", "displayOptions", "properties", "isLocked", "projectId", "organizationTeamId", "projectModuleId", "organizationSprintId" FROM "task_view"` + ); + await queryRunner.query(`DROP TABLE "task_view"`); + await queryRunner.query(`ALTER TABLE "temporary_task_view" RENAME TO "task_view"`); + await queryRunner.query(`CREATE INDEX "IDX_38bcdf0455ac5ab5a925a015ab" ON "task_view" ("isActive") `); + await queryRunner.query(`CREATE INDEX "IDX_7c4f8a4d5b859c23c42ab5f984" ON "task_view" ("isArchived") `); + await queryRunner.query(`CREATE INDEX "IDX_ee94e92fccbbf8898221cb4eb5" ON "task_view" ("tenantId") `); + await queryRunner.query(`CREATE INDEX "IDX_c1c6e1c8d7c7971e234a768419" ON "task_view" ("organizationId") `); + await queryRunner.query(`CREATE INDEX "IDX_cf779d00641c3bd276a5a7e4df" ON "task_view" ("name") `); + await queryRunner.query(`CREATE INDEX "IDX_d4c182a7adfffa1a57315e8bfc" ON "task_view" ("visibilityLevel") `); + await queryRunner.query(`CREATE INDEX "IDX_1e5e43bbd58c370c538dac0b17" ON "task_view" ("projectId") `); + await queryRunner.query(`CREATE INDEX "IDX_3c1eb880f298e646d43736e911" ON "task_view" ("organizationTeamId") `); + await queryRunner.query(`CREATE INDEX "IDX_e58e58a3fd113bf4b336c90997" ON "task_view" ("projectModuleId") `); + await queryRunner.query( + `CREATE INDEX "IDX_4814ca7712537f79bed938d9a1" ON "task_view" ("organizationSprintId") ` + ); + } + + /** + * SqliteDB and BetterSQlite3DB Down Migration + * + * @param queryRunner + */ + public async sqliteDownQueryRunner(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP INDEX "IDX_4814ca7712537f79bed938d9a1"`); + await queryRunner.query(`DROP INDEX "IDX_e58e58a3fd113bf4b336c90997"`); + await queryRunner.query(`DROP INDEX "IDX_3c1eb880f298e646d43736e911"`); + await queryRunner.query(`DROP INDEX "IDX_1e5e43bbd58c370c538dac0b17"`); + await queryRunner.query(`DROP INDEX "IDX_d4c182a7adfffa1a57315e8bfc"`); + await queryRunner.query(`DROP INDEX "IDX_cf779d00641c3bd276a5a7e4df"`); + await queryRunner.query(`DROP INDEX "IDX_c1c6e1c8d7c7971e234a768419"`); + await queryRunner.query(`DROP INDEX "IDX_ee94e92fccbbf8898221cb4eb5"`); + await queryRunner.query(`DROP INDEX "IDX_7c4f8a4d5b859c23c42ab5f984"`); + await queryRunner.query(`DROP INDEX "IDX_38bcdf0455ac5ab5a925a015ab"`); + await queryRunner.query(`ALTER TABLE "task_view" RENAME TO "temporary_task_view"`); + await queryRunner.query( + `CREATE TABLE "task_view" ("deletedAt" datetime, "id" varchar PRIMARY KEY NOT NULL, "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "isActive" boolean DEFAULT (1), "isArchived" boolean DEFAULT (0), "archivedAt" datetime, "tenantId" varchar, "organizationId" varchar, "name" varchar NOT NULL, "description" text, "visibilityLevel" integer, "queryParams" text, "filterOptions" text, "displayOptions" text, "properties" text, "isLocked" boolean NOT NULL DEFAULT (0), "projectId" varchar, "organizationTeamId" varchar, "projectModuleId" varchar, "organizationSprintId" varchar)` + ); + await queryRunner.query( + `INSERT INTO "task_view"("deletedAt", "id", "createdAt", "updatedAt", "isActive", "isArchived", "archivedAt", "tenantId", "organizationId", "name", "description", "visibilityLevel", "queryParams", "filterOptions", "displayOptions", "properties", "isLocked", "projectId", "organizationTeamId", "projectModuleId", "organizationSprintId") SELECT "deletedAt", "id", "createdAt", "updatedAt", "isActive", "isArchived", "archivedAt", "tenantId", "organizationId", "name", "description", "visibilityLevel", "queryParams", "filterOptions", "displayOptions", "properties", "isLocked", "projectId", "organizationTeamId", "projectModuleId", "organizationSprintId" FROM "temporary_task_view"` + ); + await queryRunner.query(`DROP TABLE "temporary_task_view"`); + await queryRunner.query( + `CREATE INDEX "IDX_4814ca7712537f79bed938d9a1" ON "task_view" ("organizationSprintId") ` + ); + await queryRunner.query(`CREATE INDEX "IDX_e58e58a3fd113bf4b336c90997" ON "task_view" ("projectModuleId") `); + await queryRunner.query(`CREATE INDEX "IDX_3c1eb880f298e646d43736e911" ON "task_view" ("organizationTeamId") `); + await queryRunner.query(`CREATE INDEX "IDX_1e5e43bbd58c370c538dac0b17" ON "task_view" ("projectId") `); + await queryRunner.query(`CREATE INDEX "IDX_d4c182a7adfffa1a57315e8bfc" ON "task_view" ("visibilityLevel") `); + await queryRunner.query(`CREATE INDEX "IDX_cf779d00641c3bd276a5a7e4df" ON "task_view" ("name") `); + await queryRunner.query(`CREATE INDEX "IDX_c1c6e1c8d7c7971e234a768419" ON "task_view" ("organizationId") `); + await queryRunner.query(`CREATE INDEX "IDX_ee94e92fccbbf8898221cb4eb5" ON "task_view" ("tenantId") `); + await queryRunner.query(`CREATE INDEX "IDX_7c4f8a4d5b859c23c42ab5f984" ON "task_view" ("isArchived") `); + await queryRunner.query(`CREATE INDEX "IDX_38bcdf0455ac5ab5a925a015ab" ON "task_view" ("isActive") `); + await queryRunner.query(`DROP INDEX "IDX_4814ca7712537f79bed938d9a1"`); + await queryRunner.query(`DROP INDEX "IDX_e58e58a3fd113bf4b336c90997"`); + await queryRunner.query(`DROP INDEX "IDX_3c1eb880f298e646d43736e911"`); + await queryRunner.query(`DROP INDEX "IDX_1e5e43bbd58c370c538dac0b17"`); + await queryRunner.query(`DROP INDEX "IDX_d4c182a7adfffa1a57315e8bfc"`); + await queryRunner.query(`DROP INDEX "IDX_cf779d00641c3bd276a5a7e4df"`); + await queryRunner.query(`DROP INDEX "IDX_c1c6e1c8d7c7971e234a768419"`); + await queryRunner.query(`DROP INDEX "IDX_ee94e92fccbbf8898221cb4eb5"`); + await queryRunner.query(`DROP INDEX "IDX_7c4f8a4d5b859c23c42ab5f984"`); + await queryRunner.query(`DROP INDEX "IDX_38bcdf0455ac5ab5a925a015ab"`); + await queryRunner.query(`DROP TABLE "task_view"`); + } + + /** + * MySQL Up Migration + * + * @param queryRunner + */ + public async mysqlUpQueryRunner(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `CREATE TABLE \`task_view\` (\`deletedAt\` datetime(6) NULL, \`id\` varchar(36) NOT NULL, \`createdAt\` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), \`updatedAt\` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6), \`isActive\` tinyint NULL DEFAULT 1, \`isArchived\` tinyint NULL DEFAULT 0, \`archivedAt\` datetime NULL, \`tenantId\` varchar(255) NULL, \`organizationId\` varchar(255) NULL, \`name\` varchar(255) NOT NULL, \`description\` text NULL, \`visibilityLevel\` int NULL, \`queryParams\` json NULL, \`filterOptions\` json NULL, \`displayOptions\` json NULL, \`properties\` json NULL, \`isLocked\` tinyint NOT NULL DEFAULT 0, \`projectId\` varchar(255) NULL, \`organizationTeamId\` varchar(255) NULL, \`projectModuleId\` varchar(255) NULL, \`organizationSprintId\` varchar(255) NULL, INDEX \`IDX_38bcdf0455ac5ab5a925a015ab\` (\`isActive\`), INDEX \`IDX_7c4f8a4d5b859c23c42ab5f984\` (\`isArchived\`), INDEX \`IDX_ee94e92fccbbf8898221cb4eb5\` (\`tenantId\`), INDEX \`IDX_c1c6e1c8d7c7971e234a768419\` (\`organizationId\`), INDEX \`IDX_cf779d00641c3bd276a5a7e4df\` (\`name\`), INDEX \`IDX_d4c182a7adfffa1a57315e8bfc\` (\`visibilityLevel\`), INDEX \`IDX_1e5e43bbd58c370c538dac0b17\` (\`projectId\`), INDEX \`IDX_3c1eb880f298e646d43736e911\` (\`organizationTeamId\`), INDEX \`IDX_e58e58a3fd113bf4b336c90997\` (\`projectModuleId\`), INDEX \`IDX_4814ca7712537f79bed938d9a1\` (\`organizationSprintId\`), PRIMARY KEY (\`id\`)) ENGINE=InnoDB` + ); + await queryRunner.query( + `ALTER TABLE \`task_view\` ADD CONSTRAINT \`FK_ee94e92fccbbf8898221cb4eb53\` FOREIGN KEY (\`tenantId\`) REFERENCES \`tenant\`(\`id\`) ON DELETE CASCADE ON UPDATE NO ACTION` + ); + await queryRunner.query( + `ALTER TABLE \`task_view\` ADD CONSTRAINT \`FK_c1c6e1c8d7c7971e234a768419c\` FOREIGN KEY (\`organizationId\`) REFERENCES \`organization\`(\`id\`) ON DELETE CASCADE ON UPDATE CASCADE` + ); + await queryRunner.query( + `ALTER TABLE \`task_view\` ADD CONSTRAINT \`FK_1e5e43bbd58c370c538dac0b17c\` FOREIGN KEY (\`projectId\`) REFERENCES \`organization_project\`(\`id\`) ON DELETE CASCADE ON UPDATE NO ACTION` + ); + await queryRunner.query( + `ALTER TABLE \`task_view\` ADD CONSTRAINT \`FK_3c1eb880f298e646d43736e911a\` FOREIGN KEY (\`organizationTeamId\`) REFERENCES \`organization_team\`(\`id\`) ON DELETE CASCADE ON UPDATE NO ACTION` + ); + await queryRunner.query( + `ALTER TABLE \`task_view\` ADD CONSTRAINT \`FK_e58e58a3fd113bf4b336c90997b\` FOREIGN KEY (\`projectModuleId\`) REFERENCES \`organization_project_module\`(\`id\`) ON DELETE CASCADE ON UPDATE NO ACTION` + ); + await queryRunner.query( + `ALTER TABLE \`task_view\` ADD CONSTRAINT \`FK_4814ca7712537f79bed938d9a15\` FOREIGN KEY (\`organizationSprintId\`) REFERENCES \`organization_sprint\`(\`id\`) ON DELETE CASCADE ON UPDATE NO ACTION` + ); + } + + /** + * MySQL Down Migration + * + * @param queryRunner + */ + public async mysqlDownQueryRunner(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE \`task_view\` DROP FOREIGN KEY \`FK_4814ca7712537f79bed938d9a15\``); + await queryRunner.query(`ALTER TABLE \`task_view\` DROP FOREIGN KEY \`FK_e58e58a3fd113bf4b336c90997b\``); + await queryRunner.query(`ALTER TABLE \`task_view\` DROP FOREIGN KEY \`FK_3c1eb880f298e646d43736e911a\``); + await queryRunner.query(`ALTER TABLE \`task_view\` DROP FOREIGN KEY \`FK_1e5e43bbd58c370c538dac0b17c\``); + await queryRunner.query(`ALTER TABLE \`task_view\` DROP FOREIGN KEY \`FK_c1c6e1c8d7c7971e234a768419c\``); + await queryRunner.query(`ALTER TABLE \`task_view\` DROP FOREIGN KEY \`FK_ee94e92fccbbf8898221cb4eb53\``); + await queryRunner.query(`DROP INDEX \`IDX_4814ca7712537f79bed938d9a1\` ON \`task_view\``); + await queryRunner.query(`DROP INDEX \`IDX_e58e58a3fd113bf4b336c90997\` ON \`task_view\``); + await queryRunner.query(`DROP INDEX \`IDX_3c1eb880f298e646d43736e911\` ON \`task_view\``); + await queryRunner.query(`DROP INDEX \`IDX_1e5e43bbd58c370c538dac0b17\` ON \`task_view\``); + await queryRunner.query(`DROP INDEX \`IDX_d4c182a7adfffa1a57315e8bfc\` ON \`task_view\``); + await queryRunner.query(`DROP INDEX \`IDX_cf779d00641c3bd276a5a7e4df\` ON \`task_view\``); + await queryRunner.query(`DROP INDEX \`IDX_c1c6e1c8d7c7971e234a768419\` ON \`task_view\``); + await queryRunner.query(`DROP INDEX \`IDX_ee94e92fccbbf8898221cb4eb5\` ON \`task_view\``); + await queryRunner.query(`DROP INDEX \`IDX_7c4f8a4d5b859c23c42ab5f984\` ON \`task_view\``); + await queryRunner.query(`DROP INDEX \`IDX_38bcdf0455ac5ab5a925a015ab\` ON \`task_view\``); + await queryRunner.query(`DROP TABLE \`task_view\``); + } +} diff --git a/packages/core/src/employee-recurring-expense/commands/handlers/employee-recurring-expense.create.handler.ts b/packages/core/src/employee-recurring-expense/commands/handlers/employee-recurring-expense.create.handler.ts index 0563daff967..e792b467823 100644 --- a/packages/core/src/employee-recurring-expense/commands/handlers/employee-recurring-expense.create.handler.ts +++ b/packages/core/src/employee-recurring-expense/commands/handlers/employee-recurring-expense.create.handler.ts @@ -10,34 +10,35 @@ import { EmployeeRecurringExpenseCreateCommand } from '../employee-recurring-exp * The parentRecurringExpenseId is it's own id since this is a new expense. */ @CommandHandler(EmployeeRecurringExpenseCreateCommand) -export class EmployeeRecurringExpenseCreateHandler - implements ICommandHandler { - constructor( - private readonly employeeRecurringExpenseService: EmployeeRecurringExpenseService - ) {} +export class EmployeeRecurringExpenseCreateHandler implements ICommandHandler { + constructor(private readonly employeeRecurringExpenseService: EmployeeRecurringExpenseService) {} - public async execute( - command: EmployeeRecurringExpenseCreateCommand - ): Promise { + /** + * Executes the command to create a recurring expense for an employee. + * + * @param command - The command containing the input data for creating the recurring expense. + * @returns A promise that resolves with the created employee recurring expense. + * @throws BadRequestException if there is an error during the creation process. + */ + public async execute(command: EmployeeRecurringExpenseCreateCommand): Promise { try { const { input } = command; - /** - * If employee create self recurring expense - */ - if (!RequestContext.hasPermission( - PermissionsEnum.CHANGE_SELECTED_EMPLOYEE - )) { + + // If the user does not have permission to change the selected employee, set the current employee's ID + if (!RequestContext.hasPermission(PermissionsEnum.CHANGE_SELECTED_EMPLOYEE)) { input.employeeId = RequestContext.currentEmployeeId(); - } else { - input.employeeId = input.employeeId || null; } - const recurringExpense = await this.employeeRecurringExpenseService.create( - input - ); + + // Create the recurring expense + const recurringExpense = await this.employeeRecurringExpenseService.create(input); + + // Update the parent recurring expense to reference itself await this.employeeRecurringExpenseService.update(recurringExpense.id, { parentRecurringExpenseId: recurringExpense.id }); - return await this.employeeRecurringExpenseService.findOneByIdString(recurringExpense.id) + + // Return the newly created recurring expense + return await this.employeeRecurringExpenseService.findOneByIdString(recurringExpense.id); } catch (error) { throw new BadRequestException(error); } diff --git a/packages/core/src/employee/commands/handlers/update-employee-total-worked-hours.handler.ts b/packages/core/src/employee/commands/handlers/update-employee-total-worked-hours.handler.ts index ad8591e63e0..0a97159396d 100644 --- a/packages/core/src/employee/commands/handlers/update-employee-total-worked-hours.handler.ts +++ b/packages/core/src/employee/commands/handlers/update-employee-total-worked-hours.handler.ts @@ -1,72 +1,126 @@ import { CommandHandler, ICommandHandler } from '@nestjs/cqrs'; import { InjectRepository } from '@nestjs/typeorm'; -import { DatabaseTypeEnum, getConfig } from '@gauzy/config'; +import { ConfigService, DatabaseTypeEnum } from '@gauzy/config'; +import { ID } from '@gauzy/contracts'; import { prepareSQLQuery as p } from './../../../database/database.helper'; -import { UpdateEmployeeTotalWorkedHoursCommand } from '../update-employee-total-worked-hours.command'; -import { EmployeeService } from '../../employee.service'; -import { TimeLog } from './../../../core/entities/internal'; +import { TimeLog, TimeSlot } from './../../../core/entities/internal'; import { RequestContext } from './../../../core/context'; +import { EmployeeService } from '../../employee.service'; +import { UpdateEmployeeTotalWorkedHoursCommand } from '../update-employee-total-worked-hours.command'; import { TypeOrmTimeLogRepository } from '../../../time-tracking/time-log/repository/type-orm-time-log.repository'; -import { MikroOrmTimeLogRepository } from '../../../time-tracking/time-log/repository/mikro-orm-time-log.repository'; - -const config = getConfig(); +import { TypeOrmTimeSlotRepository } from '../../../time-tracking/time-slot/repository/type-orm-time-slot.repository'; @CommandHandler(UpdateEmployeeTotalWorkedHoursCommand) export class UpdateEmployeeTotalWorkedHoursHandler implements ICommandHandler { constructor( - private readonly employeeService: EmployeeService, - - @InjectRepository(TimeLog) - readonly typeOrmTimeLogRepository: TypeOrmTimeLogRepository, - - readonly mikroOrmTimeLogRepository: MikroOrmTimeLogRepository, - ) { } + @InjectRepository(TimeLog) readonly typeOrmTimeLogRepository: TypeOrmTimeLogRepository, + @InjectRepository(TimeSlot) readonly typeOrmTimeSlotRepository: TypeOrmTimeSlotRepository, + private readonly _employeeService: EmployeeService, + private readonly _configService: ConfigService + ) {} /** + * Updates the total worked hours for an employee. * - * @param command + * @param command The command containing employee ID and worked hours. */ public async execute(command: UpdateEmployeeTotalWorkedHoursCommand) { const { employeeId, hours } = command; const tenantId = RequestContext.currentTenantId(); - let totalWorkHours: number; - if (hours) { - totalWorkHours = hours; - } else { - let sumQuery: string = this.getSumQuery(); - const query = this.typeOrmTimeLogRepository.createQueryBuilder(); - query.select(sumQuery, `duration`); - query.where({ employeeId, tenantId }); - const logs = await query.getRawOne(); - totalWorkHours = (logs.duration || 0) / 3600; - } + // Determine total work hours, calculate if not provided + const totalWorkHours = (await this.calculateTotalWorkHours(employeeId, tenantId)) || hours; + console.log('Updated Employee Total Worked Hours: %s', Math.floor(totalWorkHours)); - await this.employeeService.update(employeeId, { - totalWorkHours: parseInt(totalWorkHours + '', 10) + // Update employee's total worked hours + await this._employeeService.update(employeeId, { + totalWorkHours: Math.floor(totalWorkHours) // Use Math.floor for integer conversion }); } + /** + * Calculates the total work hours for an employee. + * @param employeeId The ID of the employee. + * @param tenantId The tenant ID. + * @returns The total work hours. + */ + private async calculateTotalWorkHours(employeeId: ID, tenantId: ID): Promise { + // Create a query builder for the TimeSlot entity + const query = this.typeOrmTimeLogRepository.createQueryBuilder(); + query.innerJoin(`${query.alias}.timeSlots`, 'time_slot'); + + // Get the sum of durations between startedAt and stoppedAt + const sumQuery = this.getSumQuery(query.alias); + console.log('sum of durations between startedAt and stoppedAt', sumQuery); + + // Execute the query and get the duration + const result = await query + .select(sumQuery, 'duration') + .where({ + employeeId, + tenantId + }) + .getRawOne(); + + console.log(`get sum duration for specific employee: ${employeeId}`, +result.duration); + + // Convert duration from seconds to hours + return Number(+result.duration || 0) / 3600; + } + /** * Get the database-specific sum query for calculating time duration between "startedAt" and "stoppedAt". - * @returns The database-specific sum query. + * @param logQueryAlias The alias for the table in the query. + * @returns The database-specific sum query that returns a Number. */ - private getSumQuery(): string { + private getSumQuery(logQueryAlias: string): string { let sumQuery: string; - switch (config.dbConnectionOptions.type) { + const { dbConnectionOptions } = this._configService; + + switch (dbConnectionOptions.type) { case DatabaseTypeEnum.sqlite: case DatabaseTypeEnum.betterSqlite3: - sumQuery = 'SUM((julianday("stoppedAt") - julianday("startedAt")) * 86400)'; + sumQuery = ` + CAST( + SUM( + CASE + WHEN (julianday("${logQueryAlias}"."stoppedAt") - julianday("${logQueryAlias}"."startedAt")) * 86400 >= 0 + THEN (julianday("${logQueryAlias}"."stoppedAt") - julianday("${logQueryAlias}"."startedAt")) * 86400 + ELSE 0 + END + ) AS REAL + ) + `; break; case DatabaseTypeEnum.postgres: - sumQuery = 'SUM(extract(epoch from ("stoppedAt" - "startedAt")))'; + sumQuery = ` + CAST( + SUM( + CASE + WHEN extract(epoch from ("${logQueryAlias}"."stoppedAt" - "${logQueryAlias}"."startedAt")) >= 0 + THEN extract(epoch from ("${logQueryAlias}"."stoppedAt" - "${logQueryAlias}"."startedAt")) + ELSE 0 + END + ) AS DOUBLE PRECISION + ) + `; break; case DatabaseTypeEnum.mysql: - sumQuery = p('SUM(TIMESTAMPDIFF(SECOND, "startedAt", "stoppedAt"))'); + sumQuery = p(` + CAST( + SUM( + CASE + WHEN TIMESTAMPDIFF(SECOND, \`${logQueryAlias}\`.\`startedAt\`, \`${logQueryAlias}\`.\`stoppedAt\`) >= 0 + THEN TIMESTAMPDIFF(SECOND, \`${logQueryAlias}\`.\`startedAt\`, \`${logQueryAlias}\`.\`stoppedAt\`) + ELSE 0 + END + ) AS DECIMAL(10, 6) + ) + `); break; default: - throw Error(`cannot update employee total worked hours due to unsupported database type: ${config.dbConnectionOptions.type}`); + throw new Error(`Unsupported database type: ${dbConnectionOptions.type}`); } return sumQuery; diff --git a/packages/core/src/employee/commands/update-employee-total-worked-hours.command.ts b/packages/core/src/employee/commands/update-employee-total-worked-hours.command.ts index 0f5307b415c..d6efd1981f8 100644 --- a/packages/core/src/employee/commands/update-employee-total-worked-hours.command.ts +++ b/packages/core/src/employee/commands/update-employee-total-worked-hours.command.ts @@ -1,10 +1,8 @@ import { ICommand } from '@nestjs/cqrs'; +import { ID } from '@gauzy/contracts'; export class UpdateEmployeeTotalWorkedHoursCommand implements ICommand { static readonly type = '[Employee] Update Total Worked Hours'; - constructor( - public readonly employeeId: string, - public readonly hours?: number - ) {} + constructor(public readonly employeeId: ID, public readonly hours?: number) {} } diff --git a/packages/core/src/employee/default-employees.ts b/packages/core/src/employee/default-employees.ts index bad4803283f..351533542f9 100644 --- a/packages/core/src/employee/default-employees.ts +++ b/packages/core/src/employee/default-employees.ts @@ -182,6 +182,18 @@ export const DEFAULT_EVER_EMPLOYEES: any = [ preferredLanguage: LanguagesEnum.ENGLISH, preferredComponentLayout: ComponentLayoutStyleEnum.TABLE }, + { + email: 'rahulrathore576@gmail.com', + password: '123456', + firstName: 'Rahul', + lastName: 'R.', + imageUrl: 'assets/images/avatars/rahul.png', + startedWorkOn: '2020-09-10', + endWork: null, + employeeLevel: null, + preferredLanguage: LanguagesEnum.ENGLISH, + preferredComponentLayout: ComponentLayoutStyleEnum.TABLE + }, { email: 'julia@example-ever.co', password: '123456', diff --git a/packages/core/src/employee/employee.controller.ts b/packages/core/src/employee/employee.controller.ts index d869b782403..6ae93257561 100644 --- a/packages/core/src/employee/employee.controller.ts +++ b/packages/core/src/employee/employee.controller.ts @@ -289,14 +289,12 @@ export class EmployeeController extends CrudController { @Param('id', UUIDValidationPipe) id: ID, @Query() params: OptionParams ): Promise { - const currentEmployeeId = RequestContext.currentEmployeeId(); - // Check permissions to determine the correct ID to retrieve const searchCriteria = { where: { ...(RequestContext.hasPermission(PermissionsEnum.CHANGE_SELECTED_EMPLOYEE) ? { id } - : { id: currentEmployeeId }) + : { id: RequestContext.currentEmployeeId() }) }, ...(params.relations ? { relations: params.relations } : {}), withDeleted: true diff --git a/packages/core/src/employee/employee.entity.ts b/packages/core/src/employee/employee.entity.ts index 1c2243481e1..a95c7117774 100644 --- a/packages/core/src/employee/employee.entity.ts +++ b/packages/core/src/employee/employee.entity.ts @@ -35,7 +35,8 @@ import { IOrganizationProjectModule, ID, IFavorite, - IComment + IComment, + IOrganizationSprint } from '@gauzy/contracts'; import { ColumnIndex, @@ -67,6 +68,7 @@ import { OrganizationPosition, OrganizationProjectEmployee, OrganizationProjectModule, + OrganizationSprintEmployee, OrganizationTeamEmployee, RequestApprovalEmployee, Skill, @@ -496,6 +498,12 @@ export class Employee extends TenantOrganizationBaseEntity implements IEmployee, }) projects?: IOrganizationProject[]; + // Employee Sprint + @MultiORMOneToMany(() => OrganizationSprintEmployee, (it) => it.employee, { + cascade: true + }) + sprints?: IOrganizationSprint[]; + /** * Estimations */ diff --git a/packages/core/src/employee/employee.module.ts b/packages/core/src/employee/employee.module.ts index 57b7f9939c3..f49ac696dd5 100644 --- a/packages/core/src/employee/employee.module.ts +++ b/packages/core/src/employee/employee.module.ts @@ -2,7 +2,7 @@ import { forwardRef, Module } from '@nestjs/common'; import { CqrsModule } from '@nestjs/cqrs'; import { TypeOrmModule } from '@nestjs/typeorm'; import { MikroOrmModule } from '@mikro-orm/nestjs'; -import { TimeLog } from './../core/entities/internal'; +import { TimeLog, TimeSlot } from './../core/entities/internal'; import { Employee } from './employee.entity'; import { UserModule } from './../user/user.module'; import { CommandHandlers } from './commands/handlers'; @@ -17,8 +17,8 @@ import { TypeOrmEmployeeRepository } from './repository/type-orm-employee.reposi @Module({ imports: [ - TypeOrmModule.forFeature([Employee, TimeLog]), - MikroOrmModule.forFeature([Employee, TimeLog]), + TypeOrmModule.forFeature([Employee, TimeLog, TimeSlot]), + MikroOrmModule.forFeature([Employee, TimeLog, TimeSlot]), forwardRef(() => EmailSendModule), forwardRef(() => UserOrganizationModule), forwardRef(() => RolePermissionModule), diff --git a/packages/core/src/expense/commands/handlers/expense.delete.handler.ts b/packages/core/src/expense/commands/handlers/expense.delete.handler.ts index 902baaea90b..e69dc6cce7d 100644 --- a/packages/core/src/expense/commands/handlers/expense.delete.handler.ts +++ b/packages/core/src/expense/commands/handlers/expense.delete.handler.ts @@ -2,7 +2,7 @@ import { BadRequestException, ForbiddenException } from '@nestjs/common'; import { CommandHandler, ICommandHandler } from '@nestjs/cqrs'; import { DeleteResult } from 'typeorm'; import { isNotEmpty } from '@gauzy/common'; -import { PermissionsEnum } from '@gauzy/contracts'; +import { ID, PermissionsEnum } from '@gauzy/contracts'; import { ExpenseService } from '../../expense.service'; import { EmployeeService } from '../../../employee/employee.service'; import { EmployeeStatisticsService } from '../../../employee-statistics'; @@ -10,53 +10,63 @@ import { ExpenseDeleteCommand } from '../expense.delete.command'; import { RequestContext } from './../../../core/context'; @CommandHandler(ExpenseDeleteCommand) -export class ExpenseDeleteHandler - implements ICommandHandler { +export class ExpenseDeleteHandler implements ICommandHandler { constructor( private readonly expenseService: ExpenseService, private readonly employeeService: EmployeeService, private readonly employeeStatisticsService: EmployeeStatisticsService ) {} + /** + * Executes the deletion of an expense and updates the employee's average expenses if applicable. + * + * @param command - The command containing the expense ID to delete and the optional employee ID. + * @returns A promise that resolves with the result of the delete operation. + * @throws BadRequestException if there is an error updating employee average expenses. + */ public async execute(command: ExpenseDeleteCommand): Promise { - const { expenseId } = command; + const { expenseId, employeeId } = command; + // Delete the expense by ID const result = await this.deleteExpense(expenseId); + try { - const { employeeId } = command; + // If employeeId exists, update the employee's average expenses if (isNotEmpty(employeeId)) { - let averageExpense = 0; - const stat = await this.employeeStatisticsService.getStatisticsByEmployeeId( - employeeId - ); - averageExpense = this.expenseService.countStatistic( - stat.expenseStatistics - ); + const stat = await this.employeeStatisticsService.getStatisticsByEmployeeId(employeeId); + const averageExpense = this.expenseService.countStatistic(stat.expenseStatistics); + await this.employeeService.create({ id: employeeId, averageExpenses: averageExpense }); } } catch (error) { - throw new BadRequestException(error); + console.error('Error while updating employee average expenses', error); + throw new BadRequestException('Error while updating employee average expenses'); } + return result; } - public async deleteExpense(expenseId: string): Promise { - try { - if (RequestContext.hasPermission( - PermissionsEnum.CHANGE_SELECTED_EMPLOYEE - )) { - return await this.expenseService.delete(expenseId); - } else { - return await this.expenseService.delete({ + /** + * Delete the expense based on user permissions + * + * @param expenseId - The ID of the expense to delete + * @returns Promise - The result of the delete operation + */ + public async deleteExpense(expenseId: ID): Promise { + const query = RequestContext.hasPermission(PermissionsEnum.CHANGE_SELECTED_EMPLOYEE) + ? { id: expenseId } + : { id: expenseId, employeeId: RequestContext.currentEmployeeId(), tenantId: RequestContext.currentTenantId() - }); - } + }; + + try { + return await this.expenseService.delete(query); } catch (error) { - throw new ForbiddenException(); + throw new ForbiddenException('You do not have permission to delete this expense.'); } } } diff --git a/packages/core/src/income/commands/handlers/income.delete.handler.ts b/packages/core/src/income/commands/handlers/income.delete.handler.ts index becca870fe0..14d578b337e 100644 --- a/packages/core/src/income/commands/handlers/income.delete.handler.ts +++ b/packages/core/src/income/commands/handlers/income.delete.handler.ts @@ -10,58 +10,65 @@ import { IncomeDeleteCommand } from '../income.delete.command'; import { RequestContext } from './../../../core/context'; @CommandHandler(IncomeDeleteCommand) -export class IncomeDeleteHandler - implements ICommandHandler { +export class IncomeDeleteHandler implements ICommandHandler { constructor( private readonly incomeService: IncomeService, private readonly employeeService: EmployeeService, private readonly employeeStatisticsService: EmployeeStatisticsService ) {} + /** + * Deletes an income record and updates the employee's statistics if necessary. + * + * @param command - The command containing the income ID to delete and the optional employee ID. + * @returns A promise that resolves with the result of the delete operation. + * @throws BadRequestException if there is an error updating employee statistics. + */ public async execute(command: IncomeDeleteCommand): Promise { - const { incomeId } = command; + const { incomeId, employeeId } = command; + // Delete the income const result = await this.deleteIncome(incomeId); + try { - const { employeeId } = command; if (isNotEmpty(employeeId)) { - let averageIncome = 0; - let averageBonus = 0; - const stat = await this.employeeStatisticsService.getStatisticsByEmployeeId( - employeeId - ); - averageIncome = this.incomeService.countStatistic( - stat.incomeStatistics - ); - averageBonus = this.incomeService.countStatistic( - stat.bonusStatistics - ); + // Fetch statistics and calculate averages + const stat = await this.employeeStatisticsService.getStatisticsByEmployeeId(employeeId); + const averageIncome = this.incomeService.countStatistic(stat.incomeStatistics); + const averageBonus = this.incomeService.countStatistic(stat.bonusStatistics); + + // Update employee with the calculated averages await this.employeeService.create({ id: employeeId, - averageIncome: averageIncome, - averageBonus: averageBonus + averageIncome, + averageBonus }); } } catch (error) { - throw new BadRequestException(error); + throw new BadRequestException('Error while updating employee statistics', error); } + return result; } + /** + * Deletes income by ID with permission check + * + * @param incomeId - The ID of the income to delete + * @returns Promise - The result of the delete operation + */ public async deleteIncome(incomeId: string): Promise { - try { - if (RequestContext.hasPermission( - PermissionsEnum.CHANGE_SELECTED_EMPLOYEE - )) { - return await this.incomeService.delete(incomeId); - } else { - return await this.incomeService.delete({ + const deleteQuery = RequestContext.hasPermission(PermissionsEnum.CHANGE_SELECTED_EMPLOYEE) + ? { id: incomeId } + : { id: incomeId, employeeId: RequestContext.currentEmployeeId(), tenantId: RequestContext.currentTenantId() - }); - } + }; + + try { + return await this.incomeService.delete(deleteQuery); } catch (error) { - throw new ForbiddenException(); + throw new ForbiddenException('You do not have permission to delete this income.'); } } } diff --git a/packages/core/src/organization-project-module/organization-project-module.entity.ts b/packages/core/src/organization-project-module/organization-project-module.entity.ts index 3a282cbcc97..429b71c1fb3 100644 --- a/packages/core/src/organization-project-module/organization-project-module.entity.ts +++ b/packages/core/src/organization-project-module/organization-project-module.entity.ts @@ -20,6 +20,7 @@ import { IOrganizationSprint, IOrganizationTeam, ITask, + ITaskView, IUser, ProjectModuleStatusEnum } from '@gauzy/contracts'; @@ -29,6 +30,7 @@ import { OrganizationSprint, OrganizationTeam, Task, + TaskView, TenantOrganizationBaseEntity, User } from '../core/entities/internal'; @@ -184,6 +186,12 @@ export class OrganizationProjectModule extends TenantOrganizationBaseEntity impl @MultiORMOneToMany(() => OrganizationProjectModule, (module) => module.parent) children?: OrganizationProjectModule[]; + /** + * Project Module views + */ + @MultiORMOneToMany(() => TaskView, (module) => module.projectModule) + views?: ITaskView[]; + /* |-------------------------------------------------------------------------- | @ManyToMany diff --git a/packages/core/src/organization-project-module/organization-project-module.service.ts b/packages/core/src/organization-project-module/organization-project-module.service.ts index 19e839e1d0d..57f059acee9 100644 --- a/packages/core/src/organization-project-module/organization-project-module.service.ts +++ b/packages/core/src/organization-project-module/organization-project-module.service.ts @@ -5,7 +5,6 @@ import { ActionTypeEnum, ActivityLogEntityEnum, ActorTypeEnum, - IActivityLogUpdatedValues, ID, IOrganizationProjectModule, IOrganizationProjectModuleCreateInput, @@ -20,7 +19,7 @@ import { isPostgres } from '@gauzy/config'; import { PaginationParams, TenantAwareCrudService } from './../core/crud'; import { RequestContext } from '../core/context'; import { ActivityLogEvent } from '../activity-log/events'; -import { generateActivityLogDescription } from '../activity-log/activity-log.helper'; +import { activityLogUpdatedFieldsAndValues, generateActivityLogDescription } from '../activity-log/activity-log.helper'; import { OrganizationProjectModule } from './organization-project-module.entity'; import { prepareSQLQuery as p } from './../database/database.helper'; import { TypeOrmOrganizationProjectModuleRepository } from './repository/type-orm-organization-project-module.repository'; @@ -118,21 +117,10 @@ export class OrganizationProjectModuleService extends TenantAwareCrudService { // Extracts the input from the command and executes the command logic const { input } = command; + + // Update the organization project by an employee return await this.organizationProjectService.updateByEmployee(input); } } diff --git a/packages/core/src/organization-project/dto/organization-project.dto.ts b/packages/core/src/organization-project/dto/organization-project.dto.ts index 463883c80b4..d7a55b90377 100644 --- a/packages/core/src/organization-project/dto/organization-project.dto.ts +++ b/packages/core/src/organization-project/dto/organization-project.dto.ts @@ -1,6 +1,5 @@ -import { ApiPropertyOptional, IntersectionType, PartialType, PickType } from '@nestjs/swagger'; -import { IsArray, IsOptional } from 'class-validator'; -import { ID } from '@gauzy/contracts'; +import { IntersectionType, PartialType, PickType } from '@nestjs/swagger'; +import { MemberEntityBasedDTO } from '../../core/dto'; import { OrganizationProject } from './../organization-project.entity'; import { UpdateTaskModeDTO } from './update-task-mode.dto'; @@ -9,15 +8,5 @@ import { UpdateTaskModeDTO } from './update-task-mode.dto'; */ export class OrganizationProjectDTO extends IntersectionType( PickType(OrganizationProject, ['imageId', 'name', 'billing', 'budgetType'] as const), - PartialType(UpdateTaskModeDTO) -) { - @ApiPropertyOptional({ type: () => String }) - @IsOptional() - @IsArray() - memberIds?: ID[] = []; - - @ApiPropertyOptional({ type: () => String }) - @IsOptional() - @IsArray() - managerIds?: ID[] = []; -} + IntersectionType(PartialType(UpdateTaskModeDTO), MemberEntityBasedDTO) +) {} diff --git a/packages/core/src/organization-project/organization-project.entity.ts b/packages/core/src/organization-project/organization-project.entity.ts index 5b7af7a3a18..4fb74471801 100644 --- a/packages/core/src/organization-project/organization-project.entity.ts +++ b/packages/core/src/organization-project/organization-project.entity.ts @@ -23,6 +23,7 @@ import { ITaskSize, ITaskStatus, ITaskVersion, + ITaskView, ITimeLog, OrganizationProjectBudgetTypeEnum, ProjectBillingEnum, @@ -50,6 +51,7 @@ import { TaskSize, TaskStatus, TaskVersion, + TaskView, TenantOrganizationBaseEntity, TimeLog } from '../core/entities/internal'; @@ -422,6 +424,12 @@ export class OrganizationProject @MultiORMOneToMany(() => TaskVersion, (it) => it.project) versions?: ITaskVersion[]; + /** + * Project views Relationship + */ + @MultiORMOneToMany(() => TaskView, (it) => it.project) + views?: ITaskView[]; + /** * Organization modules Relationship */ diff --git a/packages/core/src/organization-project/organization-project.service.ts b/packages/core/src/organization-project/organization-project.service.ts index e89632bde64..43856d0df9f 100644 --- a/packages/core/src/organization-project/organization-project.service.ts +++ b/packages/core/src/organization-project/organization-project.service.ts @@ -1,4 +1,4 @@ -import { BadRequestException, HttpException, HttpStatus, Injectable } from '@nestjs/common'; +import { HttpException, HttpStatus, Injectable } from '@nestjs/common'; import { EventBus } from '@nestjs/cqrs'; import { ILike, In, IsNull, SelectQueryBuilder } from 'typeorm'; import { @@ -116,7 +116,7 @@ export class OrganizationProjectService extends TenantAwareCrudService { try { + const tenantId = RequestContext.currentTenantId() ?? input.tenantId; const { organizationId, addedProjectIds = [], removedProjectIds = [], member } = input; // Handle adding projects if (addedProjectIds.length > 0) { const projectsToAdd = await this.find({ - where: { - id: In(addedProjectIds), - organizationId - }, - relations: { - members: true - } + where: { id: In(addedProjectIds), organizationId, tenantId }, + relations: { members: true } }); - const updatedProjectsToAdd = projectsToAdd.map((project) => { - const existingMembers = project.members || []; - - // Verify if member already exists on project - const isMemberAlreadyInProject = existingMembers.some( - (existingMember) => existingMember.employeeId === member.employeeId - ); + const updatedProjectsToAdd = projectsToAdd + .filter((project: IOrganizationProject) => { + // Filter only projects where the member is not already assigned + return !project.members?.some(({ employeeId }) => employeeId === member.id); + }) + .map((project: IOrganizationProject) => { + // Create new member object for the projects where the member is not yet assigned + const newMember = new OrganizationProjectEmployee({ + employeeId: member.id, + organizationProjectId: project.id, + organizationId, + tenantId + }); - if (!isMemberAlreadyInProject) { + // Return the project with the new member added to the members array return { ...project, - members: [...existingMembers, { ...member, organizationProjectId: project.id }] + members: [...project.members, newMember] // Add new member while keeping existing members intact }; - } - - return project; // If member already assigned to project, no change needed - }); + }); - // save updated projects - await Promise.all(updatedProjectsToAdd.map(async (project) => await this.save(project))); + // Save updated projects + await Promise.all(updatedProjectsToAdd.map((project) => this.save(project))); } // Handle removing projects @@ -568,8 +578,8 @@ export class OrganizationProjectService extends TenantAwareCrudService { + constructor(private readonly _organizationSprintService: OrganizationSprintService) {} + + /** + * Executes the creation of an organization sprint + * @param {OrganizationSprintCreateCommand} command The command containing the input data for creating the organization sprint. + * @returns {Promise} - Returns a promise that resolves with the created organization sprint. + * @throws {BadRequestException} - Throws a BadRequestException if an error occurs during the creation process. + * @memberof OrganizationSprintCreateHandler + */ + public async execute(command: OrganizationSprintCreateCommand): Promise { + // Destructure the input data from command + const { input } = command; + + // Create and return organization sprint + return await this._organizationSprintService.create(input); + } +} diff --git a/packages/core/src/organization-sprint/commands/handlers/organization-sprint.update.handler.ts b/packages/core/src/organization-sprint/commands/handlers/organization-sprint.update.handler.ts index 8cdadc604e9..e343269756a 100644 --- a/packages/core/src/organization-sprint/commands/handlers/organization-sprint.update.handler.ts +++ b/packages/core/src/organization-sprint/commands/handlers/organization-sprint.update.handler.ts @@ -1,28 +1,19 @@ -import { NotFoundException } from '@nestjs/common'; import { CommandHandler, ICommandHandler } from '@nestjs/cqrs'; import { IOrganizationSprint } from '@gauzy/contracts'; import { OrganizationSprintService } from '../../organization-sprint.service'; import { OrganizationSprintUpdateCommand } from '../organization-sprint.update.command'; @CommandHandler(OrganizationSprintUpdateCommand) -export class OrganizationSprintUpdateHandler - implements ICommandHandler { - constructor( - private readonly organizationSprintService: OrganizationSprintService - ) {} +export class OrganizationSprintUpdateHandler implements ICommandHandler { + constructor(private readonly organizationSprintService: OrganizationSprintService) {} - public async execute( - command: OrganizationSprintUpdateCommand - ): Promise { + public async execute(command: OrganizationSprintUpdateCommand): Promise { const { id, input } = command; - const record = await this.organizationSprintService.findOneByIdString(id); - if (!record) { - throw new NotFoundException(`The requested record was not found`); - } - //This will call save() with the id so that task[] also get saved accordingly - return this.organizationSprintService.create({ - id, - ...input - }); + + // Update the organization sprint using the provided input + await this.organizationSprintService.update(id, input); + + // Find the updated organization project by ID + return await this.organizationSprintService.findOneByIdString(id); } } diff --git a/packages/core/src/organization-sprint/commands/index.ts b/packages/core/src/organization-sprint/commands/index.ts index accc17486ac..e6b9832495d 100644 --- a/packages/core/src/organization-sprint/commands/index.ts +++ b/packages/core/src/organization-sprint/commands/index.ts @@ -1 +1,2 @@ -export * from './organization-sprint.update.command'; \ No newline at end of file +export * from './organization-sprint.create.command'; +export * from './organization-sprint.update.command'; diff --git a/packages/core/src/organization-sprint/commands/organization-sprint.create.command.ts b/packages/core/src/organization-sprint/commands/organization-sprint.create.command.ts new file mode 100644 index 00000000000..8908be03b12 --- /dev/null +++ b/packages/core/src/organization-sprint/commands/organization-sprint.create.command.ts @@ -0,0 +1,8 @@ +import { ICommand } from '@nestjs/cqrs'; +import { IOrganizationSprintCreateInput } from '@gauzy/contracts'; + +export class OrganizationSprintCreateCommand implements ICommand { + static readonly type = '[OrganizationSprint] Create'; + + constructor(readonly input: IOrganizationSprintCreateInput) {} +} diff --git a/packages/core/src/organization-sprint/dto/create-organization-sprint.dto.ts b/packages/core/src/organization-sprint/dto/create-organization-sprint.dto.ts new file mode 100644 index 00000000000..3946a07c664 --- /dev/null +++ b/packages/core/src/organization-sprint/dto/create-organization-sprint.dto.ts @@ -0,0 +1,11 @@ +import { IntersectionType } from '@nestjs/swagger'; +import { IOrganizationSprintCreateInput } from '@gauzy/contracts'; +import { TenantOrganizationBaseDTO } from './../../core/dto'; +import { OrganizationSprintDTO } from './organization-sprint.dto'; + +/** + * Create Organization Sprint DTO request validation + */ +export class CreateOrganizationSprintDTO + extends IntersectionType(OrganizationSprintDTO, TenantOrganizationBaseDTO) + implements IOrganizationSprintCreateInput {} diff --git a/packages/core/src/organization-sprint/dto/index.ts b/packages/core/src/organization-sprint/dto/index.ts new file mode 100644 index 00000000000..7253d6e420f --- /dev/null +++ b/packages/core/src/organization-sprint/dto/index.ts @@ -0,0 +1,3 @@ +export * from './organization-sprint.dto'; +export * from './create-organization-sprint.dto'; +export * from './update-organization-sprint.dto'; diff --git a/packages/core/src/organization-sprint/dto/organization-sprint.dto.ts b/packages/core/src/organization-sprint/dto/organization-sprint.dto.ts new file mode 100644 index 00000000000..347847b8d91 --- /dev/null +++ b/packages/core/src/organization-sprint/dto/organization-sprint.dto.ts @@ -0,0 +1,5 @@ +import { IntersectionType } from '@nestjs/swagger'; +import { MemberEntityBasedDTO } from '../../core/dto'; +import { OrganizationSprint } from './../organization-sprint.entity'; + +export class OrganizationSprintDTO extends IntersectionType(OrganizationSprint, MemberEntityBasedDTO) {} diff --git a/packages/core/src/organization-sprint/dto/update-organization-sprint.dto.ts b/packages/core/src/organization-sprint/dto/update-organization-sprint.dto.ts new file mode 100644 index 00000000000..7b48df646d7 --- /dev/null +++ b/packages/core/src/organization-sprint/dto/update-organization-sprint.dto.ts @@ -0,0 +1,11 @@ +import { IntersectionType, PartialType } from '@nestjs/swagger'; +import { IOrganizationSprintUpdateInput } from '@gauzy/contracts'; +import { TenantOrganizationBaseDTO } from '../../core/dto'; +import { OrganizationSprintDTO } from './organization-sprint.dto'; + +/** + * Update Organization Project DTO request validation + */ +export class UpdateOrganizationSprintDTO + extends IntersectionType(TenantOrganizationBaseDTO, PartialType(OrganizationSprintDTO)) + implements IOrganizationSprintUpdateInput {} diff --git a/packages/core/src/organization-sprint/organization-sprint-employee.entity.ts b/packages/core/src/organization-sprint/organization-sprint-employee.entity.ts new file mode 100644 index 00000000000..440dbdc9bb5 --- /dev/null +++ b/packages/core/src/organization-sprint/organization-sprint-employee.entity.ts @@ -0,0 +1,88 @@ +import { RelationId } from 'typeorm'; +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { IsBoolean, IsDateString, IsNotEmpty, IsOptional, IsUUID } from 'class-validator'; +import { ID, IEmployee, IOrganizationSprintEmployee, IRole } from '@gauzy/contracts'; +import { Employee, OrganizationSprint, Role, TenantOrganizationBaseEntity } from '../core/entities/internal'; +import { ColumnIndex, MultiORMColumn, MultiORMEntity, MultiORMManyToOne } from '../core/decorators/entity'; +import { MikroOrmOrganizationSprintEmployeeRepository } from './repository/mikro-orm-organization-sprint-employee.repository'; + +@MultiORMEntity('organization_sprint_employee', { + mikroOrmRepository: () => MikroOrmOrganizationSprintEmployeeRepository +}) +export class OrganizationSprintEmployee extends TenantOrganizationBaseEntity implements IOrganizationSprintEmployee { + // Manager of the organization project + @ApiPropertyOptional({ type: () => Boolean, default: false }) + @IsOptional() + @IsBoolean() + @ColumnIndex() + @MultiORMColumn({ type: Boolean, nullable: true, default: false }) + isManager?: boolean; + + // Assigned At Manager of the organization project + @ApiPropertyOptional({ type: () => Date }) + @IsOptional() + @IsDateString() + @ColumnIndex() + @MultiORMColumn({ nullable: true }) + assignedAt?: Date; + + /* + |-------------------------------------------------------------------------- + | @ManyToOne + |-------------------------------------------------------------------------- + */ + + /** + * OrganizationSprint + */ + @MultiORMManyToOne(() => OrganizationSprint, (it) => it.members, { + /** Database cascade action on delete. */ + onDelete: 'CASCADE' + }) + organizationSprint!: OrganizationSprint; + + @ApiProperty({ type: () => String }) + @IsNotEmpty() + @IsUUID() + @RelationId((it: OrganizationSprintEmployee) => it.organizationSprint) + @ColumnIndex() + @MultiORMColumn({ relationId: true }) + organizationSprintId: ID; + + /** + * Employee + */ + @MultiORMManyToOne(() => Employee, (it) => it.sprints, { + /** Database cascade action on delete. */ + onDelete: 'CASCADE' + }) + employee!: IEmployee; + + @ApiProperty({ type: () => String }) + @IsNotEmpty() + @IsUUID() + @RelationId((it: OrganizationSprintEmployee) => it.employee) + @ColumnIndex() + @MultiORMColumn({ relationId: true }) + employeeId?: ID; + + /** + * Role + */ + @MultiORMManyToOne(() => Role, { + /** Indicates if relation column value can be nullable or not. */ + nullable: true, + + /** Database cascade action on delete. */ + onDelete: 'CASCADE' + }) + role!: IRole; + + @ApiPropertyOptional({ type: () => String }) + @IsOptional() + @IsUUID() + @RelationId((it: OrganizationSprintEmployee) => it.role) + @ColumnIndex() + @MultiORMColumn({ nullable: true, relationId: true }) + roleId?: ID; +} diff --git a/packages/core/src/organization-sprint/organization-sprint-task-history.entity.ts b/packages/core/src/organization-sprint/organization-sprint-task-history.entity.ts new file mode 100644 index 00000000000..8bac37f4c47 --- /dev/null +++ b/packages/core/src/organization-sprint/organization-sprint-task-history.entity.ts @@ -0,0 +1,97 @@ +import { JoinColumn, RelationId } from 'typeorm'; +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { IsNotEmpty, IsOptional, IsString, IsUUID } from 'class-validator'; +import { ID, IOrganizationSprintTaskHistory, ITask, IUser } from '@gauzy/contracts'; +import { OrganizationSprint, Task, TenantOrganizationBaseEntity, User } from '../core/entities/internal'; +import { ColumnIndex, MultiORMColumn, MultiORMEntity, MultiORMManyToOne } from '../core/decorators/entity'; +import { MikroOrmOrganizationSprintTaskRepository } from './repository/mikro-orm-organization-sprint-task.repository'; + +@MultiORMEntity('organization_sprint_task_history', { + mikroOrmRepository: () => MikroOrmOrganizationSprintTaskRepository +}) +export class OrganizationSprintTaskHistory + extends TenantOrganizationBaseEntity + implements IOrganizationSprintTaskHistory +{ + @ApiPropertyOptional({ type: () => String }) + @IsOptional() + @IsString() + @ColumnIndex() + @MultiORMColumn({ type: 'text', nullable: true }) + reason?: string; + + /* + |-------------------------------------------------------------------------- + | @ManyToOne + |-------------------------------------------------------------------------- + */ + + /** + * Task + */ + @MultiORMManyToOne(() => Task, (it) => it.taskSprintHistories, { + /** Database cascade action on delete. */ + onDelete: 'CASCADE' + }) + task!: ITask; + + @ApiProperty({ type: () => String }) + @IsNotEmpty() + @IsUUID() + @RelationId((it: OrganizationSprintTaskHistory) => it.task) + @ColumnIndex() + @MultiORMColumn({ relationId: true }) + taskId: ID; + + /** + * From OrganizationSprint + */ + @MultiORMManyToOne(() => OrganizationSprint, (it) => it.fromSprintTaskHistories, { + /** Database cascade action on delete. */ + onDelete: 'CASCADE' + }) + fromSprint!: OrganizationSprint; + + @ApiProperty({ type: () => String }) + @IsNotEmpty() + @IsUUID() + @RelationId((it: OrganizationSprintTaskHistory) => it.fromSprint) + @ColumnIndex() + @MultiORMColumn({ relationId: true }) + fromSprintId: ID; + + /** + * To OrganizationSprint + */ + @MultiORMManyToOne(() => OrganizationSprint, (it) => it.toSprintTaskHistories, { + /** Database cascade action on delete. */ + onDelete: 'CASCADE' + }) + toSprint!: OrganizationSprint; + + @ApiProperty({ type: () => String }) + @IsNotEmpty() + @IsUUID() + @RelationId((it: OrganizationSprintTaskHistory) => it.toSprint) + @ColumnIndex() + @MultiORMColumn({ relationId: true }) + toSprintId: ID; + + /** + * User moved issue + */ + @MultiORMManyToOne(() => User, { + /** Indicates if relation column value can be nullable or not. */ + nullable: true, + + /** Database cascade action on delete. */ + onDelete: 'CASCADE' + }) + @JoinColumn() + movedBy?: IUser; + + @RelationId((it: OrganizationSprintTaskHistory) => it.movedBy) + @ColumnIndex() + @MultiORMColumn({ nullable: true, relationId: true }) + movedById?: ID; +} diff --git a/packages/core/src/organization-sprint/organization-sprint-task.entity.ts b/packages/core/src/organization-sprint/organization-sprint-task.entity.ts new file mode 100644 index 00000000000..8723cd38088 --- /dev/null +++ b/packages/core/src/organization-sprint/organization-sprint-task.entity.ts @@ -0,0 +1,59 @@ +import { RelationId } from 'typeorm'; +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { IsNotEmpty, IsNumber, IsOptional, IsUUID } from 'class-validator'; +import { ID, IOrganizationSprintTask, ITask } from '@gauzy/contracts'; +import { OrganizationSprint, Task, TenantOrganizationBaseEntity } from '../core/entities/internal'; +import { ColumnIndex, MultiORMColumn, MultiORMEntity, MultiORMManyToOne } from '../core/decorators/entity'; +import { MikroOrmOrganizationSprintTaskRepository } from './repository/mikro-orm-organization-sprint-task.repository'; + +@MultiORMEntity('organization_sprint_task', { + mikroOrmRepository: () => MikroOrmOrganizationSprintTaskRepository +}) +export class OrganizationSprintTask extends TenantOrganizationBaseEntity implements IOrganizationSprintTask { + @ApiPropertyOptional({ type: () => Number }) + @IsOptional() + @IsNumber() + @ColumnIndex() + @MultiORMColumn({ nullable: true }) + totalWorkedHours?: number; + + /* + |-------------------------------------------------------------------------- + | @ManyToOne + |-------------------------------------------------------------------------- + */ + + /** + * OrganizationSprint + */ + @MultiORMManyToOne(() => OrganizationSprint, (it) => it.taskSprints, { + /** Database cascade action on delete. */ + onDelete: 'CASCADE' + }) + organizationSprint!: OrganizationSprint; + + @ApiProperty({ type: () => String }) + @IsNotEmpty() + @IsUUID() + @RelationId((it: OrganizationSprintTask) => it.organizationSprint) + @ColumnIndex() + @MultiORMColumn({ relationId: true }) + organizationSprintId: ID; + + /** + * Task + */ + @MultiORMManyToOne(() => Task, (it) => it.taskSprints, { + /** Database cascade action on delete. */ + onDelete: 'CASCADE' + }) + task!: ITask; + + @ApiProperty({ type: () => String }) + @IsNotEmpty() + @IsUUID() + @RelationId((it: OrganizationSprintTask) => it.task) + @ColumnIndex() + @MultiORMColumn({ relationId: true }) + taskId: ID; +} diff --git a/packages/core/src/organization-sprint/organization-sprint.controller.ts b/packages/core/src/organization-sprint/organization-sprint.controller.ts index 9423e3a7925..c7a91190f6c 100644 --- a/packages/core/src/organization-sprint/organization-sprint.controller.ts +++ b/packages/core/src/organization-sprint/organization-sprint.controller.ts @@ -8,24 +8,25 @@ import { Put, Query, UseGuards, - Post + Post, + Delete } from '@nestjs/common'; import { CommandBus } from '@nestjs/cqrs'; import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger'; -import { - IOrganizationSprint, - IOrganizationSprintUpdateInput, - IPagination -} from '@gauzy/contracts'; -import { CrudController } from './../core/crud'; +import { DeleteResult } from 'typeorm'; +import { ID, IOrganizationSprint, IPagination, PermissionsEnum } from '@gauzy/contracts'; +import { CrudController, PaginationParams } from './../core/crud'; +import { Permissions } from './../shared/decorators'; +import { PermissionGuard, TenantPermissionGuard } from './../shared/guards'; import { OrganizationSprint } from './organization-sprint.entity'; import { OrganizationSprintService } from './organization-sprint.service'; -import { OrganizationSprintUpdateCommand } from './commands'; -import { TenantPermissionGuard } from './../shared/guards'; -import { ParseJsonPipe, UUIDValidationPipe } from './../shared/pipes'; +import { OrganizationSprintCreateCommand, OrganizationSprintUpdateCommand } from './commands'; +import { ParseJsonPipe, UseValidationPipe, UUIDValidationPipe } from './../shared/pipes'; +import { CreateOrganizationSprintDTO, UpdateOrganizationSprintDTO } from './dto'; @ApiTags('OrganizationSprint') -@UseGuards(TenantPermissionGuard) +@UseGuards(TenantPermissionGuard, PermissionGuard) +@Permissions(PermissionsEnum.ALL_ORG_EDIT) @Controller() export class OrganizationSprintController extends CrudController { constructor( @@ -35,12 +36,6 @@ export class OrganizationSprintController extends CrudController> { + @UseValidationPipe() + async findAll(@Query('data', ParseJsonPipe) data: any): Promise> { const { relations, findInput } = data; return this.organizationSprintService.findAll({ where: findInput, @@ -64,12 +59,21 @@ export class OrganizationSprintController extends CrudController + ): Promise { + return await this.organizationSprintService.findOneByIdString(id, params); + } + /** * CREATE organization sprint - * - * @param entity - * @param options - * @returns + * + * @param entity + * @param options + * @returns */ @ApiOperation({ summary: 'Create new record' }) @ApiResponse({ @@ -78,23 +82,22 @@ export class OrganizationSprintController extends CrudController { - return this.organizationSprintService.create(body); + async create(@Body() entity: CreateOrganizationSprintDTO): Promise { + return await this.commandBus.execute(new OrganizationSprintCreateCommand(entity)); } /** * UPDATE organization sprint by id - * - * @param id - * @param entity - * @returns + * + * @param id + * @param entity + * @returns */ @ApiOperation({ summary: 'Update an existing record' }) @ApiResponse({ @@ -107,17 +110,22 @@ export class OrganizationSprintController extends CrudController { - return this.commandBus.execute( - new OrganizationSprintUpdateCommand(id, body) - ); + return this.commandBus.execute(new OrganizationSprintUpdateCommand(id, entity)); + } + + @Permissions(PermissionsEnum.ALL_ORG_EDIT, PermissionsEnum.ORG_SPRINT_EDIT) + @Delete(':id') + async delete(@Param('id', UUIDValidationPipe) id: ID): Promise { + return await this.organizationSprintService.delete(id); } } diff --git a/packages/core/src/organization-sprint/organization-sprint.entity.ts b/packages/core/src/organization-sprint/organization-sprint.entity.ts index cb8aacaa473..1637d1b668d 100644 --- a/packages/core/src/organization-sprint/organization-sprint.entity.ts +++ b/packages/core/src/organization-sprint/organization-sprint.entity.ts @@ -1,14 +1,31 @@ import { JoinColumn } from 'typeorm'; -import { IOrganizationProjectModule, IOrganizationSprint, SprintStartDayEnum } from '@gauzy/contracts'; import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; -import { IsDate, IsNotEmpty, IsNumber, IsOptional, IsString } from 'class-validator'; +import { IsDate, IsEnum, IsNotEmpty, IsNumber, IsOptional, IsString } from 'class-validator'; +import { isMySQL, isPostgres } from '@gauzy/config'; +import { + ID, + IOrganizationProjectModule, + IOrganizationSprint, + IOrganizationSprintEmployee, + IOrganizationSprintTask, + IOrganizationSprintTaskHistory, + JsonData, + ITaskView, + OrganizationSprintStatusEnum, + SprintStartDayEnum +} from '@gauzy/contracts'; import { OrganizationProject, OrganizationProjectModule, + OrganizationSprintEmployee, + OrganizationSprintTask, + OrganizationSprintTaskHistory, Task, + TaskView, TenantOrganizationBaseEntity } from '../core/entities/internal'; import { + ColumnIndex, MultiORMColumn, MultiORMEntity, MultiORMManyToMany, @@ -48,11 +65,24 @@ export class OrganizationSprint extends TenantOrganizationBaseEntity implements @MultiORMColumn({ nullable: true }) endDate?: Date; - @ApiProperty({ type: () => Number, enum: SprintStartDayEnum }) - @IsNumber() + @ApiPropertyOptional({ type: () => String, enum: OrganizationSprintStatusEnum }) + @IsNotEmpty() + @IsEnum(OrganizationSprintStatusEnum) + @ColumnIndex() + @MultiORMColumn({ nullable: true }) + status?: OrganizationSprintStatusEnum; + + @ApiPropertyOptional({ type: () => Number, enum: SprintStartDayEnum }) + @IsOptional() + @IsEnum(SprintStartDayEnum) @MultiORMColumn({ nullable: true }) dayStart?: number; + @ApiPropertyOptional({ type: () => Object }) + @IsOptional() + @MultiORMColumn({ type: isPostgres() ? 'jsonb' : isMySQL() ? 'json' : 'text', nullable: true }) + sprintProgress?: JsonData; + /* |-------------------------------------------------------------------------- | @ManyToOne @@ -71,13 +101,13 @@ export class OrganizationSprint extends TenantOrganizationBaseEntity implements onDelete: 'CASCADE' }) @JoinColumn() - project?: OrganizationProject; + project: OrganizationProject; @ApiProperty({ type: () => String }) @IsString() @IsNotEmpty() @MultiORMColumn({ relationId: true }) - projectId: string; + projectId: ID; /* |-------------------------------------------------------------------------- @@ -85,11 +115,56 @@ export class OrganizationSprint extends TenantOrganizationBaseEntity implements |-------------------------------------------------------------------------- */ + /** + * OrganizationTeamEmployee + */ + @MultiORMOneToMany(() => OrganizationSprintEmployee, (it) => it.organizationSprint, { + /** If set to true then it means that related object can be allowed to be inserted or updated in the database. */ + cascade: true + }) + members?: IOrganizationSprintEmployee[]; + + /** + * Sprint Tasks (Many-To-Many sprint tasks) + */ + @MultiORMOneToMany(() => OrganizationSprintTask, (it) => it.organizationSprint, { + /** If set to true then it means that related object can be allowed to be inserted or updated in the database. */ + cascade: true + }) + taskSprints?: IOrganizationSprintTask[]; + + /** + * Tasks (Task active sprint) + */ @ApiProperty({ type: () => Task }) @MultiORMOneToMany(() => Task, (task) => task.organizationSprint) @JoinColumn() tasks?: Task[]; + /** + * Sprint views + */ + @MultiORMOneToMany(() => TaskView, (sprint) => sprint.organizationSprint) + views?: ITaskView[]; + + /** + * From OrganizationSprint histories + */ + @MultiORMOneToMany(() => OrganizationSprintTaskHistory, (it) => it.fromSprint, { + /** If set to true then it means that related object can be allowed to be inserted or updated in the database. */ + cascade: true + }) + fromSprintTaskHistories?: IOrganizationSprintTaskHistory[]; + + /** + * From OrganizationSprint histories + */ + @MultiORMOneToMany(() => OrganizationSprintTaskHistory, (it) => it.toSprint, { + /** If set to true then it means that related object can be allowed to be inserted or updated in the database. */ + cascade: true + }) + toSprintTaskHistories?: IOrganizationSprintTaskHistory[]; + /* |-------------------------------------------------------------------------- | @ManyToMany diff --git a/packages/core/src/organization-sprint/organization-sprint.module.ts b/packages/core/src/organization-sprint/organization-sprint.module.ts index 760039b6793..036ca3a9a32 100644 --- a/packages/core/src/organization-sprint/organization-sprint.module.ts +++ b/packages/core/src/organization-sprint/organization-sprint.module.ts @@ -3,23 +3,50 @@ import { Module } from '@nestjs/common'; import { CqrsModule } from '@nestjs/cqrs'; import { RouterModule } from '@nestjs/core'; import { MikroOrmModule } from '@mikro-orm/nestjs'; +import { OrganizationSprintEmployee } from './organization-sprint-employee.entity'; +import { OrganizationSprintTaskHistory } from './organization-sprint-task-history.entity'; +import { RoleModule } from './../role/role.module'; +import { EmployeeModule } from './../employee/employee.module'; import { OrganizationSprintService } from './organization-sprint.service'; import { OrganizationSprintController } from './organization-sprint.controller'; import { OrganizationSprint } from './organization-sprint.entity'; import { Task } from '../tasks/task.entity'; import { CommandHandlers } from './commands/handlers'; import { RolePermissionModule } from '../role-permission/role-permission.module'; +import { TypeOrmOrganizationSprintRepository } from './repository/type-orm-organization-sprint.repository'; +import { TypeOrmOrganizationSprintEmployeeRepository } from './repository/type-orm-organization-sprint-employee.repository'; +import { TypeOrmOrganizationSprintTaskHistoryRepository } from './repository/type-orm-organization-sprint-task-history.repository'; @Module({ imports: [ RouterModule.register([{ path: '/organization-sprint', module: OrganizationSprintModule }]), - TypeOrmModule.forFeature([OrganizationSprint, Task]), - MikroOrmModule.forFeature([OrganizationSprint, Task]), + TypeOrmModule.forFeature([OrganizationSprint, Task, OrganizationSprintEmployee, OrganizationSprintTaskHistory]), + MikroOrmModule.forFeature([ + OrganizationSprint, + Task, + OrganizationSprintEmployee, + OrganizationSprintTaskHistory + ]), + RoleModule, + EmployeeModule, RolePermissionModule, CqrsModule ], controllers: [OrganizationSprintController], - providers: [OrganizationSprintService, ...CommandHandlers], - exports: [OrganizationSprintService] + providers: [ + OrganizationSprintService, + TypeOrmOrganizationSprintRepository, + TypeOrmOrganizationSprintEmployeeRepository, + TypeOrmOrganizationSprintTaskHistoryRepository, + ...CommandHandlers + ], + exports: [ + TypeOrmModule, + MikroOrmModule, + OrganizationSprintService, + TypeOrmOrganizationSprintRepository, + TypeOrmOrganizationSprintEmployeeRepository, + TypeOrmOrganizationSprintTaskHistoryRepository + ] }) -export class OrganizationSprintModule { } +export class OrganizationSprintModule {} diff --git a/packages/core/src/organization-sprint/organization-sprint.service.ts b/packages/core/src/organization-sprint/organization-sprint.service.ts index b768d597d4c..622854ae373 100644 --- a/packages/core/src/organization-sprint/organization-sprint.service.ts +++ b/packages/core/src/organization-sprint/organization-sprint.service.ts @@ -1,18 +1,334 @@ -import { Injectable } from '@nestjs/common'; -import { InjectRepository } from '@nestjs/typeorm'; -import { OrganizationSprint } from './organization-sprint.entity'; +import { HttpException, HttpStatus, Injectable } from '@nestjs/common'; +import { EventBus } from '@nestjs/cqrs'; +import { + ActionTypeEnum, + ActivityLogEntityEnum, + ActorTypeEnum, + FavoriteEntityEnum, + IActivityLogUpdatedValues, + ID, + IEmployee, + IOrganizationSprint, + IOrganizationSprintCreateInput, + IOrganizationSprintUpdateInput, + RolesEnum +} from '@gauzy/contracts'; +import { isNotEmpty } from '@gauzy/common'; import { TenantAwareCrudService } from './../core/crud'; -import { TypeOrmOrganizationSprintRepository } from './repository/type-orm-organization-sprint.repository'; -import { MikroOrmOrganizationSprintRepository } from './repository/mikro-orm-organization-sprint.repository'; +import { RequestContext } from '../core/context'; +import { OrganizationSprintEmployee } from '../core/entities/internal'; +import { FavoriteService } from '../core/decorators'; +// import { prepareSQLQuery as p } from './../database/database.helper'; +import { ActivityLogEvent } from '../activity-log/events'; +import { generateActivityLogDescription } from '../activity-log/activity-log.helper'; +import { RoleService } from '../role/role.service'; +import { EmployeeService } from '../employee/employee.service'; +import { OrganizationSprint } from './organization-sprint.entity'; +import { TypeOrmEmployeeRepository } from '../employee/repository'; +import { + MikroOrmOrganizationSprintEmployeeRepository, + MikroOrmOrganizationSprintRepository, + TypeOrmOrganizationSprintEmployeeRepository, + TypeOrmOrganizationSprintRepository +} from './repository'; +@FavoriteService(FavoriteEntityEnum.OrganizationSprint) @Injectable() export class OrganizationSprintService extends TenantAwareCrudService { constructor( - @InjectRepository(OrganizationSprint) - typeOrmOrganizationSprintRepository: TypeOrmOrganizationSprintRepository, - - mikroOrmOrganizationSprintRepository: MikroOrmOrganizationSprintRepository + readonly typeOrmOrganizationSprintRepository: TypeOrmOrganizationSprintRepository, + readonly mikroOrmOrganizationSprintRepository: MikroOrmOrganizationSprintRepository, + readonly typeOrmOrganizationSprintEmployeeRepository: TypeOrmOrganizationSprintEmployeeRepository, + readonly mikroOrmOrganizationSprintEmployeeRepository: MikroOrmOrganizationSprintEmployeeRepository, + readonly typeOrmEmployeeRepository: TypeOrmEmployeeRepository, + private readonly _roleService: RoleService, + private readonly _employeeService: EmployeeService, + private readonly _eventBus: EventBus ) { super(typeOrmOrganizationSprintRepository, mikroOrmOrganizationSprintRepository); } + + /** + * Creates an organization sprint based on the provided input. + * @param input - Input data for creating the organization sprint. + * @returns A Promise resolving to the created organization sprint. + * @throws BadRequestException if there is an error in the creation process. + */ + async create(input: IOrganizationSprintCreateInput): Promise { + const tenantId = RequestContext.currentTenantId() || input.tenantId; + const employeeId = RequestContext.currentEmployeeId(); + const currentRoleId = RequestContext.currentRoleId(); + + // Destructure the input data + const { memberIds = [], managerIds = [], organizationId, ...entity } = input; + + try { + // If the current employee creates the sprint, default add him as a manager + try { + // Check if the current role is EMPLOYEE + await this._roleService.findOneByIdString(currentRoleId, { where: { name: RolesEnum.EMPLOYEE } }); + + // Add the current employee to the managerIds if they have the EMPLOYEE role and are not already included. + if (!managerIds.includes(employeeId)) { + // If not included, add the employeeId to the managerIds array. + managerIds.push(employeeId); + } + } catch (error) {} + + // Combine memberIds and managerIds into a single array. + const employeeIds = [...memberIds, ...managerIds].filter(Boolean); + + // Retrieve a collection of employees based on specified criteria. + const employees = await this._employeeService.findActiveEmployeesByEmployeeIds( + employeeIds, + organizationId, + tenantId + ); + + // Find the manager role + const managerRole = await this._roleService.findOneByWhereOptions({ + name: RolesEnum.MANAGER + }); + + // Create a Set for faster membership checks + const managerIdsSet = new Set(managerIds); + + // Use destructuring to directly extract 'id' from 'employee' + const members = employees.map(({ id: employeeId }) => { + // If the employee is manager, assign the existing manager with the latest assignedAt date. + const isManager = managerIdsSet.has(employeeId); + const assignedAt = new Date(); + + return new OrganizationSprintEmployee({ + employeeId, + organizationId, + tenantId, + isManager, + assignedAt, + role: isManager ? managerRole : null + }); + }); + + // Create the organization sprint with the prepared members. + const sprint = await super.create({ + ...entity, + members, + organizationId, + tenantId + }); + + // Generate the activity log description. + const description = generateActivityLogDescription( + ActionTypeEnum.Created, + ActivityLogEntityEnum.OrganizationSprint, + sprint.name + ); + + // Emit an event to log the activity + this._eventBus.publish( + new ActivityLogEvent({ + entity: ActivityLogEntityEnum.OrganizationSprint, + entityId: sprint.id, + action: ActionTypeEnum.Created, + actorType: ActorTypeEnum.User, + description, + data: sprint, + organizationId, + tenantId + }) + ); + + return sprint; + } catch (error) { + // Handle errors and return an appropriate error response + throw new HttpException(`Failed to create organization sprint: ${error.message}`, HttpStatus.BAD_REQUEST); + } + } + + /** + * Update an organization sprint. + * + * @param id - The ID of the organization sprint to be updated. + * @param input - The updated information for the organization sprint. + * @returns A Promise resolving to the updated organization sprint. + * @throws ForbiddenException if the user lacks permission or if certain conditions are not met. + * @throws BadRequestException if there's an error during the update process. + */ + async update(id: ID, input: IOrganizationSprintUpdateInput): Promise { + const tenantId = RequestContext.currentTenantId() || input.tenantId; + + // Destructure the input data + const { memberIds = [], managerIds = [], organizationId, projectId } = input; + + try { + // Search for existing Organization Sprint + let organizationSprint = await super.findOneByIdString(id, { + where: { organizationId, tenantId, projectId }, + relations: { + members: true, + modules: true + } + }); + + // Retrieve members and managers IDs + if (isNotEmpty(memberIds) || isNotEmpty(managerIds)) { + // Combine memberIds and managerIds into a single array + const employeeIds = [...memberIds, ...managerIds].filter(Boolean); + + // Retrieve a collection of employees based on specified criteria. + const sprintMembers = await this._employeeService.findActiveEmployeesByEmployeeIds( + employeeIds, + organizationId, + tenantId + ); + + // Update nested entity (Organization Sprint Members) + await this.updateOrganizationSprintMembers(id, organizationId, sprintMembers, managerIds, memberIds); + + // Update the organization sprint with the prepared members + const { id: organizationSprintId } = organizationSprint; + const updatedSprint = await super.create({ + ...input, + organizationId, + tenantId, + id: organizationSprintId + }); + + const description = generateActivityLogDescription( + ActionTypeEnum.Updated, + ActivityLogEntityEnum.OrganizationSprint, + updatedSprint.name + ); + + // Compare values before and after update then add updates to fields + const updatedFields = []; + const previousValues: IActivityLogUpdatedValues[] = []; + const updatedValues: IActivityLogUpdatedValues[] = []; + + for (const key of Object.keys(input)) { + if (organizationSprint[key] !== input[key]) { + // Add updated field + updatedFields.push(key); + + // Add old and new values + previousValues.push({ [key]: organizationSprint[key] }); + updatedValues.push({ [key]: updatedSprint[key] }); + } + } + + // Emit event to log activity + this._eventBus.publish( + new ActivityLogEvent({ + entity: ActivityLogEntityEnum.OrganizationSprint, + entityId: updatedSprint.id, + action: ActionTypeEnum.Updated, + actorType: ActorTypeEnum.User, + description, + updatedFields, + updatedValues, + previousValues, + data: updatedSprint, + organizationId, + tenantId + }) + ); + + // return updated sprint + return updatedSprint; + } + } catch (error) { + // Handle errors and return an appropriate error response + throw new HttpException(`Failed to update organization sprint: ${error.message}`, HttpStatus.BAD_REQUEST); + } + } + + /** + * Delete sprint members by IDs. + * + * @param memberIds - Array of member IDs to delete + * @returns A promise that resolves when all deletions are complete + */ + async deleteMemberByIds(memberIds: ID[]): Promise { + // Map member IDs to deletion promises + const deletePromises = memberIds.map((memberId) => + this.typeOrmOrganizationSprintEmployeeRepository.delete(memberId) + ); + + // Wait for all deletions to complete + await Promise.all(deletePromises); + } + + /** + * Updates an organization sprint by managing its members and their roles. + * + * @param organizationSprintId - ID of the organization sprint + * @param organizationId - ID of the organization + * @param employees - Array of employees to be assigned to the sprint + * @param managerIds - Array of employee IDs to be assigned as managers + * @param memberIds - Array of employee IDs to be assigned as members + * @returns Promise + */ + async updateOrganizationSprintMembers( + organizationSprintId: ID, + organizationId: ID, + employees: IEmployee[], + managerIds: ID[], + memberIds: ID[] + ): Promise { + const tenantId = RequestContext.currentTenantId(); + const membersToUpdate = new Set([...managerIds, ...memberIds].filter(Boolean)); + + // Find the manager role. + const managerRole = await this._roleService.findOneByWhereOptions({ + name: RolesEnum.MANAGER + }); + + // Fetch existing sprint members with their roles. + const sprintMembers = await this.typeOrmOrganizationSprintEmployeeRepository.find({ + where: { tenantId, organizationId, organizationSprintId } + }); + + // Create a map of existing members for quick lookup + const existingMemberMap = new Map(sprintMembers.map((member) => [member.employeeId, member])); + + // Separate members into removed, updated and new members + const removedMembers = sprintMembers.filter((member) => !membersToUpdate.has(member.employeeId)); + const updatedMembers = sprintMembers.filter((member) => membersToUpdate.has(member.employeeId)); + const newMembers = employees.filter((employee) => !existingMemberMap.has(employee.id)); + + // 1. Remove members who are no longer assigned to the sprint + if (removedMembers.length) { + await this.deleteMemberByIds(removedMembers.map((member) => member.id)); + } + + // 2. Update roles for existing members where necessary. + await Promise.all( + updatedMembers.map(async (member) => { + const isManager = managerIds.includes(member.employeeId); + const newRole = isManager ? managerRole : null; + + // Only update if the role has changed + if (newRole && newRole.id !== member.roleId) { + await this.typeOrmOrganizationSprintEmployeeRepository.update(member.id, { role: newRole }); + } + }) + ); + + // 3. Add new members to the sprint + if (newMembers.length) { + const newSprintMembers = newMembers.map( + (employee) => + new OrganizationSprintEmployee({ + organizationSprintId, + employeeId: employee.id, + tenantId, + organizationId, + isManager: managerIds.includes(employee.id), + roleId: managerIds.includes(employee.id) ? managerRole.id : null + }) + ); + + await this.typeOrmOrganizationSprintEmployeeRepository.save(newSprintMembers); + } + } } diff --git a/packages/core/src/organization-sprint/repository/index.ts b/packages/core/src/organization-sprint/repository/index.ts new file mode 100644 index 00000000000..e97a0699c62 --- /dev/null +++ b/packages/core/src/organization-sprint/repository/index.ts @@ -0,0 +1,8 @@ +export * from './mikro-orm-organization-sprint-employee.repository'; +export * from './mikro-orm-organization-sprint.repository'; +export * from './type-orm-organization-sprint.repository'; +export * from './type-orm-organization-sprint-employee.repository'; +export * from './type-orm-organization-sprint-task.repository'; +export * from './mikro-orm-organization-sprint-task.repository'; +export * from './type-orm-organization-sprint-task-history.repository'; +export * from './mikro-orm-organization-sprint-task-history.repository'; diff --git a/packages/core/src/organization-sprint/repository/mikro-orm-organization-sprint-employee.repository.ts b/packages/core/src/organization-sprint/repository/mikro-orm-organization-sprint-employee.repository.ts new file mode 100644 index 00000000000..52bf29d08ea --- /dev/null +++ b/packages/core/src/organization-sprint/repository/mikro-orm-organization-sprint-employee.repository.ts @@ -0,0 +1,4 @@ +import { MikroOrmBaseEntityRepository } from '../../core/repository/mikro-orm-base-entity.repository'; +import { OrganizationSprintEmployee } from '../organization-sprint-employee.entity'; + +export class MikroOrmOrganizationSprintEmployeeRepository extends MikroOrmBaseEntityRepository {} diff --git a/packages/core/src/organization-sprint/repository/mikro-orm-organization-sprint-task-history.repository.ts b/packages/core/src/organization-sprint/repository/mikro-orm-organization-sprint-task-history.repository.ts new file mode 100644 index 00000000000..a06b99b37ad --- /dev/null +++ b/packages/core/src/organization-sprint/repository/mikro-orm-organization-sprint-task-history.repository.ts @@ -0,0 +1,4 @@ +import { MikroOrmBaseEntityRepository } from '../../core/repository/mikro-orm-base-entity.repository'; +import { OrganizationSprintTaskHistory } from '../organization-sprint-task-history.entity'; + +export class MikroOrmOrganizationSprintTaskHistoryRepository extends MikroOrmBaseEntityRepository {} diff --git a/packages/core/src/organization-sprint/repository/mikro-orm-organization-sprint-task.repository.ts b/packages/core/src/organization-sprint/repository/mikro-orm-organization-sprint-task.repository.ts new file mode 100644 index 00000000000..779315bcad8 --- /dev/null +++ b/packages/core/src/organization-sprint/repository/mikro-orm-organization-sprint-task.repository.ts @@ -0,0 +1,4 @@ +import { MikroOrmBaseEntityRepository } from '../../core/repository/mikro-orm-base-entity.repository'; +import { OrganizationSprintTask } from '../organization-sprint-task.entity'; + +export class MikroOrmOrganizationSprintTaskRepository extends MikroOrmBaseEntityRepository {} diff --git a/packages/core/src/organization-sprint/repository/type-orm-organization-sprint-employee.repository.ts b/packages/core/src/organization-sprint/repository/type-orm-organization-sprint-employee.repository.ts new file mode 100644 index 00000000000..c8a0a01266a --- /dev/null +++ b/packages/core/src/organization-sprint/repository/type-orm-organization-sprint-employee.repository.ts @@ -0,0 +1,13 @@ +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { OrganizationSprintEmployee } from '../organization-sprint-employee.entity'; + +@Injectable() +export class TypeOrmOrganizationSprintEmployeeRepository extends Repository { + constructor( + @InjectRepository(OrganizationSprintEmployee) readonly repository: Repository + ) { + super(repository.target, repository.manager, repository.queryRunner); + } +} diff --git a/packages/core/src/organization-sprint/repository/type-orm-organization-sprint-task-history.repository.ts b/packages/core/src/organization-sprint/repository/type-orm-organization-sprint-task-history.repository.ts new file mode 100644 index 00000000000..f32ad19756a --- /dev/null +++ b/packages/core/src/organization-sprint/repository/type-orm-organization-sprint-task-history.repository.ts @@ -0,0 +1,13 @@ +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { OrganizationSprintTaskHistory } from '../organization-sprint-task-history.entity'; + +@Injectable() +export class TypeOrmOrganizationSprintTaskHistoryRepository extends Repository { + constructor( + @InjectRepository(OrganizationSprintTaskHistory) readonly repository: Repository + ) { + super(repository.target, repository.manager, repository.queryRunner); + } +} diff --git a/packages/core/src/organization-sprint/repository/type-orm-organization-sprint-task.repository.ts b/packages/core/src/organization-sprint/repository/type-orm-organization-sprint-task.repository.ts new file mode 100644 index 00000000000..59efc27409f --- /dev/null +++ b/packages/core/src/organization-sprint/repository/type-orm-organization-sprint-task.repository.ts @@ -0,0 +1,11 @@ +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { OrganizationSprintTask } from '../organization-sprint-task.entity'; + +@Injectable() +export class TypeOrmOrganizationSprintTaskRepository extends Repository { + constructor(@InjectRepository(OrganizationSprintTask) readonly repository: Repository) { + super(repository.target, repository.manager, repository.queryRunner); + } +} diff --git a/packages/core/src/organization-team-employee/organization-team-employee.service.ts b/packages/core/src/organization-team-employee/organization-team-employee.service.ts index d65073d5c51..f8155439e1f 100644 --- a/packages/core/src/organization-team-employee/organization-team-employee.service.ts +++ b/packages/core/src/organization-team-employee/organization-team-employee.service.ts @@ -42,8 +42,8 @@ export class OrganizationTeamEmployeeService extends TenantAwareCrudService { const tenantId = RequestContext.currentTenantId(); const membersToUpdate = [...managerIds, ...memberIds]; diff --git a/packages/core/src/organization-team/dto/organization-team.dto.ts b/packages/core/src/organization-team/dto/organization-team.dto.ts index c2e2e95e226..40c1d43c199 100644 --- a/packages/core/src/organization-team/dto/organization-team.dto.ts +++ b/packages/core/src/organization-team/dto/organization-team.dto.ts @@ -1,14 +1,17 @@ import { ApiPropertyOptional, IntersectionType, PartialType, PickType } from '@nestjs/swagger'; import { IsArray, IsBoolean, IsOptional, IsString } from 'class-validator'; import { IOrganizationProject, IOrganizationTeam } from '@gauzy/contracts'; -import { TenantOrganizationBaseDTO } from './../../core/dto'; +import { MemberEntityBasedDTO, TenantOrganizationBaseDTO } from './../../core/dto'; import { RelationalTagDTO } from './../../tags/dto'; import { OrganizationTeam } from './../organization-team.entity'; import { OrganizationProject } from '../../organization-project/organization-project.entity'; export class OrganizationTeamDTO extends IntersectionType( - IntersectionType(TenantOrganizationBaseDTO, PartialType(RelationalTagDTO)), + IntersectionType( + TenantOrganizationBaseDTO, + IntersectionType(PartialType(RelationalTagDTO), MemberEntityBasedDTO) + ), PickType(OrganizationTeam, ['logo', 'prefix', 'imageId', 'shareProfileView', 'requirePlanToTrack']) ) implements Omit @@ -36,16 +39,6 @@ export class OrganizationTeamDTO @IsString() readonly teamSize?: string; - @ApiPropertyOptional({ type: () => String, isArray: true }) - @IsOptional() - @IsArray() - readonly memberIds?: string[] = []; - - @ApiPropertyOptional({ type: () => String, isArray: true }) - @IsOptional() - @IsArray() - readonly managerIds?: string[] = []; - @ApiPropertyOptional({ type: () => OrganizationProject, isArray: true }) @IsOptional() @IsArray() diff --git a/packages/core/src/organization-team/organization-team.entity.ts b/packages/core/src/organization-team/organization-team.entity.ts index ec315ebe5e7..af8b0198637 100644 --- a/packages/core/src/organization-team/organization-team.entity.ts +++ b/packages/core/src/organization-team/organization-team.entity.ts @@ -19,6 +19,7 @@ import { ITaskSize, ITaskStatus, ITaskVersion, + ITaskView, IUser } from '@gauzy/contracts'; import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; @@ -41,6 +42,7 @@ import { TaskSize, TaskStatus, TaskVersion, + TaskView, TenantOrganizationBaseEntity, User } from '../core/entities/internal'; @@ -256,6 +258,12 @@ export class OrganizationTeam extends TenantOrganizationBaseEntity implements IO @MultiORMOneToMany(() => TaskVersion, (it) => it.organizationTeam) versions?: ITaskVersion[]; + /** + * Team views + */ + @MultiORMOneToMany(() => TaskView, (it) => it.organizationTeam) + views?: ITaskView[]; + /** * Team Labels */ diff --git a/packages/core/src/organization-team/organization-team.service.ts b/packages/core/src/organization-team/organization-team.service.ts index bf1acd1541b..f9f0e4a999c 100644 --- a/packages/core/src/organization-team/organization-team.service.ts +++ b/packages/core/src/organization-team/organization-team.service.ts @@ -172,8 +172,8 @@ export class OrganizationTeamService extends TenantAwareCrudService { diff --git a/packages/core/src/shared/dto/filters-query.dto.ts b/packages/core/src/shared/dto/filters-query.dto.ts index 462480029c5..481038d6663 100644 --- a/packages/core/src/shared/dto/filters-query.dto.ts +++ b/packages/core/src/shared/dto/filters-query.dto.ts @@ -1,28 +1,45 @@ -import { ApiPropertyOptional } from "@nestjs/swagger"; -import { IsEnum, IsOptional } from "class-validator"; -import { ITimeLogFilters, TimeLogSourceEnum, TimeLogType } from "@gauzy/contracts"; -import { IsBetweenActivty } from "./../../shared/validators"; +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { Type } from 'class-transformer'; +import { IsEnum, IsOptional } from 'class-validator'; +import { ITimeLogFilters, TimeLogSourceEnum, TimeLogType } from '@gauzy/contracts'; +import { IsBetweenActivity } from './../../shared/validators'; /** - * Get filters common request DTO validation + * Data Transfer Object for filtering time logs based on source, log type, and activity level. + * This DTO provides optional filters to refine time log queries. */ export class FiltersQueryDTO implements ITimeLogFilters { + /** + * Filters time logs by their source. + * Can include multiple sources like Desktop, Web, or Mobile. + * If not provided, no filtering by source will be applied. + */ + @ApiPropertyOptional({ enum: TimeLogSourceEnum }) + @IsOptional() + @IsEnum(TimeLogSourceEnum, { each: true }) + source: TimeLogSourceEnum[]; - @ApiPropertyOptional({ enum: TimeLogSourceEnum }) - @IsOptional() - @IsEnum(TimeLogSourceEnum, { each: true }) - readonly source: TimeLogSourceEnum[]; + /** + * Filters time logs by their log type (Manual, Tracked, etc.). + * Multiple log types can be specified. + * If not provided, no filtering by log type will be applied. + */ + @ApiPropertyOptional({ enum: TimeLogType }) + @IsOptional() + @IsEnum(TimeLogType, { each: true }) + logType: TimeLogType[]; - @ApiPropertyOptional({ enum: TimeLogType }) - @IsOptional() - @IsEnum(TimeLogType, { each: true }) - readonly logType: TimeLogType[]; - - @ApiPropertyOptional({ type: () => 'object' }) - @IsOptional() - @IsBetweenActivty(FiltersQueryDTO, (it) => it.activityLevel) - readonly activityLevel: { - start: number; - end: number; - }; + /** + * Filters time logs by activity level, specifying a range between `start` and `end`. + * This filter limits logs to a specific activity range (e.g., from 10% to 90% activity). + * If not provided, no filtering by activity level will be applied. + */ + @ApiPropertyOptional({ type: () => Object }) + @IsOptional() + @IsBetweenActivity(FiltersQueryDTO, (it) => it.activityLevel) + @Type(() => Object) + activityLevel: { + start: number; + end: number; + }; } diff --git a/packages/core/src/shared/dto/relations-query.dto.ts b/packages/core/src/shared/dto/relations-query.dto.ts index bbb1b1a5a68..fec66747dbf 100644 --- a/packages/core/src/shared/dto/relations-query.dto.ts +++ b/packages/core/src/shared/dto/relations-query.dto.ts @@ -1,16 +1,15 @@ -import { ApiPropertyOptional } from "@nestjs/swagger"; -import { Transform, TransformFnParams } from "class-transformer"; -import { IsArray, IsOptional } from "class-validator"; -import { IBaseRelationsEntityModel } from "@gauzy/contracts"; +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { Transform, TransformFnParams } from 'class-transformer'; +import { IsArray, IsOptional } from 'class-validator'; +import { IBaseRelationsEntityModel } from '@gauzy/contracts'; /** * Get relations request DTO validation */ export class RelationsQueryDTO implements IBaseRelationsEntityModel { - - @ApiPropertyOptional({ type: () => Array, isArray: true }) - @IsOptional() - @IsArray() - @Transform(({ value }: TransformFnParams) => (value) ? value.map((element: string) => element.trim()) : []) - readonly relations: string[] = []; + @ApiPropertyOptional({ type: () => Array, isArray: true }) + @IsOptional() + @IsArray() + @Transform(({ value }: TransformFnParams) => (value ? value.map((element: string) => element.trim()) : [])) + readonly relations: string[] = []; } diff --git a/packages/core/src/shared/dto/selectors-query.dto.ts b/packages/core/src/shared/dto/selectors-query.dto.ts index 7af3cf1e25e..078276a92e2 100644 --- a/packages/core/src/shared/dto/selectors-query.dto.ts +++ b/packages/core/src/shared/dto/selectors-query.dto.ts @@ -1,43 +1,46 @@ import { ApiPropertyOptional } from '@nestjs/swagger'; import { IsOptional, IsArray } from 'class-validator'; -import { ITimeLogFilters } from '@gauzy/contracts'; +import { ID, ITimeLogFilters } from '@gauzy/contracts'; import { DateRangeQueryDTO } from './date-range-query.dto'; /** - * Get selectors common request DTO validation. - * Extends DateRangeQueryDTO to include date range filters. + * Data Transfer Object for filtering time logs by various selectors. + * Extends DateRangeQueryDTO to include date range filters alongside employee, project, task, and team selectors. */ export class SelectorsQueryDTO extends DateRangeQueryDTO implements ITimeLogFilters { + /** + * An array of employee IDs to filter the time logs by specific employees. + * If not provided, no filtering by employee will be applied. + */ + @ApiPropertyOptional({ type: () => Array, isArray: true }) + @IsOptional() + @IsArray() + employeeIds: ID[]; - /** - * An array of employee IDs for filtering time logs. - */ - @ApiPropertyOptional({ type: () => Array, isArray: true }) - @IsOptional() - @IsArray() - readonly employeeIds: string[]; + /** + * An array of project IDs to filter the time logs by specific projects. + * If not provided, no filtering by project will be applied. + */ + @ApiPropertyOptional({ type: () => Array, isArray: true }) + @IsOptional() + @IsArray() + projectIds: ID[]; - /** - * An array of project IDs for filtering time logs. - */ - @ApiPropertyOptional({ type: () => Array, isArray: true }) - @IsOptional() - @IsArray() - readonly projectIds: string[]; + /** + * An array of task IDs to filter the time logs by specific tasks. + * If not provided, no filtering by task will be applied. + */ + @ApiPropertyOptional({ type: () => Array, isArray: true }) + @IsOptional() + @IsArray() + taskIds: ID[]; - /** - * An array of task IDs for filtering time logs. - */ - @ApiPropertyOptional({ type: () => Array, isArray: true }) - @IsOptional() - @IsArray() - readonly taskIds: string[]; - - /** - * An array of team IDs for filtering time logs. - */ - @ApiPropertyOptional({ type: () => Array, isArray: true }) - @IsOptional() - @IsArray() - readonly teamIds: string[]; + /** + * An array of team IDs to filter the time logs by specific teams. + * If not provided, no filtering by team will be applied. + */ + @ApiPropertyOptional({ type: () => Array, isArray: true }) + @IsOptional() + @IsArray() + teamIds: ID[]; } diff --git a/packages/core/src/shared/validators/is-between-activity.decorator.ts b/packages/core/src/shared/validators/is-between-activity.decorator.ts index 5d79fd81c62..8e7b01c2828 100644 --- a/packages/core/src/shared/validators/is-between-activity.decorator.ts +++ b/packages/core/src/shared/validators/is-between-activity.decorator.ts @@ -1,11 +1,11 @@ -import { ClassConstructor } from "class-transformer"; +import { ClassConstructor } from 'class-transformer'; import { - ValidationArguments, - ValidationOptions, - ValidatorConstraint, - ValidatorConstraintInterface, - registerDecorator -} from "class-validator"; + ValidationArguments, + ValidationOptions, + ValidatorConstraint, + ValidatorConstraintInterface, + registerDecorator +} from 'class-validator'; /** * IsBetweenActivity custom decorator. @@ -13,16 +13,20 @@ import { * @param validationOptions - Validation options. * @returns {PropertyDecorator} - Decorator function. */ -export const IsBetweenActivty = (type: ClassConstructor, property: (o: T) => any, validationOptions?: ValidationOptions): PropertyDecorator => { - return (object: any, propertyName: string) => { - registerDecorator({ - target: object.constructor, - propertyName, - options: validationOptions, - constraints: [property], - validator: BetweenActivtyConstraint, - }); - }; +export const IsBetweenActivity = ( + type: ClassConstructor, + property: (o: T) => any, + validationOptions?: ValidationOptions +): PropertyDecorator => { + return (object: any, propertyName: string) => { + registerDecorator({ + target: object.constructor, + propertyName, + options: validationOptions, + constraints: [property], + validator: BetweenActivityConstraint + }); + }; }; /** @@ -31,32 +35,35 @@ export const IsBetweenActivty = (type: ClassConstructor, property: (o: T) * @param validationOptions * @returns */ -@ValidatorConstraint({ name: "IsBetweenActivty", async: false }) -export class BetweenActivtyConstraint implements ValidatorConstraintInterface { - /** - * Validate if the start and end values in the activityLevel object are between 0 and 100 (inclusive). - * - * @param activityLevel - The object containing start and end properties to be validated. - * @param args - Validation arguments. - * @returns {boolean} - Returns `true` if both start and end values are between 0 and 100 (inclusive); otherwise, `false`. - */ - validate(activityLevel: { - start: number; - end: number; - }, args: ValidationArguments): boolean { - const { start, end } = activityLevel; +@ValidatorConstraint({ name: 'IsBetweenActivity', async: false }) +export class BetweenActivityConstraint implements ValidatorConstraintInterface { + /** + * Validate if the start and end values in the activityLevel object are between 0 and 100 (inclusive). + * + * @param activityLevel - The object containing start and end properties to be validated. + * @param args - Validation arguments. + * @returns {boolean} - Returns `true` if both start and end values are between 0 and 100 (inclusive); otherwise, `false`. + */ + validate( + activityLevel: { + start: number; + end: number; + }, + args: ValidationArguments + ): boolean { + const { start, end } = activityLevel; - // Check if start and end values are within the range [0, 100] - return (start >= 0) && (end <= 100); - } + // Check if start and end values are within the range [0, 100] + return start >= 0 && end <= 100; + } - /** - * Get the default error message for the IsBetweenActivty constraint. - * - * @param args - Validation arguments. - * @returns {string} - The default error message. - */ - defaultMessage(args: ValidationArguments): string { - return "Start & End must be between 0 and 100"; - } + /** + * Get the default error message for the IsBetweenActivity constraint. + * + * @param args - Validation arguments. + * @returns {string} - The default error message. + */ + defaultMessage(args: ValidationArguments): string { + return 'Start & End must be between 0 and 100'; + } } diff --git a/packages/core/src/tasks/commands/handlers/task-create.handler.ts b/packages/core/src/tasks/commands/handlers/task-create.handler.ts index c2ac42af5ce..20255083409 100644 --- a/packages/core/src/tasks/commands/handlers/task-create.handler.ts +++ b/packages/core/src/tasks/commands/handlers/task-create.handler.ts @@ -33,67 +33,84 @@ export class TaskCreateHandler implements ICommandHandler { try { // Destructure input and triggered event flag from the command const { input, triggeredEvent } = command; - let { organizationId, project } = input; + const { organizationId } = input; // Retrieve current tenant ID from request context or use input tenant ID - const tenantId = RequestContext.currentTenantId() || input.tenantId; + const tenantId = RequestContext.currentTenantId() ?? input.tenantId; - // If input contains project ID, fetch project details - if (input.projectId) { - const { projectId } = input; - project = await this._organizationProjectService.findOneByIdString(projectId); - } + // Check if projectId is provided, if not use the provided project object from the input. + // If neither is provided, set project to null. + const project = input.projectId + ? await this._organizationProjectService.findOneByIdString(input.projectId) + : input.project || null; + + // Check if project exists and extract the project prefix (first 3 characters of the project name) + const projectPrefix = project?.name?.substring(0, 3) ?? null; - // Determine project ID and task prefix based on project existence - const projectId = project ? project.id : null; - const taskPrefix = project ? project.name.substring(0, 3) : null; + // Log or throw an exception if both projectId and project are not provided (optional) + if (!project) { + this.logger.warn('No projectId or project provided. Proceeding without project information.'); + } - // Retrieve the maximum task number for the specified project + // Retrieve the maximum task number for the specified project, or handle null projectId if no project const maxNumber = await this._taskService.getMaxTaskNumberByProject({ tenantId, organizationId, - projectId + projectId: project?.id ?? null // If no project is provided, this will pass null for projectId }); - // Create the task with incremented number, prefix, and other details - const createdTask = await this._taskService.create({ - ...input, - number: maxNumber + 1, - prefix: taskPrefix, - tenantId, - organizationId + // Create the task with incremented number, project prefix, and other task details + const task = await this._taskService.create({ + ...input, // Spread the input properties + number: maxNumber + 1, // Increment the task number + prefix: projectPrefix, // Use the project prefix, or null if no project + tenantId, // Pass the tenant ID + organizationId // Pass the organization ID }); // Publish a task created event if triggeredEvent flag is set if (triggeredEvent) { - // Publish the task created event const ctx = RequestContext.currentRequestContext(); // Get current request context; - const event = new TaskEvent(ctx, createdTask, BaseEntityEventTypeEnum.CREATED, input); - this._eventBus.publish(event); // Publish the event using EventBus + this._eventBus.publish(new TaskEvent(ctx, task, BaseEntityEventTypeEnum.CREATED, input)); // Publish the event using EventBus } // Generate the activity log description const description = generateActivityLogDescription( ActionTypeEnum.Created, ActivityLogEntityEnum.Task, - createdTask.title + task.title ); + console.log(`Generating activity log description: ${description}`); + // Emit an event to log the activity this._cqrsEventBus.publish( new ActivityLogEvent({ entity: ActivityLogEntityEnum.Task, - entityId: createdTask.id, + entityId: task.id, action: ActionTypeEnum.Created, actorType: ActorTypeEnum.User, // TODO : Since we have Github Integration, make sure we can also store "System" for actor description, - data: createdTask, + data: task, organizationId, tenantId }) ); - return createdTask; // Return the created task + console.log( + `Task created with ID: ${task.id} with activity log: ${JSON.stringify({ + entity: ActivityLogEntityEnum.Task, + entityId: task.id, + action: ActionTypeEnum.Created, + actorType: ActorTypeEnum.User, // TODO : Since we have Github Integration, make sure we can also store "System" for actor + description, + data: task, + organizationId, + tenantId + })}` + ); + + return task; // Return the created task } catch (error) { // Handle errors during task creation this.logger.error(`Error while creating task: ${error.message}`, error.message); diff --git a/packages/core/src/tasks/commands/handlers/task-update.handler.ts b/packages/core/src/tasks/commands/handlers/task-update.handler.ts index 10771a45c47..0464095287a 100644 --- a/packages/core/src/tasks/commands/handlers/task-update.handler.ts +++ b/packages/core/src/tasks/commands/handlers/task-update.handler.ts @@ -1,16 +1,6 @@ -import { CommandHandler, ICommandHandler, EventBus as CqrsEventBus } from '@nestjs/cqrs'; +import { CommandHandler, ICommandHandler } from '@nestjs/cqrs'; import { HttpException, HttpStatus, Logger } from '@nestjs/common'; -import { - ActionTypeEnum, - ActivityLogEntityEnum, - ActorTypeEnum, - IActivityLogUpdatedValues, - ID, - ITask, - ITaskUpdateInput -} from '@gauzy/contracts'; -import { ActivityLogEvent } from '../../../activity-log/events'; -import { generateActivityLogDescription } from '../../../activity-log/activity-log.helper'; +import { ID, ITask, ITaskUpdateInput } from '@gauzy/contracts'; import { TaskEvent } from '../../../event-bus/events'; import { EventBus } from '../../../event-bus/event-bus'; import { BaseEntityEventTypeEnum } from '../../../event-bus/base-entity-event'; @@ -22,11 +12,7 @@ import { TaskUpdateCommand } from '../task-update.command'; export class TaskUpdateHandler implements ICommandHandler { private readonly logger = new Logger('TaskUpdateHandler'); - constructor( - private readonly _eventBus: EventBus, - private readonly _cqrsEventBus: CqrsEventBus, - private readonly _taskService: TaskService - ) {} + constructor(private readonly _eventBus: EventBus, private readonly _taskService: TaskService) {} /** * Executes the TaskUpdateCommand. @@ -52,31 +38,8 @@ export class TaskUpdateHandler implements ICommandHandler { */ public async update(id: ID, input: ITaskUpdateInput, triggeredEvent: boolean): Promise { try { - const tenantId = RequestContext.currentTenantId() || input.tenantId; - const task = await this._taskService.findOneByIdString(id); - - if (input.projectId && input.projectId !== task.projectId) { - const { organizationId, projectId } = task; - - // Get the maximum task number for the project - const maxNumber = await this._taskService.getMaxTaskNumberByProject({ - tenantId, - organizationId, - projectId - }); - - // Update the task with the new project and task number - await this._taskService.update(id, { - projectId, - number: maxNumber + 1 - }); - } - // Update the task with the provided data - const updatedTask = await this._taskService.create({ - ...input, - id - }); + const updatedTask = await this._taskService.update(id, input); // The "2 Way Sync Triggered Event" for Synchronization if (triggeredEvent) { @@ -86,63 +49,10 @@ export class TaskUpdateHandler implements ICommandHandler { this._eventBus.publish(event); // Publish the event using EventBus } - // Generate the activity log description - const description = generateActivityLogDescription( - ActionTypeEnum.Updated, - ActivityLogEntityEnum.Task, - updatedTask.title - ); - - const { updatedFields, previousValues, updatedValues } = this.activityLogUpdatedFieldsAndValues( - updatedTask, - input - ); - - // Emit an event to log the activity - this._cqrsEventBus.publish( - new ActivityLogEvent({ - entity: ActivityLogEntityEnum.Task, - entityId: updatedTask.id, - action: ActionTypeEnum.Updated, - actorType: ActorTypeEnum.User, // TODO : Since we have Github Integration, make sure we can also store "System" for actor - description, - updatedFields, - updatedValues, - previousValues, - data: updatedTask, - organizationId: updatedTask.organizationId, - tenantId - }) - ); - return updatedTask; } catch (error) { this.logger.error(`Error while updating task: ${error.message}`, error.message); throw new HttpException({ message: error?.message, error }, HttpStatus.BAD_REQUEST); } } - - /** - * @description - Compare values before and after update then add updates to fields - * @param {ITask} task - Updated task - * @param {ITaskUpdateInput} entity - Input data with new values - */ - private activityLogUpdatedFieldsAndValues(task: ITask, entity: ITaskUpdateInput) { - const updatedFields = []; - const previousValues: IActivityLogUpdatedValues[] = []; - const updatedValues: IActivityLogUpdatedValues[] = []; - - for (const key of Object.keys(entity)) { - if (task[key] !== entity[key]) { - // Add updated field - updatedFields.push(key); - - // Add old and new values - previousValues.push({ [key]: task[key] }); - updatedValues.push({ [key]: task[key] }); - } - } - - return { updatedFields, previousValues, updatedValues }; - } } diff --git a/packages/core/src/tasks/estimation/commands/handlers/task-estimation-calculate.handler.ts b/packages/core/src/tasks/estimation/commands/handlers/task-estimation-calculate.handler.ts index dc1708ed196..09ae3060c76 100644 --- a/packages/core/src/tasks/estimation/commands/handlers/task-estimation-calculate.handler.ts +++ b/packages/core/src/tasks/estimation/commands/handlers/task-estimation-calculate.handler.ts @@ -5,9 +5,7 @@ import { TaskEstimationService } from '../../task-estimation.service'; import { TaskService } from '../../../task.service'; @CommandHandler(TaskEstimationCalculateCommand) -export class TaskEstimationCalculateHandler - implements ICommandHandler -{ +export class TaskEstimationCalculateHandler implements ICommandHandler { constructor( private readonly _taskEstimationService: TaskEstimationService, private readonly _taskService: TaskService @@ -19,18 +17,13 @@ export class TaskEstimationCalculateHandler const taskEstimations = await this._taskEstimationService.findAll({ where: { - taskId, - }, + taskId + } }); - const totalEstimation = taskEstimations.items.reduce( - (sum, current) => sum + current.estimate, - 0 - ); - const averageEstimation = Math.ceil( - totalEstimation / taskEstimations.items.length - ); + const totalEstimation = taskEstimations.items.reduce((sum, current) => sum + current.estimate, 0); + const averageEstimation = Math.ceil(totalEstimation / taskEstimations.items.length); await this._taskService.update(taskId, { - estimate: averageEstimation, + estimate: averageEstimation }); } catch (error) { console.log('Error while creating task estimation', error?.message); diff --git a/packages/core/src/tasks/task.controller.ts b/packages/core/src/tasks/task.controller.ts index 01f8d54c953..7515dc39d88 100644 --- a/packages/core/src/tasks/task.controller.ts +++ b/packages/core/src/tasks/task.controller.ts @@ -14,7 +14,7 @@ import { import { ApiTags, ApiOperation, ApiResponse } from '@nestjs/swagger'; import { CommandBus } from '@nestjs/cqrs'; import { DeleteResult } from 'typeorm'; -import { PermissionsEnum, ITask, IPagination, IEmployee, IOrganizationTeam } from '@gauzy/contracts'; +import { PermissionsEnum, ITask, IPagination, ID } from '@gauzy/contracts'; import { UUIDValidationPipe, UseValidationPipe } from './../shared/pipes'; import { PermissionGuard, TenantPermissionGuard } from './../shared/guards'; import { Permissions } from './../shared/decorators'; @@ -28,7 +28,7 @@ import { CreateTaskDTO, GetTaskByIdDTO, TaskMaxNumberQueryDTO, UpdateTaskDTO } f @ApiTags('Tasks') @UseGuards(TenantPermissionGuard, PermissionGuard) @Permissions(PermissionsEnum.ALL_ORG_EDIT) -@Controller() +@Controller('/tasks') export class TaskController extends CrudController { constructor(private readonly taskService: TaskService, private readonly commandBus: CommandBus) { super(taskService); @@ -37,258 +37,244 @@ export class TaskController extends CrudController { /** * GET task count * - * @param options - * @returns + * @param options The filter options for counting tasks. + * @returns The total number of tasks. */ @Permissions(PermissionsEnum.ALL_ORG_VIEW, PermissionsEnum.ORG_TASK_VIEW) - @Get('count') + @Get('/count') @UseValidationPipe() + @ApiOperation({ summary: 'Get the total count of tasks.' }) + @ApiResponse({ status: HttpStatus.OK, description: 'Task count retrieved successfully.' }) + @ApiResponse({ status: HttpStatus.BAD_REQUEST, description: 'Invalid input.' }) async getCount(@Query() options: CountQueryDTO): Promise { - return await this.taskService.countBy(options); + return this.taskService.countBy(options); } /** * GET tasks by pagination * - * @param params - * @returns + * @param params The pagination and filter parameters. + * @returns A paginated list of tasks. */ @Permissions(PermissionsEnum.ALL_ORG_VIEW, PermissionsEnum.ORG_TASK_VIEW) - @Get('pagination') + @Get('/pagination') @UseValidationPipe({ transform: true }) + @ApiOperation({ summary: 'Get tasks by pagination.' }) + @ApiResponse({ status: HttpStatus.OK, description: 'Tasks retrieved successfully.' }) + @ApiResponse({ status: HttpStatus.BAD_REQUEST, description: 'Invalid input.' }) async pagination(@Query() params: PaginationParams): Promise> { - return await this.taskService.pagination(params); + return this.taskService.pagination(params); } /** * GET maximum task number * - * @param options - * @returns + * @param options The query options to filter the tasks by project. + * @returns The maximum task number for a given project. */ - @ApiOperation({ summary: 'Find maximum task number.' }) - @ApiResponse({ - status: HttpStatus.OK, - description: 'Found maximum task number', - type: Task - }) - @ApiResponse({ - status: HttpStatus.NOT_FOUND, - description: 'Records not found' - }) + @ApiOperation({ summary: 'Get the maximum task number by project.' }) + @ApiResponse({ status: HttpStatus.OK, description: 'Maximum task number retrieved successfully.' }) + @ApiResponse({ status: HttpStatus.NOT_FOUND, description: 'No records found.' }) @Permissions(PermissionsEnum.ALL_ORG_VIEW, PermissionsEnum.ORG_TASK_VIEW) - @Get('max-number') + @Get('/max-number') @UseValidationPipe() async getMaxTaskNumberByProject(@Query() options: TaskMaxNumberQueryDTO): Promise { - return await this.taskService.getMaxTaskNumberByProject(options); + return this.taskService.getMaxTaskNumberByProject(options); } /** * GET my tasks * - * @param params - * @returns + * @param params The filter and pagination options for retrieving tasks. + * @returns A paginated list of tasks assigned to the current user. */ - @ApiOperation({ summary: 'Find my tasks.' }) - @ApiResponse({ - status: HttpStatus.OK, - description: 'Found tasks', - type: Task - }) - @ApiResponse({ - status: HttpStatus.NOT_FOUND, - description: 'Records not found' - }) + @ApiOperation({ summary: 'Get tasks assigned to the current user.' }) + @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('me') + @Get('/me') @UseValidationPipe({ transform: true }) async findMyTasks(@Query() params: PaginationParams): Promise> { - return await this.taskService.getMyTasks(params); + return this.taskService.getMyTasks(params); } /** * GET employee tasks * - * @param params - * @returns + * @param params The filter and pagination options for retrieving employee tasks. + * @returns A paginated list of tasks assigned to the specified employee. */ - @ApiOperation({ summary: 'Find employee tasks.' }) - @ApiResponse({ - status: HttpStatus.OK, - description: 'Found tasks', - type: Task - }) - @ApiResponse({ - status: HttpStatus.NOT_FOUND, - description: 'Records not found' - }) + @ApiOperation({ summary: 'Get tasks assigned to a specific employee.' }) + @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('employee') + @Get('/employee') @UseValidationPipe({ transform: true }) async findEmployeeTask(@Query() params: PaginationParams): Promise> { - return await this.taskService.getEmployeeTasks(params); + return this.taskService.getEmployeeTasks(params); } /** * GET my team tasks * - * @param params - * @returns + * @param params The filter and pagination options for retrieving team tasks. + * @returns A paginated list of tasks assigned to the current user's team. */ - @ApiOperation({ summary: 'Find my team tasks.' }) - @ApiResponse({ - status: HttpStatus.OK, - description: 'Found tasks', - type: Task - }) - @ApiResponse({ - status: HttpStatus.NOT_FOUND, - description: 'Records not found' - }) + @ApiOperation({ summary: "Get tasks assigned to the current user's team." }) + @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('team') + @Get('/team') @UseValidationPipe({ transform: true }) async findTeamTasks(@Query() params: PaginationParams): Promise> { - return await this.taskService.findTeamTasks(params); + return this.taskService.findTeamTasks(params); } /** * GET module tasks * - * @param params - * @returns + * @param params The filter and pagination options for retrieving module tasks. + * @returns A paginated list of tasks by module. */ - @ApiOperation({ summary: 'Find module tasks.' }) - @ApiResponse({ - status: HttpStatus.OK, - description: 'Found tasks', - type: Task - }) - @ApiResponse({ - status: HttpStatus.NOT_FOUND, - description: 'Records not found' - }) + @ApiOperation({ summary: 'Get tasks by module.' }) + @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('module') + @Get('/module') @UseValidationPipe({ transform: true }) async findModuleTasks(@Query() params: PaginationParams): Promise> { - return await this.taskService.findModuleTasks(params); + return this.taskService.findModuleTasks(params); } - @ApiOperation({ summary: 'Find by id' }) - @ApiResponse({ - status: HttpStatus.OK, - description: 'Found one record' /*, type: T*/ - }) - @ApiResponse({ - status: HttpStatus.NOT_FOUND, - description: 'Record not found' - }) + /** + * GET task by ID + * + * @param id The ID of the task. + * @param params The options for task retrieval. + * @returns The task with the specified ID. + */ + @ApiOperation({ summary: 'Get task by ID.' }) + @ApiResponse({ status: HttpStatus.OK, description: 'Task retrieved successfully.' }) + @ApiResponse({ status: HttpStatus.NOT_FOUND, description: 'Task not found.' }) @Permissions(PermissionsEnum.ALL_ORG_VIEW, PermissionsEnum.ORG_TASK_VIEW) - @Get(':id') - async findById(@Param('id', UUIDValidationPipe) id: Task['id'], @Query() params: GetTaskByIdDTO): Promise { + @Get('/:id') + async findById(@Param('id', UUIDValidationPipe) id: ID, @Query() params: GetTaskByIdDTO): Promise { return this.taskService.findById(id, params); } /** * GET tasks by employee * - * @param employeeId - * @param findInput - * @returns + * @param employeeId The ID of the employee. + * @param params The pagination and filter parameters for tasks. + * @returns A list of tasks assigned to the specified employee. */ - @ApiOperation({ - summary: 'Find Employee Task.' - }) - @ApiResponse({ - status: HttpStatus.OK, - description: 'Found Employee Task', - type: Task - }) - @ApiResponse({ - status: HttpStatus.NOT_FOUND, - description: 'Record not found' - }) + @ApiOperation({ summary: 'Get tasks assigned to a specific employee.' }) + @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('employee/:id') + @Get('/employee/:id') @UseValidationPipe() async getAllTasksByEmployee( - @Param('id') employeeId: IEmployee['id'], + @Param('id') employeeId: ID, @Query() params: PaginationParams ): Promise { - return await this.taskService.getAllTasksByEmployee(employeeId, params); + return this.taskService.getAllTasksByEmployee(employeeId, params); } - @ApiOperation({ summary: 'Find all tasks.' }) - @ApiResponse({ - status: HttpStatus.OK, - description: 'Found tasks', - type: Task - }) - @ApiResponse({ - status: HttpStatus.NOT_FOUND, - description: 'Record not found' - }) + /** + * GET all tasks + * + * @param params The pagination and filter parameters for retrieving tasks. + * @returns A paginated list of all tasks. + */ + @ApiOperation({ summary: 'Get all tasks.' }) + @ApiResponse({ status: HttpStatus.OK, description: 'Tasks retrieved successfully.' }) + @ApiResponse({ status: HttpStatus.NOT_FOUND, description: 'No tasks found.' }) @Permissions(PermissionsEnum.ALL_ORG_VIEW, PermissionsEnum.ORG_TASK_VIEW) - @Get() + @Get('/') @UseValidationPipe() async findAll(@Query() params: PaginationParams): Promise> { - return await this.taskService.findAll(params); + return this.taskService.findAll(params); } - @ApiOperation({ summary: 'create a task' }) + /** + * POST create a task + * + * @param entity The data for creating the task. + * @returns The created task. + */ + @ApiOperation({ summary: 'Create a new task.' }) @ApiResponse({ status: HttpStatus.CREATED, - description: 'The record has been successfully created.' - }) - @ApiResponse({ - status: HttpStatus.BAD_REQUEST, - description: 'Invalid input, The response body may contain clues as to what went wrong' + description: 'The task has been successfully created.' }) + @ApiResponse({ status: HttpStatus.BAD_REQUEST, description: 'Invalid input.' }) @HttpCode(HttpStatus.ACCEPTED) @Permissions(PermissionsEnum.ALL_ORG_EDIT, PermissionsEnum.ORG_TASK_ADD) - @Post() + @Post('/') @UseValidationPipe({ whitelist: true }) async create(@Body() entity: CreateTaskDTO): Promise { - return await this.commandBus.execute(new TaskCreateCommand(entity)); + return this.commandBus.execute(new TaskCreateCommand(entity)); } - @ApiOperation({ summary: 'Update an existing task' }) - @ApiResponse({ - status: HttpStatus.CREATED, - description: 'The record has been successfully edited.' - }) - @ApiResponse({ - status: HttpStatus.NOT_FOUND, - description: 'Record not found' - }) + /** + * PUT update an existing task + * + * @param id The ID of the task to update. + * @param entity The data for updating the task. + * @returns The updated task. + */ + @ApiOperation({ summary: 'Update an existing task.' }) @ApiResponse({ - status: HttpStatus.BAD_REQUEST, - description: 'Invalid input, The response body may contain clues as to what went wrong' + status: HttpStatus.OK, + description: 'The task has been successfully updated.' }) + @ApiResponse({ status: HttpStatus.NOT_FOUND, description: 'Task not found.' }) + @ApiResponse({ status: HttpStatus.BAD_REQUEST, description: 'Invalid input.' }) @HttpCode(HttpStatus.ACCEPTED) @Permissions(PermissionsEnum.ALL_ORG_EDIT, PermissionsEnum.ORG_TASK_EDIT) - @Put(':id') + @Put('/:id') @UseValidationPipe({ whitelist: true }) - async update(@Param('id', UUIDValidationPipe) id: ITask['id'], @Body() entity: UpdateTaskDTO): Promise { - return await this.commandBus.execute(new TaskUpdateCommand(id, entity)); + async update(@Param('id', UUIDValidationPipe) id: ID, @Body() entity: UpdateTaskDTO): Promise { + return this.commandBus.execute(new TaskUpdateCommand(id, entity)); } + /** + * DELETE task by ID + * + * @param id The ID of the task to delete. + * @returns The result of the deletion. + */ @Permissions(PermissionsEnum.ALL_ORG_EDIT, PermissionsEnum.ORG_TASK_DELETE) - @Delete(':id') - async delete(@Param('id', UUIDValidationPipe) id: ITask['id']): Promise { - return await this.taskService.delete(id); + @Delete('/:id') + @ApiOperation({ summary: 'Delete a task by ID.' }) + @ApiResponse({ + status: HttpStatus.OK, + description: 'The task has been successfully deleted.' + }) + @ApiResponse({ status: HttpStatus.NOT_FOUND, description: 'Task not found.' }) + async delete(@Param('id', UUIDValidationPipe) id: ID): Promise { + return this.taskService.delete(id); } + /** + * DELETE employee from team tasks + * + * Unassign an employee from tasks associated with a specific organization team. + * + * @param employeeId The ID of the employee to be unassigned from tasks. + * @param organizationTeamId The ID of the organization team from which to unassign the employee. + * @returns A Promise that resolves with the result of the no assignment. + */ @HttpCode(HttpStatus.OK) @Permissions(PermissionsEnum.ALL_ORG_EDIT, PermissionsEnum.ORG_TASK_EDIT) - @Delete('employee/:employeeId') + @Delete('/employee/:employeeId') @UseValidationPipe({ whitelist: true }) async deleteEmployeeFromTasks( - @Param('employeeId', UUIDValidationPipe) employeeId: IEmployee['id'], - @Query('organizationTeamId', UUIDValidationPipe) - organizationTeamId: IOrganizationTeam['id'] - ) { - return await this.taskService.unassignEmployeeFromTeamTasks(employeeId, organizationTeamId); + @Param('employeeId', UUIDValidationPipe) employeeId: ID, + @Query('organizationTeamId', UUIDValidationPipe) organizationTeamId: ID + ): Promise { + return this.taskService.unassignEmployeeFromTeamTasks(employeeId, organizationTeamId); } } diff --git a/packages/core/src/tasks/task.entity.ts b/packages/core/src/tasks/task.entity.ts index f631b891d99..94b4d5445a7 100644 --- a/packages/core/src/tasks/task.entity.ts +++ b/packages/core/src/tasks/task.entity.ts @@ -11,6 +11,7 @@ import { IOrganizationProject, IOrganizationProjectModule, IOrganizationSprint, + IOrganizationSprintTaskHistory, IOrganizationTeam, ITag, ITask, @@ -32,6 +33,8 @@ import { OrganizationProject, OrganizationProjectModule, OrganizationSprint, + OrganizationSprintTask, + OrganizationSprintTaskHistory, OrganizationTeam, OrganizationTeamEmployee, Tag, @@ -353,6 +356,22 @@ export class Task extends TenantOrganizationBaseEntity implements ITask { @JoinColumn() linkedIssues?: TaskLinkedIssue[]; + /* + * Task Sprint + */ + @MultiORMOneToMany(() => OrganizationSprintTask, (it) => it.task, { + cascade: true + }) + taskSprints?: IOrganizationSprint[]; + + /* + * Sprint Task Histories + */ + @MultiORMOneToMany(() => OrganizationSprintTaskHistory, (it) => it.task, { + cascade: true + }) + taskSprintHistories?: IOrganizationSprintTaskHistory[]; + /* |-------------------------------------------------------------------------- | @ManyToMany diff --git a/packages/core/src/tasks/task.module.ts b/packages/core/src/tasks/task.module.ts index 119fe2c861d..cff608dccf3 100644 --- a/packages/core/src/tasks/task.module.ts +++ b/packages/core/src/tasks/task.module.ts @@ -1,16 +1,16 @@ import { forwardRef, Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; -import { RouterModule } from '@nestjs/core'; import { CqrsModule } from '@nestjs/cqrs'; import { MikroOrmModule } from '@mikro-orm/nestjs'; import { EventBusModule } from '../event-bus/event-bus.module'; import { IntegrationMap, TaskStatus } from '../core/entities/internal'; -import { OrganizationProjectModule } from './../organization-project/organization-project.module'; import { CommandHandlers } from './commands/handlers'; import { RolePermissionModule } from '../role-permission/role-permission.module'; import { UserModule } from './../user/user.module'; import { RoleModule } from './../role/role.module'; import { EmployeeModule } from './../employee/employee.module'; +import { OrganizationProjectModule } from './../organization-project/organization-project.module'; +import { OrganizationSprintModule } from './../organization-sprint/organization-sprint.module'; import { Task } from './task.entity'; import { TaskService } from './task.service'; import { TaskController } from './task.controller'; @@ -18,7 +18,6 @@ import { TypeOrmTaskRepository } from './repository'; @Module({ imports: [ - RouterModule.register([{ path: '/tasks', module: TaskModule }]), TypeOrmModule.forFeature([Task, TaskStatus, IntegrationMap]), MikroOrmModule.forFeature([Task, TaskStatus, IntegrationMap]), RolePermissionModule, @@ -26,6 +25,7 @@ import { TypeOrmTaskRepository } from './repository'; RoleModule, EmployeeModule, OrganizationProjectModule, + OrganizationSprintModule, CqrsModule, EventBusModule ], diff --git a/packages/core/src/tasks/task.service.ts b/packages/core/src/tasks/task.service.ts index 010d71ba2c5..aa3c890a48b 100644 --- a/packages/core/src/tasks/task.service.ts +++ b/packages/core/src/tasks/task.service.ts @@ -1,12 +1,27 @@ import { Injectable, BadRequestException, HttpStatus, HttpException } from '@nestjs/common'; +import { EventBus } from '@nestjs/cqrs'; import { IsNull, SelectQueryBuilder, Brackets, WhereExpressionBuilder, Raw, In } from 'typeorm'; import { isBoolean, isUUID } from 'class-validator'; -import { IEmployee, IGetTaskOptions, IPagination, ITask, PermissionsEnum } from '@gauzy/contracts'; +import { + ActionTypeEnum, + ActivityLogEntityEnum, + ActorTypeEnum, + ID, + IEmployee, + IGetTaskOptions, + IPagination, + ITask, + ITaskUpdateInput, + PermissionsEnum +} from '@gauzy/contracts'; import { isEmpty, isNotEmpty } from '@gauzy/common'; import { isPostgres } from '@gauzy/config'; import { PaginationParams, TenantAwareCrudService } from './../core/crud'; import { RequestContext } from '../core/context'; +import { ActivityLogEvent } from '../activity-log/events'; +import { activityLogUpdatedFieldsAndValues, generateActivityLogDescription } from '../activity-log/activity-log.helper'; import { Task } from './task.entity'; +import { TypeOrmOrganizationSprintTaskHistoryRepository } from './../organization-sprint/repository/type-orm-organization-sprint-task-history.repository'; import { GetTaskByIdDTO } from './dto'; import { prepareSQLQuery as p } from './../database/database.helper'; import { TypeOrmTaskRepository } from './repository/type-orm-task.repository'; @@ -16,52 +31,146 @@ import { MikroOrmTaskRepository } from './repository/mikro-orm-task.repository'; export class TaskService extends TenantAwareCrudService { constructor( readonly typeOrmTaskRepository: TypeOrmTaskRepository, - readonly mikroOrmTaskRepository: MikroOrmTaskRepository + readonly mikroOrmTaskRepository: MikroOrmTaskRepository, + readonly typeOrmOrganizationSprintTaskHistoryRepository: TypeOrmOrganizationSprintTaskHistoryRepository, + private readonly _eventBus: EventBus ) { super(typeOrmTaskRepository, mikroOrmTaskRepository); } /** + * Update task, if already exist * - * @param id - * @param relations - * @returns + * @param id - The ID of the task to update + * @param input - The data to update the task with + * @returns The updated task + */ + async update(id: ID, input: Partial): Promise { + try { + const tenantId = RequestContext.currentTenantId() || input.tenantId; + const userId = RequestContext.currentUserId(); + const { organizationSprintId } = input; + const task = await this.findOneByIdString(id); + + if (input.projectId && input.projectId !== task.projectId) { + const { organizationId, projectId } = task; + + // Get the maximum task number for the project + const maxNumber = await this.getMaxTaskNumberByProject({ + tenantId, + organizationId, + projectId + }); + + // Update the task with the new project and task number + await super.update(id, { + projectId, + number: maxNumber + 1 + }); + } + + // Update the task with the provided data + const updatedTask = await super.create({ + ...input, + id + }); + + // Register Task Sprint moving history + if (organizationSprintId && organizationSprintId !== task.organizationSprintId) { + await this.typeOrmOrganizationSprintTaskHistoryRepository.save({ + fromSprintId: task.organizationSprintId, + toSprintId: organizationSprintId, + taskId: updatedTask.id, + movedById: userId, + reason: input.taskSprintMoveReason, + organizationId: input.organizationId, + tenantId + }); + } + + // Generate the activity log description + const description = generateActivityLogDescription( + ActionTypeEnum.Updated, + ActivityLogEntityEnum.Task, + updatedTask.title + ); + + const { updatedFields, previousValues, updatedValues } = activityLogUpdatedFieldsAndValues( + updatedTask, + input + ); + + // Emit an event to log the activity + this._eventBus.publish( + new ActivityLogEvent({ + entity: ActivityLogEntityEnum.Task, + entityId: updatedTask.id, + action: ActionTypeEnum.Updated, + actorType: ActorTypeEnum.User, // TODO : Since we have Github Integration, make sure we can also store "System" for actor + description, + updatedFields, + updatedValues, + previousValues, + data: updatedTask, + organizationId: updatedTask.organizationId, + tenantId + }) + ); + + return updatedTask; + } catch (error) { + console.error(`Error while updating task: ${error.message}`, error.message); + throw new HttpException({ message: error?.message, error }, HttpStatus.BAD_REQUEST); + } + } + + /** + * Retrieves a task by its ID and includes optional related data. + * + * @param id The unique identifier of the task. + * @param params Additional parameters for fetching task details, including related entities. + * @returns A Promise that resolves to the task entity. */ - async findById(id: ITask['id'], params: GetTaskByIdDTO): Promise { + async findById(id: ID, params: GetTaskByIdDTO): Promise { const task = await this.findOneByIdString(id, params); - if (params.includeRootEpic) { + // Include the root epic if requested + if (params.includeRootEpic && task) { task.rootEpic = await this.findParentUntilEpic(task.id); } return task; } - async findParentUntilEpic(issueId: string): Promise { - // Define the recursive SQL query + /** + * Recursively searches for the parent epic of a given task (issue) using a SQL recursive query. + * + * @param issueId The ID of the task (issue) to start the search from. + * @returns A Promise that resolves to the epic task if found, otherwise null. + */ + async findParentUntilEpic(issueId: ID): Promise { + // Define the recursive SQL query to find the parent epic const query = p(` - WITH RECURSIVE IssueHierarchy AS (SELECT * + WITH RECURSIVE IssueHierarchy AS ( + SELECT * FROM task WHERE id = $1 UNION ALL SELECT i.* FROM task i - INNER JOIN IssueHierarchy ih ON i.id = ih."parentId") + INNER JOIN IssueHierarchy ih ON i.id = ih."parentId" + ) SELECT * - FROM IssueHierarchy - WHERE "issueType" = 'Epic' + FROM IssueHierarchy + WHERE "issueType" = 'Epic' LIMIT 1; `); // Execute the raw SQL query with the issueId parameter const result = await this.typeOrmRepository.query(query, [issueId]); - // Check if any epic was found and return it, or return null - if (result.length > 0) { - return result[0]; - } else { - return null; - } + // Return the first epic task found or null if no epic is found + return result.length > 0 ? result[0] : null; } /** @@ -338,87 +447,94 @@ export class TaskService extends TenantAwareCrudService { } /** - * GET tasks by pagination + * GET tasks by pagination with filtering options. * - * @param options - * @returns + * @param options The pagination and filtering parameters. + * @returns A Promise that resolves to a paginated list of tasks. */ public async pagination(options: PaginationParams): Promise> { - if ('where' in options) { + // Define the like operator based on the database type + const likeOperator = isPostgres() ? 'ILIKE' : 'LIKE'; + + // Check if there are any filters in the options + if (options?.where) { const { where } = options; - const likeOperator = isPostgres() ? 'ILIKE' : 'LIKE'; - if ('title' in where) { - const { title } = where; - options['where']['title'] = Raw((alias) => `${alias} ${likeOperator} '%${title}%'`); + + // Apply filters for task title with like operator + if (where.title) { + options.where.title = Raw((alias) => `${alias} ${likeOperator} '%${where.title}%'`); } - if ('prefix' in where) { - const { prefix } = where; - options['where']['prefix'] = Raw((alias) => `${alias} ${likeOperator} '%${prefix}%'`); + + // Apply filters for task prefix with like operator + if (where.prefix) { + options.where.prefix = Raw((alias) => `${alias} ${likeOperator} '%${where.prefix}%'`); } - if ('isDraft' in where) { - const { isDraft } = where; - if (!isBoolean(isDraft)) { - options.where.isDraft = IsNull(); - } + + // Apply filters for isDraft, setting null if not a boolean + if (where.isDraft !== undefined && !isBoolean(where.isDraft)) { + options.where.isDraft = IsNull(); } - if ('organizationSprintId' in where) { - const { organizationSprintId } = where; - if (!isUUID(organizationSprintId)) { - options['where']['organizationSprintId'] = IsNull(); - } + + // Apply filters for organizationSprintId, setting null if not a valid UUID + if (where.organizationSprintId && !isUUID(where.organizationSprintId)) { + options.where.organizationSprintId = IsNull(); } - if ('teams' in where) { - const { teams } = where; + + // Apply filters for teams, ensuring it uses In for array comparison + if (where.teams) { options.where.teams = { - id: In(teams as string[]) + id: In(where.teams as string[]) }; } } + + // Call the base paginate method return await super.paginate(options); } /** * GET maximum task number by project filter * - * @param options + * @param options The filtering options including tenant, organization, and project details. + * @returns A Promise that resolves to the maximum task number for the given project. */ - public async getMaxTaskNumberByProject(options: IGetTaskOptions) { + public async getMaxTaskNumberByProject(options: IGetTaskOptions): Promise { try { - // Extract necessary options + // Extract tenantId from context or options const tenantId = RequestContext.currentTenantId() || options.tenantId; const { organizationId, projectId } = options; + // Create a query builder for the Task entity const query = this.typeOrmRepository.createQueryBuilder(this.tableName); // Build the query to get the maximum task number query.select(p(`COALESCE(MAX("${query.alias}"."number"), 0)`), 'maxTaskNumber'); - // Filter by organization and tenant + // Apply filters for organization and tenant query.andWhere( new Brackets((qb: WhereExpressionBuilder) => { - qb.andWhere(p(`"${query.alias}"."organizationId" = :organizationId`), { - organizationId - }); - qb.andWhere(p(`"${query.alias}"."tenantId" = :tenantId`), { - tenantId - }); + qb.andWhere(p(`"${query.alias}"."organizationId" = :organizationId`), { organizationId }); + qb.andWhere(p(`"${query.alias}"."tenantId" = :tenantId`), { tenantId }); }) ); - // Filter by project (if provided) + // Apply project filter if provided, otherwise check for null if (isNotEmpty(projectId)) { - query.andWhere(p(`"${query.alias}"."projectId" = :projectId`), { - projectId - }); + query.andWhere(p(`"${query.alias}"."projectId" = :projectId`), { projectId }); } else { query.andWhere(p(`"${query.alias}"."projectId" IS NULL`)); } - // Execute the query and get the maximum task number - const { maxTaskNumber } = await query.getRawOne(); + // Execute the query and parse the result to a number + const result = await query.getRawOne(); + const maxTaskNumber = parseInt(result.maxTaskNumber, 10); + console.log('get max task number', maxTaskNumber); + return maxTaskNumber; } catch (error) { - throw new HttpException({ message: error?.message, error }, HttpStatus.BAD_REQUEST); + // Log the error and throw a detailed exception + console.log(`Error fetching max task number: ${error.message}`, error.stack); + throw new HttpException({ message: 'Failed to get the max task number', error }, HttpStatus.BAD_REQUEST); } } @@ -539,7 +655,7 @@ export class TaskService extends TenantAwareCrudService { }); } - // Filter by project_module_task with a subquery + // Filter by project_module_task with a sub query query.andWhere((qb: SelectQueryBuilder) => { const subQuery = qb .subQuery() diff --git a/packages/core/src/tasks/views/commands/handlers/index.ts b/packages/core/src/tasks/views/commands/handlers/index.ts new file mode 100644 index 00000000000..c49b8c2c720 --- /dev/null +++ b/packages/core/src/tasks/views/commands/handlers/index.ts @@ -0,0 +1,4 @@ +import { TaskViewCreateHandler } from './task-view-create.handler'; +import { TaskViewUpdateHandler } from './task-view-update.handler'; + +export const CommandHandlers = [TaskViewCreateHandler, TaskViewUpdateHandler]; diff --git a/packages/core/src/tasks/views/commands/handlers/task-view-create.handler.ts b/packages/core/src/tasks/views/commands/handlers/task-view-create.handler.ts new file mode 100644 index 00000000000..9326110e806 --- /dev/null +++ b/packages/core/src/tasks/views/commands/handlers/task-view-create.handler.ts @@ -0,0 +1,14 @@ +import { CommandHandler, ICommandHandler } from '@nestjs/cqrs'; +import { ITaskView } from '@gauzy/contracts'; +import { TaskViewService } from '../../view.service'; +import { TaskViewCreateCommand } from '../task-view-create.command'; + +@CommandHandler(TaskViewCreateCommand) +export class TaskViewCreateHandler implements ICommandHandler { + constructor(private readonly taskViewService: TaskViewService) {} + + public async execute(command: TaskViewCreateCommand): Promise { + const { input } = command; + return await this.taskViewService.create(input); + } +} diff --git a/packages/core/src/tasks/views/commands/handlers/task-view-update.handler.ts b/packages/core/src/tasks/views/commands/handlers/task-view-update.handler.ts new file mode 100644 index 00000000000..43bc869c9be --- /dev/null +++ b/packages/core/src/tasks/views/commands/handlers/task-view-update.handler.ts @@ -0,0 +1,17 @@ +import { ICommandHandler } from '@nestjs/cqrs'; +import { ITaskView } from '@gauzy/contracts'; +import { TaskViewUpdateCommand } from '../task-view-update.command'; +import { TaskViewService } from '../../view.service'; + +export class TaskViewUpdateHandler implements ICommandHandler { + constructor(private readonly taskViewService: TaskViewService) {} + + public async execute(command: TaskViewUpdateCommand): Promise { + const { id, input } = command; + + return await this.taskViewService.create({ + ...input, + id + }); + } +} diff --git a/packages/core/src/tasks/views/commands/index.ts b/packages/core/src/tasks/views/commands/index.ts new file mode 100644 index 00000000000..50d8cf56461 --- /dev/null +++ b/packages/core/src/tasks/views/commands/index.ts @@ -0,0 +1,2 @@ +export * from './task-view-create.command'; +export * from './task-view-update.command'; diff --git a/packages/core/src/tasks/views/commands/task-view-create.command.ts b/packages/core/src/tasks/views/commands/task-view-create.command.ts new file mode 100644 index 00000000000..94fdef29826 --- /dev/null +++ b/packages/core/src/tasks/views/commands/task-view-create.command.ts @@ -0,0 +1,8 @@ +import { ITaskViewCreateInput } from '@gauzy/contracts'; +import { ICommand } from '@nestjs/cqrs'; + +export class TaskViewCreateCommand implements ICommand { + static readonly type = '[Task View] Create'; + + constructor(public readonly input: ITaskViewCreateInput) {} +} diff --git a/packages/core/src/tasks/views/commands/task-view-update.command.ts b/packages/core/src/tasks/views/commands/task-view-update.command.ts new file mode 100644 index 00000000000..0be76d86857 --- /dev/null +++ b/packages/core/src/tasks/views/commands/task-view-update.command.ts @@ -0,0 +1,8 @@ +import { ID, ITaskViewUpdateInput } from '@gauzy/contracts'; +import { ICommand } from '@nestjs/cqrs'; + +export class TaskViewUpdateCommand implements ICommand { + static readonly type = '[Task View] Update'; + + constructor(public readonly id: ID, public readonly input: ITaskViewUpdateInput) {} +} diff --git a/packages/core/src/tasks/views/dto/create-view.dto.ts b/packages/core/src/tasks/views/dto/create-view.dto.ts new file mode 100644 index 00000000000..91793314244 --- /dev/null +++ b/packages/core/src/tasks/views/dto/create-view.dto.ts @@ -0,0 +1,8 @@ +import { IntersectionType, PartialType } from '@nestjs/swagger'; +import { ITaskViewCreateInput } from '@gauzy/contracts'; +import { TenantOrganizationBaseDTO } from '../../../core/dto'; +import { TaskView } from '../view.entity'; + +export class CreateViewDTO + extends IntersectionType(PartialType(TenantOrganizationBaseDTO), TaskView) + implements ITaskViewCreateInput {} diff --git a/packages/core/src/tasks/views/dto/index.ts b/packages/core/src/tasks/views/dto/index.ts new file mode 100644 index 00000000000..cf545f96723 --- /dev/null +++ b/packages/core/src/tasks/views/dto/index.ts @@ -0,0 +1,2 @@ +export * from './create-view.dto'; +export * from './update-view.dto'; diff --git a/packages/core/src/tasks/views/dto/update-view.dto.ts b/packages/core/src/tasks/views/dto/update-view.dto.ts new file mode 100644 index 00000000000..e1c6f33ea6d --- /dev/null +++ b/packages/core/src/tasks/views/dto/update-view.dto.ts @@ -0,0 +1,8 @@ +import { IntersectionType, PartialType } from '@nestjs/swagger'; +import { TenantOrganizationBaseDTO } from '../../../core/dto'; +import { TaskView } from '../view.entity'; +import { ITaskViewUpdateInput } from '@gauzy/contracts'; + +export class UpdateViewDTO + extends IntersectionType(PartialType(TenantOrganizationBaseDTO), PartialType(TaskView)) + implements ITaskViewUpdateInput {} diff --git a/packages/core/src/tasks/views/repository/index.ts b/packages/core/src/tasks/views/repository/index.ts new file mode 100644 index 00000000000..cde16c5dd1d --- /dev/null +++ b/packages/core/src/tasks/views/repository/index.ts @@ -0,0 +1,2 @@ +export * from './mikro-orm-task-view.repository'; +export * from './type-orm-task-view.repository'; diff --git a/packages/core/src/tasks/views/repository/mikro-orm-task-view.repository.ts b/packages/core/src/tasks/views/repository/mikro-orm-task-view.repository.ts new file mode 100644 index 00000000000..538e929a389 --- /dev/null +++ b/packages/core/src/tasks/views/repository/mikro-orm-task-view.repository.ts @@ -0,0 +1,4 @@ +import { MikroOrmBaseEntityRepository } from '../../../core/repository/mikro-orm-base-entity.repository'; +import { TaskView } from '../view.entity'; + +export class MikroOrmTaskViewRepository extends MikroOrmBaseEntityRepository {} diff --git a/packages/core/src/tasks/views/repository/type-orm-task-view.repository.ts b/packages/core/src/tasks/views/repository/type-orm-task-view.repository.ts new file mode 100644 index 00000000000..94c4f5e42a3 --- /dev/null +++ b/packages/core/src/tasks/views/repository/type-orm-task-view.repository.ts @@ -0,0 +1,11 @@ +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { TaskView } from '../view.entity'; + +@Injectable() +export class TypeOrmTaskViewRepository extends Repository { + constructor(@InjectRepository(TaskView) readonly repository: Repository) { + super(repository.target, repository.manager, repository.queryRunner); + } +} diff --git a/packages/core/src/tasks/views/view.controller.ts b/packages/core/src/tasks/views/view.controller.ts new file mode 100644 index 00000000000..fc109e3dbc4 --- /dev/null +++ b/packages/core/src/tasks/views/view.controller.ts @@ -0,0 +1,119 @@ +import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger'; +import { + Body, + Controller, + Delete, + Get, + HttpCode, + HttpStatus, + Param, + Post, + Put, + Query, + UseGuards +} from '@nestjs/common'; +import { CommandBus } from '@nestjs/cqrs'; +import { DeleteResult } from 'typeorm'; +import { ID, IPagination, ITaskView } from '@gauzy/contracts'; +import { UUIDValidationPipe, UseValidationPipe } from '../../shared/pipes'; +import { PermissionGuard, TenantPermissionGuard } from '../../shared/guards'; +import { CrudController, OptionParams, PaginationParams } from '../../core/crud'; +import { TaskView } from './view.entity'; +import { TaskViewService } from './view.service'; +import { CreateViewDTO, UpdateViewDTO } from './dto'; +import { TaskViewCreateCommand, TaskViewUpdateCommand } from './commands'; + +@ApiTags('Task views') +@UseGuards(TenantPermissionGuard, PermissionGuard) +@Controller() +export class TaskViewController extends CrudController { + constructor(private readonly taskViewService: TaskViewService, private readonly commandBus: CommandBus) { + super(taskViewService); + } + + @ApiOperation({ + summary: 'Find all views.' + }) + @ApiResponse({ + status: HttpStatus.OK, + description: 'Found views', + type: TaskView + }) + @ApiResponse({ + status: HttpStatus.NOT_FOUND, + description: 'Record not found' + }) + @Get() + @UseValidationPipe() + async findAll(@Query() params: PaginationParams): Promise> { + return await this.taskViewService.findAll(params); + } + + @ApiOperation({ summary: 'Find by id' }) + @ApiResponse({ + status: HttpStatus.OK, + description: 'Found one record' /*, type: T*/ + }) + @ApiResponse({ + status: HttpStatus.NOT_FOUND, + description: 'Record not found' + }) + @Get(':id') + async findById( + @Param('id', UUIDValidationPipe) id: ID, + @Query() params: OptionParams + ): Promise { + return this.taskViewService.findOneByIdString(id, params); + } + + @ApiOperation({ summary: 'Create view' }) + @ApiResponse({ + status: HttpStatus.CREATED, + description: 'The record has been successfully created.' + }) + @ApiResponse({ + status: HttpStatus.BAD_REQUEST, + description: 'Invalid input, The response body may contain clues as to what went wrong' + }) + @HttpCode(HttpStatus.ACCEPTED) + @Post() + @UseValidationPipe() + async create(@Body() entity: CreateViewDTO): Promise { + return await this.commandBus.execute(new TaskViewCreateCommand(entity)); + } + + @ApiOperation({ summary: 'Update an existing view' }) + @ApiResponse({ + status: HttpStatus.CREATED, + description: 'The record has been successfully edited.' + }) + @ApiResponse({ + status: HttpStatus.NOT_FOUND, + description: 'Record not found' + }) + @ApiResponse({ + status: HttpStatus.BAD_REQUEST, + description: 'Invalid input, The response body may contain clues as to what went wrong' + }) + @HttpCode(HttpStatus.ACCEPTED) + @Put(':id') + @UseValidationPipe() + async update(@Param('id', UUIDValidationPipe) id: ID, @Body() entity: UpdateViewDTO): Promise { + return await this.commandBus.execute(new TaskViewUpdateCommand(id, entity)); + } + + @ApiOperation({ summary: 'Delete view' }) + @ApiResponse({ + status: HttpStatus.NO_CONTENT, + description: 'The record has been successfully deleted' + }) + @ApiResponse({ + status: HttpStatus.NOT_FOUND, + description: 'Record not found' + }) + @HttpCode(HttpStatus.ACCEPTED) + @Delete('/:id') + async delete(@Param('id', UUIDValidationPipe) id: ID): Promise { + return await this.taskViewService.delete(id); + } +} diff --git a/packages/core/src/tasks/views/view.entity.ts b/packages/core/src/tasks/views/view.entity.ts new file mode 100644 index 00000000000..6cbee6c6fb4 --- /dev/null +++ b/packages/core/src/tasks/views/view.entity.ts @@ -0,0 +1,174 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { RelationId } from 'typeorm'; +import { + ID, + IOrganizationProject, + IOrganizationProjectModule, + IOrganizationSprint, + IOrganizationTeam, + ITaskView, + JsonData, + VisibilityLevelEnum +} from '@gauzy/contracts'; +import { isMySQL, isPostgres } from '@gauzy/config'; +import { IsBoolean, IsEnum, IsNotEmpty, IsObject, IsOptional, IsString, IsUUID } from 'class-validator'; +import { + OrganizationProject, + OrganizationProjectModule, + OrganizationSprint, + OrganizationTeam, + TenantOrganizationBaseEntity +} from '../../core/entities/internal'; +import { ColumnIndex, MultiORMColumn, MultiORMEntity, MultiORMManyToOne } from '../../core/decorators/entity'; +import { MikroOrmTaskViewRepository } from './repository/mikro-orm-task-view.repository'; + +@MultiORMEntity('task_view', { mikroOrmRepository: () => MikroOrmTaskViewRepository }) +export class TaskView extends TenantOrganizationBaseEntity implements ITaskView { + @ApiProperty({ type: () => String }) + @IsNotEmpty() + @IsString() + @ColumnIndex() + @MultiORMColumn() + name: string; + + @ApiPropertyOptional({ type: () => String }) + @IsOptional() + @IsString() + @MultiORMColumn({ nullable: true, type: 'text' }) + description?: string; + + @ApiPropertyOptional({ type: () => String, enum: VisibilityLevelEnum }) + @IsOptional() + @IsEnum(VisibilityLevelEnum) + @ColumnIndex() + @MultiORMColumn({ nullable: true }) + visibilityLevel?: VisibilityLevelEnum; + + @ApiPropertyOptional({ type: () => Object }) + @IsOptional() + @IsObject() + @MultiORMColumn({ type: isPostgres() ? 'jsonb' : isMySQL() ? 'json' : 'text', nullable: true }) + queryParams?: JsonData; + + @ApiPropertyOptional({ type: () => Object }) + @IsOptional() + @IsObject() + @MultiORMColumn({ type: isPostgres() ? 'jsonb' : isMySQL() ? 'json' : 'text', nullable: true }) + filterOptions?: JsonData; + + @ApiPropertyOptional({ type: () => Object }) + @IsOptional() + @IsObject() + @MultiORMColumn({ type: isPostgres() ? 'jsonb' : isMySQL() ? 'json' : 'text', nullable: true }) + displayOptions?: JsonData; + + @ApiPropertyOptional({ type: () => Object }) + @IsOptional() + @IsObject() + @MultiORMColumn({ type: isPostgres() ? 'jsonb' : isMySQL() ? 'json' : 'text', nullable: true }) + properties?: Record; + + @ApiPropertyOptional({ type: () => Boolean, default: false }) + @IsOptional() + @IsBoolean() + @MultiORMColumn({ default: false, update: false }) + isLocked?: boolean; + + /* + |-------------------------------------------------------------------------- + | @ManyToOne + |-------------------------------------------------------------------------- + */ + + /** + * Organization Project Relationship + */ + @MultiORMManyToOne(() => OrganizationProject, (it) => it.views, { + /** Indicates if the relation column value can be nullable or not. */ + nullable: true, + + /** Defines the database cascade action on delete. */ + onDelete: 'CASCADE' + }) + project?: IOrganizationProject; + + /** + * Organization Project ID + */ + @ApiPropertyOptional({ type: () => String }) + @IsOptional() + @IsUUID() + @RelationId((it: TaskView) => it.project) + @ColumnIndex() + @MultiORMColumn({ nullable: true, relationId: true }) + projectId?: ID; + + /** + * Organization Team Relationship + */ + @MultiORMManyToOne(() => OrganizationTeam, (it) => it.views, { + /** Indicates if the relation column value can be nullable or not. */ + nullable: true, + + /** Defines the database cascade action on delete. */ + onDelete: 'CASCADE' + }) + organizationTeam?: IOrganizationTeam; + + /** + * Organization Team ID + */ + @ApiPropertyOptional({ type: () => String }) + @IsOptional() + @IsUUID() + @RelationId((it: TaskView) => it.organizationTeam) + @ColumnIndex() + @MultiORMColumn({ nullable: true, relationId: true }) + organizationTeamId?: ID; + + /** + * Organization Project Module Relationship + */ + @MultiORMManyToOne(() => OrganizationProjectModule, (it) => it.views, { + /** Indicates if the relation column value can be nullable or not. */ + nullable: true, + + /** Defines the database cascade action on delete. */ + onDelete: 'CASCADE' + }) + projectModule?: IOrganizationProjectModule; + + /** + * Organization Project Module ID + */ + @ApiPropertyOptional({ type: () => String }) + @IsOptional() + @IsUUID() + @RelationId((it: TaskView) => it.projectModule) + @ColumnIndex() + @MultiORMColumn({ nullable: true, relationId: true }) + projectModuleId?: ID; + + /** + * Organization Sprint Relationship + */ + @MultiORMManyToOne(() => OrganizationSprint, (it) => it.views, { + /** Indicates if the relation column value can be nullable or not. */ + nullable: true, + + /** Defines the database cascade action on delete. */ + onDelete: 'CASCADE' + }) + organizationSprint?: IOrganizationSprint; + + /** + * Organization Sprint ID + */ + @ApiPropertyOptional({ type: () => String }) + @IsOptional() + @IsUUID() + @RelationId((it: TaskView) => it.organizationSprint) + @ColumnIndex() + @MultiORMColumn({ nullable: true, relationId: true }) + organizationSprintId?: ID; +} diff --git a/packages/core/src/tasks/views/view.module.ts b/packages/core/src/tasks/views/view.module.ts new file mode 100644 index 00000000000..2d67b2f5692 --- /dev/null +++ b/packages/core/src/tasks/views/view.module.ts @@ -0,0 +1,25 @@ +import { Module } from '@nestjs/common'; +import { CqrsModule } from '@nestjs/cqrs'; +import { RouterModule } from '@nestjs/core'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { MikroOrmModule } from '@mikro-orm/nestjs'; +import { RolePermissionModule } from '../../role-permission/role-permission.module'; +import { TaskView } from './view.entity'; +import { CommandHandlers } from './commands/handlers'; +import { TaskViewService } from './view.service'; +import { TaskViewController } from './view.controller'; +import { TypeOrmTaskViewRepository } from './repository/type-orm-task-view.repository'; + +@Module({ + imports: [ + RouterModule.register([{ path: '/task-views', module: TaskViewModule }]), + TypeOrmModule.forFeature([TaskView]), + MikroOrmModule.forFeature([TaskView]), + RolePermissionModule, + CqrsModule + ], + providers: [TaskViewService, TypeOrmTaskViewRepository, ...CommandHandlers], + controllers: [TaskViewController], + exports: [TaskViewService, TypeOrmTaskViewRepository] +}) +export class TaskViewModule {} diff --git a/packages/core/src/tasks/views/view.service.ts b/packages/core/src/tasks/views/view.service.ts new file mode 100644 index 00000000000..54e2c089665 --- /dev/null +++ b/packages/core/src/tasks/views/view.service.ts @@ -0,0 +1,21 @@ +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { FavoriteEntityEnum } from '@gauzy/contracts'; +import { FavoriteService } from '../../core/decorators'; +import { TenantAwareCrudService } from '../../core/crud'; +import { TaskView } from './view.entity'; +import { TypeOrmTaskViewRepository } from './repository/type-orm-task-view.repository'; +import { MikroOrmTaskViewRepository } from './repository/mikro-orm-task-view.repository'; + +@FavoriteService(FavoriteEntityEnum.OrganizationTeam) +@Injectable() +export class TaskViewService extends TenantAwareCrudService { + constructor( + @InjectRepository(TaskView) + typeOrmTaskViewRepository: TypeOrmTaskViewRepository, + + mikroOrmTaskViewRepository: MikroOrmTaskViewRepository + ) { + super(typeOrmTaskViewRepository, mikroOrmTaskViewRepository); + } +} diff --git a/packages/core/src/time-tracking/activity/activity.controller.ts b/packages/core/src/time-tracking/activity/activity.controller.ts index f1ad29869dc..5b8da83247d 100644 --- a/packages/core/src/time-tracking/activity/activity.controller.ts +++ b/packages/core/src/time-tracking/activity/activity.controller.ts @@ -1,12 +1,13 @@ import { Controller, UseGuards, HttpStatus, Get, Query, Post, Body } from '@nestjs/common'; import { ApiTags, ApiOperation, ApiResponse } from '@nestjs/swagger'; -import { IGetActivitiesInput, IBulkActivitiesInput, ReportGroupFilterEnum, PermissionsEnum } from '@gauzy/contracts'; +import { IGetActivitiesInput, ReportGroupFilterEnum, PermissionsEnum, IActivity } from '@gauzy/contracts'; import { PermissionGuard, TenantPermissionGuard } from './../../shared/guards'; import { Permissions } from './../../shared/decorators'; import { UseValidationPipe } from '../../shared/pipes'; import { ActivityService } from './activity.service'; import { ActivityMapService } from './activity.map.service'; -import { ActivityQueryDTO } from './dto/query'; +import { BulkActivityInputDTO } from './dto/bulk-activities-input.dto'; +import { ActivityQueryDTO } from './dto'; @ApiTags('Activity') @UseGuards(TenantPermissionGuard, PermissionGuard) @@ -16,41 +17,77 @@ export class ActivityController { constructor( private readonly activityService: ActivityService, private readonly activityMapService: ActivityMapService - ) { } + ) {} - @ApiOperation({ summary: 'Get Activities' }) + /** + * Retrieves a paginated list of activities based on the provided query parameters. + * + * @param options - The query parameters for fetching activities, including pagination options. + * @returns A promise resolving to a paginated list of activities. + */ + @ApiOperation({ + summary: 'Retrieve paginated activities', + description: 'Fetches a paginated list of activities based on filters like date, employee, and project.' + }) + @ApiResponse({ + status: HttpStatus.OK, + description: 'Successfully retrieved activities' + }) @ApiResponse({ status: HttpStatus.BAD_REQUEST, - description: 'Invalid input, The response body may contain clues as to what went wrong' + description: 'Invalid input, the request parameters may contain errors' }) - @Get() + @Get('/') @UseValidationPipe({ transform: true, whitelist: true }) - async getActivities(@Query() options: ActivityQueryDTO) { - const defaultParams: Partial = { - page: 0, - limit: 30 - }; + async getActivities(@Query() options: ActivityQueryDTO): Promise { + const defaultParams: Partial = { page: 0, limit: 30 }; options = Object.assign({}, defaultParams, options); return await this.activityService.getActivities(options); } - @ApiOperation({ summary: 'Get Daily Activities' }) + /** + * Retrieves daily activities based on the provided query parameters. + * + * @param options - The query parameters for fetching daily activities. + * @returns A promise resolving to a list of daily activities. + */ + @ApiOperation({ + summary: 'Retrieve daily activities', + description: 'Fetches a list of daily activities filtered by parameters such as date, employee, and project.' + }) + @ApiResponse({ + status: HttpStatus.OK, + description: 'Successfully retrieved daily activities' + }) @ApiResponse({ status: HttpStatus.BAD_REQUEST, - description: 'Invalid input, The response body may contain clues as to what went wrong' + description: 'Invalid input, the request parameters may contain errors' }) - @Get('daily') + @Get('/daily') @UseValidationPipe({ transform: true, whitelist: true }) async getDailyActivities(@Query() options: ActivityQueryDTO) { return await this.activityService.getDailyActivities(options); } - @ApiOperation({ summary: 'Get Daily Activities' }) + /** + * Retrieves a report of daily activities based on the provided query parameters. + * + * @param options - The query parameters for fetching the daily activities report, including grouping options. + * @returns A promise resolving to a grouped report of daily activities. + */ + @ApiOperation({ + summary: 'Retrieve daily activities report', + description: 'Fetches a report of daily activities grouped by parameters like date, employee, or project.' + }) + @ApiResponse({ + status: HttpStatus.OK, + description: 'Successfully retrieved the daily activities report' + }) @ApiResponse({ status: HttpStatus.BAD_REQUEST, - description: 'Invalid input, The response body may contain clues as to what went wrong' + description: 'Invalid input, the request parameters may contain errors' }) - @Get('report') + @Get('/report') @UseValidationPipe({ transform: true, whitelist: true }) async getDailyActivitiesReport(@Query() options: ActivityQueryDTO) { let activities = await this.activityService.getDailyActivitiesReport(options); @@ -64,13 +101,27 @@ export class ActivityController { return activities; } - @ApiOperation({ summary: 'Save bulk Activities' }) + /** + * Saves multiple activities in bulk. + * + * @param entities - The list of activities to be saved in bulk. + * @returns A promise resolving when the bulk save is complete. + */ + @ApiOperation({ + summary: 'Bulk save activities', + description: 'Saves multiple activities in one request. Useful for bulk data insertion.' + }) + @ApiResponse({ + status: HttpStatus.OK, + description: 'The activities have been successfully saved' + }) @ApiResponse({ status: HttpStatus.BAD_REQUEST, - description: 'Invalid input, The response body may contain clues as to what went wrong' + description: 'Invalid input, the request body may contain errors' }) - @Post('bulk') - async bulkSaveActivities(@Body() entities: IBulkActivitiesInput) { + @Post('/bulk') + @UseValidationPipe() + async bulkSaveActivities(@Body() entities: BulkActivityInputDTO) { return await this.activityService.bulkSave(entities); } } diff --git a/packages/core/src/time-tracking/activity/commands/handlers/bulk-activities-save.handler.ts b/packages/core/src/time-tracking/activity/commands/handlers/bulk-activities-save.handler.ts index 13aed0dab7f..dac26cd393c 100644 --- a/packages/core/src/time-tracking/activity/commands/handlers/bulk-activities-save.handler.ts +++ b/packages/core/src/time-tracking/activity/commands/handlers/bulk-activities-save.handler.ts @@ -1,85 +1,70 @@ import { CommandHandler, ICommandHandler } from '@nestjs/cqrs'; -import { InjectRepository } from '@nestjs/typeorm'; import { IActivity, PermissionsEnum } from '@gauzy/contracts'; import { isEmpty, isNotEmpty } from '@gauzy/common'; import { Activity } from '../../activity.entity'; import { BulkActivitiesSaveCommand } from '../bulk-activities-save.command'; import { RequestContext } from '../../../../core/context'; -import { Employee } from './../../../../core/entities/internal'; import { TypeOrmActivityRepository } from '../../repository/type-orm-activity.repository'; import { TypeOrmEmployeeRepository } from '../../../../employee/repository/type-orm-employee.repository'; @CommandHandler(BulkActivitiesSaveCommand) export class BulkActivitiesSaveHandler implements ICommandHandler { - constructor( - @InjectRepository(Activity) private readonly typeOrmActivityRepository: TypeOrmActivityRepository, - - @InjectRepository(Employee) private readonly typeOrmEmployeeRepository: TypeOrmEmployeeRepository - ) { } + ) {} /** + * Executes the bulk save operation for activities. * - * @param command - * @returns + * @param command - The command containing the input data for saving multiple activities. + * @returns A promise that resolves with the saved activities. + * @throws BadRequestException if there is an error during the save process. */ public async execute(command: BulkActivitiesSaveCommand): Promise { const { input } = command; - let { employeeId, organizationId, activities = [] } = input; + let { employeeId, organizationId, activities = [], projectId } = input; const user = RequestContext.currentUser(); - const tenantId = RequestContext.currentTenantId(); + const tenantId = RequestContext.currentTenantId() ?? input.tenantId; - /** - * Check logged user does not have employee selection permission - */ - if (!RequestContext.hasPermission(PermissionsEnum.CHANGE_SELECTED_EMPLOYEE)) { - try { - let employee = await this.typeOrmEmployeeRepository.findOneByOrFail({ - userId: user.id, - tenantId - }); - employeeId = employee.id; - organizationId = employee.organizationId; - } catch (error) { - console.log(`Error while finding logged in employee for (${user.name}) create bulk activities`, error); - } - } else if (isEmpty(employeeId) && RequestContext.currentEmployeeId()) { + // Check if the logged user has permission to change the selected employee + const hasChangeEmployeePermission = RequestContext.hasPermission(PermissionsEnum.CHANGE_SELECTED_EMPLOYEE); + + // Assign current employeeId if the user doesn't have permission or if employeeId is not provided + if (!hasChangeEmployeePermission || (isEmpty(employeeId) && RequestContext.currentEmployeeId())) { employeeId = RequestContext.currentEmployeeId(); } - /* - * If organization not found in request then assign current logged user organization - */ - if (isEmpty(organizationId)) { - let employee = await this.typeOrmEmployeeRepository.findOneBy({ - id: employeeId - }); + // Assign the current user's organizationId if it's not provided + if (isEmpty(organizationId) && employeeId) { + const employee = await this.typeOrmEmployeeRepository.findOneBy({ id: employeeId }); organizationId = employee ? employee.organizationId : null; } - console.log(`Empty bulk App & URL's activities for employee (${user.name}): ${employeeId}`, activities.filter( - (activity: IActivity) => Object.keys(activity).length === 0 - )); + // Log empty activities and filter out any invalid ones + console.log( + `Empty bulk App & URL's activities for employee (${user.name}): ${employeeId}`, + activities.filter((activity: IActivity) => Object.keys(activity).length === 0) + ); - activities = activities.filter( - (activity: IActivity) => Object.keys(activity).length !== 0 - ).map((activity: IActivity) => new Activity({ - ...activity, - ...(input.projectId ? { projectId: input.projectId } : {}), - employeeId, - organizationId, - tenantId, - })); + activities = activities + .filter((activity: IActivity) => Object.keys(activity).length !== 0) + .map( + (activity: IActivity) => + new Activity({ + ...activity, + ...(projectId ? { projectId } : {}), + employeeId, + organizationId, + tenantId + }) + ); - console.log(`Activities should be insert into database for employee (${user.name})`, { activities }); + // Log the activities that will be inserted into the database + console.log(`Activities should be inserted into database for employee (${user.name})`, { activities }); - if (isNotEmpty(activities)) { - return await this.typeOrmActivityRepository.save(activities); - } else { - return []; - } + // Save activities if they exist, otherwise return an empty array + return isNotEmpty(activities) ? await this.typeOrmActivityRepository.save(activities) : []; } } diff --git a/packages/core/src/time-tracking/activity/dto/activity-query.dto.ts b/packages/core/src/time-tracking/activity/dto/activity-query.dto.ts new file mode 100644 index 00000000000..3c9e0a6e031 --- /dev/null +++ b/packages/core/src/time-tracking/activity/dto/activity-query.dto.ts @@ -0,0 +1,27 @@ +import { IGetActivitiesInput, ReportGroupFilterEnum } from '@gauzy/contracts'; +import { ApiPropertyOptional, IntersectionType } from '@nestjs/swagger'; +import { IsArray, IsEnum, IsOptional } from 'class-validator'; +import { FiltersQueryDTO, SelectorsQueryDTO } from '../../../shared/dto'; + +/** + * Get activities request DTO validation + */ +export class ActivityQueryDTO + extends IntersectionType(FiltersQueryDTO, SelectorsQueryDTO) + implements IGetActivitiesInput +{ + @ApiPropertyOptional({ type: () => Array, enum: ReportGroupFilterEnum }) + @IsOptional() + @IsEnum(ReportGroupFilterEnum) + readonly groupBy: ReportGroupFilterEnum; + + @ApiPropertyOptional({ type: () => Array, isArray: true }) + @IsOptional() + @IsArray() + readonly types: string[]; + + @ApiPropertyOptional({ type: () => Array, isArray: true }) + @IsOptional() + @IsArray() + readonly titles: string[]; +} diff --git a/packages/core/src/time-tracking/activity/dto/bulk-activities-input.dto.ts b/packages/core/src/time-tracking/activity/dto/bulk-activities-input.dto.ts new file mode 100644 index 00000000000..420146363ff --- /dev/null +++ b/packages/core/src/time-tracking/activity/dto/bulk-activities-input.dto.ts @@ -0,0 +1,14 @@ +import { IntersectionType } from '@nestjs/swagger'; +import { IActivity, IBulkActivitiesInput } from '@gauzy/contracts'; +import { TenantOrganizationBaseDTO } from '../../../core/dto'; +import { EmployeeFeatureDTO } from '../../../employee/dto'; + +/** + * Get activities request DTO validation + */ +export class BulkActivityInputDTO + extends IntersectionType(TenantOrganizationBaseDTO, EmployeeFeatureDTO) + implements IBulkActivitiesInput +{ + readonly activities: IActivity[]; +} diff --git a/packages/core/src/time-tracking/activity/dto/index.ts b/packages/core/src/time-tracking/activity/dto/index.ts new file mode 100644 index 00000000000..aa4563922b0 --- /dev/null +++ b/packages/core/src/time-tracking/activity/dto/index.ts @@ -0,0 +1,2 @@ +export * from './bulk-activities-input.dto'; +export * from './activity-query.dto'; diff --git a/packages/core/src/time-tracking/activity/dto/query/activity-query.dto.ts b/packages/core/src/time-tracking/activity/dto/query/activity-query.dto.ts deleted file mode 100644 index 710afaea9bc..00000000000 --- a/packages/core/src/time-tracking/activity/dto/query/activity-query.dto.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { IGetActivitiesInput, ReportGroupFilterEnum } from "@gauzy/contracts"; -import { IntersectionType } from "@nestjs/swagger"; -import { ApiPropertyOptional } from "@nestjs/swagger"; -import { IsArray, IsEnum, IsOptional } from "class-validator"; -import { FiltersQueryDTO, SelectorsQueryDTO } from "../../../../shared/dto"; - -/** - * Get activities request DTO validation - */ -export class ActivityQueryDTO extends IntersectionType( - FiltersQueryDTO, - SelectorsQueryDTO -) implements IGetActivitiesInput { - - @ApiPropertyOptional({ type: () => Array, enum: ReportGroupFilterEnum }) - @IsOptional() - @IsEnum(ReportGroupFilterEnum) - readonly groupBy: ReportGroupFilterEnum; - - @ApiPropertyOptional({ type: () => Array, isArray: true }) - @IsOptional() - @IsArray() - readonly types: string[]; - - @ApiPropertyOptional({ type: () => Array, isArray: true }) - @IsOptional() - @IsArray() - readonly titles: string[]; -} \ No newline at end of file diff --git a/packages/core/src/time-tracking/activity/dto/query/index.ts b/packages/core/src/time-tracking/activity/dto/query/index.ts deleted file mode 100644 index 72efc3ae5cf..00000000000 --- a/packages/core/src/time-tracking/activity/dto/query/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { ActivityQueryDTO } from './activity-query.dto'; \ No newline at end of file diff --git a/packages/core/src/time-tracking/dto/force-delete.dto.ts b/packages/core/src/time-tracking/dto/force-delete.dto.ts new file mode 100644 index 00000000000..82315b157aa --- /dev/null +++ b/packages/core/src/time-tracking/dto/force-delete.dto.ts @@ -0,0 +1,23 @@ +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { Transform, TransformFnParams } from 'class-transformer'; +import { IsOptional, IsBoolean } from 'class-validator'; +import { parseToBoolean } from '@gauzy/common'; +import { DeleteQueryDTO } from '../../shared/dto'; + +/** + * Common base DTO with the `forceDelete` flag. + * If `true`, a hard delete will be performed; otherwise, a soft delete is used. + * This field is optional and defaults to `false`. + */ +export class ForceDeleteDTO extends DeleteQueryDTO { + /** + * A flag to determine whether to force delete the records. + * If `true`, a hard delete will be performed; otherwise, a soft delete is used. + * This field is optional and defaults to `false`. + */ + @ApiPropertyOptional({ type: () => Boolean }) + @IsOptional() + @IsBoolean() + @Transform(({ value }: TransformFnParams) => (value ? parseToBoolean(value) : false)) + readonly forceDelete: boolean; +} diff --git a/packages/core/src/time-tracking/screenshot/dto/delete-screenshot.dto.ts b/packages/core/src/time-tracking/screenshot/dto/delete-screenshot.dto.ts new file mode 100644 index 00000000000..5034a7e6c71 --- /dev/null +++ b/packages/core/src/time-tracking/screenshot/dto/delete-screenshot.dto.ts @@ -0,0 +1,13 @@ +import { IDeleteScreenshot } from '@gauzy/contracts'; +import { ForceDeleteDTO } from '../../../time-tracking/dto/force-delete.dto'; +import { Screenshot } from '../screenshot.entity'; + +/** + * Data Transfer Object for deleting screenshots. + * + * This DTO is used to define the structure of the data required for deleting + * screenshots. It extends the `DeleteQueryDTO`, which includes tenant and + * organization context. The DTO also extends `ForceDeleteDTO` to include an optional + * `forceDelete` flag to determine whether a hard or soft delete should be performed. + */ +export class DeleteScreenshotDTO extends ForceDeleteDTO implements IDeleteScreenshot {} diff --git a/packages/core/src/time-tracking/screenshot/screenshot.helper.ts b/packages/core/src/time-tracking/screenshot/screenshot-file-storage.helper.ts similarity index 76% rename from packages/core/src/time-tracking/screenshot/screenshot.helper.ts rename to packages/core/src/time-tracking/screenshot/screenshot-file-storage.helper.ts index 609eae545c2..f4973f388b8 100644 --- a/packages/core/src/time-tracking/screenshot/screenshot.helper.ts +++ b/packages/core/src/time-tracking/screenshot/screenshot-file-storage.helper.ts @@ -15,6 +15,10 @@ export function createFileStorage() { // Generate unique sub directories based on the current tenant and employee IDs const subDirectory = getSubDirectory(); + console.log( + `--------------------screenshot full path: ${path.join(baseDirectory, subDirectory)}--------------------` + ); + return new FileStorage().storage({ dest: () => path.join(baseDirectory, subDirectory), prefix: 'screenshots' @@ -25,7 +29,7 @@ export function createFileStorage() { * Gets the base directory for storing screenshots based on the current date. * @returns The base directory path */ -function getBaseDirectory(): string { +export function getBaseDirectory(): string { return path.join('screenshots', moment().format('YYYY/MM/DD')); } @@ -33,9 +37,12 @@ function getBaseDirectory(): string { * Generates a unique sub-directory based on the current tenant and employee IDs. * @returns The sub-directory path */ -function getSubDirectory(): string { +export function getSubDirectory(): string { + const user = RequestContext.currentUser(); + // Retrieve the tenant ID from the current context or a random UUID - const tenantId = RequestContext.currentTenantId() || uuid(); - const employeeId = RequestContext.currentEmployeeId() || uuid(); + const tenantId = user?.tenantId || uuid(); + const employeeId = user?.employeeId || uuid(); + return path.join(tenantId, employeeId); } diff --git a/packages/core/src/time-tracking/screenshot/screenshot.controller.ts b/packages/core/src/time-tracking/screenshot/screenshot.controller.ts index d55e8c1f849..530a12020f8 100644 --- a/packages/core/src/time-tracking/screenshot/screenshot.controller.ts +++ b/packages/core/src/time-tracking/screenshot/screenshot.controller.ts @@ -4,7 +4,7 @@ import { isUUID } from 'class-validator'; import * as path from 'path'; import * as fs from 'fs'; import * as Jimp from 'jimp'; -import { IScreenshot, PermissionsEnum, UploadedFile } from '@gauzy/contracts'; +import { ID, IScreenshot, PermissionsEnum, UploadedFile } from '@gauzy/contracts'; import { EventBus } from '../../event-bus/event-bus'; import { ScreenshotEvent } from '../../event-bus/events/screenshot.event'; import { BaseEntityEventTypeEnum } from '../../event-bus/base-entity-event'; @@ -15,10 +15,10 @@ import { LazyFileInterceptor } from './../../core/interceptors'; import { Permissions } from './../../shared/decorators'; import { PermissionGuard, TenantPermissionGuard } from './../../shared/guards'; import { UUIDValidationPipe, UseValidationPipe } from './../../shared/pipes'; -import { DeleteQueryDTO } from './../../shared/dto'; +import { DeleteScreenshotDTO } from './dto/delete-screenshot.dto'; import { Screenshot } from './screenshot.entity'; import { ScreenshotService } from './screenshot.service'; -import { createFileStorage } from './screenshot.helper'; +import { createFileStorage } from './screenshot-file-storage.helper'; @ApiTags('Screenshot') @UseGuards(TenantPermissionGuard, PermissionGuard) @@ -147,28 +147,37 @@ export class ScreenshotController { } /** + * Deletes a screenshot record by its ID. * - * @param screenshotId - * @param options - * @returns + * This endpoint allows authorized users to delete a screenshot record by providing its ID. + * Additional query options can be provided to customize the delete operation. + * + * @param screenshotId - The UUID of the screenshot to delete. + * @param options - Additional query options for deletion (e.g., soft delete or force delete). + * @returns A Promise that resolves with the details of the deleted screenshot. */ @ApiOperation({ - summary: 'Delete record' + summary: 'Delete a screenshot by ID', + description: 'Deletes a screenshot record from the system based on the provided ID.' }) @ApiResponse({ status: HttpStatus.OK, - description: 'The record has been successfully deleted' + description: 'The screenshot has been successfully deleted.' }) @ApiResponse({ status: HttpStatus.NOT_FOUND, - description: 'Record not found' + description: 'Screenshot record not found.' + }) + @ApiResponse({ + status: HttpStatus.FORBIDDEN, + description: 'User does not have permission to delete screenshots.' }) @Permissions(PermissionsEnum.DELETE_SCREENSHOTS) @Delete(':id') @UseValidationPipe() async delete( - @Param('id', UUIDValidationPipe) screenshotId: IScreenshot['id'], - @Query() options: DeleteQueryDTO + @Param('id', UUIDValidationPipe) screenshotId: ID, + @Query() options: DeleteScreenshotDTO ): Promise { return await this._screenshotService.deleteScreenshot(screenshotId, options); } diff --git a/packages/core/src/time-tracking/screenshot/screenshot.entity.ts b/packages/core/src/time-tracking/screenshot/screenshot.entity.ts index 91d91755d36..1be4fd67ce0 100644 --- a/packages/core/src/time-tracking/screenshot/screenshot.entity.ts +++ b/packages/core/src/time-tracking/screenshot/screenshot.entity.ts @@ -2,9 +2,15 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { RelationId, JoinColumn } from 'typeorm'; import { IsString, IsOptional, IsDateString, IsUUID, IsNotEmpty, IsEnum, IsBoolean } from 'class-validator'; import { Exclude } from 'class-transformer'; -import { FileStorageProvider, FileStorageProviderEnum, IScreenshot, ITimeSlot, IUser } from '@gauzy/contracts'; +import { FileStorageProvider, FileStorageProviderEnum, ID, IScreenshot, ITimeSlot, IUser } from '@gauzy/contracts'; import { isBetterSqlite3, isSqlite } from '@gauzy/config'; -import { ColumnIndex, MultiORMColumn, MultiORMEntity, MultiORMManyToOne, VirtualMultiOrmColumn } from '../../core/decorators/entity'; +import { + ColumnIndex, + MultiORMColumn, + MultiORMEntity, + MultiORMManyToOne, + VirtualMultiOrmColumn +} from '../../core/decorators/entity'; import { TenantOrganizationBaseEntity, TimeSlot, User } from './../../core/entities/internal'; import { MikroOrmScreenshotRepository } from './repository/mikro-orm-screenshot.repository'; @@ -115,7 +121,7 @@ export class Screenshot extends TenantOrganizationBaseEntity implements IScreens @RelationId((it: Screenshot) => it.timeSlot) @ColumnIndex() @MultiORMColumn({ nullable: true, relationId: true }) - timeSlotId?: ITimeSlot['id']; + timeSlotId?: ID; /** * User @@ -136,5 +142,5 @@ export class Screenshot extends TenantOrganizationBaseEntity implements IScreens @RelationId((it: Screenshot) => it.user) @ColumnIndex() @MultiORMColumn({ nullable: true, relationId: true }) - userId?: IUser['id']; + userId?: ID; } diff --git a/packages/core/src/time-tracking/screenshot/screenshot.service.ts b/packages/core/src/time-tracking/screenshot/screenshot.service.ts index c69fe7765de..84d245440d5 100644 --- a/packages/core/src/time-tracking/screenshot/screenshot.service.ts +++ b/packages/core/src/time-tracking/screenshot/screenshot.service.ts @@ -1,8 +1,8 @@ import { ForbiddenException, Injectable } from '@nestjs/common'; -import { FindOptionsWhere } from 'typeorm'; -import { ID, IScreenshot, PermissionsEnum } from '@gauzy/contracts'; +import { ID, IDeleteScreenshot, IScreenshot, PermissionsEnum } from '@gauzy/contracts'; import { RequestContext } from './../../core/context'; import { TenantAwareCrudService } from './../../core/crud'; +import { prepareSQLQuery as p } from '../../database/database.helper'; import { Screenshot } from './screenshot.entity'; import { MikroOrmScreenshotRepository, TypeOrmScreenshotRepository } from './repository'; @@ -23,33 +23,50 @@ export class ScreenshotService extends TenantAwareCrudService { * @returns The deleted screenshot * @throws ForbiddenException if the screenshot cannot be found or deleted */ - async deleteScreenshot(id: ID, options?: FindOptionsWhere): Promise { + async deleteScreenshot(id: ID, options?: IDeleteScreenshot): Promise { try { - const tenantId = RequestContext.currentTenantId() || options?.tenantId; - const { organizationId } = options || {}; - - const query = this.typeOrmRepository.createQueryBuilder(this.tableName); - query.setFindOptions({ - where: { - ...(options ? options : {}), - id, - tenantId, - organizationId - } - }); - - if (!RequestContext.hasPermission(PermissionsEnum.CHANGE_SELECTED_EMPLOYEE)) { + const tenantId = RequestContext.currentTenantId() ?? options.tenantId; + const { organizationId, forceDelete } = options; + + // Check if the current user has the permission to change the selected employee + const hasChangeSelectedEmployeePermission: boolean = RequestContext.hasPermission( + PermissionsEnum.CHANGE_SELECTED_EMPLOYEE + ); + + // Create a query builder for the Screenshot entity + const query = this.typeOrmRepository.createQueryBuilder(); + + // Add the WHERE clause to the query + query + .where(p(`"${query.alias}"."id" = :id`), { id }) + .andWhere(p(`"${query.alias}"."tenantId" = :tenantId`), { tenantId }) + .andWhere(p(`"${query.alias}"."organizationId" = :organizationId`), { organizationId }); + + // Restrict by employeeId if the user doesn't have permission + if (!hasChangeSelectedEmployeePermission) { + // Get the current employee ID from the request context const employeeId = RequestContext.currentEmployeeId(); + + // Join the timeSlot table and filter by employeeId, tenantId, and organizationId query.leftJoin( `${query.alias}.timeSlot`, 'time_slot', - 'time_slot.employeeId = :employeeId AND time_slot.tenantId = :tenantId', - { employeeId, tenantId } + 'time_slot.employeeId = :employeeId AND time_slot.tenantId = :tenantId AND time_slot.organizationId = :organizationId', + { + employeeId, + tenantId, + organizationId + } ); } + // Find the screenshot const screenshot = await query.getOneOrFail(); - return await this.typeOrmRepository.remove(screenshot); + + // Handle force delete or soft delete based on the flag + return forceDelete + ? await this.typeOrmRepository.remove(screenshot) + : await this.typeOrmRepository.softRemove(screenshot); } catch (error) { throw new ForbiddenException('You do not have permission to delete this screenshot.'); } diff --git a/packages/core/src/time-tracking/screenshot/screenshot.subscriber.ts b/packages/core/src/time-tracking/screenshot/screenshot.subscriber.ts index 1054380df8d..dddb8fd8f84 100644 --- a/packages/core/src/time-tracking/screenshot/screenshot.subscriber.ts +++ b/packages/core/src/time-tracking/screenshot/screenshot.subscriber.ts @@ -163,7 +163,7 @@ export class ScreenshotSubscriber extends BaseEntityEventSubscriber return; // Early exit if the entity is not a Screenshot } const { id: entityId, storageProvider, file, thumb } = entity; - console.log(`BEFORE SCREENSHOT ENTITY WITH ID ${entityId} REMOVED`); + console.log(`AFTER SCREENSHOT ENTITY WITH ID ${entityId} REMOVED`); console.log('ScreenshotSubscriber: Deleting files...', file, thumb); // Initialize the file storage instance with the provided storage provider. @@ -176,4 +176,24 @@ export class ScreenshotSubscriber extends BaseEntityEventSubscriber console.error(`ScreenshotSubscriber: Error deleting files for entity ID ${entity?.id}:`, error.message); } } + + /** + * Called after entity is soft removed from the database. + * This method handles the removal of associated files (both the main file and its thumbnail) from the storage system. + * + * @param entity The entity that was soft removed. + * @returns {Promise} A promise that resolves when the file soft removal operations are complete. + */ + async afterEntitySoftRemove(entity: Screenshot): Promise { + try { + if (!(entity instanceof Screenshot)) { + return; // Early exit if the entity is not a Screenshot + } + const { id: entityId, file, thumb } = entity; + console.log(`AFTER SCREENSHOT ENTITY WITH ID ${entityId} SOFT REMOVED`); + console.log('ScreenshotSubscriber: Soft removing files...', file, thumb); + } catch (error) { + console.error(`ScreenshotSubscriber: Error soft removing entity ID ${entity?.id}:`, error.message); + } + } } diff --git a/packages/core/src/time-tracking/time-log/commands/delete-time-span.command.ts b/packages/core/src/time-tracking/time-log/commands/delete-time-span.command.ts index 814aec9c4ee..e82380a2d13 100644 --- a/packages/core/src/time-tracking/time-log/commands/delete-time-span.command.ts +++ b/packages/core/src/time-tracking/time-log/commands/delete-time-span.command.ts @@ -8,6 +8,7 @@ export class DeleteTimeSpanCommand implements ICommand { constructor( public readonly newTime: IDateRange, public readonly timeLog: TimeLog, - public readonly timeSlot: ITimeSlot + public readonly timeSlot: ITimeSlot, + public readonly forceDelete: boolean = false ) {} } diff --git a/packages/core/src/time-tracking/time-log/commands/handlers/delete-time-span.handler.ts b/packages/core/src/time-tracking/time-log/commands/handlers/delete-time-span.handler.ts index 675436b2e55..a845f763f1e 100644 --- a/packages/core/src/time-tracking/time-log/commands/handlers/delete-time-span.handler.ts +++ b/packages/core/src/time-tracking/time-log/commands/handlers/delete-time-span.handler.ts @@ -1,10 +1,8 @@ import { ICommandHandler, CommandBus, CommandHandler } from '@nestjs/cqrs'; -import { InjectRepository } from '@nestjs/typeorm'; -import * as _ from 'underscore'; -import { ITimeLog } from '@gauzy/contracts'; +import { omit } from 'underscore'; +import { ID, ITimeLog, ITimeSlot } from '@gauzy/contracts'; import { isEmpty, isNotEmpty } from '@gauzy/common'; import { moment } from '../../../../core/moment-extend'; -import { TimeSlot } from './../../../../core/entities/internal'; import { TimesheetRecalculateCommand } from './../../../timesheet/commands'; import { TimeLog } from './../../time-log.entity'; import { DeleteTimeSpanCommand } from '../delete-time-span.command'; @@ -18,79 +16,46 @@ import { TypeOrmTimeSlotRepository } from '../../../time-slot/repository/type-or @CommandHandler(DeleteTimeSpanCommand) export class DeleteTimeSpanHandler implements ICommandHandler { - constructor( - @InjectRepository(TimeLog) readonly typeOrmTimeLogRepository: TypeOrmTimeLogRepository, + readonly typeOrmTimeSlotRepository: TypeOrmTimeSlotRepository, + private readonly _commandBus: CommandBus, + private readonly _timeSlotService: TimeSlotService + ) {} - @InjectRepository(TimeSlot) - private readonly typeOrmTimeSlotRepository: TypeOrmTimeSlotRepository, - - private readonly commandBus: CommandBus, - private readonly timeSlotService: TimeSlotService - ) { } - + /** + * Execute delete time span logic + * + * @param command - The command containing newTime, timeLog, and timeSlot + * @returns Promise + */ public async execute(command: DeleteTimeSpanCommand) { - const { newTime, timeLog, timeSlot } = command; + const { newTime, timeLog, timeSlot, forceDelete } = command; const { id } = timeLog; const { start, end } = newTime; - const refreshTimeLog = await this.typeOrmTimeLogRepository.findOne({ - where: { - id: id - }, - relations: { - timeSlots: true - } + // Retrieve the time log with the specified ID + const log = await this.typeOrmTimeLogRepository.findOne({ + where: { id }, + relations: { timeSlots: true } }); + const { startedAt, stoppedAt, employeeId, organizationId } = log; - const { startedAt, stoppedAt, employeeId, organizationId, timesheetId } = refreshTimeLog; - - const newTimeRange = moment.range(start, end); - const dbTimeRange = moment.range(startedAt, stoppedAt); + const newTimeRange = moment.range(start, end); // Calculate the new time rang + const dbTimeRange = moment.range(startedAt, stoppedAt); // Calculate the database time range - console.log({ newTimeRange, dbTimeRange }); /* * Check is overlapping time or not. */ if (!newTimeRange.overlaps(dbTimeRange, { adjacent: false })) { console.log('Not Overlapping', newTimeRange, dbTimeRange); - /** - * If TimeSlot Not Overlapping the TimeLog - * Still we have to remove that TimeSlot with screenshots/activities - */ - if (employeeId && start && end) { - const timeSlotsIds = [timeSlot.id]; - await this.commandBus.execute( - new TimeSlotBulkDeleteCommand({ - organizationId, - employeeId, - timeLog: refreshTimeLog, - timeSlotsIds - }, true) - ); - await this.commandBus.execute( - new TimesheetRecalculateCommand(timesheetId) - ); - } + + // Handle non-overlapping time ranges + await this.handleNonOverlappingTimeRange(log, timeSlot, employeeId, organizationId, forceDelete); } - if ( - moment(startedAt).isBetween( - moment(start), - moment(end), - null, - '[]' - ) - ) { - if ( - moment(stoppedAt).isBetween( - moment(start), - moment(end), - null, - '[]' - ) - ) { + if (moment(startedAt).isBetween(moment(start), moment(end), null, '[]')) { + if (moment(stoppedAt).isBetween(moment(start), moment(end), null, '[]')) { /* * Delete time log because overlap entire time. * New Start time New Stop time @@ -98,14 +63,9 @@ export class DeleteTimeSpanHandler implements ICommandHandler 0) { - try { - console.log('Update startedAt time.'); - let updatedTimeLog: ITimeLog = await this.commandBus.execute( - new TimeLogUpdateCommand( - { - startedAt: end - }, - refreshTimeLog, - true - ) - ); - const timeSlotsIds = [timeSlot.id]; - await this.commandBus.execute( - new TimeSlotBulkDeleteCommand({ - organizationId, - employeeId, - timeLog: updatedTimeLog, - timeSlotsIds - }, true) - ); - /* - * Delete TimeLog if remaining timeSlots are 0 - */ - updatedTimeLog = await this.typeOrmTimeLogRepository.findOne({ - where: { - id: updatedTimeLog.id - }, - relations: { - timeSlots: true - } - }); - if (isEmpty(updatedTimeLog.timeSlots)) { - await this.commandBus.execute( - new TimeLogDeleteCommand(updatedTimeLog, true) - ); - } - } catch (error) { - console.log('Error while, updating startedAt time', error); - } - } else { - console.log('Delete startedAt time log.'); - try { - /* - * Delete if remaining duration 0 seconds - */ - await this.commandBus.execute( - new TimeLogDeleteCommand(refreshTimeLog, true) - ); - } catch (error) { - console.log('Error while, deleting time log for startedAt time', error); - } - } } } else { - if ( - moment(timeLog.stoppedAt).isBetween( - moment(start), - moment(end), - null, - '[]' - ) - ) { + if (moment(timeLog.stoppedAt).isBetween(moment(start), moment(end), null, '[]')) { /* * Update stopped time * New Start time New Stop time @@ -188,64 +95,17 @@ export class DeleteTimeSpanHandler implements ICommandHandler 0) { - console.log('Update stoppedAt time.'); - try { - let updatedTimeLog: ITimeLog = await this.commandBus.execute( - new TimeLogUpdateCommand( - { - stoppedAt: start - }, - timeLog, - true - ) - ); - const timeSlotsIds = [timeSlot.id]; - await this.commandBus.execute( - new TimeSlotBulkDeleteCommand({ - organizationId, - employeeId, - timeLog: updatedTimeLog, - timeSlotsIds - }, true) - ); - - /* - * Delete TimeLog if remaining timeSlots are 0 - */ - updatedTimeLog = await this.typeOrmTimeLogRepository.findOne({ - where: { - id: updatedTimeLog.id - }, - relations: { - timeSlots: true - } - }); - if (isEmpty(updatedTimeLog.timeSlots)) { - await this.commandBus.execute( - new TimeLogDeleteCommand(updatedTimeLog, true) - ); - } - } catch (error) { - console.log('Error while, updating stoppedAt time', error); - } - } else { - console.log('Delete stoppedAt time log.'); - try { - /* - * Delete if remaining duration 0 seconds - */ - await this.commandBus.execute( - new TimeLogDeleteCommand(refreshTimeLog, true) - ); - } catch (error) { - console.log('Error while, deleting time log for stoppedAt time', error); - } - } } else { /* * Split database time in two entries. @@ -255,98 +115,345 @@ export class DeleteTimeSpanHandler implements ICommandHandler 0) { - try { - timeLog.stoppedAt = start; - await this.typeOrmTimeLogRepository.save(timeLog); - } catch (error) { - console.error(`Error while updating old timelog`, error); - } - } else { - /* - * Delete if remaining duration 0 seconds - */ - try { - await this.commandBus.execute( - new TimeLogDeleteCommand(refreshTimeLog, true) - ); - } catch (error) { - console.error(`Error while deleting old timelog`, error); - } - } - const timeSlotsIds = [timeSlot.id]; - await this.commandBus.execute( - new TimeSlotBulkDeleteCommand({ + } + } + + return true; + } + + /** + * Handles non-overlapping time ranges by deleting the time log and associated time slots, + * and recalculating the timesheet. + * + * @param timeLog - The time log associated with the non-overlapping time range. + * @param timeSlot - The time slot to be deleted. + * @param employeeId - The ID of the employee associated with the time log. + * @param organizationId - The ID of the organization. + * @param forceDelete - A flag indicating whether to perform a hard delete. + */ + private async handleNonOverlappingTimeRange( + timeLog: ITimeLog, + timeSlot: ITimeSlot, + employeeId: ID, + organizationId: ID, + forceDelete: boolean = false + ): Promise { + // Delete the associated time slots + const timeSlotsIds = [timeSlot.id]; + + // Bulk delete the time slots + await this._commandBus.execute( + new TimeSlotBulkDeleteCommand( + { + organizationId, + employeeId, + timeLog, + timeSlotsIds + }, + forceDelete, + true + ) + ); + + // Recalculate the timesheet + await this._commandBus.execute(new TimesheetRecalculateCommand(timeLog.timesheetId)); + } + + /** + * Updates the start time or deletes the time log if remaining duration is 0. + * + * @param log - The time log to update or delete. + * @param slot - The related time slot. + * @param organizationId - The organization ID. + * @param employeeId - The employee ID. + * @param end - The new end time. + * @param stoppedAt - The current stopped time of the log. + */ + private async updateStartTimeOrDelete( + log: ITimeLog, + slot: ITimeSlot, + organizationId: ID, + employeeId: ID, + end: Date, + stoppedAt: Date, + forceDelete: boolean = false + ): Promise { + const stoppedAtMoment = moment(stoppedAt); // Get the stopped at moment + const endMoment = moment(end); // Get the end moment + const remainingDuration = stoppedAtMoment.diff(endMoment, 'seconds'); // Calculate the remaining duration + + // If there is remaining duration + if (remainingDuration > 0) { + // Update the start time if there is remaining duration + try { + console.log(`update startedAt time to ${end}`); + // Update the started at time + let timeLog: ITimeLog = await this._commandBus.execute( + new TimeLogUpdateCommand({ startedAt: end }, log, true, forceDelete) + ); + + // Delete the associated time slots + const timeSlotsIds = [slot.id]; + + // Bulk delete the time slots + await this._commandBus.execute( + new TimeSlotBulkDeleteCommand( + { organizationId, employeeId, timeLog, timeSlotsIds - }, true) - ); - } catch (error) { - console.error(`Error while split time entires: ${remainingDuration}`); + }, + forceDelete, + true + ) + ); + + // Check if there are any remaining time slots + timeLog = await this.typeOrmTimeLogRepository.findOne({ + where: { id: timeLog.id }, + relations: { timeSlots: true } + }); + + // If no remaining time slots, delete the time log + if (isEmpty(timeLog.timeSlots)) { + // Delete TimeLog if remaining timeSlots are 0 + await this.deleteTimeLog(timeLog, forceDelete); } + } catch (error) { + console.log('Error while updating startedAt time', error); + } + } else { + // Delete the time log if remaining duration is 0 + console.log('Remaining duration is 0, so we are deleting the time log during update startedAt time'); + await this.deleteTimeLog(log, forceDelete); + } + } - const newLog = timeLogClone; - newLog.startedAt = end; + /** + * Updates the stoppedAt time for a given time log, or deletes it if the remaining duration is 0. + * + * @param log - The time log to update or delete. + * @param slot - The related time slot. + * @param organizationId - The organization ID. + * @param employeeId - The employee ID. + * @param start - The new start time. + * @param startedAt - The original start time of the time log. + * @param end - The new end time for the time log. + */ + private async updateStopTimeOrDelete( + log: ITimeLog, + slot: ITimeSlot, + organizationId: ID, + employeeId: ID, + start: Date, + startedAt: Date, + end: Date, + forceDelete: boolean = false + ): Promise { + const startedAtMoment = moment(startedAt); // Get the started at moment + const endMoment = moment(end); // Get the end moment + const remainingDuration = endMoment.diff(startedAtMoment, 'seconds'); // Calculate the remaining duration - const newLogRemainingDuration = moment(newLog.stoppedAt).diff( - moment(newLog.startedAt), - 'seconds' + // If there is remaining duration + if (remainingDuration > 0) { + // Update the stoppedAt time if there is remaining duration + try { + console.log(`update stoppedAt time to ${start}`); + + // Update the stoppedAt time + let timeLog: ITimeLog = await this._commandBus.execute( + new TimeLogUpdateCommand({ stoppedAt: start }, log, true, forceDelete) ); - /* - * Insert if remaining duration is more 0 seconds - */ - if (newLogRemainingDuration > 0) { - try { - await this.typeOrmTimeLogRepository.save(newLog); - } catch (error) { - console.log('Error while creating new log', error, newLog); - } - try { - const timeSlots = await this.syncTimeSlots(newLog); - console.log('Sync TimeSlots for new log', { timeSlots }, { newLog }); - if (isNotEmpty(timeSlots)) { - let timeLogs: ITimeLog[] = []; - timeLogs = timeLogs.concat(newLog); - - for await (const timeSlot of timeSlots) { - timeSlot.timeLogs = timeLogs; - } - - try { - await this.typeOrmTimeSlotRepository.save(timeSlots); - } catch (error) { - console.log('Error while creating new TimeSlot & TimeLog entires', error, timeSlots) - } - } - } catch (error) { - console.log('Error while syncing TimeSlot & TimeLog', error) + // Delete the associated time slots + const timeSlotsIds = [slot.id]; + + // Bulk delete the time slots + await this._commandBus.execute( + new TimeSlotBulkDeleteCommand( + { + organizationId, + employeeId, + timeLog, + timeSlotsIds + }, + forceDelete, + true + ) + ); + + // Check if there are any remaining time slots + timeLog = await this.typeOrmTimeLogRepository.findOne({ + where: { id: timeLog.id }, + relations: { timeSlots: true } + }); + + // If no remaining time slots, delete the time log + if (isEmpty(timeLog.timeSlots)) { + await this.deleteTimeLog(timeLog, forceDelete); + } + } catch (error) { + console.log('Error while updating stoppedAt time', error); + } + } else { + console.log('Remaining duration is 0, so we are deleting the time log during update stoppedAt time'); + await this.deleteTimeLog(log, forceDelete); + } + } + + /** + * Handles splitting a time log into two entries and processing the associated time slots. + * + * @param timeLog - The original time log to split. + * @param timeSlot - The related time slot. + * @param organizationId - The organization ID. + * @param employeeId - The employee ID. + * @param start - The new start time. + * @param end - The new end time. + * @param startedAt - The original start time of the time log. + */ + private async handleTimeLogSplitting( + timeLog: ITimeLog, + timeSlot: ITimeSlot, + organizationId: ID, + employeeId: ID, + start: Date, + end: Date, + startedAt: Date, + forceDelete: boolean = false + ): Promise { + const startedAtMoment = moment(startedAt); // Get the started at moment + const startMoment = moment(start); // Get the start moment + const remainingDuration = startMoment.diff(startedAtMoment, 'seconds'); // Calculate the remaining duration + + // If there is remaining duration + if (remainingDuration > 0) { + try { + timeLog.stoppedAt = start; + await this.typeOrmTimeLogRepository.save(timeLog); + } catch (error) { + console.error(`Error while updating stoppedAt time for ID: ${timeLog.id}`, error); + } + } else { + // Delete the old time log if remaining duration is 0 + await this.deleteTimeLog(timeLog, forceDelete); + } + + try { + // Delete the associated time slots + const timeSlotsIds = [timeSlot.id]; + + // Bulk delete the time slots + await this._commandBus.execute( + new TimeSlotBulkDeleteCommand( + { + organizationId, + employeeId, + timeLog, + timeSlotsIds + }, + forceDelete, + true + ) + ); + } catch (error) { + console.error(`Error while splitting time entries: ${remainingDuration}`, error); + } + + // Handle the creation of the new time log + await this.createAndSyncNewTimeLog(timeLog, end); + } + + /** + * Creates and syncs the new time log if the duration is greater than 0. + * + * @param timeLog - The original time log (will be cloned). + * @param end - The new start time for the new log. + */ + private async createAndSyncNewTimeLog(timeLog: ITimeLog, end: Date): Promise { + const clone: TimeLog = omit(timeLog, ['createdAt', 'updatedAt', 'id']); + const newLog = clone; + newLog.startedAt = end; + + // Calculate the remaining duration of the new log + const newLogRemainingDuration = moment(newLog.stoppedAt).diff(moment(newLog.startedAt), 'seconds'); + + // If there is remaining duration + if (newLogRemainingDuration > 0) { + try { + await this.typeOrmTimeLogRepository.save(newLog); + } catch (error) { + console.log('Error while creating new log', error, newLog); + } + + try { + // Sync time slots for the new time log + const slots = await this.syncTimeSlots(newLog); + console.log('sync time slots for new log', { slots }, { newLog }); + + // Assign the new log to time slots and save + if (isNotEmpty(slots)) { + // Assign the new log to time slots and save + for await (const ts of slots) { + ts.timeLogs = [newLog]; } + + await this.typeOrmTimeSlotRepository.save(slots); } + } catch (error) { + console.error('Error while creating or syncing new log and time slots', error); } } - return true; } - private async syncTimeSlots(timeLog: ITimeLog) { + /** + * Deletes a time log if it overlaps the entire time range. + * + * @param timeLog - The log to delete. + * @param forceDelete - Whether to hard delete (default: false). + * @returns Promise - Resolves when deletion is complete. + */ + + private async deleteTimeLog(timeLog: ITimeLog, forceDelete: boolean = false): Promise { + try { + // Execute the TimeLogDeleteCommand to delete the time log + await this._commandBus.execute(new TimeLogDeleteCommand(timeLog, forceDelete)); + } catch (error) { + // Log any errors that occur during deletion + console.log(`Error while, delete time log because overlap entire time for ID: ${timeLog.id}`, error); + } + } + + /** + * Synchronizes time slots for the provided time log. + * + * This method calculates the start and end intervals based on the `startedAt` and `stoppedAt` + * values from the provided time log. It then retrieves the corresponding time slots for the + * specified employee and organization within that time range. The time slot synchronization + * is triggered with the `syncSlots` flag set to true. + * + * @param timeLog - The time log containing the data used to synchronize time slots (start, stop, employeeId, organizationId). + * @returns A promise that resolves to the retrieved time slots within the specified range for the employee and organization. + */ + private async syncTimeSlots(timeLog: ITimeLog): Promise { const { startedAt, stoppedAt, employeeId, organizationId } = timeLog; - const { start, end } = getStartEndIntervals( - moment(startedAt), - moment(stoppedAt) - ); - return await this.timeSlotService.getTimeSlots({ + + // Calculate start and end intervals based on the time log's start and stop times + const { start, end } = getStartEndIntervals(moment(startedAt), moment(stoppedAt)); + + // Retrieve and return the corresponding time slots within the interval for the given employee and organization + return await this._timeSlotService.getTimeSlots({ startDate: moment(start).toDate(), endDate: moment(end).toDate(), organizationId, diff --git a/packages/core/src/time-tracking/time-log/commands/handlers/get-time-log-group-by-client.handler.ts b/packages/core/src/time-tracking/time-log/commands/handlers/get-time-log-group-by-client.handler.ts index b096e26aad2..7f0a266208d 100644 --- a/packages/core/src/time-tracking/time-log/commands/handlers/get-time-log-group-by-client.handler.ts +++ b/packages/core/src/time-tracking/time-log/commands/handlers/get-time-log-group-by-client.handler.ts @@ -84,12 +84,13 @@ export class GetTimeLogGroupByClientHandler implements ICommandHandler 0 ? timeLogs[0].employee : null; - const task = timeLogs.length > 0 ? timeLogs[0].task : null; - const description = timeLogs.length > 0 ? timeLogs[0].description : null; - + const tasks = timeLogs.map((log) => ({ + task: log.task, + description: log.description, + duration: log.duration + })); return { - description, - task, + tasks, employee, sum, activity: parseFloat(avgActivity.toFixed(2)) diff --git a/packages/core/src/time-tracking/time-log/commands/handlers/get-time-log-group-by-date.handler.ts b/packages/core/src/time-tracking/time-log/commands/handlers/get-time-log-group-by-date.handler.ts index 717ee653396..3cd576e9b40 100644 --- a/packages/core/src/time-tracking/time-log/commands/handlers/get-time-log-group-by-date.handler.ts +++ b/packages/core/src/time-tracking/time-log/commands/handlers/get-time-log-group-by-date.handler.ts @@ -32,17 +32,8 @@ export class GetTimeLogGroupByDateHandler implements ICommandHandler 0 ? byProjectLogs[0].project : null; - // Extract client information using optional chaining - const client = - byProjectLogs.length > 0 - ? byProjectLogs[0].organizationContact - : project - ? project.organizationContact - : null; - return { project, - client, employeeLogs: this.getGroupByEmployee(byProjectLogs) }; }) @@ -77,14 +68,18 @@ export class GetTimeLogGroupByDateHandler implements ICommandHandler 0 ? timeLogs[0].employee : null; - const task = timeLogs.length > 0 ? timeLogs[0].task : null; - const description = timeLogs.length > 0 ? timeLogs[0].description : null; + + const tasks = timeLogs.map((log) => ({ + task: log.task, + description: log.description, + duration: log.duration, + client: log.organizationContact ? log.organizationContact : null + })); return { - description, employee, sum, - task, + tasks, activity: parseFloat(parseFloat(avgActivity + '').toFixed(2)) }; }) diff --git a/packages/core/src/time-tracking/time-log/commands/handlers/get-time-log-group-by-employee.handler.ts b/packages/core/src/time-tracking/time-log/commands/handlers/get-time-log-group-by-employee.handler.ts index 7f044275c7e..cebaa71ea49 100644 --- a/packages/core/src/time-tracking/time-log/commands/handlers/get-time-log-group-by-employee.handler.ts +++ b/packages/core/src/time-tracking/time-log/commands/handlers/get-time-log-group-by-employee.handler.ts @@ -66,20 +66,20 @@ export class GetTimeLogGroupByEmployeeHandler implements ICommandHandler 0 ? timeLogs[0].project : null; - const task = timeLogs.length > 0 ? timeLogs[0].task : null; - const client = - timeLogs.length > 0 - ? timeLogs[0].organizationContact + + const tasks = timeLogs.map((log) => ({ + task: log.task, + description: log.description, + duration: log.duration, + client: log.organizationContact + ? log.organizationContact : project ? project.organizationContact - : null; - const description = timeLogs.length > 0 ? timeLogs[0].description : null; - + : null + })); return { - description, - task, + tasks, project, - client, sum, activity: parseFloat(parseFloat(avgActivity + '').toFixed(2)) }; diff --git a/packages/core/src/time-tracking/time-log/commands/handlers/get-time-log-group-by-project.handler.ts b/packages/core/src/time-tracking/time-log/commands/handlers/get-time-log-group-by-project.handler.ts index 92623b07e9a..e87bc705912 100644 --- a/packages/core/src/time-tracking/time-log/commands/handlers/get-time-log-group-by-project.handler.ts +++ b/packages/core/src/time-tracking/time-log/commands/handlers/get-time-log-group-by-project.handler.ts @@ -30,14 +30,6 @@ export class GetTimeLogGroupByProjectHandler implements ICommandHandler 0 ? byProjectLogs[0].project : null; - // Extract client information using optional chaining - const client = - byProjectLogs.length > 0 - ? byProjectLogs[0].organizationContact - : project - ? project.organizationContact - : null; - // Group projectLogs by date const byDate = chain(byProjectLogs) .groupBy((log: ITimeLog) => moment.utc(log.startedAt).tz(timeZone).format('YYYY-MM-DD')) @@ -49,7 +41,6 @@ export class GetTimeLogGroupByProjectHandler implements ICommandHandler 0 ? timeLogs[0].task : null; + const employee = timeLogs.length > 0 ? timeLogs[0].employee : null; - const description = timeLogs.length > 0 ? timeLogs[0].description : null; + const tasks = timeLogs.map((log) => ({ + task: log.task, + description: log.description, + duration: log.duration, + client: log.organizationContact ? log.organizationContact : null + })); return { - description, - task, + tasks, employee, sum, activity: parseFloat(parseFloat(avgActivity + '').toFixed(2)) diff --git a/packages/core/src/time-tracking/time-log/commands/handlers/time-log-create.handler.ts b/packages/core/src/time-tracking/time-log/commands/handlers/time-log-create.handler.ts index e68f9459333..11b0edd4036 100644 --- a/packages/core/src/time-tracking/time-log/commands/handlers/time-log-create.handler.ts +++ b/packages/core/src/time-tracking/time-log/commands/handlers/time-log-create.handler.ts @@ -2,10 +2,7 @@ import { ICommandHandler, CommandBus, CommandHandler } from '@nestjs/cqrs'; import * as moment from 'moment'; import { TimeLogType, TimeLogSourceEnum, ID, ITimeSlot, ITimesheet } from '@gauzy/contracts'; import { TimeSlotService } from '../../../time-slot/time-slot.service'; -import { - TimesheetFirstOrCreateCommand, - TimesheetRecalculateCommand -} from '../../../timesheet/commands'; +import { TimesheetFirstOrCreateCommand, TimesheetRecalculateCommand } from '../../../timesheet/commands'; import { UpdateEmployeeTotalWorkedHoursCommand } from '../../../../employee/commands'; import { RequestContext } from '../../../../core/context'; import { TimeLogService } from '../../time-log.service'; @@ -20,7 +17,7 @@ export class TimeLogCreateHandler implements ICommandHandler ({ + const standardizedInputSlots = inputSlots.map((slot) => ({ ...slot, employeeId, organizationId, tenantId })); - return generatedSlots.map(blankSlot => { - const matchingSlot = standardizedInputSlots.find(slot => + return generatedSlots.map((blankSlot) => { + const matchingSlot = standardizedInputSlots.find((slot) => moment(slot.startedAt).isSame(blankSlot.startedAt) ); return matchingSlot ? { ...matchingSlot } : blankSlot; @@ -187,9 +176,7 @@ export class TimeLogCreateHandler implements ICommandHandler { if (timesheet?.id) { - await this._commandBus.execute( - new TimesheetRecalculateCommand(timesheet.id) - ); + await this._commandBus.execute(new TimesheetRecalculateCommand(timesheet.id)); } } @@ -199,8 +186,6 @@ export class TimeLogCreateHandler implements ICommandHandler { - await this._commandBus.execute( - new UpdateEmployeeTotalWorkedHoursCommand(employeeId) - ); + await this._commandBus.execute(new UpdateEmployeeTotalWorkedHoursCommand(employeeId)); } } diff --git a/packages/core/src/time-tracking/time-log/commands/handlers/time-log-delete.handler.ts b/packages/core/src/time-tracking/time-log/commands/handlers/time-log-delete.handler.ts index 892e450cda1..495ace497e4 100644 --- a/packages/core/src/time-tracking/time-log/commands/handlers/time-log-delete.handler.ts +++ b/packages/core/src/time-tracking/time-log/commands/handlers/time-log-delete.handler.ts @@ -1,93 +1,153 @@ import { ICommandHandler, CommandBus, CommandHandler } from '@nestjs/cqrs'; -import { InjectRepository } from '@nestjs/typeorm'; import { In, DeleteResult, UpdateResult } from 'typeorm'; -import { chain, pluck } from 'underscore'; -import { TimeLog } from './../../time-log.entity'; +import { pluck } from 'underscore'; +import { ID } from '@gauzy/contracts'; import { TimesheetRecalculateCommand } from './../../../timesheet/commands/timesheet-recalculate.command'; -import { TimeLogDeleteCommand } from '../time-log-delete.command'; import { UpdateEmployeeTotalWorkedHoursCommand } from '../../../../employee/commands'; import { TimeSlotBulkDeleteCommand } from './../../../time-slot/commands'; +import { TimeLogDeleteCommand } from '../time-log-delete.command'; +import { TimeLog } from './../../time-log.entity'; import { TypeOrmTimeLogRepository } from '../../repository/type-orm-time-log.repository'; import { MikroOrmTimeLogRepository } from '../..//repository/mikro-orm-time-log.repository'; @CommandHandler(TimeLogDeleteCommand) export class TimeLogDeleteHandler implements ICommandHandler { - constructor( - @InjectRepository(TimeLog) readonly typeOrmTimeLogRepository: TypeOrmTimeLogRepository, - readonly mikroOrmTimeLogRepository: MikroOrmTimeLogRepository, + private readonly _commandBus: CommandBus + ) {} - private readonly commandBus: CommandBus - ) { } + /** + * Executes the TimeLogDeleteCommand to handle both soft and hard deletions of time logs, + * and ensures that associated time slots are deleted. It also recalculates relevant + * timesheet and employee worked hours based on the deleted time logs. + * + * This method performs the following operations: + * 1. Fetches the time logs based on the provided IDs. + * 2. Deletes associated time slots for each time log sequentially. + * 3. Soft deletes the time logs (or hard deletes them if `forceDelete` is true). + * 4. Recalculates timesheet and employee worked hours for the affected time logs. + * + * @param command - The TimeLogDeleteCommand containing the IDs or TimeLog objects to delete, along with the `forceDelete` flag. + * @returns A promise that resolves to a DeleteResult (for hard delete) or UpdateResult (for soft delete). + */ + public async execute(command: TimeLogDeleteCommand): Promise { + const { ids, forceDelete = false } = command; - public async execute( - command: TimeLogDeleteCommand - ): Promise { - const { ids, forceDelete } = command; + // Step 1: Fetch time logs based on the provided IDs + const timeLogs = await this.fetchTimeLogs(ids); - let timeLogs: TimeLog[]; + // Step 2: Delete associated time slots for each time log sequentially + await this.deleteTimeSlotsForLogs(timeLogs, forceDelete); + + // Step 3: Perform soft delete or hard delete based on the `forceDelete` flag + const updateResult = await this.deleteTimeLogs(timeLogs, forceDelete); + + // Step 4: Recalculate timesheet and employee worked hours after deletion + await this.recalculateTimesheetAndEmployeeHours(timeLogs); + + return updateResult; + } + + /** + * Fetches time logs based on provided IDs or time log objects. + * + * @param ids - A string, array of strings, or TimeLog object(s). + * @returns A promise that resolves to an array of TimeLogs. + */ + private async fetchTimeLogs(ids: ID | ID[] | TimeLog | TimeLog[]): Promise { if (typeof ids === 'string') { - timeLogs = await this.typeOrmTimeLogRepository.findBy({ id: ids }); - } else if (ids instanceof Array && typeof ids[0] === 'string') { - timeLogs = await this.typeOrmTimeLogRepository.findBy({ - id: In(ids as string[]) - }); - } else if (ids instanceof TimeLog) { - timeLogs = [ids]; + // Fetch single time log by ID + return this.typeOrmTimeLogRepository.findBy({ id: ids }); + } else if (Array.isArray(ids)) { + if (typeof ids[0] === 'string') { + // Fetch multiple time logs by IDs + return this.typeOrmTimeLogRepository.findBy({ id: In(ids as ID[]) }); + } + // Return the array of TimeLog objects + return ids as TimeLog[]; } else { - timeLogs = ids as TimeLog[]; + // Return single TimeLog object wrapped in an array + return [ids as TimeLog]; } - console.log('TimeLog will be delete:', timeLogs); + } + /** + * Deletes associated time slots for each time log sequentially. + * + * @param timeLogs - An array of time logs whose associated time slots will be deleted. + */ + private async deleteTimeSlotsForLogs(timeLogs: TimeLog[], forceDelete = false): Promise { + // Loop through each time log and delete its associated time slots for await (const timeLog of timeLogs) { const { employeeId, organizationId, timeSlots } = timeLog; const timeSlotsIds = pluck(timeSlots, 'id'); - await this.commandBus.execute( - new TimeSlotBulkDeleteCommand({ - organizationId, - employeeId, - timeLog, - timeSlotsIds - }) + + // Delete time slots sequentially + await this._commandBus.execute( + new TimeSlotBulkDeleteCommand( + { + organizationId, + employeeId, + timeLog, + timeSlotsIds + }, + forceDelete + ) ); } + } + + /** + * Deletes the provided time logs, either soft or hard depending on the `forceDelete` flag. + * + * If `forceDelete` is true, the time logs are permanently deleted. Otherwise, they are soft deleted. + * The method uses the TypeORM repository to perform the appropriate operation. + * + * @param timeLogs - An array of time logs to be deleted or soft deleted. + * @param forceDelete - A boolean flag indicating whether to force delete (hard delete) the time logs. + * Defaults to `false`, meaning soft delete is performed by default. + * @returns A promise that resolves to a DeleteResult (for hard delete) or UpdateResult (for soft delete). + */ + private async deleteTimeLogs(timeLogs: TimeLog[], forceDelete = false): Promise { + const logIds = timeLogs.map((log) => log.id); // Extract ids using map for simplicity + console.log('deleting time logs', logIds, forceDelete); - let deleteResult: DeleteResult | UpdateResult; if (forceDelete) { - deleteResult = await this.typeOrmTimeLogRepository.delete({ - id: In(pluck(timeLogs, 'id')) - }); - } else { - deleteResult = await this.typeOrmTimeLogRepository.softDelete({ - id: In(pluck(timeLogs, 'id')) - }); + // Hard delete (permanent deletion) + return await this.typeOrmTimeLogRepository.delete({ id: In(logIds) }); } + // Soft delete (mark records as deleted) + return await this.typeOrmTimeLogRepository.softDelete({ id: In(logIds) }); + } + + /** + * Recalculates timesheet and employee worked hours for the deleted time logs. + * + * @param timeLogs - An array of time logs for which the recalculations will be made. + */ + private async recalculateTimesheetAndEmployeeHours(timeLogs: TimeLog[]): Promise { try { - /** - * Timesheet Recalculate Command - */ - const timesheetIds = chain(timeLogs).pluck('timesheetId').uniq().value(); - for await (const timesheetId of timesheetIds) { - await this.commandBus.execute( - new TimesheetRecalculateCommand(timesheetId) - ); - } + const timesheetIds = [...new Set(timeLogs.map((log) => log.timesheetId))]; + const employeeIds = [...new Set(timeLogs.map((log) => log.employeeId))]; - /** - * Employee Worked Hours Recalculate Command - */ - const employeeIds = chain(timeLogs).pluck('employeeId').uniq().value(); - for await (const employeeId of employeeIds) { - await this.commandBus.execute( - new UpdateEmployeeTotalWorkedHoursCommand(employeeId) - ); - } + // Recalculate timesheets + await Promise.all( + timesheetIds.map((timesheetId: ID) => + this._commandBus.execute(new TimesheetRecalculateCommand(timesheetId)) + ) + ); + + // Recalculate employee worked hours + await Promise.all( + employeeIds.map((employeeId: ID) => + this._commandBus.execute(new UpdateEmployeeTotalWorkedHoursCommand(employeeId)) + ) + ); } catch (error) { - console.log('TimeLogDeleteHandler', { error }); + console.error('Error while recalculating timesheet and employee worked hours:', error); } - return deleteResult; } } diff --git a/packages/core/src/time-tracking/time-log/commands/handlers/time-log-update.handler.ts b/packages/core/src/time-tracking/time-log/commands/handlers/time-log-update.handler.ts index f26a1c8d480..2d3d596030d 100644 --- a/packages/core/src/time-tracking/time-log/commands/handlers/time-log-update.handler.ts +++ b/packages/core/src/time-tracking/time-log/commands/handlers/time-log-update.handler.ts @@ -1,185 +1,266 @@ import { ICommandHandler, CommandBus, CommandHandler } from '@nestjs/cqrs'; -import { InjectRepository } from '@nestjs/typeorm'; -import { SelectQueryBuilder } from 'typeorm'; import * as moment from 'moment'; -import { ITimeLog, ITimesheet, TimeLogSourceEnum } from '@gauzy/contracts'; -import { TimeLog } from './../../time-log.entity'; -import { TimeLogUpdateCommand } from '../time-log-update.command'; -import { - TimesheetFirstOrCreateCommand, - TimesheetRecalculateCommand -} from './../../../timesheet/commands'; +import { ID, ITimeLog, ITimeSlot, ITimesheet, TimeLogSourceEnum } from '@gauzy/contracts'; +import { isEmpty } from '@gauzy/common'; +import { TimesheetFirstOrCreateCommand, TimesheetRecalculateCommand } from './../../../timesheet/commands'; import { TimeSlotService } from '../../../time-slot/time-slot.service'; import { UpdateEmployeeTotalWorkedHoursCommand } from '../../../../employee/commands'; import { RequestContext } from './../../../../core/context'; -import { TimeSlot } from './../../../../core/entities/internal'; import { prepareSQLQuery as p } from './../../../../database/database.helper'; +import { TimeLog } from './../../time-log.entity'; +import { TimeLogUpdateCommand } from '../time-log-update.command'; import { TypeOrmTimeLogRepository } from '../../repository/type-orm-time-log.repository'; import { TypeOrmTimeSlotRepository } from '../../../time-slot/repository/type-orm-time-slot.repository'; @CommandHandler(TimeLogUpdateCommand) export class TimeLogUpdateHandler implements ICommandHandler { - constructor( - @InjectRepository(TimeLog) private readonly typeOrmTimeLogRepository: TypeOrmTimeLogRepository, - - @InjectRepository(TimeSlot) private readonly typeOrmTimeSlotRepository: TypeOrmTimeSlotRepository, - private readonly commandBus: CommandBus, private readonly timeSlotService: TimeSlotService - ) { } + ) {} - public async execute(command: TimeLogUpdateCommand): Promise { - const { id, input, manualTimeSlot } = command; + /** + * Updates a time log, manages associated time slots, and recalculates timesheet and employee hours. + * + * This method retrieves the time log, updates its details, and handles time slot conflicts if the start or stop time is modified. + * It creates new time slots if necessary, saves the updated time log, and recalculates the timesheet and employee hours. + * + * @param command - The command containing the time log update data, including options for force delete and manual time slots. + * @returns A promise that resolves to the updated `TimeLog`. + */ - let timeLog: ITimeLog; - if (id instanceof TimeLog) { - timeLog = id; - } else { - timeLog = await this.typeOrmTimeLogRepository.findOneBy({ id }); - } + public async execute(command: TimeLogUpdateCommand): Promise { + // Extract input parameters from the command + const { id, input, manualTimeSlot, forceDelete = false } = command; + console.log('Executing TimeLogUpdateCommand:', { id, input, manualTimeSlot, forceDelete }); - const tenantId = RequestContext.currentTenantId(); - const { employeeId, organizationId } = timeLog; + // Retrieve the tenant ID from the request context or the provided input + const tenantId = RequestContext.currentTenantId() ?? input.tenantId; + console.log('Tenant ID:', tenantId); - let needToUpdateTimeSlots = false; - if (input.startedAt || input.stoppedAt) { - needToUpdateTimeSlots = true; - } + let timeLog: ITimeLog = await this.getTimeLogByIdOrInstance(id); + console.log('Retrieved TimeLog:', timeLog); + + const { employeeId, organizationId } = timeLog; let timesheet: ITimesheet; - let updateTimeSlots = []; + let updateTimeSlots: ITimeSlot[] = []; + + // Check if time slots need to be updated + let needToUpdateTimeSlots = Boolean(input.startedAt || input.stoppedAt); + console.log('Need to update time slots:', needToUpdateTimeSlots); if (needToUpdateTimeSlots) { timesheet = await this.commandBus.execute( - new TimesheetFirstOrCreateCommand( - input.startedAt, - employeeId, - organizationId - ) - ); - const { startedAt, stoppedAt } = Object.assign({}, timeLog, input); - updateTimeSlots = this.timeSlotService.generateTimeSlots( - startedAt, - stoppedAt + new TimesheetFirstOrCreateCommand(input.startedAt, employeeId, organizationId) ); - } + console.log('Generated or retrieved Timesheet:', timesheet); - console.log('Stopped Timer Request Updated TimeLog Request', { - input - }); + // Generate time slots based on the updated time log details + const { startedAt, stoppedAt } = { ...timeLog, ...input }; + updateTimeSlots = this.timeSlotService.generateTimeSlots(startedAt, stoppedAt); + console.log('Generated updated TimeSlots:', updateTimeSlots); + } + // Update the time log in the repository await this.typeOrmTimeLogRepository.update(timeLog.id, { ...input, ...(timesheet ? { timesheetId: timesheet.id } : {}) }); + console.log('Updated TimeLog in the repository:', { id: timeLog.id, input }); - const timeSlots = this.timeSlotService.generateTimeSlots( - timeLog.startedAt, - timeLog.stoppedAt - ); + // Regenerate the existing time slots for the time log + const timeSlots = this.timeSlotService.generateTimeSlots(timeLog.startedAt, timeLog.stoppedAt); + console.log('Generated existing TimeSlots for TimeLog:', timeSlots); - timeLog = await this.typeOrmTimeLogRepository.findOneBy({ - id: timeLog.id - }); - const { timesheetId } = timeLog; + // Retrieve the updated time log + timeLog = await this.typeOrmTimeLogRepository.findOneBy({ id: timeLog.id }); + console.log('Retrieved updated TimeLog from repository:', timeLog); + // Check if time slots need to be updated if (needToUpdateTimeSlots) { - const startTimes = timeSlots - .filter((timeSlot) => { - return ( - updateTimeSlots.filter( - (newSlot) => moment(newSlot.startedAt).isSame( - timeSlot.startedAt - ) - ).length === 0 - ); - }) - .map((timeSlot) => new Date(timeSlot.startedAt)); + // Identify conflicting start times + const startTimes = this.getConflictingStartTimes(timeSlots, updateTimeSlots); + console.log('Identified conflicting start times:', startTimes); + // Remove conflicting time slots if (startTimes.length > 0) { - /** - * Removed Deleted TimeSlots - */ - const query = this.typeOrmTimeSlotRepository.createQueryBuilder('time_slot'); - query.setFindOptions({ - relations: { - screenshots: true - } - }); - query.where((qb: SelectQueryBuilder) => { - qb.andWhere(p(`"${qb.alias}"."organizationId" = :organizationId`), { - organizationId - }); - qb.andWhere(p(`"${qb.alias}"."tenantId" = :tenantId`), { - tenantId - }); - qb.andWhere(p(`"${qb.alias}"."employeeId" = :employeeId`), { - employeeId - }); - qb.andWhere(p(`"${qb.alias}"."startedAt" IN (:...startTimes)`), { - startTimes - }); - }); - const timeSlots = await query.getMany(); - await this.typeOrmTimeSlotRepository.remove(timeSlots); + await this.removeConflictingTimeSlots(tenantId, organizationId, employeeId, startTimes, forceDelete); + console.log('Removed conflicting TimeSlots:', startTimes); } - + // Create new time slots if needed for Web Timer if (!manualTimeSlot && timeLog.source === TimeLogSourceEnum.WEB_TIMER) { - updateTimeSlots = updateTimeSlots - .map((slot) => ({ - ...slot, - employeeId, - organizationId, - tenantId, - keyboard: 0, - mouse: 0, - overall: 0, - timeLogId: timeLog.id - })) - .filter((slot) => slot.tenantId && slot.organizationId); - /** - * Assign regenerated TimeSlot entries for existed TimeLog - */ - await this.timeSlotService.bulkCreate( - updateTimeSlots, - employeeId, - organizationId - ); + await this.bulkCreateTimeSlots(updateTimeSlots, timeLog, employeeId, organizationId, tenantId); + console.log('Created new TimeSlots for Web Timer:', updateTimeSlots); } - console.log('Last Updated Timer Time Log', { timeLog }); + // Update the time log in the repository + await this.saveUpdatedTimeLog(timeLog); + console.log('Saved updated TimeLog in the repository:', timeLog); - /** - * Update TimeLog Entry - */ - try { - await this.typeOrmTimeLogRepository.save(timeLog); - } catch (error) { - console.error('Error while updating TimeLog', error); - } + // Recalculate timesheets and employee hours + await this.recalculateTimesheetAndEmployeeHours(timeLog.timesheetId, employeeId); + console.log('Recalculated timesheets and employee hours:', timeLog.timesheetId, employeeId); + } - /** - * RECALCULATE timesheet activity - */ - await this.commandBus.execute( - new TimesheetRecalculateCommand(timesheetId) - ); + // Return the updated time log + const updatedTimeLog = await this.typeOrmTimeLogRepository.findOneBy({ id: timeLog.id }); + console.log('Final updated TimeLog:', updatedTimeLog); - /** - * UPDATE employee total worked hours - */ - if (employeeId) { - await this.commandBus.execute( - new UpdateEmployeeTotalWorkedHoursCommand(employeeId) - ); - } + return updatedTimeLog; + } + + /** + * Retrieves a time log by its ID or directly returns the instance if provided. + * + * If the `id` parameter is already a `TimeLog` instance, it is returned as is. Otherwise, it fetches + * the time log from the repository using the provided `id`. + * + * @param id - The time log ID or an instance of `TimeLog`. + * @returns A promise that resolves to the `ITimeLog` instance. + */ + private async getTimeLogByIdOrInstance(id: ID | TimeLog): Promise { + return id instanceof TimeLog ? id : this.typeOrmTimeLogRepository.findOneBy({ id }); + } + + /** + * Identifies the conflicting start times that need to be removed from time slots. + * + * This method filters out time slots that have matching `startedAt` times in the new slots and returns + * the start times of the slots that need to be removed. + * + * @param slots - The existing time slots. + * @param newSlots - The newly generated time slots. + * @returns An array of conflicting start times that need to be removed. + */ + private getConflictingStartTimes(slots: ITimeSlot[], newSlots: ITimeSlot[]): Date[] { + return slots + .filter( + (existingSlot) => !newSlots.some((newSlot) => moment(newSlot.startedAt).isSame(existingSlot.startedAt)) + ) + .map((slot) => new Date(slot.startedAt)); + } + + /** + * Removes or soft deletes conflicting time slots for a given employee within the specified time range. + * + * If `forceDelete` is true, the conflicting time slots will be hard deleted. Otherwise, they will be soft deleted. + * + * @param params - An object containing `tenantId`, `organizationId`, `employeeId`, and `startTimes`. + * @param forceDelete - A boolean flag indicating whether to perform a hard delete (`true`) or a soft delete (`false`). + * @returns A promise that resolves after the time slots have been deleted or soft deleted. + */ + private async removeConflictingTimeSlots( + tenantId: ID, + organizationId: ID, + employeeId: ID, + startTimes: Date[], + forceDelete: boolean + ): Promise { + // Query to fetch conflicting time slots + const query = this.typeOrmTimeSlotRepository.createQueryBuilder('time_slot'); + + // Add joins to the query + query + .leftJoinAndSelect(`${query.alias}.timeLogs`, 'timeLogs') + .leftJoinAndSelect(`${query.alias}.screenshots`, 'screenshots') + .leftJoinAndSelect(`${query.alias}.activities`, 'activities') + .leftJoinAndSelect(`${query.alias}.timeSlotMinutes`, 'timeSlotMinutes'); + + // Add where clauses to the query + query + .where(p(`"${query.alias}"."organizationId" = :organizationId`), { organizationId }) + .andWhere(p(`"${query.alias}"."tenantId" = :tenantId`), { tenantId }) + .andWhere(p(`"${query.alias}"."employeeId" = :employeeId`), { employeeId }) + .andWhere(p(`"${query.alias}"."startedAt" IN (:...startTimes)`), { startTimes }); + + // Get the conflicting time slots + const slots = await query.getMany(); + console.log(`conflicting time slots for ${forceDelete ? 'hard' : 'soft'} deleting: %s`, slots.length); + + if (isEmpty(slots)) { + return []; } - return await this.typeOrmTimeLogRepository.findOneBy({ - id: timeLog.id - }); + // Delete or soft delete the conflicting time slots + return forceDelete + ? this.typeOrmTimeSlotRepository.remove(slots) + : this.typeOrmTimeSlotRepository.softRemove(slots); + } + + /** + * Bulk creates time slots for a given time log. + * + * This method enriches the provided time slots by adding additional fields like `employeeId`, `organizationId`, + * `tenantId`, and `timeLogId`, along with initializing `keyboard`, `mouse`, and `overall` activity metrics to zero. + * It filters out any slots that do not have valid `tenantId` or `organizationId` values and then performs a bulk creation of time slots. + * + * @param updateTimeSlots - The array of time slots that need to be enriched and created. + * @param timeLog - The time log associated with the time slots. + * @param employeeId - The ID of the employee associated with the time slots. + * @param organizationId - The ID of the organization associated with the time slots. + * @param tenantId - The tenant ID associated with the time slots. + * @returns A promise that resolves to an array of created time slots. + */ + private async bulkCreateTimeSlots( + updateTimeSlots: ITimeSlot[], + timeLog: ITimeLog, + employeeId: ID, + organizationId: ID, + tenantId: ID + ): Promise { + const slots = updateTimeSlots + .map((slot) => ({ + ...slot, + employeeId, + organizationId, + tenantId, + keyboard: 0, + mouse: 0, + overall: 0, + timeLogId: timeLog.id + })) + .filter((slot) => slot.tenantId && slot.organizationId); // Filter slots with valid tenant and organization IDs + + // Assign regenerated TimeSlot entries for existed TimeLog + return await this.timeSlotService.bulkCreate(slots, employeeId, organizationId); + } + + /** + * Saves the updated time log to the repository. + * + * @param timeLog - The time log to be saved. + * @returns A promise that resolves to the saved `ITimeLog` or throws an error if saving fails. + */ + private async saveUpdatedTimeLog(timeLog: ITimeLog): Promise { + try { + return await this.typeOrmTimeLogRepository.save(timeLog); + } catch (error) { + console.log('Failed to update the time log at line: 217', error); + } + } + + /** + * Recalculates the timesheet activities and updates the employee's total worked hours. + * + * This method first recalculates the total activity for the given timesheet by executing the + * `TimesheetRecalculateCommand`. Then, if an `employeeId` is provided, it updates the total + * worked hours for that employee by executing the `UpdateEmployeeTotalWorkedHoursCommand`. + * + * @param timesheetId - The ID of the timesheet for which the activity needs to be recalculated. + * @param employeeId - The ID of the employee whose total worked hours should be updated. If `null` or `undefined`, no update will be performed for the employee. + * @returns A promise that resolves when both recalculation operations are complete. + */ + + private async recalculateTimesheetAndEmployeeHours(timesheetId: ID, employeeId: ID): Promise { + // Recalculate timesheets + await this.commandBus.execute(new TimesheetRecalculateCommand(timesheetId)); + + // Update employee total worked hours + if (employeeId) { + await this.commandBus.execute(new UpdateEmployeeTotalWorkedHoursCommand(employeeId)); + } } } diff --git a/packages/core/src/time-tracking/time-log/commands/time-log-delete.command.ts b/packages/core/src/time-tracking/time-log/commands/time-log-delete.command.ts index b602226f313..3c0d125bb72 100644 --- a/packages/core/src/time-tracking/time-log/commands/time-log-delete.command.ts +++ b/packages/core/src/time-tracking/time-log/commands/time-log-delete.command.ts @@ -1,11 +1,9 @@ import { ICommand } from '@nestjs/cqrs'; +import { ID } from '@gauzy/contracts'; import { TimeLog } from './../time-log.entity'; export class TimeLogDeleteCommand implements ICommand { static readonly type = '[TimeLog] delete'; - constructor( - public readonly ids: string | string[] | TimeLog | TimeLog[], - public readonly forceDelete = false - ) {} + constructor(public readonly ids: ID | ID[] | TimeLog | TimeLog[], public readonly forceDelete = false) {} } diff --git a/packages/core/src/time-tracking/time-log/commands/time-log-update.command.ts b/packages/core/src/time-tracking/time-log/commands/time-log-update.command.ts index 5e2cbf3a86f..15cd88864f6 100644 --- a/packages/core/src/time-tracking/time-log/commands/time-log-update.command.ts +++ b/packages/core/src/time-tracking/time-log/commands/time-log-update.command.ts @@ -8,6 +8,7 @@ export class TimeLogUpdateCommand implements ICommand { constructor( public readonly input: Partial, public readonly id: ID | TimeLog, - public readonly manualTimeSlot?: boolean | null + public readonly manualTimeSlot?: boolean | null, + public readonly forceDelete: boolean = false ) {} } diff --git a/packages/core/src/time-tracking/time-log/dto/delete-time-log.dto.ts b/packages/core/src/time-tracking/time-log/dto/delete-time-log.dto.ts index 5a6506c47bc..a7e82910023 100644 --- a/packages/core/src/time-tracking/time-log/dto/delete-time-log.dto.ts +++ b/packages/core/src/time-tracking/time-log/dto/delete-time-log.dto.ts @@ -1,16 +1,24 @@ -import { IDeleteTimeLog } from "@gauzy/contracts"; -import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger"; -import { ArrayNotEmpty, IsBoolean, IsOptional } from "class-validator"; -import { TenantOrganizationBaseDTO } from "./../../../core/dto"; - -export class DeleteTimeLogDTO extends TenantOrganizationBaseDTO implements IDeleteTimeLog { +import { ApiProperty } from '@nestjs/swagger'; +import { ArrayNotEmpty } from 'class-validator'; +import { ID, IDeleteTimeLog } from '@gauzy/contracts'; +import { ForceDeleteDTO } from '../../dto/force-delete.dto'; +import { TimeLog } from '../time-log.entity'; +/** + * Data Transfer Object for deleting time logs. + * + * This DTO is used to define the structure of the data required for deleting + * time logs. It extends the `TenantOrganizationBaseDTO`, ensuring tenant and + * organization context is maintained. The DTO includes an array of log IDs + * that must not be empty and an optional `forceDelete` flag to determine + * whether a hard or soft delete should be performed. + */ +export class DeleteTimeLogDTO extends ForceDeleteDTO implements IDeleteTimeLog { + /** + * An array of time log IDs that need to be deleted. + * This field is required and must contain at least one ID. + */ @ApiProperty({ type: () => Array }) @ArrayNotEmpty() - logIds: string[] = []; - - @ApiPropertyOptional({ type: () => Boolean }) - @IsOptional() - @IsBoolean() - forceDelete: boolean = true; + readonly logIds: ID[] = []; } diff --git a/packages/core/src/time-tracking/time-log/dto/index.ts b/packages/core/src/time-tracking/time-log/dto/index.ts index de80001f618..b6bf47183be 100644 --- a/packages/core/src/time-tracking/time-log/dto/index.ts +++ b/packages/core/src/time-tracking/time-log/dto/index.ts @@ -1,3 +1,3 @@ +export * from './create-time-log.dto'; export * from './delete-time-log.dto'; export * from './update-time-log.dto'; -export * from './create-time-log.dto'; diff --git a/packages/core/src/time-tracking/time-log/time-log.controller.ts b/packages/core/src/time-tracking/time-log/time-log.controller.ts index a7b2111d9cb..983d383e283 100644 --- a/packages/core/src/time-tracking/time-log/time-log.controller.ts +++ b/packages/core/src/time-tracking/time-log/time-log.controller.ts @@ -14,7 +14,7 @@ import { import { CommandBus } from '@nestjs/cqrs'; import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger'; import { DeleteResult, FindOneOptions, UpdateResult } from 'typeorm'; -import { ITimeLog, PermissionsEnum, IGetTimeLogConflictInput } from '@gauzy/contracts'; +import { ITimeLog, PermissionsEnum, IGetTimeLogConflictInput, ID } from '@gauzy/contracts'; import { TimeLogService } from './time-log.service'; import { Permissions } from './../../shared/decorators'; import { OrganizationPermissionGuard, PermissionGuard, TenantBaseGuard } from './../../shared/guards'; @@ -29,11 +29,7 @@ import { IGetConflictTimeLogCommand } from './commands'; @Permissions(PermissionsEnum.TIME_TRACKER, PermissionsEnum.ALL_ORG_EDIT, PermissionsEnum.ALL_ORG_VIEW) @Controller() export class TimeLogController { - - constructor( - private readonly timeLogService: TimeLogService, - private readonly commandBus: CommandBus - ) { } + constructor(private readonly _timeLogService: TimeLogService, private readonly _commandBus: CommandBus) {} /** * Get conflicting timer logs based on the provided entity. @@ -52,7 +48,7 @@ export class TimeLogController { }) @Get('conflict') async getConflict(@Query() request: IGetTimeLogConflictInput): Promise { - return await this.commandBus.execute(new IGetConflictTimeLogCommand(request)); + return await this._commandBus.execute(new IGetConflictTimeLogCommand(request)); } /** @@ -72,7 +68,7 @@ export class TimeLogController { @Get('report/daily') @UseValidationPipe({ whitelist: true, transform: true }) async getDailyReport(@Query() options: TimeLogQueryDTO): Promise { - return await this.timeLogService.getDailyReport(options); + return await this._timeLogService.getDailyReport(options); } /** @@ -92,7 +88,7 @@ export class TimeLogController { @Get('report/daily-chart') @UseValidationPipe({ whitelist: true }) async getDailyReportChartData(@Query() options: TimeLogQueryDTO): Promise { - return await this.timeLogService.getDailyReportCharts(options); + return await this._timeLogService.getDailyReportCharts(options); } /** @@ -112,7 +108,7 @@ export class TimeLogController { @Get('report/owed-report') @UseValidationPipe({ whitelist: true, transform: true }) async getOwedAmountReport(@Query() options: TimeLogQueryDTO): Promise { - return await this.timeLogService.getOwedAmountReport(options); + return await this._timeLogService.getOwedAmountReport(options); } /** @@ -132,7 +128,7 @@ export class TimeLogController { @Get('report/owed-charts') @UseValidationPipe({ whitelist: true, transform: true }) async getOwedAmountReportChartData(@Query() options: TimeLogQueryDTO): Promise { - return await this.timeLogService.getOwedAmountReportCharts(options); + return await this._timeLogService.getOwedAmountReportCharts(options); } /** @@ -152,7 +148,7 @@ export class TimeLogController { @Get('report/weekly') @UseValidationPipe({ whitelist: true, transform: true }) async getWeeklyReport(@Query() options: TimeLogQueryDTO): Promise { - return await this.timeLogService.getWeeklyReport(options); + return await this._timeLogService.getWeeklyReport(options); } /** @@ -172,7 +168,7 @@ export class TimeLogController { @Get('time-limit') @UseValidationPipe({ whitelist: true, transform: true }) async getTimeLimitReport(@Query() options: TimeLogLimitQueryDTO): Promise { - return await this.timeLogService.getTimeLimit(options); + return await this._timeLogService.getTimeLimit(options); } /** @@ -192,7 +188,7 @@ export class TimeLogController { @Get('project-budget-limit') @UseValidationPipe({ whitelist: true, transform: true }) async getProjectBudgetLimit(@Query() options: TimeLogQueryDTO) { - return await this.timeLogService.getProjectBudgetLimit(options); + return await this._timeLogService.getProjectBudgetLimit(options); } /** @@ -212,7 +208,7 @@ export class TimeLogController { @Get('client-budget-limit') @UseValidationPipe({ whitelist: true, transform: true }) async clientBudgetLimit(@Query() options: TimeLogQueryDTO) { - return await this.timeLogService.getClientBudgetLimit(options); + return await this._timeLogService.getClientBudgetLimit(options); } /** @@ -233,7 +229,7 @@ export class TimeLogController { @Get() @UseValidationPipe({ whitelist: true, transform: true }) async getLogs(@Query() options: TimeLogQueryDTO): Promise { - return await this.timeLogService.getTimeLogs(options); + return await this._timeLogService.getTimeLogs(options); } /** @@ -243,11 +239,8 @@ export class TimeLogController { * @returns The found time log. */ @Get(':id') - async findById( - @Param('id', UUIDValidationPipe) id: ITimeLog['id'], - @Query() options: FindOneOptions - ): Promise { - return await this.timeLogService.findOneByIdString(id, options); + async findById(@Param('id', UUIDValidationPipe) id: ID, @Query() options: FindOneOptions): Promise { + return await this._timeLogService.findOneByIdString(id, options); } /** @@ -270,7 +263,7 @@ export class TimeLogController { async addManualTime( @Body(TimeLogBodyTransformPipe, new ValidationPipe({ transform: true })) entity: CreateManualTimeLogDTO ): Promise { - return await this.timeLogService.addManualTime(entity); + return await this._timeLogService.addManualTime(entity); } /** @@ -292,15 +285,18 @@ export class TimeLogController { @UseGuards(OrganizationPermissionGuard) @Permissions(PermissionsEnum.ALLOW_MODIFY_TIME) async updateManualTime( - @Param('id', UUIDValidationPipe) id: ITimeLog['id'], + @Param('id', UUIDValidationPipe) id: ID, @Body(TimeLogBodyTransformPipe, new ValidationPipe({ transform: true })) entity: UpdateManualTimeLogDTO ): Promise { - return await this.timeLogService.updateManualTime(id, entity); + return await this._timeLogService.updateManualTime(id, entity); } /** - * Delete time log - * @param deleteQuery The query parameters for deleting time logs. + * Deletes a time log based on the provided query parameters. + * + * @param options - The query parameters for deleting time logs, including conditions like log IDs and force delete flag. + * @returns A Promise that resolves to either a DeleteResult or UpdateResult, depending on whether it's a soft or hard delete. + * @throws BadRequestException if the input is invalid or deletion fails. */ @ApiOperation({ summary: 'Delete time log' }) @ApiResponse({ @@ -309,13 +305,13 @@ export class TimeLogController { }) @ApiResponse({ status: HttpStatus.BAD_REQUEST, - description: 'Invalid input, The response body may contain clues as to what went wrong' + description: 'Invalid input, The response body may contain clues as to what went wrong.' }) @UseGuards(OrganizationPermissionGuard) @Permissions(PermissionsEnum.ALLOW_DELETE_TIME) @Delete() @UseValidationPipe({ transform: true }) - async deleteTimeLog(@Query() deleteQuery: DeleteTimeLogDTO): Promise { - return await this.timeLogService.deleteTimeLogs(deleteQuery); + async deleteTimeLog(@Query() options: DeleteTimeLogDTO): Promise { + return await this._timeLogService.deleteTimeLogs(options); } } diff --git a/packages/core/src/time-tracking/time-log/time-log.service.ts b/packages/core/src/time-tracking/time-log/time-log.service.ts index 6b2b483a0fd..3bb908331cf 100644 --- a/packages/core/src/time-tracking/time-log/time-log.service.ts +++ b/packages/core/src/time-tracking/time-log/time-log.service.ts @@ -1,7 +1,7 @@ import { Injectable, BadRequestException, NotAcceptableException } from '@nestjs/common'; -import { TimeLog } from './time-log.entity'; +import { CommandBus } from '@nestjs/cqrs'; import { SelectQueryBuilder, Brackets, WhereExpressionBuilder, DeleteResult, UpdateResult } from 'typeorm'; -import { RequestContext } from '../../core/context'; +import { chain, pluck } from 'underscore'; import { IManualTimeInput, PermissionsEnum, @@ -21,10 +21,9 @@ import { IDeleteTimeLog, IOrganizationContact, IEmployee, - IOrganization + IOrganization, + ID } from '@gauzy/contracts'; -import { CommandBus } from '@nestjs/cqrs'; -import { chain, pluck } from 'underscore'; import { isEmpty, isNotEmpty } from '@gauzy/common'; import { TenantAwareCrudService } from './../../core/crud'; import { @@ -39,6 +38,7 @@ import { TimeLogUpdateCommand } from './commands'; import { getDateRangeFormat, getDaysBetweenDates } from './../../core/utils'; +import { RequestContext } from '../../core/context'; import { moment } from './../../core/moment-extend'; import { calculateAverage, calculateAverageActivity, calculateDuration } from './time-log.utils'; import { prepareSQLQuery as p } from './../../database/database.helper'; @@ -50,6 +50,7 @@ import { TypeOrmOrganizationProjectRepository } from '../../organization-project import { MikroOrmOrganizationProjectRepository } from '../../organization-project/repository/mikro-orm-organization-project.repository'; import { TypeOrmOrganizationContactRepository } from '../../organization-contact/repository/type-orm-organization-contact.repository'; import { MikroOrmOrganizationContactRepository } from '../../organization-contact/repository/mikro-orm-organization-contact.repository'; +import { TimeLog } from './time-log.entity'; @Injectable() export class TimeLogService extends TenantAwareCrudService { @@ -1063,7 +1064,7 @@ export class TimeLogService extends TenantAwareCrudService { */ async addManualTime(request: IManualTimeInput): Promise { try { - const tenantId = RequestContext.currentTenantId(); + const tenantId = RequestContext.currentTenantId() ?? request.tenantId; const { employeeId, startedAt, stoppedAt, organizationId } = request; // Validate input @@ -1077,7 +1078,7 @@ export class TimeLogService extends TenantAwareCrudService { relations: { organization: true } }); - // + // Check if future dates are allowed for the organization const futureDateAllowed: IOrganization['futureDateAllowed'] = employee.organization.futureDateAllowed; // Check if the selected date and time range is allowed for the organization @@ -1094,18 +1095,20 @@ export class TimeLogService extends TenantAwareCrudService { employeeId, organizationId, tenantId, - ...(request.id ? { ignoreId: request.id } : {}) + ...(request.id && { ignoreId: request.id }) // Simplified ternary check }) ); // Resolve conflicts by deleting conflicting time slots - if (conflicts && conflicts.length > 0) { + if (conflicts?.length) { const times: IDateRange = { start: new Date(startedAt), end: new Date(stoppedAt) }; + // Loop through each conflicting time log for await (const timeLog of conflicts) { const { timeSlots = [] } = timeLog; + // Delete conflicting time slots for await (const timeSlot of timeSlots) { await this.commandBus.execute(new DeleteTimeSpanCommand(times, timeLog, timeSlot)); } @@ -1127,9 +1130,9 @@ export class TimeLogService extends TenantAwareCrudService { * @param request The updated data for the manual time log. * @returns The updated time log entry. */ - async updateManualTime(id: ITimeLog['id'], request: IManualTimeInput): Promise { + async updateManualTime(id: ID, request: IManualTimeInput): Promise { try { - const tenantId = RequestContext.currentTenantId(); + const tenantId = RequestContext.currentTenantId() ?? request.tenantId; const { startedAt, stoppedAt, employeeId, organizationId } = request; // Validate input @@ -1143,7 +1146,7 @@ export class TimeLogService extends TenantAwareCrudService { relations: { organization: true } }); - // + // Check if future dates are allowed for the organization const futureDateAllowed: IOrganization['futureDateAllowed'] = employee.organization.futureDateAllowed; // Check if the selected date and time range is allowed for the organization @@ -1153,9 +1156,7 @@ export class TimeLogService extends TenantAwareCrudService { } // Check for conflicts with existing time logs - const timeLog = await this.typeOrmRepository.findOneBy({ - id: id - }); + const timeLog = await this.typeOrmRepository.findOneBy({ id }); // Check for conflicts with existing time logs const conflicts = await this.commandBus.execute( @@ -1165,18 +1166,18 @@ export class TimeLogService extends TenantAwareCrudService { employeeId, organizationId, tenantId, - ...(id ? { ignoreId: id } : {}) + ...(id && { ignoreId: id }) // Simplified check for id }) ); // Resolve conflicts by deleting conflicting time slots - if (isNotEmpty(conflicts)) { - const times: IDateRange = { - start: new Date(startedAt), - end: new Date(stoppedAt) - }; + if (conflicts?.length) { + const times: IDateRange = { start: new Date(startedAt), end: new Date(stoppedAt) }; + + // Loop through each conflicting time log for await (const timeLog of conflicts) { const { timeSlots = [] } = timeLog; + // Delete conflicting time slots for await (const timeSlot of timeSlots) { await this.commandBus.execute(new DeleteTimeSpanCommand(times, timeLog, timeSlot)); } @@ -1198,51 +1199,48 @@ export class TimeLogService extends TenantAwareCrudService { } /** + * Deletes time logs based on the provided parameters. * - * @param params - * @returns + * @param params - The parameters for deleting the time logs, including `logIds`, `organizationId`, and `forceDelete`. + * @returns A promise that resolves to the result of the delete or soft delete operation. + * @throws NotAcceptableException if no log IDs are provided. */ async deleteTimeLogs(params: IDeleteTimeLog): Promise { - let logIds: string | string[] = params.logIds; - if (isEmpty(logIds)) { - throw new NotAcceptableException('You can not delete time logs'); - } - if (typeof logIds === 'string') { - logIds = [logIds]; + // Early return if no logIds are provided + if (isEmpty(params.logIds)) { + throw new NotAcceptableException('You cannot delete time logs without IDs'); } - const tenantId = RequestContext.currentTenantId(); - const user = RequestContext.currentUser(); + // Ensure logIds is an array + const logIds: ID[] = Array.isArray(params.logIds) ? params.logIds : [params.logIds]; + + // Get the tenant ID from the request context or the provided tenant ID + const tenantId = RequestContext.currentTenantId() ?? params.tenantId; const { organizationId, forceDelete } = params; - const query = this.typeOrmRepository.createQueryBuilder('time_log'); + // Create a query builder for the TimeLog entity + const query = this.typeOrmRepository.createQueryBuilder(); + + // Set find options for the query query.setFindOptions({ - relations: { - timeSlots: true - } - }); - query.where((db: SelectQueryBuilder) => { - db.andWhere({ - ...(RequestContext.hasPermission(PermissionsEnum.CHANGE_SELECTED_EMPLOYEE) - ? {} - : { - employeeId: user.employeeId - }) - }); - db.andWhere( - new Brackets((web: WhereExpressionBuilder) => { - web.andWhere(p(`"${db.alias}"."tenantId" = :tenantId`), { - tenantId - }); - web.andWhere(p(`"${db.alias}"."organizationId" = :organizationId`), { organizationId }); - web.andWhere(p(`"${db.alias}"."id" IN (:...logIds)`), { - logIds - }); - }) - ); + relations: { timeSlots: true } }); + // Add where clauses to the query + query.where(p(`"${query.alias}"."id" IN (:...logIds)`), { logIds }); + query.andWhere(p(`"${query.alias}"."tenantId" = :tenantId`), { tenantId }); + query.andWhere(p(`"${query.alias}"."organizationId" = :organizationId`), { organizationId }); + + // If user don't have permission to change selected employee, filter by current employee ID + if (!RequestContext.hasPermission(PermissionsEnum.CHANGE_SELECTED_EMPLOYEE)) { + const employeeId = RequestContext.currentEmployeeId(); + query.andWhere(p(`"${query.alias}"."employeeId" = :employeeId`), { employeeId }); + } + + // Get the time logs from the database const timeLogs = await query.getMany(); + + // Invoke the command bus to delete the time logs return await this.commandBus.execute(new TimeLogDeleteCommand(timeLogs, forceDelete)); } diff --git a/packages/core/src/time-tracking/time-slot/commands/delete-time-slot.command.ts b/packages/core/src/time-tracking/time-slot/commands/delete-time-slot.command.ts index b48f6adf5e8..966da56af89 100644 --- a/packages/core/src/time-tracking/time-slot/commands/delete-time-slot.command.ts +++ b/packages/core/src/time-tracking/time-slot/commands/delete-time-slot.command.ts @@ -4,7 +4,5 @@ import { IDeleteTimeSlot } from '@gauzy/contracts'; export class DeleteTimeSlotCommand implements ICommand { static readonly type = '[TimeSlot] delete'; - constructor( - public readonly query: IDeleteTimeSlot - ) {} + constructor(public readonly options: IDeleteTimeSlot) {} } diff --git a/packages/core/src/time-tracking/time-slot/commands/handlers/delete-time-slot.handler.ts b/packages/core/src/time-tracking/time-slot/commands/handlers/delete-time-slot.handler.ts index e2c336c405c..c78d3099a99 100644 --- a/packages/core/src/time-tracking/time-slot/commands/handlers/delete-time-slot.handler.ts +++ b/packages/core/src/time-tracking/time-slot/commands/handlers/delete-time-slot.handler.ts @@ -1,10 +1,8 @@ import { CommandHandler, ICommandHandler, CommandBus } from '@nestjs/cqrs'; import { NotAcceptableException } from '@nestjs/common'; -import { InjectRepository } from '@nestjs/typeorm'; -import { Brackets, SelectQueryBuilder, WhereExpressionBuilder } from 'typeorm'; -import { ITimeSlot, PermissionsEnum } from '@gauzy/contracts'; +import * as chalk from 'chalk'; +import { ID, ITimeSlot, PermissionsEnum } from '@gauzy/contracts'; import { isEmpty, isNotEmpty } from '@gauzy/common'; -import { TimeSlot } from './../../time-slot.entity'; import { DeleteTimeSpanCommand } from '../../../time-log/commands/delete-time-span.command'; import { DeleteTimeSlotCommand } from '../delete-time-slot.command'; import { RequestContext } from './../../../../core/context'; @@ -13,69 +11,81 @@ import { TypeOrmTimeSlotRepository } from '../../repository/type-orm-time-slot.r @CommandHandler(DeleteTimeSlotCommand) export class DeleteTimeSlotHandler implements ICommandHandler { - constructor( - @InjectRepository(TimeSlot) private readonly typeOrmTimeSlotRepository: TypeOrmTimeSlotRepository, - private readonly commandBus: CommandBus - ) { } + ) {} + /** + * Executes the command to delete time slots based on the provided query. + * + * This method processes the deletion of time slots based on the provided IDs in the query. + * It checks for the current user's permission to change selected employees, and if not permitted, + * restricts the deletion to the current user's time slots. The method handles deleting time spans + * for each time slot, ensuring that only non-running time logs are deleted. + * + * @param command - The `DeleteTimeSlotCommand` containing the query with time slot IDs and organization data. + * @returns A promise that resolves to `true` if the deletion process is successful, or throws an exception if no IDs are provided. + * @throws NotAcceptableException if no time slot IDs are provided in the query. + */ public async execute(command: DeleteTimeSlotCommand): Promise { - const { query } = command; - const ids: string | string[] = query.ids; + const { ids, organizationId, forceDelete } = command.options; + + // Throw an error if no IDs are provided if (isEmpty(ids)) { throw new NotAcceptableException('You can not delete time slots'); } - let employeeIds: string[] = []; - if ( - !RequestContext.hasPermission( - PermissionsEnum.CHANGE_SELECTED_EMPLOYEE - ) - ) { - const user = RequestContext.currentUser(); - employeeIds = [user.employeeId]; - } + // Retrieve the tenant ID from the current request context + const tenantId = RequestContext.currentTenantId() || command.options.tenantId; - const tenantId = RequestContext.currentTenantId(); - const { organizationId } = query; + // Check if the current user has the permission to change the selected employee + const hasChangeSelectedEmployeePermission: boolean = RequestContext.hasPermission( + PermissionsEnum.CHANGE_SELECTED_EMPLOYEE + ); + const employeeIds: ID[] = !hasChangeSelectedEmployeePermission ? [RequestContext.currentEmployeeId()] : []; for await (const id of Object.values(ids)) { - const query = this.typeOrmTimeSlotRepository.createQueryBuilder('time_slot'); - query.setFindOptions({ - relations: { - timeLogs: true, - screenshots: true - } - }); - query.where((qb: SelectQueryBuilder) => { - qb.andWhere( - new Brackets((web: WhereExpressionBuilder) => { - web.andWhere(p(`"${qb.alias}"."tenantId" = :tenantId`), { tenantId }); - web.andWhere(p(`"${qb.alias}"."organizationId" = :organizationId`), { organizationId }); - web.andWhere(p(`"${qb.alias}"."id" = :id`), { id }); - }) - ); - if (isNotEmpty(employeeIds)) { - qb.andWhere(p(`"${qb.alias}"."employeeId" IN (:...employeeIds)`), { - employeeIds - }); - } - qb.addOrderBy(p(`"${qb.alias}"."createdAt"`), 'ASC'); - }); + // Create a query builder for the TimeSlot entity + const query = this.typeOrmTimeSlotRepository.createQueryBuilder(); + query + .leftJoinAndSelect(`${query.alias}.timeLogs`, 'timeLogs') + .leftJoinAndSelect(`${query.alias}.screenshots`, 'screenshots') + .leftJoinAndSelect(`${query.alias}.activities`, 'activities') + .leftJoinAndSelect(`${query.alias}.timeSlotMinutes`, 'timeSlotMinutes'); + + // Add where clauses to the query + query.where(p(`"${query.alias}"."id" = :id`), { id }); + query.andWhere(p(`"${query.alias}"."tenantId" = :tenantId`), { tenantId }); + query.andWhere(p(`"${query.alias}"."organizationId" = :organizationId`), { organizationId }); + + // Restrict deletion based on employeeId if permission is not granted + if (isNotEmpty(employeeIds)) { + query.andWhere(p(`"${query.alias}"."employeeId" IN (:...employeeIds)`), { employeeIds }); + } + + // Order by creation date + query.orderBy(p(`"${query.alias}"."createdAt"`), 'ASC'); const timeSlots: ITimeSlot[] = await query.getMany(); + + // If no time slots are found, stop processing if (isEmpty(timeSlots)) { continue; } + console.log(chalk.blue(`time slots for soft delete or hard delete:`), JSON.stringify(timeSlots)); + + // Loop through each time slot for await (const timeSlot of timeSlots) { - if (timeSlot && isNotEmpty(timeSlot.timeLogs)) { - const timeLogs = timeSlot.timeLogs.filter( - (timeLog) => timeLog.isRunning === false - ); - if (isNotEmpty(timeLogs)) { - for await (const timeLog of timeLogs) { + if (isNotEmpty(timeSlot.timeLogs)) { + // Filter non-running time logs + const nonRunningTimeLogs = timeSlot.timeLogs.filter((timeLog) => !timeLog.isRunning); + + // Delete non-running time logs + if (isNotEmpty(nonRunningTimeLogs)) { + // Sequentially execute delete commands for non-running time logs + for await (const timeLog of nonRunningTimeLogs) { + // Delete time span for non-running time log await this.commandBus.execute( new DeleteTimeSpanCommand( { @@ -83,7 +93,8 @@ export class DeleteTimeSlotHandler implements ICommandHandler { - constructor( - @InjectRepository(TimeLog) private readonly typeOrmTimeLogRepository: TypeOrmTimeLogRepository, - - @InjectRepository(TimeSlot) private readonly typeOrmTimeSlotRepository: TypeOrmTimeSlotRepository, - private readonly commandBus: CommandBus - ) { } + ) {} - public async execute( - command: TimeSlotBulkCreateCommand - ): Promise { + public async execute(command: TimeSlotBulkCreateCommand): Promise { let { slots, employeeId, organizationId } = command; if (slots.length === 0) { return []; } slots = slots.map((slot) => { - const { start } = getDateRangeFormat( - moment.utc(slot.startedAt), - moment.utc(slot.startedAt) - ); + const { start } = getDateRangeFormat(moment.utc(slot.startedAt), moment.utc(slot.startedAt)); slot.startedAt = start as Date; return slot; }); @@ -53,11 +41,10 @@ export class TimeSlotBulkCreateHandler implements ICommandHandler 0) { - slots = slots.filter((slot) => !insertedSlots.find( - (insertedSlot) => moment(insertedSlot.startedAt).isSame( - moment(slot.startedAt) - ) - )); + slots = slots.filter( + (slot) => + !insertedSlots.find((insertedSlot) => moment(insertedSlot.startedAt).isSame(moment(slot.startedAt))) + ); } if (slots.length === 0) { return []; @@ -102,13 +89,6 @@ export class TimeSlotBulkCreateHandler implements ICommandHandler b ? a : b; }); - return await this.commandBus.execute( - new TimeSlotMergeCommand( - organizationId, - employeeId, - minDate, - maxDate - ) - ); + return await this.commandBus.execute(new TimeSlotMergeCommand(organizationId, employeeId, minDate, maxDate)); } } diff --git a/packages/core/src/time-tracking/time-slot/commands/handlers/time-slot-bulk-delete.handler.ts b/packages/core/src/time-tracking/time-slot/commands/handlers/time-slot-bulk-delete.handler.ts index 7467d81aa15..524d21efe8c 100644 --- a/packages/core/src/time-tracking/time-slot/commands/handlers/time-slot-bulk-delete.handler.ts +++ b/packages/core/src/time-tracking/time-slot/commands/handlers/time-slot-bulk-delete.handler.ts @@ -1,8 +1,7 @@ import { CommandHandler, ICommandHandler } from '@nestjs/cqrs'; -import { InjectRepository } from '@nestjs/typeorm'; -import { SelectQueryBuilder } from 'typeorm'; -import { isNotEmpty } from '@gauzy/common'; -import { TimeSlot } from '../../time-slot.entity'; +import * as chalk from 'chalk'; +import { ID, ITimeLog, ITimeSlot } from '@gauzy/contracts'; +import { isEmpty, isNotEmpty } from '@gauzy/common'; import { TimeSlotBulkDeleteCommand } from '../time-slot-bulk-delete.command'; import { RequestContext } from '../../../../core/context'; import { prepareSQLQuery as p } from './../../../../database/database.helper'; @@ -10,64 +9,136 @@ import { TypeOrmTimeSlotRepository } from '../../repository/type-orm-time-slot.r @CommandHandler(TimeSlotBulkDeleteCommand) export class TimeSlotBulkDeleteHandler implements ICommandHandler { + constructor(private readonly typeOrmTimeSlotRepository: TypeOrmTimeSlotRepository) {} - constructor( - @InjectRepository(TimeSlot) - private readonly typeOrmTimeSlotRepository: TypeOrmTimeSlotRepository, - ) { } + /** + * Execute bulk deletion of time slots + * + * @param command - The command containing input and deletion options + * @returns Promise - Returns true if deletion was successful, otherwise false + */ + public async execute(command: TimeSlotBulkDeleteCommand): Promise { + const { input, forceDelete, entireSlots } = command; + // Extract organizationId, employeeId, timeLog, and timeSlotsIds from the input + const { organizationId, employeeId, timeLog, timeSlotsIds = [] } = input; + // Retrieve the tenant ID from the current request context or the provided input + const tenantId = RequestContext.currentTenantId() ?? input.tenantId; - public async execute( - command: TimeSlotBulkDeleteCommand - ): Promise { - const tenantId = RequestContext.currentTenantId(); + // Step 1: Fetch time slots based on input parameters + const timeSlots = await this.fetchTimeSlots({ organizationId, employeeId, tenantId, timeSlotsIds }); + console.log(`fetched time slots for soft delete or hard delete:`, timeSlots); - const { input, forceDirectDelete } = command; - const { organizationId, employeeId, timeLog, timeSlotsIds = [] } = input; + // If timeSlots is empty, return an empty array + if (isEmpty(timeSlots)) { + return []; + } - const query = this.typeOrmTimeSlotRepository.createQueryBuilder('time_slot'); - query.setFindOptions({ - relations: { - timeLogs: true, - screenshots: true - } - }); - query.where((qb: SelectQueryBuilder) => { - if (isNotEmpty(timeSlotsIds)) { - qb.andWhere(p(`"${qb.alias}"."id" IN (:...timeSlotsIds)`), { - timeSlotsIds - }); - } - qb.andWhere(p(`"${qb.alias}"."employeeId" = :employeeId`), { - employeeId - }); - qb.andWhere(p(`"${qb.alias}"."organizationId" = :organizationId`), { - organizationId - }); - qb.andWhere(p(`"${qb.alias}"."tenantId" = :tenantId`), { - tenantId - }); - console.log('Time Slots Delete Range Query', qb.getQueryAndParameters()); - }); - const timeSlots = await query.getMany(); - console.log({ timeSlots, forceDirectDelete }, 'Time Slots Delete Range'); + // Step 2: Handle deletion based on the entireSlots flag + if (entireSlots) { + return await this.bulkDeleteTimeSlots(timeSlots, forceDelete); + } else { + return await this.conditionalDeleteTimeSlots(timeSlots, timeLog, forceDelete); + } + } + + /** + * Fetches time slots based on the provided parameters. + * + * @param params - The parameters for querying time slots. + * @returns A promise that resolves to an array of time slots. + */ + private async fetchTimeSlots({ + organizationId, + employeeId, + tenantId, + timeSlotsIds = [] + }: { + organizationId: ID; + employeeId: ID; + tenantId: ID; + timeSlotsIds: ID[]; + }): Promise { + // Create a query builder for the TimeSlot entity + const query = this.typeOrmTimeSlotRepository.createQueryBuilder(); + query + .leftJoinAndSelect(`${query.alias}.timeLogs`, 'timeLogs') + .leftJoinAndSelect(`${query.alias}.screenshots`, 'screenshots') + .leftJoinAndSelect(`${query.alias}.activities`, 'activities') + .leftJoinAndSelect(`${query.alias}.timeSlotMinutes`, 'timeSlotMinutes'); + + query + .where(p(`"${query.alias}"."employeeId" = :employeeId`), { employeeId }) + .andWhere(p(`"${query.alias}"."organizationId" = :organizationId`), { organizationId }) + .andWhere(p(`"${query.alias}"."tenantId" = :tenantId`), { tenantId }); + + // If timeSlotsIds is not empty, add a WHERE clause to the query + if (isNotEmpty(timeSlotsIds)) { + query.andWhere(p(`"${query.alias}"."id" IN (:...timeSlotsIds)`), { timeSlotsIds }); + } + + console.log('fetched time slots by parameters:', query.getParameters()); + return await query.getMany(); + } + + /** + * Handles bulk deletion of time slots, either soft or hard delete based on the `forceDelete` flag. + * + * @param timeSlots - The time slots to delete. + * @param forceDelete - A boolean flag to indicate whether to hard delete or soft delete. + * @returns A promise that resolves to the deleted time slots. + */ + private bulkDeleteTimeSlots(timeSlots: ITimeSlot[], forceDelete: boolean): Promise { + console.log(`bulk ${forceDelete ? 'hard' : 'soft'} deleting time slots:`, timeSlots); + + return forceDelete + ? this.typeOrmTimeSlotRepository.remove(timeSlots) + : this.typeOrmTimeSlotRepository.softRemove(timeSlots); + } + + /** + * Conditionally deletes time slots based on associated time logs. + * + * If a time slot only has one time log and that time log matches the provided one, the time slot is deleted. + * + * @param timeSlots - The time slots to conditionally delete. + * @param timeLog - The specific time log to check for deletion. + * @param forceDelete - A boolean flag to indicate whether to hard delete or soft delete. + * @returns A promise that resolves to true after deletion. + */ + private async conditionalDeleteTimeSlots( + timeSlots: ITimeSlot[], + timeLog: ITimeLog, + forceDelete: boolean + ): Promise { + console.log(`conditional ${forceDelete ? 'hard' : 'soft'} deleting time slots:`, timeSlots); + + // Loop through each time slot + for await (const timeSlot of timeSlots) { + const { timeLogs = [] } = timeSlot; + const [firstTimeLog] = timeLogs; + + console.log('Matching TimeLog ID:', firstTimeLog.id === timeLog.id); + console.log('TimeSlots Ids Will Be Deleted:', timeSlot.id); - if (isNotEmpty(timeSlots)) { - if (forceDirectDelete) { - await this.typeOrmTimeSlotRepository.remove(timeSlots); - return true; - } else { - for await (const timeSlot of timeSlots) { - const { timeLogs } = timeSlot; - if (timeLogs.length === 1) { - const [firstTimeLog] = timeLogs; - if (firstTimeLog.id === timeLog.id) { - await this.typeOrmTimeSlotRepository.remove(timeSlot); - } + if (timeLogs.length === 1) { + const [firstTimeLog] = timeLogs; + if (firstTimeLog.id === timeLog.id) { + // If the time slot has only one time log and it matches the provided time log, delete the time slot + if (forceDelete) { + console.log( + chalk.red('--------------------hard removing time slot--------------------'), + timeSlot.id + ); + return await this.typeOrmTimeSlotRepository.remove(timeSlot); + } else { + console.log( + chalk.yellow('--------------------soft removing time slot--------------------'), + timeSlot.id + ); + return await this.typeOrmTimeSlotRepository.softRemove(timeSlot); } } - return true; } } - return false; } } diff --git a/packages/core/src/time-tracking/time-slot/commands/handlers/time-slot-merge.handler.ts b/packages/core/src/time-tracking/time-slot/commands/handlers/time-slot-merge.handler.ts index 6ae1d5e23e7..f4d237735d9 100644 --- a/packages/core/src/time-tracking/time-slot/commands/handlers/time-slot-merge.handler.ts +++ b/packages/core/src/time-tracking/time-slot/commands/handlers/time-slot-merge.handler.ts @@ -1,5 +1,4 @@ import { CommandBus, CommandHandler, ICommandHandler } from '@nestjs/cqrs'; -import { InjectRepository } from '@nestjs/typeorm'; import { In, SelectQueryBuilder } from 'typeorm'; import * as moment from 'moment'; import { chain, omit, pluck, uniq } from 'underscore'; @@ -16,13 +15,10 @@ import { TypeOrmTimeSlotRepository } from '../../repository/type-orm-time-slot.r @CommandHandler(TimeSlotMergeCommand) export class TimeSlotMergeHandler implements ICommandHandler { - constructor( - @InjectRepository(TimeSlot) private readonly typeOrmTimeSlotRepository: TypeOrmTimeSlotRepository, - private readonly commandBus: CommandBus - ) { } + ) {} /** * @@ -36,11 +32,7 @@ export class TimeSlotMergeHandler implements ICommandHandler { - const [timeSlot] = timeSlots; - - let timeLogs: ITimeLog[] = []; - let screenshots: IScreenshot[] = []; - let activities: IActivity[] = []; - - let duration = 0; - let keyboard = 0; - let mouse = 0; - let overall = 0; - - const calculateValue = (value: number | undefined): number => parseInt(value as any, 10) || 0; - - duration += timeSlots.reduce((acc, slot) => acc + calculateValue(slot.duration), 0); - keyboard += timeSlots.reduce((acc, slot) => acc + calculateValue(slot.keyboard), 0); - mouse += timeSlots.reduce((acc, slot) => acc + calculateValue(slot.mouse), 0); - overall += timeSlots.reduce((acc, slot) => acc + calculateValue(slot.overall), 0); - - screenshots = screenshots.concat(...timeSlots.map(slot => slot.screenshots || [])); - timeLogs = timeLogs.concat(...timeSlots.map(slot => slot.timeLogs || [])); - activities = activities.concat(...timeSlots.map(slot => slot.activities || [])); - - const nonZeroKeyboardSlots = timeSlots.filter((item: ITimeSlot) => item.keyboard !== 0); - const timeSlotsLength = nonZeroKeyboardSlots.length; - - keyboard = Math.round(keyboard / timeSlotsLength || 0); - mouse = Math.round(mouse / timeSlotsLength || 0); - - const activity = { - duration: Math.max(0, Math.min(600, duration)), - overall: Math.max(0, Math.min(600, overall)), - keyboard: Math.max(0, Math.min(600, keyboard)), - mouse: Math.max(0, Math.min(600, mouse)), - }; - /* - * Map old screenshots newly created TimeSlot - */ - screenshots = screenshots.map((item) => new Screenshot(omit(item, ['timeSlotId']))); - /* - * Map old activities newly created TimeSlot - */ - activities = activities.map((item) => new Activity(omit(item, ['timeSlotId']))); - - timeLogs = uniq(timeLogs, (log: ITimeLog) => log.id); - - const newTimeSlot = new TimeSlot({ - ...omit(timeSlot), - ...activity, - screenshots, - activities, - timeLogs, - startedAt: moment(slotStart).toDate(), - tenantId, - organizationId, - employeeId - }); - console.log('Newly Created Time Slot', newTimeSlot); - - await this.updateTimeLogAndEmployeeTotalWorkedHours(newTimeSlot); - - await this.typeOrmTimeSlotRepository.save(newTimeSlot); - createdTimeSlots.push(newTimeSlot); - - const ids = pluck(timeSlots, 'id'); - ids.splice(0, 1); - console.log('TimeSlots Ids Will Be Deleted:', ids); - - if (ids.length > 0) { - await this.typeOrmTimeSlotRepository.delete({ - id: In(ids) + const savePromises = groupByTimeSlots + .mapObject(async (timeSlots, slotStart) => { + const [timeSlot] = timeSlots; + + let timeLogs: ITimeLog[] = []; + let screenshots: IScreenshot[] = []; + let activities: IActivity[] = []; + + let duration = 0; + let keyboard = 0; + let mouse = 0; + let overall = 0; + + const calculateValue = (value: number | undefined): number => parseInt(value as any, 10) || 0; + + duration += timeSlots.reduce((acc, slot) => acc + calculateValue(slot.duration), 0); + keyboard += timeSlots.reduce((acc, slot) => acc + calculateValue(slot.keyboard), 0); + mouse += timeSlots.reduce((acc, slot) => acc + calculateValue(slot.mouse), 0); + overall += timeSlots.reduce((acc, slot) => acc + calculateValue(slot.overall), 0); + + screenshots = screenshots.concat(...timeSlots.map((slot) => slot.screenshots || [])); + timeLogs = timeLogs.concat(...timeSlots.map((slot) => slot.timeLogs || [])); + activities = activities.concat(...timeSlots.map((slot) => slot.activities || [])); + + const nonZeroKeyboardSlots = timeSlots.filter((item: ITimeSlot) => item.keyboard !== 0); + const timeSlotsLength = nonZeroKeyboardSlots.length; + + keyboard = Math.round(keyboard / timeSlotsLength || 0); + mouse = Math.round(mouse / timeSlotsLength || 0); + + const activity = { + duration: Math.max(0, Math.min(600, duration)), + overall: Math.max(0, Math.min(600, overall)), + keyboard: Math.max(0, Math.min(600, keyboard)), + mouse: Math.max(0, Math.min(600, mouse)) + }; + /* + * Map old screenshots newly created TimeSlot + */ + screenshots = screenshots.map((item) => new Screenshot(omit(item, ['timeSlotId']))); + /* + * Map old activities newly created TimeSlot + */ + activities = activities.map((item) => new Activity(omit(item, ['timeSlotId']))); + + timeLogs = uniq(timeLogs, (log: ITimeLog) => log.id); + + const newTimeSlot = new TimeSlot({ + ...omit(timeSlot), + ...activity, + screenshots, + activities, + timeLogs, + startedAt: moment(slotStart).toDate(), + tenantId, + organizationId, + employeeId }); - } - }).values().value(); + console.log('Newly Created Time Slot', newTimeSlot); + + await this.updateTimeLogAndEmployeeTotalWorkedHours(newTimeSlot); + + await this.typeOrmTimeSlotRepository.save(newTimeSlot); + createdTimeSlots.push(newTimeSlot); + + const ids = pluck(timeSlots, 'id'); + ids.splice(0, 1); + console.log('TimeSlots Ids Will Be Deleted:', ids); + + if (ids.length > 0) { + await this.typeOrmTimeSlotRepository.delete({ + id: In(ids) + }); + } + }) + .values() + .value(); await Promise.all(savePromises); } return createdTimeSlots; @@ -163,13 +155,7 @@ export class TimeSlotMergeHandler implements ICommandHandler { + private async getTimeSlots({ organizationId, employeeId, tenantId, startedAt, stoppedAt }): Promise { /** * GET Time Slots for given date range slot */ @@ -194,7 +180,7 @@ export class TimeSlotMergeHandler implements ICommandHandler implements IDeleteTimeSlot { + /** + * An array of IDs representing the time slots to be deleted. + * This array must not be empty and ensures that at least one time slot is selected for deletion. + */ @ApiProperty({ type: () => Array }) @ArrayNotEmpty() - readonly ids: string[] = []; -} \ No newline at end of file + readonly ids: ID[] = []; +} diff --git a/packages/core/src/time-tracking/time-slot/dto/index.ts b/packages/core/src/time-tracking/time-slot/dto/index.ts index e0967f6e6eb..8930d1d997c 100644 --- a/packages/core/src/time-tracking/time-slot/dto/index.ts +++ b/packages/core/src/time-tracking/time-slot/dto/index.ts @@ -1 +1,2 @@ -export { DeleteTimeSlotDTO } from './delete-time-slot.dto'; \ No newline at end of file +export * from './time-slot-query.dto'; +export * from './delete-time-slot.dto'; diff --git a/packages/core/src/time-tracking/time-slot/dto/query/index.ts b/packages/core/src/time-tracking/time-slot/dto/query/index.ts deleted file mode 100644 index 1f0f0102528..00000000000 --- a/packages/core/src/time-tracking/time-slot/dto/query/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { TimeSlotQueryDTO } from './time-slot-query.dto'; \ No newline at end of file diff --git a/packages/core/src/time-tracking/time-slot/dto/query/time-slot-query.dto.ts b/packages/core/src/time-tracking/time-slot/dto/query/time-slot-query.dto.ts deleted file mode 100644 index 78d81407c40..00000000000 --- a/packages/core/src/time-tracking/time-slot/dto/query/time-slot-query.dto.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { IntersectionType } from "@nestjs/swagger"; -import { IGetTimeSlotInput } from "@gauzy/contracts"; -import { FiltersQueryDTO, RelationsQueryDTO, SelectorsQueryDTO } from "./../../../../shared/dto"; - -/** - * Get time slot request DTO validation - */ -export class TimeSlotQueryDTO extends IntersectionType( - FiltersQueryDTO, - IntersectionType(RelationsQueryDTO, SelectorsQueryDTO) -) implements IGetTimeSlotInput {} \ No newline at end of file diff --git a/packages/core/src/time-tracking/time-slot/dto/time-slot-query.dto.ts b/packages/core/src/time-tracking/time-slot/dto/time-slot-query.dto.ts new file mode 100644 index 00000000000..2f5f31b122d --- /dev/null +++ b/packages/core/src/time-tracking/time-slot/dto/time-slot-query.dto.ts @@ -0,0 +1,10 @@ +import { IntersectionType } from '@nestjs/swagger'; +import { IGetTimeSlotInput } from '@gauzy/contracts'; +import { FiltersQueryDTO, RelationsQueryDTO, SelectorsQueryDTO } from '../../../shared/dto'; + +/** + * Get time slot request DTO validation + */ +export class TimeSlotQueryDTO + extends IntersectionType(FiltersQueryDTO, RelationsQueryDTO, SelectorsQueryDTO) + implements IGetTimeSlotInput {} diff --git a/packages/core/src/time-tracking/time-slot/time-slot-minute.entity.ts b/packages/core/src/time-tracking/time-slot/time-slot-minute.entity.ts index 628b79e49bc..e4be776daa4 100644 --- a/packages/core/src/time-tracking/time-slot/time-slot-minute.entity.ts +++ b/packages/core/src/time-tracking/time-slot/time-slot-minute.entity.ts @@ -1,11 +1,7 @@ -import { - RelationId, - Unique, - JoinColumn -} from 'typeorm'; -import { ITimeSlot, ITimeSlotMinute } from '@gauzy/contracts'; +import { RelationId, Unique, JoinColumn } from 'typeorm'; +import { ID, ITimeSlot, ITimeSlotMinute } from '@gauzy/contracts'; import { ApiProperty } from '@nestjs/swagger'; -import { IsNumber, IsDateString, IsString } from 'class-validator'; +import { IsNumber, IsDateString, IsUUID } from 'class-validator'; import { TenantOrganizationBaseEntity } from './../../core/entities/internal'; import { ColumnIndex, MultiORMColumn, MultiORMEntity, MultiORMManyToOne } from './../../core/decorators/entity'; import { TimeSlot } from './time-slot.entity'; @@ -14,17 +10,28 @@ import { MikroOrmTimeSlotMinuteRepository } from './repository/mikro-orm-time-sl @MultiORMEntity('time_slot_minute', { mikroOrmRepository: () => MikroOrmTimeSlotMinuteRepository }) @Unique(['timeSlotId', 'datetime']) export class TimeSlotMinute extends TenantOrganizationBaseEntity implements ITimeSlotMinute { - + /** + * The number of keyboard interactions in the given time slot minute. + * Defaults to 0 if not provided. + */ @ApiProperty({ type: () => Number }) @IsNumber() @MultiORMColumn({ default: 0 }) keyboard?: number; + /** + * The number of mouse interactions in the given time slot minute. + * Defaults to 0 if not provided. + */ @ApiProperty({ type: () => Number }) @IsNumber() @MultiORMColumn({ default: 0 }) mouse?: number; + /** + * The specific datetime for this time slot minute. + * It records the exact minute in which the activity was tracked. + */ @ApiProperty({ type: () => 'timestamptz' }) @IsDateString() @MultiORMColumn() @@ -32,21 +39,28 @@ export class TimeSlotMinute extends TenantOrganizationBaseEntity implements ITim /* |-------------------------------------------------------------------------- - | @ManyToOne + | @ManyToOne Relationship |-------------------------------------------------------------------------- */ - - @ApiProperty({ type: () => TimeSlot }) + /** + * The reference to the `TimeSlot` entity to which this minute belongs. + * This establishes a many-to-one relationship with the `TimeSlot` entity. + * The deletion of a `TimeSlot` cascades down to its `TimeSlotMinute` records. + */ @MultiORMManyToOne(() => TimeSlot, (it) => it.timeSlotMinutes, { onDelete: 'CASCADE' }) @JoinColumn() timeSlot?: ITimeSlot; - @ApiProperty({ type: () => String, readOnly: true }) + /** + * The ID of the related `TimeSlot` entity, stored as a UUID. + * This is a relation ID that helps link the minute to the corresponding `TimeSlot`. + */ + @ApiProperty({ type: () => String }) @RelationId((it: TimeSlotMinute) => it.timeSlot) - @IsString() + @IsUUID() @ColumnIndex() @MultiORMColumn({ relationId: true }) - readonly timeSlotId?: string; + timeSlotId?: ID; } diff --git a/packages/core/src/time-tracking/time-slot/time-slot.controller.ts b/packages/core/src/time-tracking/time-slot/time-slot.controller.ts index 8433e640eb1..ca283238efc 100644 --- a/packages/core/src/time-tracking/time-slot/time-slot.controller.ts +++ b/packages/core/src/time-tracking/time-slot/time-slot.controller.ts @@ -1,67 +1,63 @@ -import { - Controller, - UseGuards, - Get, - Query, - HttpStatus, - Delete, - Param, - Post, - Body, - Put, - ValidationPipe, - UsePipes -} from '@nestjs/common'; +import { Controller, UseGuards, Get, Query, HttpStatus, Delete, Param, Post, Body, Put } from '@nestjs/common'; import { ApiTags, ApiOperation, ApiResponse } from '@nestjs/swagger'; import { CommandBus } from '@nestjs/cqrs'; import { DeleteResult, FindOneOptions, UpdateResult } from 'typeorm'; import { ID, ITimeSlot, PermissionsEnum } from '@gauzy/contracts'; -import { CreateTimeSlotCommand, DeleteTimeSlotCommand, UpdateTimeSlotCommand } from './commands'; -import { TimeSlotService } from './time-slot.service'; -import { TimeSlot } from './time-slot.entity'; +import { Permissions } from './../../shared/decorators'; import { OrganizationPermissionGuard, PermissionGuard, TenantPermissionGuard } from '../../shared/guards'; import { UUIDValidationPipe, UseValidationPipe } from './../../shared/pipes'; -import { Permissions } from './../../shared/decorators'; -import { DeleteTimeSlotDTO } from './dto'; -import { TimeSlotQueryDTO } from './dto/query'; +import { CreateTimeSlotCommand, DeleteTimeSlotCommand, UpdateTimeSlotCommand } from './commands'; +import { TimeSlotService } from './time-slot.service'; +import { DeleteTimeSlotDTO, TimeSlotQueryDTO } from './dto'; @ApiTags('TimeSlot') @UseGuards(TenantPermissionGuard, PermissionGuard) @Permissions(PermissionsEnum.TIME_TRACKER, PermissionsEnum.ALL_ORG_EDIT, PermissionsEnum.ALL_ORG_VIEW) @Controller() export class TimeSlotController { - constructor(private readonly timeSlotService: TimeSlotService, private readonly commandBus: CommandBus) {} + constructor(private readonly _timeSlotService: TimeSlotService, private readonly _commandBus: CommandBus) {} /** + * Retrieves all time slots based on the provided query options. + * + * This method accepts query parameters to filter the list of time slots + * and uses the `TimeSlotQueryDTO` for validation and transformation. + * The method calls the `timeSlotService` to fetch the matching time slots. * - * @param options - * @returns + * @param options - Query parameters for filtering the time slots. + * @returns A promise that resolves to an array of time slots matching the specified criteria. */ @ApiOperation({ summary: 'Get Time Slots' }) @ApiResponse({ status: HttpStatus.BAD_REQUEST, description: 'Invalid input, The response body may contain clues as to what went wrong' }) - @Get() + @Get('/') @UseValidationPipe({ whitelist: true, transform: true }) async findAll(@Query() options: TimeSlotQueryDTO): Promise { - return await this.timeSlotService.getTimeSlots(options); + return await this._timeSlotService.getTimeSlots(options); } /** + * Retrieves a specific time slot by its ID. + * + * This method accepts a time slot ID as a parameter and query options for + * additional filtering or selecting specific fields. It uses `UUIDValidationPipe` + * to ensure that the provided ID is a valid UUID. The method calls the + * `timeSlotService` to find the time slot by its ID. * - * @param id - * @param options - * @returns + * @param id - The UUID of the time slot to retrieve. + * @param options - Additional query options to refine the search (e.g., relations). + * @returns A promise that resolves to the time slot object if found. */ @ApiOperation({ summary: 'Get Time Slot By Id' }) @ApiResponse({ status: HttpStatus.BAD_REQUEST, description: 'Invalid input, The response body may contain clues as to what went wrong' }) - @Get(':id') + @Get('/:id') async findById(@Param('id', UUIDValidationPipe) id: ID, @Query() options: FindOneOptions): Promise { - return await this.timeSlotService.findOneByIdString(id, options); + return await this._timeSlotService.findOneByIdString(id, options); } /** @@ -77,16 +73,22 @@ export class TimeSlotController { status: HttpStatus.BAD_REQUEST, description: 'Invalid input, The response body may contain clues as to what went wrong' }) - @Post() + @Post('/') async create(@Body() request: ITimeSlot): Promise { - return await this.commandBus.execute(new CreateTimeSlotCommand(request)); + return await this._commandBus.execute(new CreateTimeSlotCommand(request)); } /** + * Updates a specific time slot by its ID. * - * @param id - * @param entity - * @returns + * This method allows modifying the details of a time slot using its unique ID. + * It accepts a time slot ID as a parameter and the updated time slot data as the + * request body. The method is guarded by `OrganizationPermissionGuard` to ensure + * only authorized users with the `ALLOW_MODIFY_TIME` permission can perform updates. + * + * @param id - The UUID of the time slot to update. + * @param request - The updated time slot data to apply. + * @returns A promise that resolves to the updated time slot. */ @ApiOperation({ summary: 'Update Time Slot' }) @ApiResponse({ @@ -95,15 +97,20 @@ export class TimeSlotController { }) @UseGuards(OrganizationPermissionGuard) @Permissions(PermissionsEnum.ALLOW_MODIFY_TIME) - @Put(':id') - async update(@Param('id', UUIDValidationPipe) id: ITimeSlot['id'], @Body() request: TimeSlot): Promise { - return await this.commandBus.execute(new UpdateTimeSlotCommand(id, request)); + @Put('/:id') + async update(@Param('id', UUIDValidationPipe) id: ID, @Body() request: ITimeSlot): Promise { + return await this._commandBus.execute(new UpdateTimeSlotCommand(id, request)); } /** + * Deletes time slots based on the provided query parameters. + * + * This method allows deleting multiple time slots by accepting a list of time slot IDs + * in the query parameters. The method is protected by `OrganizationPermissionGuard` + * to ensure that only authorized users with the `ALLOW_DELETE_TIME` permission can delete time slots. * - * @param query - * @returns + * @param query - The DTO containing the IDs of the time slots to delete. + * @returns A promise that resolves to either a `DeleteResult` or `UpdateResult` indicating the outcome of the deletion process. */ @ApiOperation({ summary: 'Delete TimeSlot' }) @ApiResponse({ @@ -116,9 +123,9 @@ export class TimeSlotController { }) @UseGuards(OrganizationPermissionGuard) @Permissions(PermissionsEnum.ALLOW_DELETE_TIME) - @Delete() + @Delete('/') @UseValidationPipe({ transform: true }) - async deleteTimeSlot(@Query() query: DeleteTimeSlotDTO): Promise { - return await this.commandBus.execute(new DeleteTimeSlotCommand(query)); + async deleteTimeSlot(@Query() options: DeleteTimeSlotDTO): Promise { + return await this._commandBus.execute(new DeleteTimeSlotCommand(options)); } } diff --git a/packages/core/src/time-tracking/time-slot/time-slot.entity.ts b/packages/core/src/time-tracking/time-slot/time-slot.entity.ts index a229b3e85dc..3c2ad2d8259 100644 --- a/packages/core/src/time-tracking/time-slot/time-slot.entity.ts +++ b/packages/core/src/time-tracking/time-slot/time-slot.entity.ts @@ -1,33 +1,22 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; -import { - RelationId, - JoinTable -} from 'typeorm'; +import { RelationId, JoinTable } from 'typeorm'; import { IsNumber, IsDateString, IsUUID, IsNotEmpty, IsOptional } from 'class-validator'; -import { - ITimeSlot, - ITimeSlotMinute, - IActivity, - IScreenshot, - IEmployee, - ITimeLog, - ID -} from '@gauzy/contracts'; -import { - Activity, - Employee, - Screenshot, - TenantOrganizationBaseEntity, - TimeLog -} from './../../core/entities/internal'; +import { ITimeSlot, ITimeSlotMinute, IActivity, IScreenshot, IEmployee, ITimeLog, ID } from '@gauzy/contracts'; +import { Activity, Employee, Screenshot, TenantOrganizationBaseEntity, TimeLog } from './../../core/entities/internal'; import { TimeSlotMinute } from './time-slot-minute.entity'; -import { ColumnIndex, MultiORMColumn, MultiORMEntity, MultiORMManyToMany, MultiORMManyToOne, MultiORMOneToMany, VirtualMultiOrmColumn } from './../../core/decorators/entity'; +import { + ColumnIndex, + MultiORMColumn, + MultiORMEntity, + MultiORMManyToMany, + MultiORMManyToOne, + MultiORMOneToMany, + VirtualMultiOrmColumn +} from './../../core/decorators/entity'; import { MikroOrmTimeSlotRepository } from './repository/mikro-orm-time-slot.repository'; @MultiORMEntity('time_slot', { mikroOrmRepository: () => MikroOrmTimeSlotRepository }) -export class TimeSlot extends TenantOrganizationBaseEntity - implements ITimeSlot { - +export class TimeSlot extends TenantOrganizationBaseEntity implements ITimeSlot { @ApiPropertyOptional({ type: () => Number, default: 0 }) @IsOptional() @IsNumber() @@ -107,7 +96,9 @@ export class TimeSlot extends TenantOrganizationBaseEntity /** * Screenshot */ - @MultiORMOneToMany(() => Screenshot, (it) => it.timeSlot) + @MultiORMOneToMany(() => Screenshot, (it) => it.timeSlot, { + cascade: true + }) screenshots?: IScreenshot[]; /** diff --git a/packages/core/src/time-tracking/time-slot/time-slot.module.ts b/packages/core/src/time-tracking/time-slot/time-slot.module.ts index 48738d925b4..2e848d2d0af 100644 --- a/packages/core/src/time-tracking/time-slot/time-slot.module.ts +++ b/packages/core/src/time-tracking/time-slot/time-slot.module.ts @@ -12,6 +12,7 @@ import { TimeLogModule } from './../time-log/time-log.module'; import { EmployeeModule } from './../../employee/employee.module'; import { ActivityModule } from './../activity/activity.module'; import { TypeOrmTimeSlotRepository } from './repository/type-orm-time-slot.repository'; +import { TypeOrmTimeSlotMinuteRepository } from './repository/type-orm-time-slot-minute.repository'; @Module({ controllers: [TimeSlotController], @@ -24,7 +25,13 @@ import { TypeOrmTimeSlotRepository } from './repository/type-orm-time-slot.repos forwardRef(() => ActivityModule), CqrsModule ], - providers: [TimeSlotService, TypeOrmTimeSlotRepository, ...CommandHandlers], - exports: [TypeOrmModule, MikroOrmModule, TimeSlotService, TypeOrmTimeSlotRepository] + providers: [TimeSlotService, TypeOrmTimeSlotRepository, TypeOrmTimeSlotMinuteRepository, ...CommandHandlers], + exports: [ + TypeOrmModule, + MikroOrmModule, + TimeSlotService, + TypeOrmTimeSlotRepository, + TypeOrmTimeSlotMinuteRepository + ] }) export class TimeSlotModule {} diff --git a/packages/core/src/time-tracking/time-slot/time-slot.seed.ts b/packages/core/src/time-tracking/time-slot/time-slot.seed.ts index 2d2c01faa21..430ba08ead2 100644 --- a/packages/core/src/time-tracking/time-slot/time-slot.seed.ts +++ b/packages/core/src/time-tracking/time-slot/time-slot.seed.ts @@ -1,26 +1,35 @@ import { faker } from '@faker-js/faker'; -import { TimeSlot } from './time-slot.entity'; import { generateTimeSlots } from './utils'; +import { TimeSlot } from './time-slot.entity'; -export function createTimeSlots(start, end) { - const timeSlots: TimeSlot[] = generateTimeSlots(start, end).map( - ({ duration, startedAt, stoppedAt }) => { - const keyboard = faker.number.int(duration); - const mouse = faker.number.int(duration); - const overall = (keyboard + mouse) / 2; +/** + * Generates an array of time slots between the provided start and end times. + * + * This function generates time slots using the `generateTimeSlots` function, + * which creates slot data with a duration, start time, and end time. For each + * time slot, the function randomly generates keyboard and mouse activity + * using Faker, calculates the overall activity, and constructs a `TimeSlot` object. + * + * @param start - The starting time of the time slots (as a Date object). + * @param end - The ending time of the time slots (as a Date object). + * @returns An array of `TimeSlot` objects containing the generated time slots. + */ +export function createTimeSlots(start: Date, end: Date): TimeSlot[] { + return generateTimeSlots(start, end).map(({ duration, startedAt, stoppedAt }) => { + const keyboard = faker.number.int(duration); // Randomly generate keyboard activity based on duration + const mouse = faker.number.int(duration); // Randomly generate mouse activity based on duration + const overall = Math.ceil((keyboard + mouse) / 2); // Calculate the average activity - const slot = new TimeSlot(); - slot.startedAt = startedAt; - slot.stoppedAt = stoppedAt; - slot.duration = duration; - slot.screenshots = []; - slot.timeSlotMinutes = []; - slot.keyboard = keyboard; - slot.mouse = mouse; - slot.overall = Math.ceil(overall); - return slot; - } - ); + const slot = new TimeSlot(); + slot.startedAt = startedAt; // Set the start time of the time slot + slot.stoppedAt = stoppedAt; // Set the end time of the time slot + slot.duration = duration; // Set the duration of the time slot + slot.screenshots = []; // Initialize an empty array for screenshots + slot.timeSlotMinutes = []; // Initialize an empty array for time slot minutes + slot.keyboard = keyboard; // Set the keyboard activity + slot.mouse = mouse; // Set the mouse activity + slot.overall = overall; // Set the overall activity (rounded) - return timeSlots; + return slot; // Return the constructed TimeSlot object + }); } diff --git a/packages/core/src/time-tracking/time-slot/time-slot.service.ts b/packages/core/src/time-tracking/time-slot/time-slot.service.ts index f045ce3edf7..b6c4a49db3f 100644 --- a/packages/core/src/time-tracking/time-slot/time-slot.service.ts +++ b/packages/core/src/time-tracking/time-slot/time-slot.service.ts @@ -1,7 +1,7 @@ import { Injectable } from '@nestjs/common'; import { CommandBus } from '@nestjs/cqrs'; -import { Brackets, SelectQueryBuilder, WhereExpressionBuilder } from 'typeorm'; -import { PermissionsEnum, IGetTimeSlotInput,ID, ITimeSlot } from '@gauzy/contracts'; +import { SelectQueryBuilder } from 'typeorm'; +import { PermissionsEnum, IGetTimeSlotInput, ID, ITimeSlot } from '@gauzy/contracts'; import { isEmpty, isNotEmpty } from '@gauzy/common'; import { TenantAwareCrudService } from './../../core/crud'; import { moment } from '../../core/moment-extend'; @@ -25,48 +25,62 @@ export class TimeSlotService extends TenantAwareCrudService { constructor( readonly typeOrmTimeSlotRepository: TypeOrmTimeSlotRepository, readonly mikroOrmTimeSlotRepository: MikroOrmTimeSlotRepository, - private readonly commandBus: CommandBus + private readonly _commandBus: CommandBus ) { super(typeOrmTimeSlotRepository, mikroOrmTimeSlotRepository); } /** * Retrieves time slots based on the provided input parameters. + * * @param request - Input parameters for querying time slots. * @returns A list of time slots matching the specified criteria. */ async getTimeSlots(request: IGetTimeSlotInput) { - // Extract parameters from the request object - const { organizationId, startDate, endDate, syncSlots = false } = request; - let { employeeIds = [] } = request; - - const tenantId = RequestContext.currentTenantId(); - const user = RequestContext.currentUser(); - - // Calculate start and end dates using a utility function - const { start, end } = getDateRangeFormat( - moment.utc(startDate || moment().startOf('day')), - moment.utc(endDate || moment().endOf('day')) - ); + // Extract parameters from the request object with default values + let { + organizationId, + startDate, + endDate, + syncSlots = false, + employeeIds = [], + projectIds = [], + activityLevel, + source, + logType, + onlyMe: isOnlyMeSelected // Indicates whether to retrieve data for the current user only + } = request; + + const tenantId = RequestContext.currentTenantId() ?? request.tenantId; // Retrieve the tenant ID from the request context or the provided input + const user = RequestContext.currentUser(); // Retrieve the current user from the request context // Check if the current user has the permission to change the selected employee const hasChangeSelectedEmployeePermission: boolean = RequestContext.hasPermission( PermissionsEnum.CHANGE_SELECTED_EMPLOYEE ); - // Determine if the request specifies to retrieve data for the current user only - const isOnlyMeSelected: boolean = request.onlyMe; - // Set employeeIds based on permissions and request - if ((user.employeeId && isOnlyMeSelected) || (!hasChangeSelectedEmployeePermission && user.employeeId)) { + if (user.employeeId && (isOnlyMeSelected || !hasChangeSelectedEmployeePermission)) { employeeIds = [user.employeeId]; } + // Calculate start and end dates using a utility function + const { start, end } = getDateRangeFormat( + moment.utc(startDate || moment().startOf('day')), + moment.utc(endDate || moment().endOf('day')) + ); + // Create a query builder for the TimeSlot entity const query = this.typeOrmRepository.createQueryBuilder('time_slot'); - query.leftJoin(`${query.alias}.employee`, 'employee'); + query.leftJoin( + `${query.alias}.employee`, + 'employee', + `"employee"."tenantId" = :tenantId AND "employee"."organizationId" = :organizationId`, + { tenantId, organizationId } + ); query.innerJoin(`${query.alias}.timeLogs`, 'time_log'); + // Set find options for the query query.setFindOptions({ // Define selected fields for the result select: { @@ -84,115 +98,97 @@ export class TimeSlotService extends TenantAwareCrudService { } } }, - relations: [...(request.relations ? request.relations : [])] + // Spread relations if provided, otherwise an empty array + relations: request.relations || [] }); + + // Add where conditions to the query query.where((qb: SelectQueryBuilder) => { + // Filter by time range for both time_slot and time_log + qb.andWhere(p(`"${qb.alias}"."startedAt" BETWEEN :startDate AND :endDate`), { + startDate: start, + endDate: end + }); + + // If syncSlots is true, filter by time_log.startedAt + if (isEmpty(syncSlots)) { + qb.andWhere(p(`"time_log"."startedAt" BETWEEN :startDate AND :endDate`), { + startDate: start, + endDate: end + }); + } + + // Filter by employeeIds and projectIds if provided + if (isNotEmpty(employeeIds)) { + qb.andWhere(p(`"${qb.alias}"."employeeId" IN (:...employeeIds)`), { employeeIds }); + qb.andWhere(p(`"time_log"."employeeId" IN (:...employeeIds)`), { employeeIds }); + } + if (isNotEmpty(projectIds)) { + qb.andWhere(p(`"time_log"."projectId" IN (:...projectIds)`), { projectIds }); + } + + // Filter by activity level if provided + if (isNotEmpty(activityLevel)) { + /** + * Activity Level should be 0-100% + * Convert it into a 10-minute time slot by multiplying by 6 + */ + // Filters records based on the overall column, representing the activity level. + qb.andWhere(p(`"${qb.alias}"."overall" BETWEEN :start AND :end`), { + start: activityLevel.start * 6, + end: activityLevel.end * 6 + }); + } + + // Filters records based on the source column. + if (isNotEmpty(source)) { + const whereClause = + source instanceof Array + ? p(`"time_log"."source" IN (:...source)`) + : p(`"time_log"."source" = :source`); + + qb.andWhere(whereClause, { source }); + } + + // Filter by logType if provided + if (isNotEmpty(logType)) { + const whereClause = + logType instanceof Array + ? p(`"time_log"."logType" IN (:...logType)`) + : p(`"time_log"."logType" = :logType`); + + qb.andWhere(whereClause, { logType }); + } + + // Filter by tenantId and organizationId for both time_slot and time_log in a single AND condition qb.andWhere( - new Brackets((web: WhereExpressionBuilder) => { - web.andWhere(p(`"${qb.alias}"."startedAt" BETWEEN :startDate AND :endDate`), { - startDate: start, - endDate: end - }); - if (isEmpty(syncSlots)) { - web.andWhere(p(`"time_log"."startedAt" BETWEEN :startDate AND :endDate`), { - startDate: start, - endDate: end - }); - } - }) - ); - qb.andWhere( - new Brackets((web: WhereExpressionBuilder) => { - if (isNotEmpty(employeeIds)) { - web.andWhere(p(`"${qb.alias}"."employeeId" IN (:...employeeIds)`), { - employeeIds - }); - web.andWhere(p(`"time_log"."employeeId" IN (:...employeeIds)`), { - employeeIds - }); - } - if (isNotEmpty(request.projectIds)) { - const { projectIds } = request; - web.andWhere(p('"time_log"."projectId" IN (:...projectIds)'), { - projectIds - }); - } - }) + `"${qb.alias}"."tenantId" = :tenantId AND "${qb.alias}"."organizationId" = :organizationId AND + "time_log"."tenantId" = :tenantId AND "time_log"."organizationId" = :organizationId`, + { tenantId, organizationId } ); - qb.andWhere( - new Brackets((web: WhereExpressionBuilder) => { - // Filters records based on the overall column, representing the activity level. - if (isNotEmpty(request.activityLevel)) { - /** - * Activity Level should be 0-100% - * Convert it into a 10-minute time slot by multiplying by 6 - */ - const { activityLevel } = request; - - web.andWhere(p(`"${qb.alias}"."overall" BETWEEN :start AND :end`), { - start: activityLevel.start * 6, - end: activityLevel.end * 6 - }); - } - - // Filters records based on the source column. - if (isNotEmpty(request.source)) { - const { source } = request; - - const condition = - source instanceof Array - ? p(`"time_log"."source" IN (:...source)`) - : p(`"time_log"."source" = :source`); - web.andWhere(condition, { source }); - } - // Filters records based on the logType column. - if (isNotEmpty(request.logType)) { - const { logType } = request; - const condition = - logType instanceof Array - ? p(`"time_log"."logType" IN (:...logType)`) - : p(`"time_log"."logType" = :logType`); - - web.andWhere(condition, { logType }); - } - }) - ); - // Additional conditions for filtering by tenantId and organizationId - qb.andWhere( - new Brackets((web: WhereExpressionBuilder) => { - web.andWhere(p(`"time_log"."tenantId" = :tenantId`), { tenantId }); - web.andWhere(p(`"time_log"."organizationId" = :organizationId`), { organizationId }); - }) - ); - // Additional conditions for filtering by tenantId and organizationId - qb.andWhere( - new Brackets((web: WhereExpressionBuilder) => { - web.andWhere(p(`"${qb.alias}"."tenantId" = :tenantId`), { tenantId }); - web.andWhere(p(`"${qb.alias}"."organizationId" = :organizationId`), { organizationId }); - }) - ); - qb.addOrderBy(p(`"${qb.alias}"."createdAt"`), 'ASC'); + // Sort by createdAt + qb.addOrderBy(`"${qb.alias}"."createdAt"`, 'ASC'); }); + const slots = await query.getMany(); return slots; } /** + * Bulk creates or updates time slots for a given employee within an organization. * - * @param slots - * @param employeeId - * @param organizationId - * @returns + * This method will either create new time slots or update existing ones based on + * the provided slots, employeeId, and organizationId. The actual logic for bulk + * creation or updating is delegated to a command handler (`TimeSlotBulkCreateOrUpdateCommand`). + * + * @param slots - An array of time slots to be created or updated. + * @param employeeId - The ID of the employee for whom the time slots belong. + * @param organizationId - The ID of the organization associated with the time slots. + * @returns A promise that resolves when the command is executed, performing bulk creation or update. */ - async bulkCreateOrUpdate( - slots: ITimeSlot[], - employeeId: ID, - organizationId: ID - ) { - return await this.commandBus.execute( - new TimeSlotBulkCreateOrUpdateCommand(slots, employeeId, organizationId) - ); + async bulkCreateOrUpdate(slots: ITimeSlot[], employeeId: ID, organizationId: ID) { + return await this._commandBus.execute(new TimeSlotBulkCreateOrUpdateCommand(slots, employeeId, organizationId)); } /** @@ -203,14 +199,8 @@ export class TimeSlotService extends TenantAwareCrudService { * @param organizationId The ID of the organization * @returns The result of the bulk creation command */ - async bulkCreate( - slots: ITimeSlot[], - employeeId: ID, - organizationId: ID - ) { - return await this.commandBus.execute( - new TimeSlotBulkCreateCommand(slots, employeeId, organizationId) - ); + async bulkCreate(slots: ITimeSlot[], employeeId: ID, organizationId: ID): Promise { + return await this._commandBus.execute(new TimeSlotBulkCreateCommand(slots, employeeId, organizationId)); } /** @@ -228,17 +218,13 @@ export class TimeSlotService extends TenantAwareCrudService { */ async createTimeSlotMinute(request: TimeSlotMinute) { // const { keyboard, mouse, datetime, timeSlot } = request; - return await this.commandBus.execute( - new CreateTimeSlotMinutesCommand(request) - ); + return await this._commandBus.execute(new CreateTimeSlotMinutesCommand(request)); } /* * Update TimeSlot minute activity for specific TimeSlot */ - async updateTimeSlotMinute(id: string, request: TimeSlotMinute) { - return await this.commandBus.execute( - new UpdateTimeSlotMinutesCommand(id, request) - ); + async updateTimeSlotMinute(id: ID, request: TimeSlotMinute) { + return await this._commandBus.execute(new UpdateTimeSlotMinutesCommand(id, request)); } } diff --git a/packages/core/src/time-tracking/timer/timer.service.ts b/packages/core/src/time-tracking/timer/timer.service.ts index f0ec743a930..dd0d0ec0a44 100644 --- a/packages/core/src/time-tracking/timer/timer.service.ts +++ b/packages/core/src/time-tracking/timer/timer.service.ts @@ -353,8 +353,14 @@ export class TimerService { * @param lastLog The last running time log entry. * @param tenantId The tenant ID. * @param organizationId The organization ID. + * @param forceDelete Flag indicating whether to force delete the conflicts. */ - private async handleConflictingTimeLogs(lastLog: ITimeLog, tenantId: ID, organizationId: ID): Promise { + private async handleConflictingTimeLogs( + lastLog: ITimeLog, + tenantId: ID, + organizationId: ID, + forceDelete: boolean = false + ): Promise { try { // Validate the date range and check if the timer is running validateDateRange(lastLog.startedAt, lastLog.stoppedAt); @@ -380,21 +386,23 @@ export class TimerService { tenantId }); - if (isNotEmpty(conflicts)) { + // Resolve conflicts by deleting conflicting time slots + if (conflicts?.length) { const times: IDateRange = { start: new Date(lastLog.startedAt), end: new Date(lastLog.stoppedAt) }; - // 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)) + // Loop through each conflicting time log + for await (const timeLog of conflicts) { + const { timeSlots = [] } = timeLog; + // Delete conflicting time slots + for await (const timeSlot of timeSlots) { + await this._commandBus.execute( + new DeleteTimeSpanCommand(times, timeLog, timeSlot, forceDelete) ); - }) - ); + } + } } } catch (error) { console.warn('Error while handling conflicts in time logs:', error?.message); @@ -672,7 +680,7 @@ export class TimerService { * @returns The timer status for the employee. */ public async getTimerWorkedStatus(request: ITimerStatusInput): Promise { - const tenantId = RequestContext.currentTenantId() || request.tenantId; + const tenantId = RequestContext.currentTenantId() ?? request.tenantId; const { organizationId, organizationTeamId, source } = request; // Define the array to store employeeIds @@ -682,14 +690,11 @@ export class TimerService { // Check if the current user has any of the specified permissions if (RequestContext.hasAnyPermission(permissions)) { - // If yes, set employeeIds based on request.employeeIds or request.employeeId - employeeIds = request.employeeIds - ? request.employeeIds.filter(Boolean) - : [request.employeeId].filter(Boolean); + // Set employeeIds based on request.employeeIds or request.employeeId + employeeIds = (request.employeeIds ?? [request.employeeId]).filter(Boolean); } else { // EMPLOYEE have the ability to see only their own timer status - const employeeId = RequestContext.currentEmployeeId(); - employeeIds = [employeeId]; + employeeIds = [RequestContext.currentEmployeeId()]; } let lastLogs: TimeLog[] = []; diff --git a/packages/desktop-libs/src/lib/plugin-system/data-access/plugin-manager.ts b/packages/desktop-libs/src/lib/plugin-system/data-access/plugin-manager.ts index 4a3d6f12f50..5462f8eb75d 100644 --- a/packages/desktop-libs/src/lib/plugin-system/data-access/plugin-manager.ts +++ b/packages/desktop-libs/src/lib/plugin-system/data-access/plugin-manager.ts @@ -36,7 +36,10 @@ export class PluginManager implements IPluginManager { if (plugin) { await this.updatePlugin(metadata); } else { + /* Install plugin */ await this.installPlugin(metadata, pathDirname); + /* Activate plugin */ + await this.activatePlugin(metadata.name); } process.noAsar = false; } diff --git a/packages/plugins/job-search/src/lib/employee-job-preset/commands/handlers/create-job-preset.handler.ts b/packages/plugins/job-search/src/lib/employee-job-preset/commands/handlers/create-job-preset.handler.ts index ce35f64049e..ab0ba5266e8 100644 --- a/packages/plugins/job-search/src/lib/employee-job-preset/commands/handlers/create-job-preset.handler.ts +++ b/packages/plugins/job-search/src/lib/employee-job-preset/commands/handlers/create-job-preset.handler.ts @@ -1,16 +1,16 @@ import { CommandHandler, ICommandHandler } from '@nestjs/cqrs'; -import { RequestContext, TypeOrmEmployeeRepository } from '@gauzy/core'; +import { RequestContext } from '@gauzy/core'; import { JobPresetUpworkJobSearchCriterion } from '../../job-preset-upwork-job-search-criterion.entity'; import { JobPreset } from '../../job-preset.entity'; import { CreateJobPresetCommand } from '../create-job-preset.command'; import { TypeOrmJobPresetRepository } from '../../repository/type-orm-job-preset.repository'; import { TypeOrmJobPresetUpworkJobSearchCriterionRepository } from '../../repository/type-orm-job-preset-upwork-job-search-criterion.repository'; +import { PermissionsEnum } from '@gauzy/contracts'; @CommandHandler(CreateJobPresetCommand) export class CreateJobPresetHandler implements ICommandHandler { constructor( private readonly typeOrmJobPresetRepository: TypeOrmJobPresetRepository, - private readonly typeOrmEmployeeRepository: TypeOrmEmployeeRepository, private readonly typeOrmJobPresetUpworkJobSearchCriterionRepository: TypeOrmJobPresetUpworkJobSearchCriterionRepository ) {} @@ -24,15 +24,11 @@ export class CreateJobPresetHandler implements ICommandHandler { const { input } = command; - input.tenantId = RequestContext.currentTenantId(); + input.tenantId = RequestContext.currentTenantId() ?? input.tenantId; - // If organizationId is not provided in the input, retrieve it from the current user's employee data - if (!input.organizationId) { - const employeeId = RequestContext.currentEmployeeId(); - if (employeeId) { - const employee = await this.typeOrmEmployeeRepository.findOneBy({ id: employeeId }); - input.organizationId = employee.organizationId; - } + // If the current user has the permission to change the selected employee, use their ID + if (!RequestContext.hasPermission(PermissionsEnum.CHANGE_SELECTED_EMPLOYEE)) { + input.employeeId = RequestContext.currentEmployeeId(); + } + + // Set organizationId if not provided in the input + if (!input.organizationId && input.employeeId) { + const employee = await this.typeOrmEmployeeRepository.findOneBy({ id: input.employeeId }); + input.organizationId = employee.organizationId; } // Create a new JobPresetUpworkJobSearchCriterion instance with the input data diff --git a/packages/plugins/job-search/src/lib/employee-job-preset/dto/create-job-preset.dto.ts b/packages/plugins/job-search/src/lib/employee-job-preset/dto/create-job-preset.dto.ts new file mode 100644 index 00000000000..6f2957c67e8 --- /dev/null +++ b/packages/plugins/job-search/src/lib/employee-job-preset/dto/create-job-preset.dto.ts @@ -0,0 +1,8 @@ +import { IntersectionType } from '@nestjs/swagger'; +import { TenantOrganizationBaseDTO } from '@gauzy/core'; +import { IJobPreset } from '@gauzy/contracts'; + +/** + * Data Transfer Object for creating job presets. + */ +export class CreateJobPresetDTO extends IntersectionType(TenantOrganizationBaseDTO) implements IJobPreset {} diff --git a/packages/plugins/job-search/src/lib/employee-job-preset/dto/index.ts b/packages/plugins/job-search/src/lib/employee-job-preset/dto/index.ts index 39d76d7fec1..84d525c6639 100644 --- a/packages/plugins/job-search/src/lib/employee-job-preset/dto/index.ts +++ b/packages/plugins/job-search/src/lib/employee-job-preset/dto/index.ts @@ -1 +1,3 @@ +export * from './create-job-preset.dto'; export * from './job-preset-query.dto'; +export * from './save-job-preset-criterion.dto'; diff --git a/packages/plugins/job-search/src/lib/employee-job-preset/dto/save-job-preset-criterion.dto.ts b/packages/plugins/job-search/src/lib/employee-job-preset/dto/save-job-preset-criterion.dto.ts new file mode 100644 index 00000000000..8925ffa10b1 --- /dev/null +++ b/packages/plugins/job-search/src/lib/employee-job-preset/dto/save-job-preset-criterion.dto.ts @@ -0,0 +1,7 @@ +import { IntersectionType } from '@nestjs/swagger'; +import { TenantOrganizationBaseDTO } from '@gauzy/core'; +import { IMatchingCriterions } from '@gauzy/contracts'; + +export class SaveJobPresetCriterionDTO + extends IntersectionType(TenantOrganizationBaseDTO) + implements IMatchingCriterions {} diff --git a/packages/plugins/job-search/src/lib/employee-job-preset/employee-preset.controller.ts b/packages/plugins/job-search/src/lib/employee-job-preset/employee-preset.controller.ts index e6b0eb71477..e12d714daa6 100644 --- a/packages/plugins/job-search/src/lib/employee-job-preset/employee-preset.controller.ts +++ b/packages/plugins/job-search/src/lib/employee-job-preset/employee-preset.controller.ts @@ -1,40 +1,29 @@ -import { - Controller, - HttpStatus, - Get, - Query, - Post, - Body, - Param, - Delete -} from '@nestjs/common'; +import { Controller, HttpStatus, Get, Query, Post, Body, Param, Delete } from '@nestjs/common'; import { ApiTags, ApiOperation, ApiResponse } from '@nestjs/swagger'; import { DeleteResult } from 'typeorm'; import { + ID, IEmployeePresetInput, IEmployeeUpworkJobsSearchCriterion, IGetMatchingCriterions, - IJobPreset, - IMatchingCriterions + IJobPreset } from '@gauzy/contracts'; import { UUIDValidationPipe } from '@gauzy/core'; import { JobPresetService } from './job-preset.service'; import { JobPreset } from './job-preset.entity'; +import { SaveJobPresetCriterionDTO } from './dto'; @ApiTags('EmployeeJobPreset') @Controller('employee') export class EmployeePresetController { - - constructor( - private readonly jobPresetService: JobPresetService - ) { } + constructor(private readonly jobPresetService: JobPresetService) {} /** - * Retrieves the job preset for a specific employee. - * - * @param employeeId The ID of the employee. - * @returns The job preset for the specified employee. - */ + * Retrieves the job preset for a specific employee. + * + * @param employeeId The ID of the employee. + * @returns The job preset for the specified employee. + */ @ApiOperation({ summary: 'Retrieves the job preset for a specific employee.' }) @ApiResponse({ status: HttpStatus.OK, @@ -46,19 +35,17 @@ export class EmployeePresetController { description: 'Record not found' }) @Get(':employeeId') - async getEmployeePreset( - @Param('employeeId', UUIDValidationPipe) employeeId: string - ): Promise { + async getEmployeePreset(@Param('employeeId', UUIDValidationPipe) employeeId: ID): Promise { return await this.jobPresetService.getEmployeePreset(employeeId); } /** - * Retrieves all matching criteria for job presets of a specific employee. - * - * @param employeeId The ID of the employee. - * @param request The request containing criteria for matching. - * @returns The matching criteria for job presets of the specified employee. - */ + * Retrieves all matching criteria for job presets of a specific employee. + * + * @param employeeId The ID of the employee. + * @param request The request containing criteria for matching. + * @returns The matching criteria for job presets of the specified employee. + */ @ApiOperation({ summary: 'Find all employee job posts' }) @ApiResponse({ status: HttpStatus.OK, @@ -71,7 +58,7 @@ export class EmployeePresetController { }) @Get(':employeeId/criterion') async getEmployeeCriterion( - @Param('employeeId', UUIDValidationPipe) employeeId: string, + @Param('employeeId', UUIDValidationPipe) employeeId: ID, @Query() request: IGetMatchingCriterions ): Promise { return await this.jobPresetService.getEmployeeCriterion({ @@ -81,12 +68,12 @@ export class EmployeePresetController { } /** - * Saves or updates matching criteria for job presets of a specific employee. - * - * @param employeeId The ID of the employee. - * @param request The request containing criteria for matching. - * @returns The saved or updated job presets for the specified employee. - */ + * Saves or updates matching criteria for job presets of a specific employee. + * + * @param employeeId The ID of the employee. + * @param request The request containing criteria for matching. + * @returns The saved or updated job presets for the specified employee. + */ @ApiOperation({ summary: 'Save or update employee job presets' }) @ApiResponse({ status: HttpStatus.OK, @@ -99,8 +86,8 @@ export class EmployeePresetController { }) @Post(':employeeId/criterion') async saveUpdateEmployeeCriterion( - @Param('employeeId', UUIDValidationPipe) employeeId: string, - @Body() request: IMatchingCriterions + @Param('employeeId', UUIDValidationPipe) employeeId: ID, + @Body() request: SaveJobPresetCriterionDTO ): Promise { return await this.jobPresetService.saveEmployeeCriterion({ ...request, @@ -109,11 +96,11 @@ export class EmployeePresetController { } /** - * Saves an employee preset. - * - * @param request The request containing the employee preset data. - * @returns The saved employee job preset. - */ + * Saves an employee preset. + * + * @param request The request containing the employee preset data. + * @returns The saved employee job preset. + */ @ApiOperation({ summary: 'Save Employee preset' }) @ApiResponse({ status: HttpStatus.OK, @@ -125,9 +112,7 @@ export class EmployeePresetController { description: 'Record not found' }) @Post() - async saveEmployeePreset( - @Body() request: IEmployeePresetInput - ): Promise { + async saveEmployeePreset(@Body() request: IEmployeePresetInput): Promise { return await this.jobPresetService.saveEmployeePreset(request); } @@ -150,12 +135,9 @@ export class EmployeePresetController { }) @Delete(':employeeId/criterion/:criterionId') async deleteEmployeeCriterion( - @Param('criterionId', UUIDValidationPipe) criterionId: string, - @Param('employeeId', UUIDValidationPipe) employeeId: string + @Param('criterionId', UUIDValidationPipe) criterionId: ID, + @Param('employeeId', UUIDValidationPipe) employeeId: ID ): Promise { - return await this.jobPresetService.deleteEmployeeCriterion( - criterionId, - employeeId - ); + return await this.jobPresetService.deleteEmployeeCriterion(criterionId, employeeId); } } diff --git a/packages/plugins/job-search/src/lib/employee-job-preset/job-preset.service.ts b/packages/plugins/job-search/src/lib/employee-job-preset/job-preset.service.ts index 0ca0cd3e9e7..5b4abeed207 100644 --- a/packages/plugins/job-search/src/lib/employee-job-preset/job-preset.service.ts +++ b/packages/plugins/job-search/src/lib/employee-job-preset/job-preset.service.ts @@ -3,6 +3,7 @@ import { CommandBus } from '@nestjs/cqrs'; import { DeleteResult, SelectQueryBuilder } from 'typeorm'; import { isNotEmpty } from 'class-validator'; import { + ID, IEmployeePresetInput, IGetJobPresetCriterionInput, IGetJobPresetInput, @@ -46,18 +47,16 @@ export class JobPresetService extends TenantAwareCrudService { * @returns A Promise that resolves to an array of job presets. */ public async getAll(request?: IGetJobPresetInput) { + // Tenant ID is required for the query + const tenantId = RequestContext.currentTenantId() || request?.tenantId; // Extract parameters from the request object - const { organizationId, search } = request || {}; - let employeeId = request?.employeeId; + let { organizationId, search, employeeId } = request || {}; // If the user does not have the permission to change selected employee, use the current employee ID if (!RequestContext.hasPermission(PermissionsEnum.CHANGE_SELECTED_EMPLOYEE)) { employeeId = RequestContext.currentEmployeeId(); } - // Tenant ID is required for the query - const tenantId = RequestContext.currentTenantId() || request?.tenantId; - // Determine the appropriate LIKE operator based on the database type const likeOperator = isPostgres() ? 'ILIKE' : 'LIKE'; @@ -67,9 +66,8 @@ export class JobPresetService extends TenantAwareCrudService { // Set the find options for the query query.setFindOptions({ join: { - alias: 'job_preset', - // Left join employees relation - leftJoin: { employees: 'job_preset.employees' } + alias: 'job_preset', // Alias for the job preset table + leftJoin: { employees: 'job_preset.employees' } // Left join employees relation }, // Include job preset criterions in the query result relations: { jobPresetCriterions: true }, @@ -109,7 +107,7 @@ export class JobPresetService extends TenantAwareCrudService { * @param request Additional parameters for the query, such as employeeId for fetching employee criteria. * @returns A Promise that resolves to the retrieved job preset. */ - public async get(id: string, request?: IGetJobPresetCriterionInput) { + public async get(id: ID, request?: IGetJobPresetCriterionInput) { const query = this.typeOrmRepository.createQueryBuilder(); // Left join job preset criterions @@ -189,7 +187,7 @@ export class JobPresetService extends TenantAwareCrudService { * @param employeeId The ID of the employee. * @returns A Promise that resolves to the job presets associated with the employee. */ - async getEmployeePreset(employeeId: string): Promise { + async getEmployeePreset(employeeId: ID): Promise { // Find the employee with the specified ID and include jobPresets relation const employee = await this.typeOrmEmployeeRepository.findOne({ where: { id: employeeId }, diff --git a/packages/plugins/job-search/src/lib/employee-job-preset/job-search-preset.controller.ts b/packages/plugins/job-search/src/lib/employee-job-preset/job-search-preset.controller.ts index ca2654cf6d0..05ad2991b95 100644 --- a/packages/plugins/job-search/src/lib/employee-job-preset/job-search-preset.controller.ts +++ b/packages/plugins/job-search/src/lib/employee-job-preset/job-search-preset.controller.ts @@ -1,12 +1,12 @@ import { Controller, HttpStatus, Get, Query, Post, Body, Param, Delete } from '@nestjs/common'; import { ApiTags, ApiOperation, ApiResponse } from '@nestjs/swagger'; -import { ID, IGetJobPresetCriterionInput, IJobPreset, IMatchingCriterions } from '@gauzy/contracts'; +import { ID, IGetJobPresetCriterionInput } from '@gauzy/contracts'; import { GauzyAIService } from '@gauzy/plugin-integration-ai'; import { EmployeeService, UUIDValidationPipe, UseValidationPipe } from '@gauzy/core'; import { JobPresetService } from './job-preset.service'; import { JobPreset } from './job-preset.entity'; import { JobPresetUpworkJobSearchCriterion } from './job-preset-upwork-job-search-criterion.entity'; -import { JobPresetQueryDTO } from './dto'; +import { CreateJobPresetDTO, JobPresetQueryDTO, SaveJobPresetCriterionDTO } from './dto'; @ApiTags('JobSearchPreset') @Controller() @@ -106,7 +106,8 @@ export class JobSearchPresetController { description: 'Invalid job preset data' }) @Post() - async createJobPreset(@Body() request: IJobPreset) { + @UseValidationPipe() + async createJobPreset(@Body() request: CreateJobPresetDTO) { return await this.jobPresetService.createJobPreset(request); } @@ -128,7 +129,11 @@ export class JobSearchPresetController { description: 'Invalid job preset criteria data' }) @Post(':jobPresetId/criterion') - async saveUpdate(@Param('jobPresetId', UUIDValidationPipe) jobPresetId: ID, @Body() request: IMatchingCriterions) { + @UseValidationPipe() + async saveUpdate( + @Param('jobPresetId', UUIDValidationPipe) jobPresetId: ID, + @Body() request: SaveJobPresetCriterionDTO + ) { return await this.jobPresetService.saveJobPresetCriterion({ ...request, jobPresetId diff --git a/packages/plugins/job-search/src/lib/employee-job/commands/handlers/update-employee-job-search-status.handler.ts b/packages/plugins/job-search/src/lib/employee-job/commands/handlers/update-employee-job-search-status.handler.ts index 52a2c993d5f..cca21cd8bdc 100644 --- a/packages/plugins/job-search/src/lib/employee-job/commands/handlers/update-employee-job-search-status.handler.ts +++ b/packages/plugins/job-search/src/lib/employee-job/commands/handlers/update-employee-job-search-status.handler.ts @@ -21,7 +21,7 @@ export class UpdateEmployeeJobSearchStatusHandler implements ICommandHandler { - return firstValueFrom(this._http.post(this.API_URL, body)); + /** + * Creates a new organization project. + * + * @param input The input data for creating the project. + * @returns A Promise that resolves with the newly created project. + */ + create(input: IOrganizationProjectCreateInput): Promise { + return firstValueFrom(this._http.post(this.API_URL, input)); } - edit(body: Partial): Promise { - return firstValueFrom(this._http.put(`${this.API_URL}/${body.id}`, body)); + /** + * Edits an existing organization project. + * + * @param input The input data for updating the project. Partial data is accepted. + * @returns A Promise that resolves with the updated project. + */ + edit(input: Partial): Promise { + return firstValueFrom(this._http.put(`${this.API_URL}/${input.id}`, input)); } - getAllByEmployee(id: IEmployee['id'], where?: IOrganizationProjectsFindInput): Promise { + /** + * Retrieves all projects assigned to a specific employee. + * + * @param id The employee ID. + * @param where Optional filters to apply when retrieving projects. + * @returns A Promise that resolves with a list of organization projects assigned to the employee. + */ + getAllByEmployee(id: ID, where?: IOrganizationProjectsFindInput): Promise { return firstValueFrom( this._http.get(`${this.API_URL}/employee/${id}`, { params: toParams({ ...where }) @@ -39,6 +57,13 @@ export class OrganizationProjectsService { ); } + /** + * Retrieves all organization projects, with optional relations and filters. + * + * @param relations Optional array of related entities to include. + * @param where Optional filters to apply when retrieving projects. + * @returns A Promise that resolves with paginated organization projects. + */ getAll( relations: string[] = [], where?: IOrganizationProjectsFindInput @@ -50,12 +75,25 @@ export class OrganizationProjectsService { ); } + /** + * Retrieves a specific organization project by its ID. + * + * @param id The ID of the project. + * @param relations Optional array of related entities to include. + * @returns An Observable that resolves with the requested project. + */ getById(id: ID, relations: string[] = []): Observable { return this._http.get(`${this.API_URL}/${id}`, { params: toParams({ relations }) }); } + /** + * Retrieves the total count of organization projects that match the given criteria. + * + * @param request The input criteria for finding the projects. + * @returns A Promise that resolves with the count of matching projects. + */ getCount(request: IOrganizationProjectsFindInput): Promise { return firstValueFrom( this._http.get(`${this.API_URL}/count`, { @@ -64,16 +102,35 @@ export class OrganizationProjectsService { ); } - updateByEmployee(updateInput: IEditEntityByMemberInput): Promise { + /** + * Updates project assignments for an employee. + * + * @param updateInput The input data containing employee and project information. + * @returns A Promise that resolves once the update operation is complete. + */ + updateByEmployee(updateInput: IOrganizationProjectEditByEmployeeInput): Promise { return firstValueFrom(this._http.put(`${this.API_URL}/employee`, updateInput)); } - updateTaskViewMode(id: ID, body: IOrganizationProjectUpdateInput): Promise { + /** + * Updates the task view mode for a specific project. + * + * @param id The ID of the project. + * @param input The input data for updating the task view mode. + * @returns A Promise that resolves with the updated project. + */ + updateTaskViewMode(id: ID, input: IOrganizationProjectUpdateInput): Promise { return firstValueFrom( - this._http.put(`${this.API_URL}/task-view/${id}`, body).pipe(take(1)) + this._http.put(`${this.API_URL}/task-view/${id}`, input).pipe(take(1)) ); } + /** + * Deletes an organization project by its ID. + * + * @param id The ID of the project to delete. + * @returns A Promise that resolves once the project is deleted. + */ delete(id: ID): Promise { return firstValueFrom(this._http.delete(`${this.API_URL}/${id}`)); } diff --git a/packages/ui-core/core/src/lib/services/timesheet/timesheet.service.ts b/packages/ui-core/core/src/lib/services/timesheet/timesheet.service.ts index e7e2c103f90..2a17009e06a 100644 --- a/packages/ui-core/core/src/lib/services/timesheet/timesheet.service.ts +++ b/packages/ui-core/core/src/lib/services/timesheet/timesheet.service.ts @@ -21,8 +21,9 @@ import { ReportDayData, IUpdateTimesheetStatusInput, ISubmitTimesheetInput, - IBasePerTenantAndOrganizationEntityModel, - ID + ID, + IDeleteTimeSlot, + IDeleteScreenshot } from '@gauzy/contracts'; import { API_PREFIX, toParams } from '@gauzy/ui-core/common'; @@ -243,7 +244,13 @@ export class TimesheetService { return firstValueFrom(this.http.get(`${API_PREFIX}/timesheet/time-slot`, { params })); } - deleteTimeSlots(request) { + /** + * Deletes multiple time slots based on the provided request. + * + * @param request - The request object containing parameters for deletion. + * @returns A Promise that resolves when the time slots are deleted. + */ + deleteTimeSlots(request: IDeleteTimeSlot): Promise { return firstValueFrom( this.http.delete(`${API_PREFIX}/timesheet/time-slot`, { params: toParams(request) @@ -259,7 +266,14 @@ export class TimesheetService { ); } - deleteScreenshot(id: ID, params: IBasePerTenantAndOrganizationEntityModel) { + /** + * Deletes a screenshot by its ID. + * + * @param id - The ID of the screenshot to delete. + * @param params - The parameters that include tenant and organization context. + * @returns A Promise that resolves to an object containing the result of the deletion. + */ + deleteScreenshot(id: ID, params: IDeleteScreenshot): Promise { return firstValueFrom( this.http.delete(`${API_PREFIX}/timesheet/screenshot/${id}`, { params: toParams(params) diff --git a/packages/ui-core/i18n/assets/i18n/ar.json b/packages/ui-core/i18n/assets/i18n/ar.json index 4469f790c81..f0918b8312f 100644 --- a/packages/ui-core/i18n/assets/i18n/ar.json +++ b/packages/ui-core/i18n/assets/i18n/ar.json @@ -3084,11 +3084,11 @@ "TAGS": "العلامات" }, "HEADER": { - "SELECT_EMPLOYEE": "اختر الموظف", + "SELECT_EMPLOYEE": "اختر موظف", "SELECT_A_DATE": "اختر تاريخًا", - "SELECT_AN_ORGANIZATION": "اختر منظمة", - "SELECT_PROJECT": "اختر المشروع", - "SELECT_TEAM": "اختر الفريق" + "SELECT_AN_ORGANIZATION": "اختر مؤسسة", + "SELECT_PROJECT": "اختر مشروعًا", + "SELECT_TEAM": "اختر فريقًا" }, "HEADER_TITLE": { "FOR": "لـ", diff --git a/packages/ui-core/i18n/assets/i18n/bg.json b/packages/ui-core/i18n/assets/i18n/bg.json index 4eb5840aa6d..a747f1fc234 100644 --- a/packages/ui-core/i18n/assets/i18n/bg.json +++ b/packages/ui-core/i18n/assets/i18n/bg.json @@ -3117,11 +3117,11 @@ "TAGS": "Тагове" }, "HEADER": { - "SELECT_EMPLOYEE": "Select Employee", - "SELECT_A_DATE": "Select A date", - "SELECT_AN_ORGANIZATION": "Select An Organization", - "SELECT_PROJECT": "Select Project", - "SELECT_TEAM": "Select Team" + "SELECT_EMPLOYEE": "Изберете служител", + "SELECT_A_DATE": "Изберете дата", + "SELECT_AN_ORGANIZATION": "Изберете организация", + "SELECT_PROJECT": "Изберете проект", + "SELECT_TEAM": "Изберете отбор" }, "HEADER_TITLE": { "FOR": "за", diff --git a/packages/ui-core/i18n/assets/i18n/de.json b/packages/ui-core/i18n/assets/i18n/de.json index 1bffdb58905..719d1c0d738 100644 --- a/packages/ui-core/i18n/assets/i18n/de.json +++ b/packages/ui-core/i18n/assets/i18n/de.json @@ -3082,11 +3082,11 @@ "TAGS": "Schlüsselwörter" }, "HEADER": { - "SELECT_EMPLOYEE": "Auswählen der Mitarbeitenden", - "SELECT_A_DATE": "Wählen Sie ein Datum aus.", - "SELECT_AN_ORGANIZATION": "Wählen Sie eine Organisation aus", - "SELECT_PROJECT": "Wählen Sie Projekt.", - "SELECT_TEAM": "Wählen Sie das Team." + "SELECT_EMPLOYEE": "Mitarbeiter auswählen", + "SELECT_A_DATE": "Ein Datum auswählen", + "SELECT_AN_ORGANIZATION": "Organisation auswählen", + "SELECT_PROJECT": "Projekt auswählen", + "SELECT_TEAM": "Team auswählen" }, "HEADER_TITLE": { "FOR": "für", diff --git a/packages/ui-core/i18n/assets/i18n/en.json b/packages/ui-core/i18n/assets/i18n/en.json index 9d8accb209d..81b36941756 100644 --- a/packages/ui-core/i18n/assets/i18n/en.json +++ b/packages/ui-core/i18n/assets/i18n/en.json @@ -406,8 +406,8 @@ "SELECT_SHARE_REQUEST_DATE": "Select request date", "SELECT_SHARE_START_DATE": "Select start date", "SELECT_SHARE_END_DATE": "Select end date", - "SELECT_EMPLOYEE": "Select employee", - "SELECT_TEAM": "Select team", + "SELECT_EMPLOYEE": "Select Employee", + "SELECT_TEAM": "Select Team", "ENABLE_DISABLE_FUTURE_DATE": "Enable/Disable future dates", "ALLOW_FUTURE_DATE": "Allow switching to future periods", "REGISTRATION_DATE": "Registration Date", @@ -3210,7 +3210,7 @@ }, "HEADER": { "SELECT_EMPLOYEE": "Select Employee", - "SELECT_A_DATE": "Select A date", + "SELECT_A_DATE": "Select A Date", "SELECT_AN_ORGANIZATION": "Select An Organization", "SELECT_PROJECT": "Select Project", "SELECT_TEAM": "Select Team" diff --git a/packages/ui-core/i18n/assets/i18n/es.json b/packages/ui-core/i18n/assets/i18n/es.json index a20ec0aa5aa..420bfd8e416 100644 --- a/packages/ui-core/i18n/assets/i18n/es.json +++ b/packages/ui-core/i18n/assets/i18n/es.json @@ -3089,8 +3089,8 @@ }, "HEADER": { "SELECT_EMPLOYEE": "Seleccionar empleado", - "SELECT_A_DATE": "Seleccione una fecha", - "SELECT_AN_ORGANIZATION": "Seleccione una organización", + "SELECT_A_DATE": "Seleccionar una fecha", + "SELECT_AN_ORGANIZATION": "Seleccionar una organización", "SELECT_PROJECT": "Seleccionar proyecto", "SELECT_TEAM": "Seleccionar equipo" }, diff --git a/packages/ui-core/i18n/assets/i18n/fr.json b/packages/ui-core/i18n/assets/i18n/fr.json index 0a3c2543d47..27229f171f2 100644 --- a/packages/ui-core/i18n/assets/i18n/fr.json +++ b/packages/ui-core/i18n/assets/i18n/fr.json @@ -3088,10 +3088,10 @@ }, "HEADER": { "SELECT_EMPLOYEE": "Sélectionner un employé", - "SELECT_A_DATE": "Sélectionnez une date", - "SELECT_AN_ORGANIZATION": "Sélectionnez une organisation", - "SELECT_PROJECT": "Sélectionner le projet", - "SELECT_TEAM": "Sélectionner l'équipe" + "SELECT_A_DATE": "Sélectionner une date", + "SELECT_AN_ORGANIZATION": "Sélectionner une organisation", + "SELECT_PROJECT": "Sélectionner un projet", + "SELECT_TEAM": "Sélectionner une équipe" }, "HEADER_TITLE": { "FOR": "pour", diff --git a/packages/ui-core/i18n/assets/i18n/he.json b/packages/ui-core/i18n/assets/i18n/he.json index dcaf8de0033..9c9833d4fa5 100644 --- a/packages/ui-core/i18n/assets/i18n/he.json +++ b/packages/ui-core/i18n/assets/i18n/he.json @@ -385,7 +385,7 @@ "SELECT_SHARE_START_DATE": "Select start date", "SELECT_SHARE_END_DATE": "Select end date", "SELECT_EMPLOYEE": "Select employee", - "SELECT_TEAM": "Select team", + "SELECT_TEAM": "בחר קבוצה", "ENABLE_DISABLE_FUTURE_DATE": "Enable/Disable future dates", "ALLOW_FUTURE_DATE": "Allow switching to future periods", "REGISTRATION_DATE": "Registration Date", @@ -3113,7 +3113,7 @@ "SELECT_A_DATE": "Select A date", "SELECT_AN_ORGANIZATION": "Select An Organization", "SELECT_PROJECT": "Select Project", - "SELECT_TEAM": "תבחר קבוצה" + "SELECT_TEAM": "בחר קבוצה" }, "HEADER_TITLE": { "FOR": "for", diff --git a/packages/ui-core/i18n/assets/i18n/it.json b/packages/ui-core/i18n/assets/i18n/it.json index 7f000f9e339..94da081bd7e 100644 --- a/packages/ui-core/i18n/assets/i18n/it.json +++ b/packages/ui-core/i18n/assets/i18n/it.json @@ -3090,7 +3090,7 @@ "SELECT_A_DATE": "Seleziona una data", "SELECT_AN_ORGANIZATION": "Seleziona un'organizzazione", "SELECT_PROJECT": "Seleziona progetto", - "SELECT_TEAM": "Seleziona Squadra" + "SELECT_TEAM": "Seleziona squadra" }, "HEADER_TITLE": { "FOR": "per", diff --git a/packages/ui-core/i18n/assets/i18n/nl.json b/packages/ui-core/i18n/assets/i18n/nl.json index 041daeff71f..15b6b145375 100644 --- a/packages/ui-core/i18n/assets/i18n/nl.json +++ b/packages/ui-core/i18n/assets/i18n/nl.json @@ -386,7 +386,7 @@ "SELECT_SHARE_START_DATE": "Selecteer startdatum", "SELECT_SHARE_END_DATE": "Selecteer einddatum", "SELECT_EMPLOYEE": "Selecteer werknemer", - "SELECT_TEAM": "Selecteer team", + "SELECT_TEAM": "Selecteer Team", "ENABLE_DISABLE_FUTURE_DATE": "Toekomstige datums in- of uitschakelen", "ALLOW_FUTURE_DATE": "Sta het wisselen naar toekomstige periodes toe", "REGISTRATION_DATE": "Registratiedatum", @@ -3089,8 +3089,8 @@ "SELECT_EMPLOYEE": "Selecteer medewerker", "SELECT_A_DATE": "Kies een datum", "SELECT_AN_ORGANIZATION": "Selecteer een organisatie", - "SELECT_PROJECT": "Selecteer project", - "SELECT_TEAM": "Selecteer team" + "SELECT_PROJECT": "Selecteer Project", + "SELECT_TEAM": "Selecteer Team" }, "HEADER_TITLE": { "FOR": "voor", diff --git a/packages/ui-core/i18n/assets/i18n/pt.json b/packages/ui-core/i18n/assets/i18n/pt.json index 1cb403e60c2..ea1d24727e7 100644 --- a/packages/ui-core/i18n/assets/i18n/pt.json +++ b/packages/ui-core/i18n/assets/i18n/pt.json @@ -3087,10 +3087,10 @@ }, "HEADER": { "SELECT_EMPLOYEE": "Selecionar Funcionário", - "SELECT_A_DATE": "Selecione uma data.", - "SELECT_AN_ORGANIZATION": "Selecionar uma organização", + "SELECT_A_DATE": "Selecionar uma Data", + "SELECT_AN_ORGANIZATION": "Selecionar uma Organização", "SELECT_PROJECT": "Selecionar Projeto", - "SELECT_TEAM": "Selecionar equipe" + "SELECT_TEAM": "Selecionar Equipe" }, "HEADER_TITLE": { "FOR": "para", diff --git a/packages/ui-core/i18n/assets/i18n/zh.json b/packages/ui-core/i18n/assets/i18n/zh.json index 16f17182464..e9272611368 100644 --- a/packages/ui-core/i18n/assets/i18n/zh.json +++ b/packages/ui-core/i18n/assets/i18n/zh.json @@ -3088,7 +3088,7 @@ "HEADER": { "SELECT_EMPLOYEE": "选择员工", "SELECT_A_DATE": "选择日期", - "SELECT_AN_ORGANIZATION": "选择一个组织", + "SELECT_AN_ORGANIZATION": "选择组织", "SELECT_PROJECT": "选择项目", "SELECT_TEAM": "选择团队" }, diff --git a/packages/ui-core/shared/src/lib/project/project-mutation/project-mutation.component.html b/packages/ui-core/shared/src/lib/project/project-mutation/project-mutation.component.html index 6ea858ef16c..e1b16ee7283 100644 --- a/packages/ui-core/shared/src/lib/project/project-mutation/project-mutation.component.html +++ b/packages/ui-core/shared/src/lib/project/project-mutation/project-mutation.component.html @@ -29,25 +29,9 @@ /> - - - + + +
-
- Add or Drop - Image +
+ Add or Drop Image
- +

{{ 'FORM.ERROR.PROJECT_NAME' | translate }}

- +
-
+
+
+
+ +
- + - {{ - 'SM_TABLE.' + owner | translate - }} + {{ 'SM_TABLE.' + owner | translate }}
-
-
@@ -663,22 +497,10 @@
- - diff --git a/packages/ui-core/shared/src/lib/project/project-mutation/project-mutation.component.ts b/packages/ui-core/shared/src/lib/project/project-mutation/project-mutation.component.ts index 5db4d43f295..b4dc93d18b1 100644 --- a/packages/ui-core/shared/src/lib/project/project-mutation/project-mutation.component.ts +++ b/packages/ui-core/shared/src/lib/project/project-mutation/project-mutation.component.ts @@ -58,7 +58,9 @@ export class ProjectMutationComponent extends TranslationBaseComponent implement public OrganizationProjectBudgetTypeEnum = OrganizationProjectBudgetTypeEnum; public TaskListTypeEnum = TaskListTypeEnum; public memberIds: ID[] = []; + public managerIds: ID[] = []; public selectedEmployeeIds: ID[] = []; + public selectedManagerIds: ID[] = []; public selectedTeamIds: ID[] = []; public billings: string[] = Object.values(ProjectBillingEnum); public owners: ProjectOwnerEnum[] = Object.values(ProjectOwnerEnum); @@ -302,9 +304,19 @@ export class ProjectMutationComponent extends TranslationBaseComponent implement const project: IOrganizationProject = this.project; // Selected Members Ids - this.selectedEmployeeIds = project.members.map((member: IOrganizationProjectEmployee) => member.employeeId); + this.selectedEmployeeIds = project.members + .filter((member: IOrganizationProjectEmployee) => !member.isManager) + .map((member: IOrganizationProjectEmployee) => member.employeeId); + this.memberIds = this.selectedEmployeeIds; + // Selected Managers Ids + this.selectedManagerIds = project.members + .filter((member: IOrganizationProjectEmployee) => member.isManager) + .map((member: IOrganizationProjectEmployee) => member.employeeId); + + this.managerIds = this.selectedManagerIds; + this.form.patchValue({ imageUrl: project.imageUrl || null, imageId: project.imageId || null, @@ -367,6 +379,17 @@ export class ProjectMutationComponent extends TranslationBaseComponent implement this.form.get('openSource').updateValueAndValidity(); } + /** + * Handles the selection of managers and updates the `managerIds` property. + * + * @param {ID[]} managerIds - An array of selected manager IDs. + * The function is called when managers are selected, and it sets the `managerIds` property + * with the array of selected IDs. + */ + onManagersSelected(managerIds: ID[]): void { + this.managerIds = managerIds; + } + /** * Handles the selection of members and updates the `memberIds` property. * @@ -451,7 +474,8 @@ export class ProjectMutationComponent extends TranslationBaseComponent implement organizationContactId: organizationContact?.id || null, startDate, endDate, - memberIds: this.memberIds, + memberIds: this.memberIds.filter((memberId) => !this.managerIds.includes(memberId)), + managerIds: this.managerIds, teams: teams.map((id) => this.teams.find((team) => team.id === id)).filter(Boolean), // Description Step diff --git a/packages/ui-core/shared/src/lib/report/daily-grid/daily-grid.component.html b/packages/ui-core/shared/src/lib/report/daily-grid/daily-grid.component.html index 8549bd40370..27e96fc5aec 100644 --- a/packages/ui-core/shared/src/lib/report/daily-grid/daily-grid.component.html +++ b/packages/ui-core/shared/src/lib/report/daily-grid/daily-grid.component.html @@ -150,310 +150,314 @@
-
-
-
-
-
- {{ 'REPORT_PAGE.EMPLOYEE' | translate }} -
-
- - -
-
-
-
- {{ 'REPORT_PAGE.CLIENT' | translate }} -
-
- - -
-
-
-
- {{ 'REPORT_PAGE.PROJECT' | translate }} -
-
- - -
-
-
-
- {{ 'REPORT_PAGE.TO_DO' | translate }} -
-
- - + +
+
+
+
+
+ {{ 'REPORT_PAGE.EMPLOYEE' | translate }} +
+
+ + +
-
-
-
- {{ 'REPORT_PAGE.NOTES' | translate }} +
+
+ {{ 'REPORT_PAGE.CLIENT' | translate }} +
+
+ + +
-
- {{ employeeLog?.description }} +
+
+ {{ 'REPORT_PAGE.PROJECT' | translate }} +
+
+ + +
-
-
-
- {{ 'REPORT_PAGE.TIME' | translate }} +
+
+ {{ 'REPORT_PAGE.TO_DO' | translate }} +
+
+ + +
-
- {{ employeeLog.sum | durationFormat }} +
+
+ {{ 'REPORT_PAGE.NOTES' | translate }} +
+
+ {{ task?.description | truncate : 40 }} +
-
-
-
- {{ 'REPORT_PAGE.ACTIVITY' | translate }} +
+
+ {{ 'REPORT_PAGE.TIME' | translate }} +
+
+ {{ task.duration | durationFormat }} +
-
- +
+
+ {{ 'REPORT_PAGE.ACTIVITY' | translate }} +
+
+ +
-
+ -
-
-
-
-
- {{ 'REPORT_PAGE.DATE' | translate }} -
-
- -
-
-
-
- {{ 'REPORT_PAGE.CLIENT' | translate }} -
-
- -
-
-
-
- {{ 'REPORT_PAGE.PROJECT' | translate }} -
-
- -
-
-
-
- {{ 'REPORT_PAGE.TO_DO' | translate }} -
-
- + +
+
+
+
+
+ {{ 'REPORT_PAGE.DATE' | translate }} +
+
+ +
-
-
-
- {{ 'REPORT_PAGE.NOTES' | translate }} +
+
+ {{ 'REPORT_PAGE.CLIENT' | translate }} +
+
+ +
-
- {{ projectLog?.description }} +
+
+ {{ 'REPORT_PAGE.PROJECT' | translate }} +
+
+ +
-
-
-
- {{ 'REPORT_PAGE.TIME' | translate }} +
+
+ {{ 'REPORT_PAGE.TO_DO' | translate }} +
+
+ +
-
- {{ projectLog.sum | durationFormat }} +
+
+ {{ 'REPORT_PAGE.NOTES' | translate }} +
+
+ {{ task?.description | truncate : 40 }} +
-
-
-
- {{ 'REPORT_PAGE.ACTIVITY' | translate }} +
+
+ {{ 'REPORT_PAGE.TIME' | translate }} +
+
+ {{ task.duration | durationFormat }} +
-
- +
+
+ {{ 'REPORT_PAGE.ACTIVITY' | translate }} +
+
+ +
-
+ -
-
-
-
-
- {{ 'REPORT_PAGE.DATE' | translate }} -
-
- -
-
-
-
- {{ 'REPORT_PAGE.EMPLOYEE' | translate }} -
-
- - -
-
-
-
- {{ 'REPORT_PAGE.CLIENT' | translate }} -
-
- - -
-
-
-
- {{ 'REPORT_PAGE.TO_DO' | translate }} -
-
- + +
+
+
+
+
+ {{ 'REPORT_PAGE.DATE' | translate }} +
+
+ +
-
-
-
- {{ 'REPORT_PAGE.NOTES' | translate }} +
+
+ {{ 'REPORT_PAGE.EMPLOYEE' | translate }} +
+
+ + +
-
- {{ employeeLog?.description }} +
+
+ {{ 'REPORT_PAGE.CLIENT' | translate }} +
+
+ + +
-
-
-
- {{ 'REPORT_PAGE.TIME' | translate }} +
+
+ {{ 'REPORT_PAGE.TO_DO' | translate }} +
+
+ +
-
- {{ employeeLog.sum | durationFormat }} +
+
+ {{ 'REPORT_PAGE.NOTES' | translate }} +
+
+ {{ task?.description | truncate : 40 }} +
-
-
-
- {{ 'REPORT_PAGE.ACTIVITY' | translate }} +
+
+ {{ 'REPORT_PAGE.TIME' | translate }} +
+
+ {{ task?.duration | durationFormat }} +
-
- +
+
+ {{ 'REPORT_PAGE.ACTIVITY' | translate }} +
+
+ +
-
+
-
-
-
-
- {{ 'REPORT_PAGE.DATE' | translate }} -
-
- -
-
-
-
- {{ 'REPORT_PAGE.EMPLOYEE' | translate }} + +
+
+
+
+ {{ 'REPORT_PAGE.DATE' | translate }} +
+
+ +
-
- - -
-
-
-
- {{ 'REPORT_PAGE.PROJECT' | translate }} +
+
+ {{ 'REPORT_PAGE.EMPLOYEE' | translate }} +
+
+ + +
-
- - +
+
+ {{ 'REPORT_PAGE.PROJECT' | translate }} +
+
+ + +
-
-
-
- {{ 'REPORT_PAGE.TO_DO' | translate }} +
+
+ {{ 'REPORT_PAGE.TO_DO' | translate }} +
+
+ +
-
- +
+
+ {{ 'REPORT_PAGE.NOTES' | translate }} +
+
+ {{ task?.description | truncate : 40 }} +
-
-
-
- {{ 'REPORT_PAGE.NOTES' | translate }} +
+
+ {{ 'REPORT_PAGE.TIME' | translate }} +
+
+ {{ task.duration | durationFormat }} +
-
- {{ projectLog?.description }} -
-
-
-
- {{ 'REPORT_PAGE.TIME' | translate }} -
-
- {{ projectLog.sum | durationFormat }} -
-
-
-
- {{ 'REPORT_PAGE.ACTIVITY' | translate }} -
-
- +
+
+ {{ 'REPORT_PAGE.ACTIVITY' | translate }} +
+
+ +
-
+
@@ -464,8 +468,8 @@ - - {{ task?.title }} + + {{ task?.title | truncate : 40 }} {{ 'REPORT_PAGE.NO_TASK' | translate }} diff --git a/packages/ui-core/shared/src/lib/report/daily-grid/daily-grid.component.scss b/packages/ui-core/shared/src/lib/report/daily-grid/daily-grid.component.scss index 89fbdda722d..10118629811 100644 --- a/packages/ui-core/shared/src/lib/report/daily-grid/daily-grid.component.scss +++ b/packages/ui-core/shared/src/lib/report/daily-grid/daily-grid.component.scss @@ -1,74 +1,76 @@ @import 'report'; .group-by-wrapper { - display: flex; - align-items: center; - gap: 18px; + display: flex; + align-items: center; + gap: 18px; } :host { - display: block; + display: block; - .no-data { - min-height: 10rem; - height: calc(100vh - 57.5rem) !important - } + .no-data { + min-height: 10rem; + height: calc(100vh - 57.5rem) !important; + } - ::ng-deep { - .select-button { - background-color: nb-theme(gauzy-card-1) !important; - box-shadow: var(--gauzy-shadow); - } - .names-wrapper { - max-width: 110px; - } + ::ng-deep { + .select-button { + background-color: nb-theme(gauzy-card-1) !important; + box-shadow: var(--gauzy-shadow); + } + .names-wrapper { + max-width: 110px; } + } } .employee-column { - width: 20%; - min-width: 165px; - max-width: 200px; + width: 20%; + min-width: 165px; + max-width: 200px; } .project-column { - width: 20%; - min-width: 20%; + width: 20%; + min-width: 20%; } .todo-column { - width: 30%; - &.header{ - width: 28%; - &.client{ - width: 22%; - } + width: 30%; + &.header { + width: 28%; + &.client { + width: 22%; } + } } .time-column { - width: 15%; + width: 15%; } .activity-column { - width: 15%; + width: 15%; } - @include respond(lg) { - .employee-column { - min-width: auto; - max-width: unset; - } - .single-project-template .headers-wrapper .main-header { - white-space: break-spaces; - } + .employee-column { + min-width: auto; + max-width: unset; + } + .single-project-template .headers-wrapper .main-header { + white-space: break-spaces; + } - :host { - ::ng-deep { - .avatar-wrapper { - width: unset; - max-width: 100%; - } - .names-wrapper { - max-width: unset; - } - } + :host { + ::ng-deep { + .avatar-wrapper { + width: unset; + max-width: 100%; + } + .names-wrapper { + max-width: unset; + } } + } + .cart-body { + cursor: pointer; + } } diff --git a/packages/ui-core/shared/src/lib/report/daily-grid/daily-grid.module.ts b/packages/ui-core/shared/src/lib/report/daily-grid/daily-grid.module.ts index 34e73ad9318..55f3a7742c8 100644 --- a/packages/ui-core/shared/src/lib/report/daily-grid/daily-grid.module.ts +++ b/packages/ui-core/shared/src/lib/report/daily-grid/daily-grid.module.ts @@ -7,7 +7,8 @@ import { NbCardModule, NbIconModule, NbSelectModule, - NbSpinnerModule + NbSpinnerModule, + NbTooltipModule } from '@nebular/theme'; import { TranslateModule } from '@ngx-translate/core'; import { SharedModule } from '../../shared.module'; @@ -26,6 +27,7 @@ import { NoDataMessageModule } from '../../smart-data-layout/no-data-message/no- NbIconModule, NbSelectModule, NbSpinnerModule, + NbTooltipModule, TranslateModule.forChild(), SharedModule, ProjectColumnViewModule, diff --git a/packages/ui-core/shared/src/lib/timesheet/screenshots/screenshots-item/screenshots-item.component.ts b/packages/ui-core/shared/src/lib/timesheet/screenshots/screenshots-item/screenshots-item.component.ts index 06515de8bc0..685f1b77c83 100644 --- a/packages/ui-core/shared/src/lib/timesheet/screenshots/screenshots-item/screenshots-item.component.ts +++ b/packages/ui-core/shared/src/lib/timesheet/screenshots/screenshots-item/screenshots-item.component.ts @@ -1,9 +1,9 @@ import { Component, OnInit, Input, OnDestroy, Output, EventEmitter } from '@angular/core'; -import { ITimeSlot, IScreenshot, ITimeLog, IOrganization, IEmployee, TimeFormatEnum } from '@gauzy/contracts'; import { NbDialogService } from '@nebular/theme'; import { filter, take, tap } from 'rxjs/operators'; import { UntilDestroy, untilDestroyed } from '@ngneat/until-destroy'; import { sortBy } from 'underscore'; +import { ITimeSlot, IScreenshot, ITimeLog, IOrganization, IEmployee, TimeFormatEnum } from '@gauzy/contracts'; import { DEFAULT_SVG, distinctUntilChange, isNotEmpty, progressStatus } from '@gauzy/ui-core/common'; import { ErrorHandlingService, Store, TimesheetService, ToastrService } from '@gauzy/ui-core/core'; import { GalleryItem } from '../../../gallery/gallery.directive'; @@ -104,21 +104,21 @@ export class ScreenshotsItemComponent implements OnInit, OnDestroy { this._lastScreenshot = screenshot; } - @Input() timezone: string = this.timeZoneService.currentTimeZone; + @Input() timezone: string = this._timeZoneService.currentTimeZone; @Input() timeFormat: TimeFormatEnum = TimeFormatEnum.FORMAT_12_HOURS; constructor( - private readonly nbDialogService: NbDialogService, - private readonly timesheetService: TimesheetService, - private readonly galleryService: GalleryService, - private readonly toastrService: ToastrService, - private readonly errorHandler: ErrorHandlingService, - private readonly store: Store, - private readonly timeZoneService: TimeZoneService + private readonly _nbDialogService: NbDialogService, + private readonly _timesheetService: TimesheetService, + private readonly _galleryService: GalleryService, + private readonly _toastrService: ToastrService, + private readonly _errorHandlingService: ErrorHandlingService, + private readonly _store: Store, + private readonly _timeZoneService: TimeZoneService ) {} ngOnInit(): void { - this.store.selectedOrganization$ + this._store.selectedOrganization$ .pipe( filter((organization: IOrganization) => !!organization), distinctUntilChange(), @@ -152,15 +152,15 @@ export class ScreenshotsItemComponent implements OnInit, OnDestroy { } try { + // Destructure the organization ID and tenant ID from the organization object const { id: organizationId, tenantId } = this.organization; - const request = { + + // Delete time slots + await this._timesheetService.deleteTimeSlots({ ids: [timeSlot.id], organizationId, tenantId - }; - - // Delete time slots - await this.timesheetService.deleteTimeSlots(request); + }); // Remove related screenshots from the gallery const screenshotsToRemove = timeSlot.screenshots.map((screenshot) => ({ @@ -168,12 +168,13 @@ export class ScreenshotsItemComponent implements OnInit, OnDestroy { fullUrl: screenshot.fullUrl, ...screenshot })); - this.galleryService.removeGalleryItems(screenshotsToRemove); + this._galleryService.removeGalleryItems(screenshotsToRemove); // Display success message const employeeName = timeSlot.employee?.fullName?.trim() || 'Unknown Employee'; - this.toastrService.success('TOASTR.MESSAGE.SCREENSHOT_DELETED', { + // Display success message + this._toastrService.success('TOASTR.MESSAGE.SCREENSHOT_DELETED', { name: employeeName, organization: this.organization.name }); @@ -181,7 +182,8 @@ export class ScreenshotsItemComponent implements OnInit, OnDestroy { // Trigger delete event this.delete.emit(); } catch (error) { - this.errorHandler.handleError(error); + console.log('Error while deleting time slot', error); + this._errorHandlingService.handleError(error); } } @@ -191,7 +193,7 @@ export class ScreenshotsItemComponent implements OnInit, OnDestroy { * @param timeSlot - The time slot for which information is to be viewed. */ viewInfo(timeSlot: ITimeSlot): void { - const dialog$ = this.nbDialogService.open(ViewScreenshotsModalComponent, { + const dialog$ = this._nbDialogService.open(ViewScreenshotsModalComponent, { context: { timeSlot, timeLogs: timeSlot.timeLogs