diff --git a/.env.compose b/.env.compose index ac0974c0268..a57da7da22a 100644 --- a/.env.compose +++ b/.env.compose @@ -309,6 +309,12 @@ HUBSTAFF_CLIENT_ID= HUBSTAFF_CLIENT_SECRET= HUBSTAFF_PERSONAL_ACCESS_TOKEN= -# Jitsu Configuration -JITSU_BROWSER_HOST= +# Jitsu Browser Configuration +JITSU_BROWSER_URL= JITSU_BROWSER_WRITE_KEY= + +# Jitsu Server Configuration +JITSU_SERVER_URL= +JITSU_SERVER_WRITE_KEY= +JITSU_SERVER_DEBUG= +JITSU_SERVER_ECHO_EVENTS= diff --git a/.env.docker b/.env.docker index f1472334373..12cb10ba211 100644 --- a/.env.docker +++ b/.env.docker @@ -282,6 +282,12 @@ HUBSTAFF_CLIENT_ID= HUBSTAFF_CLIENT_SECRET= HUBSTAFF_PERSONAL_ACCESS_TOKEN= -# Jitsu Configuration -JITSU_BROWSER_HOST= +# Jitsu Browser Configuration +JITSU_BROWSER_URL= JITSU_BROWSER_WRITE_KEY= + +# Jitsu Server Configuration +JITSU_SERVER_URL= +JITSU_SERVER_WRITE_KEY= +JITSU_SERVER_DEBUG= +JITSU_SERVER_ECHO_EVENTS= diff --git a/.env.local b/.env.local index 85f205cb086..ec2482079a2 100644 --- a/.env.local +++ b/.env.local @@ -269,6 +269,12 @@ HUBSTAFF_CLIENT_ID= HUBSTAFF_CLIENT_SECRET= HUBSTAFF_PERSONAL_ACCESS_TOKEN= -# Jitsu Configuration -JITSU_BROWSER_HOST= +# Jitsu Browser Configuration +JITSU_BROWSER_URL= JITSU_BROWSER_WRITE_KEY= + +# Jitsu Server Configuration +JITSU_SERVER_URL= +JITSU_SERVER_WRITE_KEY= +JITSU_SERVER_DEBUG= +JITSU_SERVER_ECHO_EVENTS= diff --git a/.env.sample b/.env.sample index e56138c1118..449079d46f0 100644 --- a/.env.sample +++ b/.env.sample @@ -302,6 +302,12 @@ HUBSTAFF_CLIENT_ID= HUBSTAFF_CLIENT_SECRET= HUBSTAFF_PERSONAL_ACCESS_TOKEN= -# Jitsu Configuration -JITSU_BROWSER_HOST= +# Jitsu Browser Configuration +JITSU_BROWSER_URL= JITSU_BROWSER_WRITE_KEY= + +# Jitsu Server Configuration +JITSU_SERVER_URL= +JITSU_SERVER_WRITE_KEY= +JITSU_SERVER_DEBUG= +JITSU_SERVER_ECHO_EVENTS= diff --git a/.scripts/configure.ts b/.scripts/configure.ts index 44071d3bf16..04a4b15c910 100644 --- a/.scripts/configure.ts +++ b/.scripts/configure.ts @@ -23,27 +23,19 @@ import { CloudinaryConfiguration } from '@cloudinary/angular-5.x'; if (!env.IS_DOCKER) { if (!env.GOOGLE_MAPS_API_KEY) { - console.warn( - 'WARNING: No Google Maps API Key defined in the .env file. Google Maps may not be working!' - ); + console.warn('WARNING: No Google Maps API Key defined in the .env file. Google Maps may not be working!'); } if (!env.SENTRY_DSN) { - console.warn( - 'WARNING: No Sentry DSN defined in the .env file. Sentry logging may not be working!' - ); + console.warn('WARNING: No Sentry DSN defined in the .env file. Sentry logging may not be working!'); } if (!env.CLOUDINARY_CLOUD_NAME || !env.CLOUDINARY_API_KEY) { - console.warn( - 'WARNING: No Cloudinary API keys defined in the .env file.' - ); + console.warn('WARNING: No Cloudinary API keys defined in the .env file.'); } - if (!env.JITSU_BROWSER_HOST || !env.JITSU_BROWSER_WRITE_KEY) { - console.warn( - 'WARNING: No Jitsu keys defined in the .env file. Jitsu analytics may not be working!' - ); + if (!env.JITSU_BROWSER_URL || !env.JITSU_BROWSER_WRITE_KEY) { + console.warn('WARNING: No Jitsu keys defined for browser in the .env file. Jitsu analytics may not be working!'); } envFileContent += ` @@ -149,7 +141,7 @@ if (!env.IS_DOCKER) { FILE_PROVIDER: '${env.FILE_PROVIDER}', - JITSU_BROWSER_HOST: '${env.JITSU_BROWSER_HOST}', + JITSU_BROWSER_URL: '${env.JITSU_BROWSER_URL}', JITSU_BROWSER_WRITE_KEY: '${env.JITSU_BROWSER_WRITE_KEY}', GAUZY_GITHUB_APP_NAME: '${env.GAUZY_GITHUB_APP_NAME}', @@ -256,7 +248,7 @@ if (!env.IS_DOCKER) { FILE_PROVIDER: '${env.FILE_PROVIDER}', - JITSU_BROWSER_HOST: '${env.JITSU_BROWSER_HOST}', + JITSU_BROWSER_URL: '${env.JITSU_BROWSER_URL}', JITSU_BROWSER_WRITE_KEY: '${env.JITSU_BROWSER_WRITE_KEY}', GAUZY_GITHUB_APP_NAME: '${env.GAUZY_GITHUB_APP_NAME}', diff --git a/.scripts/env.ts b/.scripts/env.ts index fa8fa38e5d9..c0a59597637 100644 --- a/.scripts/env.ts +++ b/.scripts/env.ts @@ -66,8 +66,8 @@ export type Env = Readonly<{ FILE_PROVIDER: string; - // Jitsu Analytics - JITSU_BROWSER_HOST: string; + // Jitsu Browser Configurations + JITSU_BROWSER_URL: string; JITSU_BROWSER_WRITE_KEY: string; GAUZY_GITHUB_APP_NAME: string; @@ -141,7 +141,7 @@ export const env: Env = cleanEnv( FILE_PROVIDER: str({ default: 'LOCAL' }), - JITSU_BROWSER_HOST: str({ default: '' }), + JITSU_BROWSER_URL: str({ default: '' }), JITSU_BROWSER_WRITE_KEY: str({ default: '' }), GAUZY_GITHUB_APP_NAME: str({ default: '' }), diff --git a/apps/gauzy-e2e/package.json b/apps/gauzy-e2e/package.json index 55fb9017f2b..46528c2dd19 100644 --- a/apps/gauzy-e2e/package.json +++ b/apps/gauzy-e2e/package.json @@ -19,6 +19,7 @@ }, "dependencies": { "@faker-js/faker": "8.0.0-alpha.0", + "dayjs": "^1.11.4", "resolve": "^1.20.0" }, "scripts": { @@ -66,7 +67,6 @@ "cypress": "^9.4.1", "cypress-cucumber-preprocessor": "^4.3.1", "cypress-file-upload": "^5.0.8", - "dayjs": "^1.10.6", "jasmine-core": "^3.6.0", "jasmine-spec-reporter": "^6.0.0", "jest": "^26.4.2", diff --git a/apps/gauzy/package.json b/apps/gauzy/package.json index fb28c0db239..a4f1a96dbc6 100644 --- a/apps/gauzy/package.json +++ b/apps/gauzy/package.json @@ -61,6 +61,7 @@ "@fullcalendar/timegrid": "^5.11.2", "@gauzy/common-angular": "^0.1.0", "@gauzy/contracts": "^0.1.0", + "@jitsu/js": "^1.3.0", "@kurkle/color": "^0.2.0", "@nebular/auth": "^9.0.3", "@nebular/bootstrap": "^9.0.3", @@ -94,6 +95,7 @@ "d3-selection-multi": "^1.0.1", "date-fns": "^2.28.0", "date-holidays": "^1.6.1", + "dayjs": "^1.11.4", "detect-passive-events": "^1.0.4", "echarts": "^5.0.1", "eva-icons": "^1.1.3", @@ -164,8 +166,7 @@ "uuid": "^8.3.2", "web-animations-js": "^2.3.2", "yargs": "^17.5.0", - "zone.js": "~0.11.4", - "@jitsu/js": "^1.3.0" + "zone.js": "~0.11.4" }, "devDependencies": { "@angular-builders/custom-webpack": "^13.0.0", diff --git a/apps/gauzy/src/app/@core/services/analytics/event.type.ts b/apps/gauzy/src/app/@core/services/analytics/event.type.ts index 81ee109837e..d2c08967990 100644 --- a/apps/gauzy/src/app/@core/services/analytics/event.type.ts +++ b/apps/gauzy/src/app/@core/services/analytics/event.type.ts @@ -1,11 +1,11 @@ interface IUserCreatedEvent { - eventType: 'UserCreated'; + eventType: JitsuAnalyticsEventsEnum.USER_CREATED; userId: string; email: string; } interface IButtonClickedEvent { - eventType: 'ButtonClicked'; + eventType: JitsuAnalyticsEventsEnum.BUTTON_CLICKED; url: string; userId: string; userEmail: string; @@ -16,28 +16,28 @@ interface IMenuItemClickedEvent extends IButtonClickedEvent { } interface IPageViewEvent { - eventType: 'PageView'; + eventType: JitsuAnalyticsEventsEnum.PAGE_VIEW; url: string; periodicity: string; } interface IPageCreatedEvent { - eventType: 'PageCreated'; + eventType: JitsuAnalyticsEventsEnum.PAGE_CREATED; slug: string; } interface IUserUpgradedEvent { - eventType: 'UserUpgraded'; + eventType: JitsuAnalyticsEventsEnum.USER_UPGRADED; email: string; } interface IUserClickDownloadAppEvent { - eventType: 'UserClickDownloadApp'; + eventType: JitsuAnalyticsEventsEnum.USER_CLICK_DOWNLOAD_APP; email: string; } interface IUserSignedInEvent { - eventType: 'UserSignedIn'; + eventType: JitsuAnalyticsEventsEnum.USER_SIGNED_IN; email: string; } @@ -54,8 +54,11 @@ type JitsuAnalyticsEvents = export default JitsuAnalyticsEvents; export enum JitsuAnalyticsEventsEnum { - USER_CREATED = 'User Created', - BUTTON_CLICKED = 'Button_Clicked', - PAGE_VIEW = 'Page_View', - PAGE_CREATED = 'Page Created', + USER_CREATED = 'UserCreated', + USER_SIGNED_IN = 'UserSignedIn', + USER_CLICK_DOWNLOAD_APP = 'UserClickDownloadApp', + USER_UPGRADED = 'UserUpgraded', + BUTTON_CLICKED = 'ButtonClicked', + PAGE_VIEW = 'PageView', + PAGE_CREATED = 'PageCreated' } diff --git a/apps/gauzy/src/app/@core/services/analytics/jitsu.service.ts b/apps/gauzy/src/app/@core/services/analytics/jitsu.service.ts index 6afc59090ff..bff0606b9d3 100644 --- a/apps/gauzy/src/app/@core/services/analytics/jitsu.service.ts +++ b/apps/gauzy/src/app/@core/services/analytics/jitsu.service.ts @@ -1,8 +1,8 @@ import { Injectable } from '@angular/core'; import { NavigationEnd, Router } from '@angular/router'; -import { jitsuAnalytics, emptyAnalytics, AnalyticsInterface } from '@jitsu/js'; -import { filter } from 'rxjs/operators'; import { Location } from '@angular/common'; +import { filter } from 'rxjs/operators'; +import { jitsuAnalytics, emptyAnalytics, AnalyticsInterface } from '@jitsu/js'; import { environment } from '@env/environment'; import JitsuAnalyticsEvents, { JitsuAnalyticsEventsEnum } from './event.type'; @@ -11,15 +11,17 @@ import JitsuAnalyticsEvents, { JitsuAnalyticsEventsEnum } from './event.type'; }) export class JitsuService { private jitsuClient: AnalyticsInterface; - constructor(private location: Location, private router: Router) { - this.jitsuClient = - environment.JITSU_BROWSER_HOST && - environment.JITSU_BROWSER_WRITE_KEY - ? jitsuAnalytics({ - host: environment.JITSU_BROWSER_HOST, - writeKey: environment.JITSU_BROWSER_WRITE_KEY, - }) - : emptyAnalytics; + + constructor( + private readonly location: Location, + private readonly router: Router + ) { + this.jitsuClient = environment.JITSU_BROWSER_URL && environment.JITSU_BROWSER_WRITE_KEY ? jitsuAnalytics({ + host: environment.JITSU_BROWSER_URL, + writeKey: environment.JITSU_BROWSER_WRITE_KEY, + debug: false, + echoEvents: false + }) : emptyAnalytics; } async identify( diff --git a/apps/gauzy/src/app/@shared/sidebar-menu/menu-items/concrete/menu-item/menu-item.component.ts b/apps/gauzy/src/app/@shared/sidebar-menu/menu-items/concrete/menu-item/menu-item.component.ts index ab58fc686fc..128ccc49c8e 100644 --- a/apps/gauzy/src/app/@shared/sidebar-menu/menu-items/concrete/menu-item/menu-item.component.ts +++ b/apps/gauzy/src/app/@shared/sidebar-menu/menu-items/concrete/menu-item/menu-item.component.ts @@ -9,12 +9,12 @@ import { Output, } from '@angular/core'; import { Router } from '@angular/router'; -import { NbSidebarService } from '@nebular/theme'; -import JitsuAnalyticsEvents from 'apps/gauzy/src/app/@core/services/analytics/event.type'; -import { JitsuService } from 'apps/gauzy/src/app/@core/services/analytics/jitsu.service'; -import { Store } from 'apps/gauzy/src/app/@core/services/store.service'; -import { IUser } from 'packages/contracts/dist'; import { tap } from 'rxjs/operators'; +import { NbSidebarService } from '@nebular/theme'; +import { IUser } from '@gauzy/contracts'; +import JitsuAnalyticsEvents, { JitsuAnalyticsEventsEnum } from './../../../../../@core/services/analytics/event.type'; +import { JitsuService } from './../../../../../@core/services/analytics/jitsu.service'; +import { Store } from './../../../../../@core/services/store.service'; import { IMenuItem } from '../../interface/menu-item.interface'; @Component({ @@ -30,19 +30,17 @@ export class MenuItemComponent implements OnInit, AfterViewChecked { private _selected: boolean; private _user: IUser; - @Output() - public collapsedChange: EventEmitter = new EventEmitter(); - @Output() - public selectedChange: EventEmitter = new EventEmitter(); + @Output() public collapsedChange: EventEmitter = new EventEmitter(); + @Output() public selectedChange: EventEmitter = new EventEmitter(); constructor( - private router: Router, - private sidebarService: NbSidebarService, - private cdr: ChangeDetectorRef, - private location: Location, - private jitsuService: JitsuService, + private readonly router: Router, + private readonly sidebarService: NbSidebarService, + private readonly cdr: ChangeDetectorRef, + private readonly location: Location, + private readonly jitsuService: JitsuService, private readonly store: Store - ) {} + ) { } ngOnInit(): void { this._user = this.store.user; @@ -60,20 +58,31 @@ export class MenuItemComponent implements OnInit, AfterViewChecked { this.cdr.detectChanges(); } + /** + * Track a click event. + * @param item The item that was clicked. + * @param user The user who clicked the item. + */ public jitsuTrackClick() { const clickEvent: JitsuAnalyticsEvents = { - eventType: 'ButtonClicked', + eventType: JitsuAnalyticsEventsEnum.BUTTON_CLICKED, url: this.item.url ?? this.item.link, userId: this._user.id, userEmail: this._user.email, menuItemName: this.item.title, }; + + // Track the click event this.jitsuService.trackEvents(clickEvent.eventType, clickEvent); + + // Identify the user this.jitsuService.identify(this._user.id, { email: this._user.email, fullName: this._user.name, timeZone: this._user.timeZone, }); + + // Group the user this.jitsuService.group(this._user.id, { email: this._user.email, fullName: this._user.name, diff --git a/apps/gauzy/src/app/@theme/components/header/selectors/date-range-picker/date-picker.interface.ts b/apps/gauzy/src/app/@theme/components/header/selectors/date-range-picker/date-picker.interface.ts new file mode 100644 index 00000000000..cbab237da55 --- /dev/null +++ b/apps/gauzy/src/app/@theme/components/header/selectors/date-range-picker/date-picker.interface.ts @@ -0,0 +1,27 @@ +// Represents a date range with a start date and end date. +export interface IDateRangePicker { + startDate: Date; // The start date of the date range. + endDate: Date; // The end date of the date range. + isCustomDate?: boolean; // Optional flag to indicate if it's a custom date range. +} + +// Represents a time period with a start date and end date using moment.js. +export interface TimePeriod { + startDate: moment.Moment; // The start date of the time period. + endDate: moment.Moment; // The end date of the time period. +} + +// Represents a collection of date ranges where each range is indexed by a string key. +export interface DateRanges { + [index: string]: [moment.Moment, moment.Moment]; // Key-value pairs of date ranges. +} + +// Enum defining keys for common date range options. +export enum DateRangeKeyEnum { + TODAY = 'Today', + YESTERDAY = 'Yesterday', + CURRENT_WEEK = 'Current week', + LAST_WEEK = 'Last week', + CURRENT_MONTH = 'Current month', + LAST_MONTH = 'Last month' +} diff --git a/apps/gauzy/src/app/@theme/components/header/selectors/date-range-picker/date-range-picker.setting.ts b/apps/gauzy/src/app/@theme/components/header/selectors/date-range-picker/date-picker.utils.ts similarity index 72% rename from apps/gauzy/src/app/@theme/components/header/selectors/date-range-picker/date-range-picker.setting.ts rename to apps/gauzy/src/app/@theme/components/header/selectors/date-range-picker/date-picker.utils.ts index 0006b995d85..e83e67bf57a 100644 --- a/apps/gauzy/src/app/@theme/components/header/selectors/date-range-picker/date-range-picker.setting.ts +++ b/apps/gauzy/src/app/@theme/components/header/selectors/date-range-picker/date-picker.utils.ts @@ -1,14 +1,6 @@ import * as moment from 'moment'; import { IDateRangePicker, ISelectedDateRange, ITimeLogFilters, WeekDaysEnum } from "@gauzy/contracts"; - -export enum DateRangeKeyEnum { - TODAY = 'Today', - YESTERDAY = 'Yesterday', - CURRENT_WEEK = 'Current week', - LAST_WEEK = 'Last week', - CURRENT_MONTH = 'Current month', - LAST_MONTH = 'Last month' -} +import { TimePeriod } from './date-picker.interface'; /** * We are having issue, when organization not allowed future date @@ -47,6 +39,24 @@ export function getAdjustDateRangeFutureAllowed(request: ITimeLogFilters | IDate } as ISelectedDateRange } +/** + * Shifts a given time range from UTC to the local time zone. + * + * @param range The time range to be shifted. + * @returns The shifted time range in the local time zone. + */ +export function shiftUTCtoLocal(range: TimePeriod): TimePeriod { + if (range && range.endDate && range.startDate) { + const offset = moment().utcOffset(); + return { + startDate: moment(range.startDate.toDate()).subtract(offset, 'minute'), + endDate: moment(range.endDate.toDate()).subtract(offset, 'minute'), + }; + } else { + return range; + } +} + /** * Converts a day string to a day number. * @@ -63,4 +73,4 @@ export function dayOfWeekAsString(weekDay: WeekDaysEnum): number { WeekDaysEnum.FRIDAY, WeekDaysEnum.SATURDAY ].indexOf(weekDay); -} \ No newline at end of file +} diff --git a/apps/gauzy/src/app/@theme/components/header/selectors/date-range-picker/date-range-picker.component.html b/apps/gauzy/src/app/@theme/components/header/selectors/date-range-picker/date-range-picker.component.html index bd4fdaa2b95..7da799e2b11 100644 --- a/apps/gauzy/src/app/@theme/components/header/selectors/date-range-picker/date-range-picker.component.html +++ b/apps/gauzy/src/app/@theme/components/header/selectors/date-range-picker/date-range-picker.component.html @@ -41,10 +41,7 @@ [(ngModel)]="selectedDateRange" (datesUpdated)="onDatesUpdated($event)" (rangeClicked)="rangeClicked($event)" - [ngClass]="{ - 'double-range': !isSingleDatePicker, - 'single-range': isSingleDatePicker - }" + [ngClass]="isSingleDatePicker ? 'single-range' : 'double-range'" /> diff --git a/apps/gauzy/src/app/@theme/components/header/selectors/date-range-picker/date-range-picker.component.ts b/apps/gauzy/src/app/@theme/components/header/selectors/date-range-picker/date-range-picker.component.ts index a7b7e6b6ebf..6f8d78f0c09 100644 --- a/apps/gauzy/src/app/@theme/components/header/selectors/date-range-picker/date-range-picker.component.ts +++ b/apps/gauzy/src/app/@theme/components/header/selectors/date-range-picker/date-range-picker.component.ts @@ -1,5 +1,4 @@ import { Component, OnInit, OnDestroy, AfterViewInit, Input, ViewChild } from '@angular/core'; -// import { ActivatedRoute, Router } from '@angular/router'; import { BehaviorSubject, combineLatest, of as observableOf, Subject, switchMap, take } from 'rxjs'; import { filter, tap } from 'rxjs/operators'; import { UntilDestroy, untilDestroyed } from '@ngneat/until-destroy'; @@ -9,7 +8,7 @@ import { LocaleConfig } from 'ngx-daterangepicker-material'; import * as moment from 'moment'; -import { IDateRangePicker, IOrganization, ITimeLogFilters, WeekDaysEnum } from '@gauzy/contracts'; +import { IDateRangePicker, IOrganization, ITimeLogFilters } from '@gauzy/contracts'; import { distinctUntilChange, isNotEmpty } from '@gauzy/common-angular'; import { TranslateService } from '@ngx-translate/core'; import { @@ -21,7 +20,8 @@ import { import { Arrow } from './arrow/context/arrow.class'; import { Next, Previous } from './arrow/strategies'; import { TranslationBaseComponent } from './../../../../../@shared/language-base'; -import { DateRangeKeyEnum, dayOfWeekAsString } from './date-range-picker.setting'; +import { dayOfWeekAsString, shiftUTCtoLocal } from './date-picker.utils'; +import { DateRangeKeyEnum, DateRanges, TimePeriod } from './date-picker.interface'; import { TimesheetFilterService } from './../../../../../@shared/timesheet/timesheet-filter.service'; @UntilDestroy({ checkProperties: true }) @@ -59,7 +59,7 @@ export class DateRangePickerComponent extends TranslationBaseComponent implement displayFormat: 'DD.MM.YYYY', // could be 'YYYY-MM-DDTHH:mm:ss.SSSSZ' format: 'DD.MM.YYYY', // default is format value direction: 'ltr', - firstDay: dayOfWeekAsString(WeekDaysEnum.MONDAY) + firstDay: dayOfWeekAsString(this.store.selectedOrganization.startWeekOn) || moment.localeData().firstDayOfWeek() }; get locale(): LocaleConfig { return this._locale; @@ -71,7 +71,7 @@ export class DateRangePickerComponent extends TranslationBaseComponent implement /** * Define ngx-daterangepicker-material range configuration */ - public ranges: any; + public ranges: DateRanges; /** * show or hide arrows button, show by default @@ -178,11 +178,10 @@ export class DateRangePickerComponent extends TranslationBaseComponent implement this._isDisableFutureDatePicker = isDisable; } - @ViewChild(DateRangePickerDirective, { static: true }) dateRangePickerDirective: DateRangePickerDirective; + /** */ + @ViewChild(DateRangePickerDirective, { static: true }) public dateRangePickerDirective: DateRangePickerDirective; constructor( - // private readonly router: Router, - // private readonly activatedRoute: ActivatedRoute, private readonly store: Store, public readonly translateService: TranslateService, private readonly organizationService: OrganizationsService, @@ -193,8 +192,6 @@ export class DateRangePickerComponent extends TranslationBaseComponent implement } ngOnInit(): void { - this.picker = this.dateRangePickerDirective.picker; - const storeOrganization$ = this.store.selectedOrganization$; const storeDatePickerConfig$ = this.dateRangePickerBuilderService.datePickerConfig$; @@ -212,6 +209,18 @@ export class DateRangePickerComponent extends TranslationBaseComponent implement observableOf(datePickerConfig) ]) ), + tap(([organization]) => { + if (organization.timeZone) { + let format: string; + if (moment.tz.zonesForCountry('US').includes(organization.timeZone)) { + format = 'MM.DD.YYYY'; + } else { + format = 'DD.MM.YYYY'; + } + this.locale.displayFormat = format; + this.locale.format = format; + } + }), tap(([organization, datePickerConfig]) => { this.organization = organization; this.futureDateAllowed = organization.futureDateAllowed; @@ -229,27 +238,6 @@ export class DateRangePickerComponent extends TranslationBaseComponent implement this.createDateRangeMenus(); this.setFutureStrategy(); }), - tap(([organization]) => { - if (organization.timeZone) { - let format: string; - if (moment.tz.zonesForCountry('US').includes(organization.timeZone)) { - format = 'MM.DD.YYYY'; - } else { - format = 'DD.MM.YYYY'; - } - this.locale = { - ...this.locale, - displayFormat: format, - format: format - }; - } - if (organization.startWeekOn) { - this.locale = { - ...this.locale, - firstDay: dayOfWeekAsString(organization.startWeekOn) - }; - } - }), untilDestroyed(this) ) .subscribe(); @@ -271,34 +259,32 @@ export class DateRangePickerComponent extends TranslationBaseComponent implement * Create Date Range Translated Menus */ createDateRangeMenus() { - this.ranges = new Object(); - this.ranges[DateRangeKeyEnum.TODAY] = [moment(), moment()]; - this.ranges[DateRangeKeyEnum.YESTERDAY] = [moment().subtract(1, 'days'), moment().subtract(1, 'days')]; - this.ranges[DateRangeKeyEnum.CURRENT_WEEK] = [moment().startOf('week'), moment().endOf('week')]; - this.ranges[DateRangeKeyEnum.LAST_WEEK] = [ - moment().subtract(1, 'week').startOf('week'), - moment().subtract(1, 'week').endOf('week') - ]; - this.ranges[DateRangeKeyEnum.CURRENT_MONTH] = [moment().startOf('month'), moment().endOf('month')]; - this.ranges[DateRangeKeyEnum.LAST_MONTH] = [ - moment().subtract(1, 'month').startOf('month'), - moment().subtract(1, 'month').endOf('month') - ]; + this.ranges = { + [DateRangeKeyEnum.TODAY]: [moment(), moment()], + [DateRangeKeyEnum.YESTERDAY]: [moment().subtract(1, 'days'), moment().subtract(1, 'days')], + [DateRangeKeyEnum.CURRENT_WEEK]: [moment().startOf('week'), moment().endOf('week')], + [DateRangeKeyEnum.LAST_WEEK]: [moment().subtract(1, 'week').startOf('week'), moment().subtract(1, 'week').endOf('week')], + [DateRangeKeyEnum.CURRENT_MONTH]: [moment().startOf('month'), moment().endOf('month')], + [DateRangeKeyEnum.LAST_MONTH]: [moment().subtract(1, 'month').startOf('month'), moment().subtract(1, 'month').endOf('month')] + }; + + // Define the units of time to remove based on conditions + const unitsToRemove = []; if (this.isLockDatePicker && this.unitOfTime !== 'day') { - delete this.ranges[DateRangeKeyEnum.TODAY]; - delete this.ranges[DateRangeKeyEnum.YESTERDAY]; + unitsToRemove.push(DateRangeKeyEnum.TODAY, DateRangeKeyEnum.YESTERDAY); } - if (this.isLockDatePicker && this.unitOfTime !== 'week') { - delete this.ranges[DateRangeKeyEnum.CURRENT_WEEK]; - delete this.ranges[DateRangeKeyEnum.LAST_WEEK]; + unitsToRemove.push(DateRangeKeyEnum.CURRENT_WEEK, DateRangeKeyEnum.LAST_WEEK); } - if (this.isLockDatePicker && this.unitOfTime !== 'month') { - delete this.ranges[DateRangeKeyEnum.CURRENT_MONTH]; - delete this.ranges[DateRangeKeyEnum.LAST_MONTH]; + unitsToRemove.push(DateRangeKeyEnum.CURRENT_MONTH, DateRangeKeyEnum.LAST_MONTH); } + + // Remove date ranges based on unitsToRemove + unitsToRemove.forEach(unit => { + delete this.ranges[unit]; + }); } /** @@ -367,9 +353,9 @@ export class DateRangePickerComponent extends TranslationBaseComponent implement * listen event on ngx-daterangepicker-material * @param event */ - onDatesUpdated(event: any) { + onDatesUpdated(event: TimePeriod) { if (this.dateRangePickerDirective) { - const { startDate, endDate } = event; + const { startDate, endDate } = shiftUTCtoLocal(event); if (startDate && endDate) { const range = {} as IDateRangePicker; if (!this.isLockDatePicker) { @@ -454,8 +440,7 @@ export class DateRangePickerComponent extends TranslationBaseComponent implement filter(() => !!this.isSaveDatePicker), tap((filters: ITimeLogFilters) => { const { startDate = range.startDate } = filters; - const date = - !this.hasFutureStrategy() && this.isSameOrAfterDay(startDate) ? moment() : moment(startDate); + const date = !this.hasFutureStrategy() && this.isSameOrAfterDay(startDate) ? moment() : moment(startDate); const start = moment(date).startOf(this.unitOfTime); const end = moment(date).endOf(this.unitOfTime); @@ -514,7 +499,6 @@ export class DateRangePickerComponent extends TranslationBaseComponent implement const { startDate, endDate, isCustomDate } = this.dates$.getValue(); const start = moment(startDate); const end = moment(endDate); - return { startDate: start.toDate(), endDate: end.toDate(), diff --git a/apps/gauzy/src/app/@theme/components/header/selectors/date-range-picker/index.ts b/apps/gauzy/src/app/@theme/components/header/selectors/date-range-picker/index.ts index 426399efd04..cfec45b8a0d 100644 --- a/apps/gauzy/src/app/@theme/components/header/selectors/date-range-picker/index.ts +++ b/apps/gauzy/src/app/@theme/components/header/selectors/date-range-picker/index.ts @@ -1,4 +1,4 @@ export * from './date-range-picker.component'; export * from './date-range-picker.module'; -export * from './date-range-picker.setting'; +export * from './date-picker.utils'; export * from './date-range-picker.resolver'; diff --git a/apps/gauzy/src/app/app.module.ts b/apps/gauzy/src/app/app.module.ts index e041948801b..dd32bada6b6 100644 --- a/apps/gauzy/src/app/app.module.ts +++ b/apps/gauzy/src/app/app.module.ts @@ -55,13 +55,14 @@ import * as moment from 'moment'; import { LegalModule } from './legal/legal.module'; import { Router } from '@angular/router'; import { FeatureToggleModule } from 'ngx-feature-toggle'; -import { IFeatureToggle, LanguagesEnum } from '@gauzy/contracts'; +import { IFeatureToggle, LanguagesEnum, WeekDaysEnum } from '@gauzy/contracts'; import { HttpLoaderFactory } from './@shared/translate/translate.module'; import { FeatureService, GoogleMapsLoaderService } from './@core/services'; import { AppInitService } from './@core/services/app-init-service'; import { NbEvaIconsModule } from '@nebular/eva-icons'; import { CookieService } from 'ngx-cookie-service'; import { NgxDaterangepickerMd } from 'ngx-daterangepicker-material'; +import { dayOfWeekAsString } from './@theme/components/header/selectors/date-range-picker'; // TODO: we should use some internal function which returns version of Gauzy; const version = '0.1.0'; @@ -146,7 +147,9 @@ if (environment.SENTRY_DSN && environment.SENTRY_DSN === 'DOCKER_SENTRY_DSN') { NgxElectronModule, FeatureToggleModule, NgxPermissionsModule.forRoot(), - NgxDaterangepickerMd.forRoot(), + NgxDaterangepickerMd.forRoot({ + firstDay: dayOfWeekAsString(WeekDaysEnum.MONDAY) + }), ], bootstrap: [AppComponent], providers: [ @@ -231,10 +234,10 @@ export class AppModule { // Set Monday as start of the week moment.locale(LanguagesEnum.ENGLISH, { week: { - dow: 1, + dow: dayOfWeekAsString(WeekDaysEnum.MONDAY), }, + fallbackLocale: LanguagesEnum.ENGLISH }); - moment.locale(LanguagesEnum.ENGLISH); } } @@ -251,7 +254,7 @@ export function serverConnectionFactory( router.navigate(['server-down']); } }) - .catch(() => {}); + .catch(() => { }); } export function googleMapsLoaderFactory(provider: GoogleMapsLoaderService) { @@ -269,5 +272,5 @@ export function featureToggleLoaderFactory( store.featureToggles = features || []; return features; }) - .catch(() => {}); + .catch(() => { }); } diff --git a/apps/gauzy/src/environments/model.ts b/apps/gauzy/src/environments/model.ts index 8ef5b3d7f40..41e223c9045 100644 --- a/apps/gauzy/src/environments/model.ts +++ b/apps/gauzy/src/environments/model.ts @@ -70,7 +70,7 @@ export interface Environment { FILE_PROVIDER: string; - JITSU_BROWSER_HOST?: string; + JITSU_BROWSER_URL?: string; JITSU_BROWSER_WRITE_KEY?: string; /** Github Integration */ diff --git a/packages/common/src/interfaces/IJitsuConfig.ts b/packages/common/src/interfaces/IJitsuConfig.ts new file mode 100644 index 00000000000..ffbbeda0034 --- /dev/null +++ b/packages/common/src/interfaces/IJitsuConfig.ts @@ -0,0 +1,24 @@ +/** + * Represents a configuration object for Jitsu server settings. + */ +export interface IJitsuConfig { + /** + * API Host. Default value: same host as script origin + */ + readonly serverHost: string; + + /** + * The write key for authenticating with the Jitsu server. + */ + readonly serverWriteKey: string; + + /** + * Whether to enable debug mode. + */ + readonly debug: boolean; + + /** + * Whether to echo events. + */ + readonly echoEvents: boolean; +} diff --git a/packages/common/src/interfaces/index.ts b/packages/common/src/interfaces/index.ts index 758058db10e..aca3abd8cd3 100644 --- a/packages/common/src/interfaces/index.ts +++ b/packages/common/src/interfaces/index.ts @@ -22,3 +22,4 @@ export * from './IUnleashConfig'; export * from './IUpworkConfig'; export * from './IWasabiConfig'; export * from './IHubstaffConfig'; +export * from './IJitsuConfig'; diff --git a/packages/config/src/environments/environment.prod.ts b/packages/config/src/environments/environment.prod.ts index 87c44de2afe..09f95f2b487 100644 --- a/packages/config/src/environments/environment.prod.ts +++ b/packages/config/src/environments/environment.prod.ts @@ -54,6 +54,16 @@ export const environment: IEnvironment = { THROTTLE_TTL: parseInt(process.env.THROTTLE_TTL) || 60, THROTTLE_LIMIT: parseInt(process.env.THROTTLE_LIMIT) || 300, + /** + * Jitsu Server Configuration + */ + jitsu: { + serverHost: process.env.JITSU_SERVER_URL, + serverWriteKey: process.env.JITSU_SERVER_WRITE_KEY, + debug: process.env.JITSU_SERVER_DEBUG === 'true' ? true : false, + echoEvents: process.env.JITSU_SERVER_ECHO_EVENTS === 'true' ? true : false, + }, + fileSystem: { name: (process.env.FILE_PROVIDER as FileStorageProviderEnum) || diff --git a/packages/config/src/environments/environment.ts b/packages/config/src/environments/environment.ts index e56e0eff8c6..22bd4127ac1 100644 --- a/packages/config/src/environments/environment.ts +++ b/packages/config/src/environments/environment.ts @@ -67,6 +67,16 @@ export const environment: IEnvironment = { THROTTLE_TTL: parseInt(process.env.THROTTLE_TTL) || 60, THROTTLE_LIMIT: parseInt(process.env.THROTTLE_LIMIT) || 300, + /** + * Jitsu Server Configuration + */ + jitsu: { + serverHost: process.env.JITSU_SERVER_URL, + serverWriteKey: process.env.JITSU_SERVER_WRITE_KEY, + debug: process.env.JITSU_SERVER_DEBUG === 'true' ? true : false, + echoEvents: process.env.JITSU_SERVER_ECHO_EVENTS === 'true' ? true : false, + }, + fileSystem: { name: (process.env.FILE_PROVIDER as FileStorageProviderEnum) || @@ -100,7 +110,8 @@ export const environment: IEnvironment = { api_key: process.env.CLOUDINARY_API_KEY, api_secret: process.env.CLOUDINARY_API_SECRET, secure: process.env.CLOUDINARY_API_SECURE === 'false' ? false : true, - delivery_url: process.env.CLOUDINARY_CDN_URL || `https://res.cloudinary.com`, + delivery_url: + process.env.CLOUDINARY_CDN_URL || `https://res.cloudinary.com`, }, facebookConfig: { @@ -109,21 +120,27 @@ export const environment: IEnvironment = { clientId: process.env.FACEBOOK_CLIENT_ID, clientSecret: process.env.FACEBOOK_CLIENT_SECRET, fbGraphVersion: process.env.FACEBOOK_GRAPH_VERSION, - oauthRedirectUri: process.env.FACEBOOK_CALLBACK_URL || `${process.env.API_BASE_URL}/api/auth/facebook/callback`, + oauthRedirectUri: + process.env.FACEBOOK_CALLBACK_URL || + `${process.env.API_BASE_URL}/api/auth/facebook/callback`, state: '{fbstate}', }, googleConfig: { clientId: process.env.GOOGLE_CLIENT_ID, clientSecret: process.env.GOOGLE_CLIENT_SECRET, - callbackUrl: process.env.GOOGLE_CALLBACK_URL || `${process.env.API_BASE_URL}/api/auth/google/callback`, + callbackUrl: + process.env.GOOGLE_CALLBACK_URL || + `${process.env.API_BASE_URL}/api/auth/google/callback`, }, github: { /**Github OAuth Configuration */ clientId: process.env.GAUZY_GITHUB_CLIENT_ID, clientSecret: process.env.GAUZY_GITHUB_CLIENT_SECRET, - callbackUrl: process.env.GAUZY_GITHUB_CALLBACK_URL || `${process.env.API_BASE_URL}/api/auth/github/callback`, + callbackUrl: + process.env.GAUZY_GITHUB_CALLBACK_URL || + `${process.env.API_BASE_URL}/api/auth/github/callback`, /** Github App Install Configuration */ appId: process.env.GAUZY_GITHUB_APP_ID, @@ -131,11 +148,15 @@ export const environment: IEnvironment = { appPrivateKey: process.env.GAUZY_GITHUB_APP_PRIVATE_KEY, /** Github App Post Install Configuration */ - postInstallUrl: process.env.GAUZY_GITHUB_POST_INSTALL_URL || `${process.env.CLIENT_BASE_URL}/#/pages/integrations/github/setup/installation`, + postInstallUrl: + process.env.GAUZY_GITHUB_POST_INSTALL_URL || + `${process.env.CLIENT_BASE_URL}/#/pages/integrations/github/setup/installation`, /** Github Webhook Configuration */ webhookSecret: process.env.GAUZY_GITHUB_WEBHOOK_SECRET, - webhookUrl: process.env.GAUZY_GITHUB_WEBHOOK_URL || `${process.env.API_BASE_URL}/api/integration/github/webhook` + webhookUrl: + process.env.GAUZY_GITHUB_WEBHOOK_URL || + `${process.env.API_BASE_URL}/api/integration/github/webhook`, }, microsoftConfig: { @@ -143,19 +164,25 @@ export const environment: IEnvironment = { clientSecret: process.env.MICROSOFT_CLIENT_SECRET, resource: process.env.MICROSOFT_RESOURCE, tenant: process.env.MICROSOFT_TENANT, - callbackUrl: process.env.MICROSOFT_CALLBACK_URL || `${process.env.API_BASE_URL}/api/auth/microsoft/callback`, + callbackUrl: + process.env.MICROSOFT_CALLBACK_URL || + `${process.env.API_BASE_URL}/api/auth/microsoft/callback`, }, linkedinConfig: { clientId: process.env.LINKEDIN_CLIENT_ID, clientSecret: process.env.LINKEDIN_CLIENT_SECRET, - callbackUrl: process.env.LINKEDIN_CALLBACK_URL || `${process.env.API_BASE_URL}/api/auth/linked/callback`, + callbackUrl: + process.env.LINKEDIN_CALLBACK_URL || + `${process.env.API_BASE_URL}/api/auth/linked/callback`, }, twitterConfig: { clientId: process.env.TWITTER_CLIENT_ID, clientSecret: process.env.TWITTER_CLIENT_SECRET, - callbackUrl: process.env.TWITTER_CALLBACK_URL || `${process.env.API_BASE_URL}/api/auth/twitter/callback`, + callbackUrl: + process.env.TWITTER_CALLBACK_URL || + `${process.env.API_BASE_URL}/api/auth/twitter/callback`, }, fiverrConfig: { @@ -181,14 +208,18 @@ export const environment: IEnvironment = { dsn: process.env.SENTRY_DSN, }, - defaultIntegratedUserPass: process.env.INTEGRATED_USER_DEFAULT_PASS || '123456', - + defaultIntegratedUserPass: + process.env.INTEGRATED_USER_DEFAULT_PASS || '123456', upwork: { apiKey: process.env.UPWORK_API_KEY, apiSecret: process.env.UPWORK_API_SECRET, - callbackUrl: process.env.UPWORK_REDIRECT_URL || `${process.env.API_BASE_URL}/api/integrations/upwork/callback`, - postInstallUrl: process.env.UPWORK_POST_INSTALL_URL || `${process.env.CLIENT_BASE_URL}/#/pages/integrations/upwork`, + callbackUrl: + process.env.UPWORK_REDIRECT_URL || + `${process.env.API_BASE_URL}/api/integrations/upwork/callback`, + postInstallUrl: + process.env.UPWORK_POST_INSTALL_URL || + `${process.env.CLIENT_BASE_URL}/#/pages/integrations/upwork`, }, hubstaff: { @@ -196,13 +227,15 @@ export const environment: IEnvironment = { clientId: process.env.HUBSTAFF_CLIENT_ID, clientSecret: process.env.HUBSTAFF_CLIENT_SECRET, /** Hubstaff Integration Post Install URL */ - postInstallUrl: process.env.HUBSTAFF_POST_INSTALL_URL || `${process.env.CLIENT_BASE_URL}/#/pages/integrations/hubstaff`, - + postInstallUrl: + process.env.HUBSTAFF_POST_INSTALL_URL || + `${process.env.CLIENT_BASE_URL}/#/pages/integrations/hubstaff`, }, isElectron: process.env.IS_ELECTRON === 'true' ? true : false, gauzyUserPath: process.env.GAUZY_USER_PATH, - allowSuperAdminRole: process.env.ALLOW_SUPER_ADMIN_ROLE === 'false' ? false : true, + allowSuperAdminRole: + process.env.ALLOW_SUPER_ADMIN_ROLE === 'false' ? false : true, /** * Endpoint for Gauzy AI API (optional), e.g.: http://localhost:3005/graphql diff --git a/packages/config/src/environments/ienvironment.ts b/packages/config/src/environments/ienvironment.ts index 6eaf1816495..ffa0b17c850 100644 --- a/packages/config/src/environments/ienvironment.ts +++ b/packages/config/src/environments/ienvironment.ts @@ -13,6 +13,7 @@ import { IGithubConfig, IGoogleConfig, IHubstaffConfig, + IJitsuConfig, IKeycloakConfig, ILinkedinConfig, IMicrosoftConfig, @@ -158,8 +159,7 @@ export interface IEnvironment { EMAIL_RESET_EXPIRATION_TIME?: number; /** - * Jitsu Config + * Jitsu Configuration */ - JITSU_BROWSER_HOST?: string; - JITSU_CONFIG_WRITE_KEY?: string; + jitsu: IJitsuConfig; } diff --git a/packages/contracts/src/organization-team-employee-model.ts b/packages/contracts/src/organization-team-employee-model.ts index 0bffca89ccd..55c53f0be58 100644 --- a/packages/contracts/src/organization-team-employee-model.ts +++ b/packages/contracts/src/organization-team-employee-model.ts @@ -7,10 +7,10 @@ import { ITask } from './task.model'; export interface IOrganizationTeamEmployee extends IBasePerTenantAndOrganizationEntityModel, - IRelationalOrganizationTeam, - IRelationalEmployee, - IRelationalRole, - ITimerStatus { + IRelationalOrganizationTeam, + IRelationalEmployee, + IRelationalRole, + ITimerStatus { isTrackingEnabled?: boolean; activeTaskId?: ITask['id']; activeTask?: ITask; @@ -18,10 +18,16 @@ export interface IOrganizationTeamEmployee export interface IOrganizationTeamEmployeeFindInput extends IBasePerTenantAndOrganizationEntityModel, - IRelationalOrganizationTeam {} + IRelationalOrganizationTeam { } export interface IOrganizationTeamEmployeeUpdateInput extends IBasePerTenantAndOrganizationEntityModel, - IRelationalOrganizationTeam { + IRelationalOrganizationTeam { isTrackingEnabled?: boolean; } + +export interface IOrganizationTeamEmployeeActiveTaskUpdateInput + extends IBasePerTenantAndOrganizationEntityModel, + IRelationalOrganizationTeam { + activeTaskId?: ITask['id']; +} diff --git a/packages/contracts/src/task.model.ts b/packages/contracts/src/task.model.ts index 222937cdd0c..7ab7fc92ab6 100644 --- a/packages/contracts/src/task.model.ts +++ b/packages/contracts/src/task.model.ts @@ -44,6 +44,8 @@ export interface ITask extends IBasePerTenantAndOrganizationEntityModel { taskStatusId?: ITaskStatus['id']; taskSizeId?: ITaskSize['id']; taskPriorityId?: ITaskPriority['id']; + + rootEpic?: ITask; } export interface IGetTaskOptions @@ -67,3 +69,7 @@ export type ITaskCreateInput = ITask; export interface ITaskUpdateInput extends ITaskCreateInput { id?: string; } + +export interface IGetTaskById { + includeRootEpic?: boolean; +} diff --git a/packages/core/package.json b/packages/core/package.json index e30e7c85a9f..e9d8fd13b2f 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -156,7 +156,8 @@ "upwork-api": "^1.3.8", "uuid": "^8.3.0", "web-push": "^3.4.4", - "yargs": "^17.5.0" + "yargs": "^17.5.0", + "@jitsu/js": "^1.3.0" }, "devDependencies": { "@nestjs/cli": "^9.1.5", diff --git a/packages/core/src/app.module.ts b/packages/core/src/app.module.ts index 41fefc21c79..be09a1e612c 100644 --- a/packages/core/src/app.module.ts +++ b/packages/core/src/app.module.ts @@ -153,7 +153,8 @@ import { EmailResetModule } from './email-reset/email-reset.module'; import { TaskLinkedIssueModule } from './tasks/linked-issue/task-linked-issue.module'; import { OrganizationTaskSettingModule } from './organization-task-setting/organization-task-setting.module'; import { TaskEstimationModule } from './tasks/estimation/task-estimation.module'; -const { unleashConfig, github } = environment; +import { JitsuAnalyticsModule } from './jitsu-analytics/jitsu-analytics.module'; +const { unleashConfig, github, jitsu } = environment; if (unleashConfig.url) { const unleashInstanceConfig: UnleashConfig = { @@ -250,7 +251,6 @@ if (environment.sentry && environment.sentry.dsn) { }), ] : []), - // Probot Configuration ProbotModule.forRoot({ isGlobal: true, @@ -264,7 +264,15 @@ if (environment.sentry && environment.sentry.dsn) { webhookSecret: github.webhookSecret }, }), - + /** Jitsu Configuration */ + JitsuAnalyticsModule.forRoot({ + config: { + host: jitsu.serverHost, + writeKey: jitsu.serverWriteKey, + debug: jitsu.debug, + echoEvents: jitsu.echoEvents + } + }), ThrottlerModule.forRootAsync({ inject: [ConfigService], useFactory: (config: ConfigService): ThrottlerModuleOptions => @@ -397,7 +405,7 @@ if (environment.sentry && environment.sentry.dsn) { IssueTypeModule, TaskLinkedIssueModule, OrganizationTaskSettingModule, - TaskEstimationModule + TaskEstimationModule, ], controllers: [AppController], providers: [ diff --git a/packages/core/src/core/entities/internal.ts b/packages/core/src/core/entities/internal.ts index 635a0dbd816..c1124ef8c21 100644 --- a/packages/core/src/core/entities/internal.ts +++ b/packages/core/src/core/entities/internal.ts @@ -184,3 +184,4 @@ export * from './../../time-tracking/screenshot/screenshot.subscriber'; export * from './../../time-tracking/time-slot/time-slot.subscriber'; export * from './../../user/user.subscriber'; export * from './../../integration/integration.subscriber'; +export * from '././../../jitsu-analytics/jitsu-events-subscriber'; diff --git a/packages/core/src/core/entities/subscribers.ts b/packages/core/src/core/entities/subscribers.ts index 52c661848b1..f98a2e90377 100644 --- a/packages/core/src/core/entities/subscribers.ts +++ b/packages/core/src/core/entities/subscribers.ts @@ -8,9 +8,11 @@ import { FeatureSubscriber, ImageAssetSubscriber, ImportHistorySubscriber, + IntegrationSubscriber, InviteSubscriber, InvoiceSubscriber, IssueTypeSubscriber, + JitsuEventsSubscriber, OrganizationContactSubscriber, OrganizationDocumentSubscriber, OrganizationProjectSubscriber, @@ -24,16 +26,15 @@ import { ScreenshotSubscriber, TagSubscriber, TaskPrioritySubscriber, - TaskSizeSubscriber, TaskRelatedIssueTypesSubscriber, + TaskSizeSubscriber, TaskStatusSubscriber, - TaskVersionSubscriber, TaskSubscriber, + TaskVersionSubscriber, TenantSubscriber, TimeOffRequestSubscriber, TimeSlotSubscriber, UserSubscriber, - IntegrationSubscriber, } from './internal'; /** @@ -53,6 +54,7 @@ export const coreSubscribers = [ InviteSubscriber, InvoiceSubscriber, IssueTypeSubscriber, + JitsuEventsSubscriber, OrganizationContactSubscriber, OrganizationDocumentSubscriber, OrganizationProjectSubscriber, @@ -74,5 +76,5 @@ export const coreSubscribers = [ TenantSubscriber, TimeOffRequestSubscriber, TimeSlotSubscriber, - UserSubscriber + UserSubscriber, ]; diff --git a/packages/core/src/jitsu-analytics/jitsu-analytics.module.ts b/packages/core/src/jitsu-analytics/jitsu-analytics.module.ts new file mode 100644 index 00000000000..ff3631484e0 --- /dev/null +++ b/packages/core/src/jitsu-analytics/jitsu-analytics.module.ts @@ -0,0 +1,32 @@ +import { DynamicModule, Module } from '@nestjs/common'; +import { JitsuAnalyticsService } from './jitsu-analytics.service'; +import { JITSU_MODULE_PROVIDER_CONFIG, JitsuModuleOptions } from './jitsu.types'; + +@Module({ + providers: [ + JitsuAnalyticsService + ], +}) +export class JitsuAnalyticsModule { + /** + * Create a dynamic module for configuring and initializing the Jitsu Analytics module. + * @param options The options for configuring the Jitsu Analytics module. + * @returns A dynamic module definition. + */ + static forRoot(options: JitsuModuleOptions): DynamicModule { + return { + global: options.isGlobal || true, + module: JitsuAnalyticsModule, + providers: [ + { + provide: JITSU_MODULE_PROVIDER_CONFIG, + useFactory: () => options.config, + }, + JitsuAnalyticsService + ], + exports: [ + JitsuAnalyticsService + ], + }; + } +} diff --git a/packages/core/src/jitsu-analytics/jitsu-analytics.service.ts b/packages/core/src/jitsu-analytics/jitsu-analytics.service.ts new file mode 100644 index 00000000000..84a971d8349 --- /dev/null +++ b/packages/core/src/jitsu-analytics/jitsu-analytics.service.ts @@ -0,0 +1,82 @@ +import { Inject, Injectable, Logger } from '@nestjs/common'; +import { AnalyticsInterface, JitsuOptions } from '@jitsu/js'; +import * as chalk from 'chalk'; +import { JITSU_MODULE_PROVIDER_CONFIG } from './jitsu.types'; +import { createJitsu } from './jitsu-helper'; + +@Injectable() +export class JitsuAnalyticsService { + + private readonly logger = new Logger(JitsuAnalyticsService.name); + private readonly jitsu: AnalyticsInterface; + + constructor( + @Inject(JITSU_MODULE_PROVIDER_CONFIG) + private readonly config: JitsuOptions + ) { + try { + // Check if the required host and writeKey configuration properties are present + if (this.config.host && this.config.writeKey) { + // Initialize the Jitsu Analytics instance + this.jitsu = createJitsu(this.config); + } else { + console.error(chalk.yellow(`Jitsu Analytics initialization failed: Missing host or writeKey.`)); + } + } catch (error) { + console.error(chalk.red(`Jitsu Analytics initialization failed: ${error.message}`)); + } + } + + /** + * Track an analytics event using Jitsu Analytics. + * @param event The name of the event to track. + * @param properties Additional event properties (optional). + * @returns A promise that resolves when the event is tracked. + */ + async trackEvent( + event: string, + properties?: Record | null + ): Promise { + // Check if this.jitsu is defined and both host and writeKey are defined + if (this.jitsu && this.config.host && this.config.writeKey) { + this.logger.log(`Jitsu Tracking Entity Events`, JSON.stringify(properties)); + return await this.jitsu.track(event, properties); + } else { + // Handle it as needed (e.g., log or return a default result) + this.logger.warn(`Jitsu tracking is not available. Unable to track event: ${event}`); + return null; // or handle it differently based on your requirements + } + } + + /** + * Identify a user with optional user traits. + * @param id The user identifier, such as a user ID or an object representing user information. + * @param traits User traits or properties to associate with the user. + * @returns A Promise that resolves when the user is identified. + */ + async identify( + id: string | object, + traits?: Record | null + ): Promise { + // Check if this.jitsu is defined and both host and writeKey are defined + if (this.jitsu && this.config.host && this.config.writeKey) { + return await this.jitsu.identify(id, traits); + } + } + + /** + * Group users into a specific segment or organization. + * @param id The identifier for the group, such as a group ID or an object representing group information. + * @param traits Additional data or traits associated with the group. + * @returns A Promise that resolves when the users are grouped. + */ + async group( + id: string | object, + traits?: Record | null + ): Promise { + // Check if this.jitsu is defined and both host and writeKey are defined + if (this.jitsu && this.config.host && this.config.writeKey) { + return await this.jitsu.group(id, traits); + } + } +} diff --git a/packages/core/src/jitsu-analytics/jitsu-events-subscriber.ts b/packages/core/src/jitsu-analytics/jitsu-events-subscriber.ts new file mode 100644 index 00000000000..1b86aa24eeb --- /dev/null +++ b/packages/core/src/jitsu-analytics/jitsu-events-subscriber.ts @@ -0,0 +1,150 @@ +import { AnalyticsInterface } from '@jitsu/js'; +import { Logger } from '@nestjs/common'; +import * as chalk from 'chalk'; +import { + InsertEvent, + RemoveEvent, + EntitySubscriberInterface, + UpdateEvent, + EventSubscriber, +} from 'typeorm'; +import { environment } from '@gauzy/config'; +import { createJitsu } from './jitsu-helper'; + +// Extract configuration values from environment +const { jitsu } = environment; + +/* Global Entity Subscriber - Listens to all entity +inserts updates and removal then sends to Jitsu */ +@EventSubscriber() +export class JitsuEventsSubscriber implements EntitySubscriberInterface { + private readonly logger = new Logger(JitsuEventsSubscriber.name); + private readonly jitsuAnalytics: AnalyticsInterface; + + constructor() { + try { + // Check if the required host and writeKey configuration properties are present + if (jitsu.serverHost && jitsu.serverWriteKey) { + const jitsuConfig = { + host: jitsu.serverHost, + writeKey: jitsu.serverWriteKey, + debug: jitsu.debug, + echoEvents: jitsu.echoEvents + }; + this.logger.log(`JITSU Configuration`, chalk.magenta(JSON.stringify(jitsuConfig))); + // Create an instance of Jitsu Analytics with configuration + this.jitsuAnalytics = createJitsu(jitsuConfig); + } else { + console.error(chalk.yellow(`Jitsu Analytics initialization failed: Missing host or writeKey.`)); + } + } catch (error) { + console.error(chalk.red(`Jitsu Analytics initialization failed: ${error.message}`)); + } + } + + + /** + * Called after entity insertion. + */ + async afterInsert(event: InsertEvent) { + this.logger.log(`AFTER ENTITY INSERTED: `, JSON.stringify(event.entity)); + + // Track an event with Jitsu Analytics + await this.analyticsTrack('afterInsert', { + data: { ...event.entity }, + }); + } + + /** + * Called after entity update. + */ + async afterUpdate(event: UpdateEvent) { + this.logger.log(`AFTER ENTITY UPDATED: `, JSON.stringify(event.entity)); + + // Track an event with Jitsu Analytics + await this.analyticsTrack('afterUpdate', { + data: { ...event.entity }, + }); + } + + /** + * Called after entity removal. + */ + async afterRemove(event: RemoveEvent) { + this.logger.log(`AFTER ENTITY REMOVED: `, JSON.stringify(event.entity)); + + // Track an event with Jitsu Analytics + await this.analyticsTrack('afterRemove', { + data: { ...event.entity }, + }); + } + + /** + * Track an analytics event using Jitsu Analytics. + * @param event The name of the event to track. + * @param properties Additional event properties (optional). + * @returns A promise that resolves when the event is tracked. + */ + async analyticsTrack( + event: string, + properties?: Record | null + ): Promise { + // Check if this.jitsu is defined and both host and writeKey are defined + if (this.jitsuAnalytics) { + try { + console.log('------------------Jitsu Tracking Start------------------'); + this.logger.log(`Before Jitsu Tracking Entity Events: ${event}`, chalk.magenta(JSON.stringify(properties))); + + const tracked = await this.trackEvent(event, properties); + + this.logger.log(`After Jitsu Tracked Entity Events`, chalk.blue(JSON.stringify(tracked))); + console.log('------------------Jitsu Tracking Finished------------------'); + } catch (error) { + this.logger.error(`Error while Jitsu tracking event. Unable to track event: ${error.message}`); + } + } else { + // Handle it as needed (e.g., log or return a default result) + this.logger.warn(`Jitsu tracking is not available. Unable to track event: ${event}`); + return null; // or handle it differently based on your requirements + } + } + + /** + * Track an event with optional properties. + * @param event The name of the event to track. + * @param properties Additional data or properties associated with the event. + * @returns A Promise that resolves when the event is tracked. + */ + async trackEvent( + event: string, + properties?: Record | null + ): Promise { + return await this.jitsuAnalytics.track(event, properties); + } + + /** + * Identify a user with optional user traits. + * @param id The user identifier, such as a user ID or an object representing user information. + * @param traits User traits or properties to associate with the user. + * @returns A Promise that resolves when the user is identified. + */ + async identify( + id: string | object, + traits?: Record | null + ): Promise { + return await this.jitsuAnalytics.identify(id, traits); + } + + /** + * Group users into a specific segment or organization. + * @param id The identifier for the group, such as a group ID or an object representing group information. + * @param traits Additional data or traits associated with the group. + * @returns A Promise that resolves when the users are grouped. + */ + async group( + id: string | object, + traits?: Record | null + ): Promise { + return await this.jitsuAnalytics.group(id, traits); + } +} diff --git a/packages/core/src/jitsu-analytics/jitsu-helper.ts b/packages/core/src/jitsu-analytics/jitsu-helper.ts new file mode 100644 index 00000000000..69657375b9e --- /dev/null +++ b/packages/core/src/jitsu-analytics/jitsu-helper.ts @@ -0,0 +1,37 @@ +import { environment } from "@gauzy/config"; +import { AnalyticsInterface, JitsuOptions, jitsuAnalytics } from "@jitsu/js"; +import fetch from 'node-fetch'; + +/** + * Parse the configuration for Jitsu Analytics. + * @param config The input configuration object. + * @returns A record containing Jitsu configuration properties. + */ +export const parseConfig = (config: JitsuOptions): Record => ({ + host: config.host || environment.jitsu.serverHost || '', // Use serverHost from environment or empty string as default + writeKey: config.writeKey || environment.jitsu.serverWriteKey || '', // Use serverWriteKey from environment or empty string as default + debug: config.debug || false, // Use debug from input config or false as default + echoEvents: config.echoEvents || false, // Use echoEvents from input config or false as default +}); + +/** + * Create a Jitsu Analytics instance. + * @param opts The JitsuOptions object for configuration. + * @returns An instance of Jitsu Analytics. + */ +export const createJitsu = (opts: JitsuOptions): AnalyticsInterface => { + // Parse the configuration options + const config = parseConfig(opts); + if (!config.host || !config.writeKey) { + // Handle the case where 'host' or 'writeKey' is missing + console.error('Jitsu Analytics initialization failed: Missing host or writeKey.'); + return; + } + + config.fetch = fetch; // Assign the 'fetch' function to 'fetch' + console.log(`JITSU Configuration`, config); + // Create and return a Jitsu Analytics instance with the parsed configuration properties + return jitsuAnalytics({ + ...config, // Spread the parsed configuration properties + }); +}; diff --git a/packages/core/src/jitsu-analytics/jitsu.types.ts b/packages/core/src/jitsu-analytics/jitsu.types.ts new file mode 100644 index 00000000000..c989af9e0ac --- /dev/null +++ b/packages/core/src/jitsu-analytics/jitsu.types.ts @@ -0,0 +1,13 @@ +import { JitsuOptions } from "@jitsu/js"; + +// Provider key for Jitsu configuration +export const JITSU_MODULE_PROVIDER_CONFIG = 'JITSU_MODULE_PROVIDER_CONFIG'; + +// Define options for the Jitsu module +export interface JitsuModuleOptions { + // Specifies if the Jitsu module should be global + isGlobal?: boolean; + + // Jitsu configuration options + config: JitsuOptions; +} diff --git a/packages/core/src/organization-team-employee/dto/index.ts b/packages/core/src/organization-team-employee/dto/index.ts index ff0437a8fc9..6420c9a1ee2 100644 --- a/packages/core/src/organization-team-employee/dto/index.ts +++ b/packages/core/src/organization-team-employee/dto/index.ts @@ -1,2 +1,3 @@ export * from './delete-team-member-query.dto'; export * from './update-team-member.dto'; +export * from './update-organization-team-active-task.dto'; diff --git a/packages/core/src/organization-team-employee/dto/update-organization-team-active-task.dto.ts b/packages/core/src/organization-team-employee/dto/update-organization-team-active-task.dto.ts new file mode 100644 index 00000000000..5ad456f95da --- /dev/null +++ b/packages/core/src/organization-team-employee/dto/update-organization-team-active-task.dto.ts @@ -0,0 +1,12 @@ +import { IOrganizationTeamEmployeeUpdateInput } from '@gauzy/contracts'; +import { IntersectionType, PickType } from '@nestjs/swagger'; +import { OrganizationTeamEmployee } from '../../core/entities/internal'; +import { TenantOrganizationBaseDTO } from '../../core/dto'; + +/** + * Update team member active task entity DTO + */ +export class UpdateOrganizationTeamActiveTaskDTO extends IntersectionType( + TenantOrganizationBaseDTO, + PickType(OrganizationTeamEmployee, ['activeTaskId', 'organizationTeamId']), +) implements IOrganizationTeamEmployeeUpdateInput { } diff --git a/packages/core/src/organization-team-employee/dto/update-team-member.dto.ts b/packages/core/src/organization-team-employee/dto/update-team-member.dto.ts index ebc4c768e85..937b635c7de 100644 --- a/packages/core/src/organization-team-employee/dto/update-team-member.dto.ts +++ b/packages/core/src/organization-team-employee/dto/update-team-member.dto.ts @@ -1,18 +1,12 @@ import { IOrganizationTeamEmployeeUpdateInput } from '@gauzy/contracts'; import { IntersectionType, PickType } from '@nestjs/swagger'; import { OrganizationTeamEmployee } from './../../core/entities/internal'; -import { TenantOrganizationBaseDTO } from './../../core/dto'; +import { UpdateOrganizationTeamActiveTaskDTO } from './update-organization-team-active-task.dto'; /** * Update team member entity DTO */ -export class UpdateTeamMemberDTO - extends IntersectionType( - TenantOrganizationBaseDTO, - PickType(OrganizationTeamEmployee, [ - 'isTrackingEnabled', - 'organizationTeamId', - 'activeTaskId', - ]) - ) - implements IOrganizationTeamEmployeeUpdateInput {} +export class UpdateTeamMemberDTO extends IntersectionType( + UpdateOrganizationTeamActiveTaskDTO, + PickType(OrganizationTeamEmployee, ['isTrackingEnabled', 'organizationTeamId']) +) implements IOrganizationTeamEmployeeUpdateInput { } diff --git a/packages/core/src/organization-team-employee/organization-team-employee.controller.ts b/packages/core/src/organization-team-employee/organization-team-employee.controller.ts index fcb879f51a5..54a564cb357 100644 --- a/packages/core/src/organization-team-employee/organization-team-employee.controller.ts +++ b/packages/core/src/organization-team-employee/organization-team-employee.controller.ts @@ -6,7 +6,7 @@ import { PermissionGuard, TenantPermissionGuard } from './../shared/guards'; import { Permissions } from './../shared/decorators'; import { UUIDValidationPipe } from './../shared/pipes'; import { OrganizationTeamEmployeeService } from './organization-team-employee.service'; -import { DeleteTeamMemberQueryDTO, UpdateTeamMemberDTO } from './dto'; +import { DeleteTeamMemberQueryDTO, UpdateOrganizationTeamActiveTaskDTO, UpdateTeamMemberDTO } from './dto'; import { OrganizationTeamEmployee } from './organization-team-employee.entity'; @ApiTags('OrganizationTeamEmployee') @@ -37,6 +37,24 @@ export class OrganizationTeamEmployeeController { return await this.organizationTeamEmployeeService.update(memberId, entity); } + + /** + * Update organization team member active task entity + * + * @param id + * @param entity + * @returns + */ + @HttpCode(HttpStatus.ACCEPTED) + @UsePipes(new ValidationPipe({ whitelist: true })) + @Put(':id/active-task') + async updateActiveTask( + @Param('id', UUIDValidationPipe) memberId: IOrganizationTeamEmployee['id'], + @Body() entity: UpdateOrganizationTeamActiveTaskDTO + ): Promise { + return await this.organizationTeamEmployeeService.updateActiveTask(memberId, entity); + } + /** * Delete team member by memberId * diff --git a/packages/core/src/organization-team-employee/organization-team-employee.entity.ts b/packages/core/src/organization-team-employee/organization-team-employee.entity.ts index 60cd6789729..5ae33ee5a46 100644 --- a/packages/core/src/organization-team-employee/organization-team-employee.entity.ts +++ b/packages/core/src/organization-team-employee/organization-team-employee.entity.ts @@ -17,10 +17,7 @@ import { } from '../core/entities/internal'; @Entity('organization_team_employee') -export class OrganizationTeamEmployee - extends TenantOrganizationBaseEntity - implements IOrganizationTeamEmployee -{ +export class OrganizationTeamEmployee extends TenantOrganizationBaseEntity implements IOrganizationTeamEmployee { /** * enabled / disabled time tracking feature for team member */ @@ -28,7 +25,7 @@ export class OrganizationTeamEmployee @IsOptional() @IsBoolean() @Column({ type: Boolean, nullable: true, default: true }) - isTrackingEnabled?: boolean; + public isTrackingEnabled?: boolean; /* |-------------------------------------------------------------------------- @@ -40,7 +37,8 @@ export class OrganizationTeamEmployee * member's active task */ @ApiProperty({ type: () => Task }) - @ManyToOne(() => Task, (task) => task.organizationTeamEmployees, { + @ManyToOne(() => Task, (it) => it.organizationTeamEmployees, { + /** Database cascade action on delete. */ onDelete: 'CASCADE', }) public activeTask?: ITask; @@ -51,19 +49,16 @@ export class OrganizationTeamEmployee @RelationId((it: OrganizationTeamEmployee) => it.activeTask) @Index() @Column({ type: String, nullable: true }) - activeTaskId?: string; + public activeTaskId?: ITask['id']; /** * OrganizationTeam */ @ApiProperty({ type: () => OrganizationTeam }) - @ManyToOne( - () => OrganizationTeam, - (organizationTeam) => organizationTeam.members, - { - onDelete: 'CASCADE', - } - ) + @ManyToOne(() => OrganizationTeam, (it) => it.members, { + /** Database cascade action on delete. */ + onDelete: 'CASCADE' + }) public organizationTeam!: IOrganizationTeam; @ApiProperty({ type: () => String }) @@ -79,6 +74,7 @@ export class OrganizationTeamEmployee */ @ApiProperty({ type: () => Employee }) @ManyToOne(() => Employee, (employee) => employee.teams, { + /** Database cascade action on delete. */ onDelete: 'CASCADE', }) public employee: IEmployee; @@ -92,14 +88,19 @@ export class OrganizationTeamEmployee /** * Role */ - @ApiProperty({ type: () => Role }) + @ApiPropertyOptional({ type: () => Role }) @ManyToOne(() => Role, { + /** Indicates if relation column value can be nullable or not. */ nullable: true, + + /** Database cascade action on delete. */ onDelete: 'CASCADE', }) public role?: IRole; - @ApiProperty({ type: () => String, readOnly: true }) + @ApiPropertyOptional({ type: () => String }) + @IsOptional() + @IsUUID() @RelationId((it: OrganizationTeamEmployee) => it.role) @Index() @Column({ nullable: true }) 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 0ee9179cb84..3d8f4d16e39 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 @@ -5,6 +5,7 @@ import { IEmployee, IOrganizationTeam, IOrganizationTeamEmployee, + IOrganizationTeamEmployeeActiveTaskUpdateInput, IOrganizationTeamEmployeeFindInput, IOrganizationTeamEmployeeUpdateInput, PermissionsEnum, @@ -67,8 +68,8 @@ export class OrganizationTeamEmployeeService extends TenantAwareCrudService { + + try { + const { organizationId, organizationTeamId } = entity; + const tenantId = RequestContext.currentTenantId(); + + // Admin, Super Admin can update activeTaskId of any Employee + if ( + RequestContext.hasPermission( + PermissionsEnum.CHANGE_SELECTED_EMPLOYEE + ) + ) { + const member = await this.repository.findOneOrFail({ + where: { + id: memberId, + tenantId, + organizationId, + organizationTeamId, + }, + }); + + return await this.repository.update(member.id, { activeTaskId: entity.activeTaskId }); + } else { + const employeeId = RequestContext.currentEmployeeId(); + if (employeeId) { + let member: OrganizationTeamEmployee; + try { + /** If employee has manager of the team, he/she should be able to update activeTaskId for team */ + await this.findOneByWhereOptions({ + organizationId, + organizationTeamId, + role: { + name: RolesEnum.MANAGER, + }, + }); + member = await this.repository.findOneOrFail({ + where: { + id: memberId, + organizationId, + tenantId, + organizationTeamId, + }, + }); + } catch (error) { + /** If employee has member of the team, he/she should be able to remove own self from team */ + member = await this.repository.findOneOrFail({ + where: { + employeeId, + organizationId, + tenantId, + organizationTeamId, + }, + }); + } + return await super.update({ id: member.id, organizationId, organizationTeamId }, { activeTaskId: entity.activeTaskId }); + + } + throw new ForbiddenException(); + } + } catch (error) { + throw new ForbiddenException(); + } + + + } + /** * Delete team member by memberId * diff --git a/packages/core/src/role-permission/default-role-permissions.ts b/packages/core/src/role-permission/default-role-permissions.ts index 959989ede02..00d0f273094 100644 --- a/packages/core/src/role-permission/default-role-permissions.ts +++ b/packages/core/src/role-permission/default-role-permissions.ts @@ -85,6 +85,7 @@ export const DEFAULT_ROLE_PERMISSIONS = [ PermissionsEnum.ORG_PROJECT_DELETE, PermissionsEnum.ORG_CONTACT_EDIT, PermissionsEnum.ORG_CONTACT_VIEW, + /** Organization Team */ PermissionsEnum.ORG_TEAM_ADD, PermissionsEnum.ORG_TEAM_VIEW, PermissionsEnum.ORG_TEAM_EDIT, @@ -218,6 +219,7 @@ export const DEFAULT_ROLE_PERMISSIONS = [ PermissionsEnum.ORG_PROJECT_DELETE, PermissionsEnum.ORG_CONTACT_EDIT, PermissionsEnum.ORG_CONTACT_VIEW, + /** Organization Team */ PermissionsEnum.ORG_TEAM_ADD, PermissionsEnum.ORG_TEAM_VIEW, PermissionsEnum.ORG_TEAM_EDIT, @@ -329,6 +331,7 @@ export const DEFAULT_ROLE_PERMISSIONS = [ PermissionsEnum.ORG_CONTACT_VIEW, PermissionsEnum.ORG_PROJECT_ADD, PermissionsEnum.ORG_PROJECT_VIEW, + /** Organization Team */ PermissionsEnum.ORG_TEAM_ADD, PermissionsEnum.ORG_TEAM_VIEW, PermissionsEnum.ORG_TEAM_EDIT, diff --git a/packages/core/src/tasks/dto/get-task-by-id.dto.ts b/packages/core/src/tasks/dto/get-task-by-id.dto.ts new file mode 100644 index 00000000000..40011e04fd6 --- /dev/null +++ b/packages/core/src/tasks/dto/get-task-by-id.dto.ts @@ -0,0 +1,14 @@ +import { IGetTaskById } from '@gauzy/contracts'; +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { IsBoolean, IsOptional } from 'class-validator'; +import { OptionParams, Task } from 'core'; + +/** + * GET task by Id DTO validation + */ +export class GetTaskByIdDTO extends OptionParams implements IGetTaskById { + @ApiPropertyOptional({ type: () => Boolean }) + @IsOptional() + @IsBoolean() + includeRootEpic?: boolean; +} diff --git a/packages/core/src/tasks/dto/index.ts b/packages/core/src/tasks/dto/index.ts index 1f97d28d026..c2595de73c6 100644 --- a/packages/core/src/tasks/dto/index.ts +++ b/packages/core/src/tasks/dto/index.ts @@ -1,3 +1,4 @@ export * from './create-task.dto'; export * from './task-max-number-query.dto'; export * from './update-task.dto'; +export * from './get-task-by-id.dto'; diff --git a/packages/core/src/tasks/task.controller.ts b/packages/core/src/tasks/task.controller.ts index d7b624d05a3..2e142c263d4 100644 --- a/packages/core/src/tasks/task.controller.ts +++ b/packages/core/src/tasks/task.controller.ts @@ -31,7 +31,12 @@ import { CrudController, PaginationParams } from './../core/crud'; import { Task } from './task.entity'; import { TaskService } from './task.service'; import { TaskCreateCommand, TaskUpdateCommand } from './commands'; -import { CreateTaskDTO, TaskMaxNumberQueryDTO, UpdateTaskDTO } from './dto'; +import { + CreateTaskDTO, + GetTaskByIdDTO, + TaskMaxNumberQueryDTO, + UpdateTaskDTO, +} from './dto'; @ApiTags('Tasks') @UseGuards(TenantPermissionGuard, PermissionGuard) @@ -173,6 +178,24 @@ export class TaskController extends CrudController { return await this.taskService.findTeamTasks(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', + }) + @Permissions(PermissionsEnum.ALL_ORG_VIEW, PermissionsEnum.ORG_TASK_VIEW) + @Get(':id') + async findById( + @Param('id', UUIDValidationPipe) id: Task['id'], + @Query() params: GetTaskByIdDTO + ): Promise { + return this.taskService.findById(id, params); + } + /** * GET tasks by employee * diff --git a/packages/core/src/tasks/task.entity.ts b/packages/core/src/tasks/task.entity.ts index 61ea93518ef..dcd58570a6a 100644 --- a/packages/core/src/tasks/task.entity.ts +++ b/packages/core/src/tasks/task.entity.ts @@ -147,6 +147,7 @@ export class Task extends TenantOrganizationBaseEntity implements ITask { * Additional exposed fields */ taskNumber?: string; + rootEpic?: ITask; /* |-------------------------------------------------------------------------- diff --git a/packages/core/src/tasks/task.service.ts b/packages/core/src/tasks/task.service.ts index 18c9bbdec67..0ac482ab034 100644 --- a/packages/core/src/tasks/task.service.ts +++ b/packages/core/src/tasks/task.service.ts @@ -20,6 +20,7 @@ import { isUUID } from 'class-validator'; import { PaginationParams, TenantAwareCrudService } from './../core/crud'; import { RequestContext } from '../core/context'; import { Task } from './task.entity'; +import { GetTaskByIdDTO } from './dto'; @Injectable() export class TaskService extends TenantAwareCrudService { @@ -30,6 +31,49 @@ export class TaskService extends TenantAwareCrudService { super(taskRepository); } + /** + * + * @param id + * @param relations + * @returns + */ + async findById(id: ITask['id'], params: GetTaskByIdDTO): Promise { + const task = await this.findOneByIdString(id, params); + + if (params.includeRootEpic) { + task.rootEpic = await this.findParentUntilEpic(task.id); + } + + return task; + } + + async findParentUntilEpic(issueId: string): Promise { + // Define the recursive SQL query + const query = ` + 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") + SELECT * + FROM IssueHierarchy + WHERE "issueType" = 'Epic' + LIMIT 1; + `; + + // Execute the raw SQL query with the issueId parameter + const result = await this.taskRepository.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; + } + } + /** * GET my tasks * diff --git a/yarn.lock b/yarn.lock index aa1c619fe5c..768f2049dc0 100644 --- a/yarn.lock +++ b/yarn.lock @@ -13986,11 +13986,16 @@ dateformat@^4.5.1, dateformat@^4.6.3: resolved "https://registry.yarnpkg.com/dateformat/-/dateformat-4.6.3.tgz#556fa6497e5217fedb78821424f8a1c22fa3f4b5" integrity sha512-2P0p0pFGzHS5EMnhdxQi7aJN+iMheud0UhG4dlE1DLAlvL8JHjJJTX/CSm4JXwV0Ka5nGk3zC5mcb5bUQUxxMA== -dayjs@^1.10.4, dayjs@^1.10.6: +dayjs@^1.10.4: version "1.11.7" resolved "https://registry.yarnpkg.com/dayjs/-/dayjs-1.11.7.tgz#4b296922642f70999544d1144a2c25730fce63e2" integrity sha512-+Yw9U6YO5TQohxLcIkrXBeY73WP3ejHWVvx8XCk3gxvQDCTEmS48ZrSZCKciI7Bhl/uCMyxYtE9UqRILmFphkQ== +dayjs@^1.11.4: + version "1.11.10" + resolved "https://registry.yarnpkg.com/dayjs/-/dayjs-1.11.10.tgz#68acea85317a6e164457d6d6947564029a6a16a0" + integrity sha512-vjAczensTgRcqDERK0SR2XMwsF/tSvnvlv6VcF2GIhg6Sx4yOIt/irsr1RDJsKiIyBzJDpCoXiWWq28MqH2cnQ== + dayjs@^1.11.5: version "1.11.9" resolved "https://registry.yarnpkg.com/dayjs/-/dayjs-1.11.9.tgz#9ca491933fadd0a60a2c19f6c237c03517d71d1a"