diff --git a/.cspell.json b/.cspell.json index 51cb39a345d..0672abff46e 100644 --- a/.cspell.json +++ b/.cspell.json @@ -399,7 +399,11 @@ "gridcell", "scrollgrid", "r_liteprofile", - "r_emailaddress" + "r_emailaddress", + "Signoz", + "OTEL", + "OTLP", + "opentelemetry" ], "useGitignore": true, "ignorePaths": [ diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts index ca88ee0ced5..17146cafe7a 100644 --- a/packages/contracts/src/index.ts +++ b/packages/contracts/src/index.ts @@ -1,4 +1,6 @@ export * from './accounting-template.model'; +/** App Setting Model */ +export * from './app.model'; export * from './appointment-employees.model'; export * from './approval-policy.model'; export * from './availability-slots.model'; @@ -47,6 +49,7 @@ export * from './expense.model'; export * from './feature.model'; export * from './file-provider'; export * from './geo-location.model'; +export * from './github.model'; export * from './goal-settings.model'; export * from './goals.model'; export * from './help-center-article.model'; @@ -77,6 +80,7 @@ export * from './organization-positions.model'; export * from './organization-projects.model'; export * from './organization-recurring-expense.model'; export * from './organization-sprint.model'; +export * from './organization-task-setting.model'; export * from './organization-team-employee-model'; export * from './organization-team-join-request.model'; export * from './organization-team.model'; @@ -96,17 +100,19 @@ export * from './request-approval-team.model'; export * from './request-approval.model'; export * from './role-permission.model'; export * from './role.model'; +export * from './screenshot.model'; export * from './seed.model'; export * from './skill-entity.model'; export * from './sms.model'; export * from './tag.model'; +export * from './task-estimation.model'; +export * from './task-linked-issue.model'; export * from './task-priority.model'; -export * from './task-size.model'; 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.model'; -export * from './task-linked-issue.model'; export * from './tenant.model'; export * from './time-off.model'; export * from './timesheet-statistics.model'; @@ -117,12 +123,6 @@ export * from './upwork.model'; export * from './user-organization.model'; export * from './user.model'; export * from './wakatime.model'; -export * from './organization-task-setting.model'; -export * from './task-estimation.model'; -export * from './github.model'; - -/** App Setting Model */ -export * from './app.model'; export { IBaseEntityModel as BaseEntityModel } from './base-entity.model'; export { diff --git a/packages/contracts/src/screenshot.model.ts b/packages/contracts/src/screenshot.model.ts new file mode 100644 index 00000000000..a07079cea5b --- /dev/null +++ b/packages/contracts/src/screenshot.model.ts @@ -0,0 +1,40 @@ +import { IBasePerTenantAndOrganizationEntityModel } from "./base-entity.model"; +import { FileStorageProviderEnum } from "./file-provider"; +import { ITimeSlot } from "./timesheet.model"; +import { IRelationalUser } from "./user.model"; + +export interface IScreenshot extends IBasePerTenantAndOrganizationEntityModel, IRelationalUser { + [x: string]: any; + file: string; + thumb?: string; + fileUrl?: string; + thumbUrl?: string; + fullUrl?: string; + recordedAt?: Date; + storageProvider?: FileStorageProviderEnum; + /** Image/Screenshot Analysis Through Gauzy AI */ + isWorkRelated?: boolean; + description?: string; + apps?: string | string[]; + /** Relations */ + timeSlot?: ITimeSlot; + timeSlotId?: ITimeSlot['id']; +} + +export interface IScreenshotMap { + startTime: string; + endTime: string; + timeSlots: ITimeSlot[]; +} + +export interface IUpdateScreenshotInput extends ICreateScreenshotInput { + id: string; +} + +export interface ICreateScreenshotInput extends IBasePerTenantAndOrganizationEntityModel { + activityTimestamp: string; + employeeId?: string; + file: string; + thumb?: string; + recordedAt: Date | string; +} diff --git a/packages/contracts/src/timesheet.model.ts b/packages/contracts/src/timesheet.model.ts index 6673f574fa3..d726301b30f 100644 --- a/packages/contracts/src/timesheet.model.ts +++ b/packages/contracts/src/timesheet.model.ts @@ -20,9 +20,9 @@ import { ITask } from './task.model'; import { ITag } from './tag.model'; import { IPaginationInput } from './core.model'; import { ReportGroupByFilter } from './report.model'; -import { FileStorageProviderEnum } from './file-provider'; -import { IRelationalUser, IUser } from './user.model'; +import { IUser } from './user.model'; import { IRelationalOrganizationTeam } from './organization-team.model'; +import { IScreenshot } from './screenshot.model'; export interface ITimesheet extends IBasePerTenantAndOrganizationEntityModel { employee: IEmployee; @@ -309,39 +309,7 @@ export interface IURLMetaData { [x: string]: any; } -export interface IUpdateScreenshotInput extends ICreateScreenshotInput { - id: string; -} - -export interface ICreateScreenshotInput - extends IBasePerTenantAndOrganizationEntityModel { - activityTimestamp: string; - employeeId?: string; - file: string; - thumb?: string; - recordedAt: Date | string; -} - -export interface IScreenshot - extends IBasePerTenantAndOrganizationEntityModel, - IRelationalEmployee, - IRelationalUser { - [x: string]: any; - timeSlot?: ITimeSlot; - timeSlotId?: ITimeSlot['id']; - file: string; - thumb?: string; - fileUrl?: string; - thumbUrl?: string; - recordedAt?: Date; - storageProvider?: FileStorageProviderEnum; -} -export interface IScreenshotMap { - startTime: string; - endTime: string; - timeSlots: ITimeSlot[]; -} export interface ITimerStatusInput extends ITimeLogTodayFilters, diff --git a/packages/core/src/database/migrations/1701081154869-AlterScreenshotTable.ts b/packages/core/src/database/migrations/1701081154869-AlterScreenshotTable.ts new file mode 100644 index 00000000000..8acce21d768 --- /dev/null +++ b/packages/core/src/database/migrations/1701081154869-AlterScreenshotTable.ts @@ -0,0 +1,119 @@ + +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class AlterScreenshotTable1701081154869 implements MigrationInterface { + + name = 'AlterScreenshotTable1701081154869'; + + /** + * Up Migration + * + * @param queryRunner + */ + public async up(queryRunner: QueryRunner): Promise { + if (['sqlite', 'better-sqlite3'].includes(queryRunner.connection.options.type)) { + await this.sqliteUpQueryRunner(queryRunner); + } else { + await this.postgresUpQueryRunner(queryRunner); + } + } + + /** + * Down Migration + * + * @param queryRunner + */ + public async down(queryRunner: QueryRunner): Promise { + if (['sqlite', 'better-sqlite3'].includes(queryRunner.connection.options.type)) { + await this.sqliteDownQueryRunner(queryRunner); + } else { + await this.postgresDownQueryRunner(queryRunner); + } + } + + /** + * PostgresDB Up Migration + * + * @param queryRunner + */ + public async postgresUpQueryRunner(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "screenshot" ADD "isWorkRelated" boolean`); + await queryRunner.query(`ALTER TABLE "screenshot" ADD "description" character varying`); + await queryRunner.query(`ALTER TABLE "screenshot" ADD "apps" json`); + await queryRunner.query(`CREATE INDEX "IDX_1b0867d86ead2332f3d4edba7d" ON "screenshot" ("isWorkRelated") `); + await queryRunner.query(`CREATE INDEX "IDX_eea7986acfb827bf5d0622c41f" ON "screenshot" ("description") `); + } + + /** + * PostgresDB Down Migration + * + * @param queryRunner + */ + public async postgresDownQueryRunner(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP INDEX "public"."IDX_eea7986acfb827bf5d0622c41f"`); + await queryRunner.query(`DROP INDEX "public"."IDX_1b0867d86ead2332f3d4edba7d"`); + await queryRunner.query(`ALTER TABLE "screenshot" DROP COLUMN "apps"`); + await queryRunner.query(`ALTER TABLE "screenshot" DROP COLUMN "description"`); + await queryRunner.query(`ALTER TABLE "screenshot" DROP COLUMN "isWorkRelated"`); + } + + /** + * SqliteDB and BetterSQlite3DB Up Migration + * + * @param queryRunner + */ + public async sqliteUpQueryRunner(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP INDEX "IDX_892e285e1da2b3e61e51e50628"`); + await queryRunner.query(`DROP INDEX "IDX_742688858e0484d66f04e4d4c4"`); + await queryRunner.query(`DROP INDEX "IDX_2b374e5cdee1145ebb2a832f20"`); + await queryRunner.query(`DROP INDEX "IDX_3d7feb5fe793e4811cdb79f983"`); + await queryRunner.query(`DROP INDEX "IDX_235004f3dafac90692cd64d915"`); + await queryRunner.query(`DROP INDEX "IDX_0951aacffe3f8d0cff54cf2f34"`); + await queryRunner.query(`DROP INDEX "IDX_5b594d02d98d5defcde323abe5"`); + await queryRunner.query(`DROP INDEX "IDX_fa1896dc735403799311968f7e"`); + await queryRunner.query(`CREATE TABLE "temporary_screenshot" ("id" varchar PRIMARY KEY NOT NULL, "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "tenantId" varchar, "organizationId" varchar, "file" varchar NOT NULL, "thumb" varchar, "recordedAt" datetime, "deletedAt" datetime, "timeSlotId" varchar, "storageProvider" varchar, "userId" varchar, "isActive" boolean DEFAULT (1), "isArchived" boolean DEFAULT (0), "isWorkRelated" boolean, "description" varchar, "apps" text, CONSTRAINT "FK_235004f3dafac90692cd64d9158" FOREIGN KEY ("tenantId") REFERENCES "tenant" ("id") ON DELETE CASCADE ON UPDATE NO ACTION, CONSTRAINT "FK_0951aacffe3f8d0cff54cf2f341" FOREIGN KEY ("organizationId") REFERENCES "organization" ("id") ON DELETE CASCADE ON UPDATE CASCADE, CONSTRAINT "FK_5b594d02d98d5defcde323abe5b" FOREIGN KEY ("timeSlotId") REFERENCES "time_slot" ("id") ON DELETE CASCADE ON UPDATE NO ACTION, CONSTRAINT "FK_fa1896dc735403799311968f7ec" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)`); + await queryRunner.query(`INSERT INTO "temporary_screenshot"("id", "createdAt", "updatedAt", "tenantId", "organizationId", "file", "thumb", "recordedAt", "deletedAt", "timeSlotId", "storageProvider", "userId", "isActive", "isArchived") SELECT "id", "createdAt", "updatedAt", "tenantId", "organizationId", "file", "thumb", "recordedAt", "deletedAt", "timeSlotId", "storageProvider", "userId", "isActive", "isArchived" FROM "screenshot"`); + await queryRunner.query(`DROP TABLE "screenshot"`); + await queryRunner.query(`ALTER TABLE "temporary_screenshot" RENAME TO "screenshot"`); + await queryRunner.query(`CREATE INDEX "IDX_892e285e1da2b3e61e51e50628" ON "screenshot" ("isArchived") `); + await queryRunner.query(`CREATE INDEX "IDX_742688858e0484d66f04e4d4c4" ON "screenshot" ("isActive") `); + await queryRunner.query(`CREATE INDEX "IDX_2b374e5cdee1145ebb2a832f20" ON "screenshot" ("storageProvider") `); + await queryRunner.query(`CREATE INDEX "IDX_3d7feb5fe793e4811cdb79f983" ON "screenshot" ("recordedAt") `); + await queryRunner.query(`CREATE INDEX "IDX_235004f3dafac90692cd64d915" ON "screenshot" ("tenantId") `); + await queryRunner.query(`CREATE INDEX "IDX_0951aacffe3f8d0cff54cf2f34" ON "screenshot" ("organizationId") `); + await queryRunner.query(`CREATE INDEX "IDX_5b594d02d98d5defcde323abe5" ON "screenshot" ("timeSlotId") `); + await queryRunner.query(`CREATE INDEX "IDX_fa1896dc735403799311968f7e" ON "screenshot" ("userId") `); + await queryRunner.query(`CREATE INDEX "IDX_1b0867d86ead2332f3d4edba7d" ON "screenshot" ("isWorkRelated") `); + await queryRunner.query(`CREATE INDEX "IDX_eea7986acfb827bf5d0622c41f" ON "screenshot" ("description") `); + } + + /** + * SqliteDB and BetterSQlite3DB Down Migration + * + * @param queryRunner + */ + public async sqliteDownQueryRunner(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP INDEX "IDX_eea7986acfb827bf5d0622c41f"`); + await queryRunner.query(`DROP INDEX "IDX_1b0867d86ead2332f3d4edba7d"`); + await queryRunner.query(`DROP INDEX "IDX_fa1896dc735403799311968f7e"`); + await queryRunner.query(`DROP INDEX "IDX_5b594d02d98d5defcde323abe5"`); + await queryRunner.query(`DROP INDEX "IDX_0951aacffe3f8d0cff54cf2f34"`); + await queryRunner.query(`DROP INDEX "IDX_235004f3dafac90692cd64d915"`); + await queryRunner.query(`DROP INDEX "IDX_3d7feb5fe793e4811cdb79f983"`); + await queryRunner.query(`DROP INDEX "IDX_2b374e5cdee1145ebb2a832f20"`); + await queryRunner.query(`DROP INDEX "IDX_742688858e0484d66f04e4d4c4"`); + await queryRunner.query(`DROP INDEX "IDX_892e285e1da2b3e61e51e50628"`); + await queryRunner.query(`ALTER TABLE "screenshot" RENAME TO "temporary_screenshot"`); + await queryRunner.query(`CREATE TABLE "screenshot" ("id" varchar PRIMARY KEY NOT NULL, "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "tenantId" varchar, "organizationId" varchar, "file" varchar NOT NULL, "thumb" varchar, "recordedAt" datetime, "deletedAt" datetime, "timeSlotId" varchar, "storageProvider" varchar, "userId" varchar, "isActive" boolean DEFAULT (1), "isArchived" boolean DEFAULT (0), CONSTRAINT "FK_235004f3dafac90692cd64d9158" FOREIGN KEY ("tenantId") REFERENCES "tenant" ("id") ON DELETE CASCADE ON UPDATE NO ACTION, CONSTRAINT "FK_0951aacffe3f8d0cff54cf2f341" FOREIGN KEY ("organizationId") REFERENCES "organization" ("id") ON DELETE CASCADE ON UPDATE CASCADE, CONSTRAINT "FK_5b594d02d98d5defcde323abe5b" FOREIGN KEY ("timeSlotId") REFERENCES "time_slot" ("id") ON DELETE CASCADE ON UPDATE NO ACTION, CONSTRAINT "FK_fa1896dc735403799311968f7ec" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)`); + await queryRunner.query(`INSERT INTO "screenshot"("id", "createdAt", "updatedAt", "tenantId", "organizationId", "file", "thumb", "recordedAt", "deletedAt", "timeSlotId", "storageProvider", "userId", "isActive", "isArchived") SELECT "id", "createdAt", "updatedAt", "tenantId", "organizationId", "file", "thumb", "recordedAt", "deletedAt", "timeSlotId", "storageProvider", "userId", "isActive", "isArchived" FROM "temporary_screenshot"`); + await queryRunner.query(`DROP TABLE "temporary_screenshot"`); + await queryRunner.query(`CREATE INDEX "IDX_fa1896dc735403799311968f7e" ON "screenshot" ("userId") `); + await queryRunner.query(`CREATE INDEX "IDX_5b594d02d98d5defcde323abe5" ON "screenshot" ("timeSlotId") `); + await queryRunner.query(`CREATE INDEX "IDX_0951aacffe3f8d0cff54cf2f34" ON "screenshot" ("organizationId") `); + await queryRunner.query(`CREATE INDEX "IDX_235004f3dafac90692cd64d915" ON "screenshot" ("tenantId") `); + await queryRunner.query(`CREATE INDEX "IDX_3d7feb5fe793e4811cdb79f983" ON "screenshot" ("recordedAt") `); + await queryRunner.query(`CREATE INDEX "IDX_2b374e5cdee1145ebb2a832f20" ON "screenshot" ("storageProvider") `); + await queryRunner.query(`CREATE INDEX "IDX_742688858e0484d66f04e4d4c4" ON "screenshot" ("isActive") `); + await queryRunner.query(`CREATE INDEX "IDX_892e285e1da2b3e61e51e50628" ON "screenshot" ("isArchived") `); + } +} diff --git a/packages/core/src/integration-tenant/integration-tenant.service.ts b/packages/core/src/integration-tenant/integration-tenant.service.ts index d17a6620742..bdbdfff4614 100644 --- a/packages/core/src/integration-tenant/integration-tenant.service.ts +++ b/packages/core/src/integration-tenant/integration-tenant.service.ts @@ -2,14 +2,12 @@ import { BadRequestException, Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { FindManyOptions, IsNull, Not, Repository } from 'typeorm'; import { - IBasePerTenantAndOrganizationEntityModel, IIntegrationEntitySetting, IIntegrationSetting, IIntegrationTenant, IIntegrationTenantCreateInput, IIntegrationTenantFindInput, - IPagination, - IntegrationEnum + IPagination } from '@gauzy/contracts'; import { RequestContext } from 'core/context'; import { TenantAwareCrudService } from 'core/crud'; @@ -98,17 +96,18 @@ export class IntegrationTenantService extends TenantAwareCrudService { try { - const { organizationId, tenantId } = options; + const tenantId = RequestContext.currentTenantId() || input.tenantId; + const { organizationId, name } = input; + return await this.findOneByOptions({ where: { organizationId, tenantId, - name: IntegrationEnum.GAUZY_AI + name, + isActive: true, + isArchived: false, + integration: { + provider: name, + isActive: true, + isArchived: false, + } }, relations: { settings: true diff --git a/packages/core/src/integration/gauzy-ai/integration-ai.middleware.ts b/packages/core/src/integration/gauzy-ai/integration-ai.middleware.ts index 7d176e0e97b..53a92ab9b37 100644 --- a/packages/core/src/integration/gauzy-ai/integration-ai.middleware.ts +++ b/packages/core/src/integration/gauzy-ai/integration-ai.middleware.ts @@ -1,6 +1,7 @@ import { Injectable, NestMiddleware } from '@nestjs/common'; import { Request, Response, NextFunction } from 'express'; import { isNotEmpty } from '@gauzy/common'; +import { IntegrationEnum } from '@gauzy/contracts'; import { RequestConfigProvider } from '@gauzy/integration-ai'; import { arrayToObject } from 'core/utils'; import { IntegrationTenantService } from 'integration-tenant/integration-tenant.service'; @@ -34,7 +35,11 @@ export class IntegrationAIMiddleware implements NestMiddleware { // Check if tenant and organization IDs are not empty if (isNotEmpty(tenantId) && isNotEmpty(organizationId)) { // Fetch integration settings from the service - const { settings = [] } = await this.integrationTenantService.getIntegrationSettings({ tenantId, organizationId }); + const { settings = [] } = await this.integrationTenantService.getIntegrationTenantSettings({ + tenantId, + organizationId, + name: IntegrationEnum.GAUZY_AI + }); // Convert settings array to an object const { apiKey: ApiKey, apiSecret: ApiSecret } = arrayToObject(settings, 'settingsName', 'settingsValue'); diff --git a/packages/core/src/time-tracking/screenshot/screenshot.controller.ts b/packages/core/src/time-tracking/screenshot/screenshot.controller.ts index b614d6a773e..65e2b352604 100644 --- a/packages/core/src/time-tracking/screenshot/screenshot.controller.ts +++ b/packages/core/src/time-tracking/screenshot/screenshot.controller.ts @@ -9,7 +9,7 @@ import { Param, Query, UsePipes, - ValidationPipe, + ValidationPipe } from '@nestjs/common'; import { ApiTags, ApiOperation, ApiResponse } from '@nestjs/swagger'; import * as path from 'path'; @@ -17,6 +17,7 @@ import * as moment from 'moment'; import * as fs from 'fs'; import { v4 as uuid } from 'uuid'; import * as Jimp from 'jimp'; +import { ImageAnalysisResult } from '@gauzy/integration-ai'; import { FileStorageProviderEnum, IScreenshot, @@ -41,9 +42,15 @@ import { DeleteQueryDTO } from './../../shared/dto'; export class ScreenshotController { constructor( - private readonly screenshotService: ScreenshotService + private readonly _screenshotService: ScreenshotService ) { } + /** + * + * @param entity + * @param file + * @returns + */ @ApiOperation({ summary: 'Create start/stop screenshot.' }) @ApiResponse({ status: HttpStatus.OK, @@ -55,10 +62,16 @@ export class ScreenshotController { }) @Post() @UseInterceptors( + // Use the LazyFileInterceptor to handle file uploads LazyFileInterceptor('file', { + // Define storage settings for uploaded files storage: () => { return new FileStorage().storage({ - dest: () => path.join('screenshots', moment().format('YYYY/MM/DD'), RequestContext.currentTenantId() || uuid()), + dest: () => path.join( + 'screenshots', + moment().format('YYYY/MM/DD'), + RequestContext.currentTenantId() || uuid() + ), prefix: 'screenshots', }); }, @@ -68,16 +81,32 @@ export class ScreenshotController { @Body() entity: Screenshot, @UploadedFileStorage() file: UploadedFile ) { - console.log('Screenshot Http Request', { entity, file }); + // Extract necessary properties from the request body + const { organizationId } = entity; + const tenantId = RequestContext.currentTenantId() || entity.tenantId; + + // Extract user information from the request context const user = RequestContext.currentUser(); + + // Initialize file storage provider and process thumbnail const provider = new FileStorage().getProvider(); let thumb: UploadedFile; + /** */ + let data: Buffer; + try { + // Retrieve file content from the file storage provider const fileContent = await provider.getFile(file.key); + + // Create temporary files for input and output of thumbnail processing const inputFile = await tempFile('screenshot-thumb'); const outputFile = await tempFile('screenshot-thumb'); + + // Write the file content to the input temporary file await fs.promises.writeFile(inputFile, fileContent); + + // Resize the image using Jimp library await new Promise(async (resolve, reject) => { const image = await Jimp.read(inputFile); @@ -85,40 +114,82 @@ export class ScreenshotController { image.resize(250, Jimp.AUTO); try { + // Write the resized image to the output temporary file await image.writeAsync(outputFile); resolve(image); } catch (error) { reject(error); } }); - const data = await fs.promises.readFile(outputFile); + + // Read the resized image data from the output temporary file + data = await fs.promises.readFile(outputFile); + + // Remove the temporary input and output files await fs.promises.unlink(inputFile); await fs.promises.unlink(outputFile); + // Define thumbnail file name and directory const thumbName = `thumb-${file.filename}`; const thumbDir = path.dirname(file.key); + // Upload the thumbnail data to the file storage provider thumb = await provider.putFile(data, path.join(thumbDir, thumbName)); console.log(`Screenshot thumb created for employee (${user.name})`, thumb); } catch (error) { - console.log('Error while uploading screenshot into file storage provider:', error); + // Log error and throw an exception if thumbnail processing fails + console.log('Error while processing screenshot thumbnail:', error); } try { + // Populate entity properties for the screenshot + entity.organizationId = organizationId; + entity.tenantId = tenantId; entity.userId = RequestContext.currentUserId(); entity.file = file.key; entity.thumb = thumb.key; entity.storageProvider = provider.name.toUpperCase() as FileStorageProviderEnum; entity.recordedAt = entity.recordedAt ? entity.recordedAt : new Date(); - const screenshot = await this.screenshotService.create(entity); + // Create the screenshot entity in the database + const screenshot = await this._screenshotService.create(entity); + + // Analyze image using Gauzy AI service + this._screenshotService.analyzeScreenshot( + screenshot, + data, + file, + async (result: ImageAnalysisResult['data']['analysis']) => { + if (result) { + const [analysis] = result; + /** */ + const isWorkRelated = analysis.work; + const description = analysis.description || ''; + const apps = analysis.apps || []; + + /** */ + await this._screenshotService.update(screenshot.id, { + isWorkRelated, + description, + apps + }); + } + } + ); + console.log(`Screenshot created for employee (${user.name})`, screenshot); - return await this.screenshotService.findOneByIdString(screenshot.id); + return await this._screenshotService.findOneByIdString(screenshot.id); } catch (error) { console.log(`Error while creating screenshot for employee (${user.name})`, error); } } + /** + * + * @param screenshotId + * @param options + * @returns + */ @ApiOperation({ summary: 'Delete record', }) @@ -137,7 +208,7 @@ export class ScreenshotController { @Param('id', UUIDValidationPipe) screenshotId: IScreenshot['id'], @Query() options: DeleteQueryDTO ): Promise { - return await this.screenshotService.deleteScreenshot( + 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 f9a0199c739..a07ff11b723 100644 --- a/packages/core/src/time-tracking/screenshot/screenshot.entity.ts +++ b/packages/core/src/time-tracking/screenshot/screenshot.entity.ts @@ -1,3 +1,4 @@ +import { TypeOrmModuleOptions } from '@nestjs/typeorm'; import { Entity, Column, @@ -6,19 +7,30 @@ import { Index, JoinColumn } from 'typeorm'; -import { FileStorageProviderEnum, IScreenshot, ITimeSlot, IUser } from '@gauzy/contracts'; +import { getConfig } from '@gauzy/config'; import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; -import { IsString, IsOptional, IsDateString, IsUUID, IsNotEmpty, IsEnum } from 'class-validator'; +import { IsString, IsOptional, IsDateString, IsUUID, IsNotEmpty, IsEnum, IsBoolean } from 'class-validator'; import { Exclude } from 'class-transformer'; +import { FileStorageProviderEnum, IScreenshot, ITimeSlot, IUser } from '@gauzy/contracts'; import { TenantOrganizationBaseEntity, TimeSlot, User } from './../../core/entities/internal'; +/** + * + */ +let options: TypeOrmModuleOptions; +try { + options = getConfig().dbConnectionOptions +} catch (error) { } + +/** + * + */ @Entity('screenshot') -export class Screenshot extends TenantOrganizationBaseEntity - implements IScreenshot { +export class Screenshot extends TenantOrganizationBaseEntity implements IScreenshot { @ApiProperty({ type: () => String, }) @IsNotEmpty() @@ -51,6 +63,54 @@ export class Screenshot extends TenantOrganizationBaseEntity }) storageProvider?: FileStorageProviderEnum; + /* + |-------------------------------------------------------------------------- + | Image/Screenshot Analysis Through Gauzy AI + |-------------------------------------------------------------------------- + */ + + /** + * Indicates whether the image or screenshot is work-related. + */ + @ApiPropertyOptional({ + type: () => String, + description: 'Specifies whether the image or screenshot is work-related.' + }) + @IsOptional() + @IsBoolean() + @Index() + @Column({ nullable: true }) + isWorkRelated?: boolean; + + /** + * Description of the image or screenshot. + */ + @ApiPropertyOptional({ + type: () => String, + description: 'Description of the image or screenshot.' + }) + @IsOptional() + @IsString() + @Index() + @Column({ nullable: true }) + description?: string; + + /** + * Applications associated with the image or screenshot. + */ + @ApiPropertyOptional({ + type: () => String, + description: 'Applications associated with the image or screenshot.' + }) + @IsOptional() + @IsString() + @Column({ + nullable: true, + type: ['sqlite', 'better-sqlite3'].includes(options.type) ? 'text' : 'json' + }) + apps?: string | string[]; + + /** Additional fields */ fullUrl?: string; thumbUrl?: string; /* @@ -62,7 +122,11 @@ export class Screenshot extends TenantOrganizationBaseEntity /** * TimeSlot */ - @ManyToOne(() => TimeSlot, (timeSlot) => timeSlot.screenshots, { + @ManyToOne(() => TimeSlot, (it) => it.screenshots, { + /** Indicates if relation column value can be nullable or not. */ + nullable: true, + + /** Database cascade action on delete. */ onDelete: 'CASCADE' }) @JoinColumn() @@ -80,6 +144,10 @@ export class Screenshot extends TenantOrganizationBaseEntity * User */ @ManyToOne(() => User, { + /** Indicates if relation column value can be nullable or not. */ + nullable: true, + + /** Database cascade action on delete. */ onDelete: 'CASCADE' }) @JoinColumn() diff --git a/packages/core/src/time-tracking/screenshot/screenshot.module.ts b/packages/core/src/time-tracking/screenshot/screenshot.module.ts index e30aa77ccfb..7a1a89b4ad9 100644 --- a/packages/core/src/time-tracking/screenshot/screenshot.module.ts +++ b/packages/core/src/time-tracking/screenshot/screenshot.module.ts @@ -1,13 +1,15 @@ import { forwardRef, Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; import { CqrsModule } from '@nestjs/cqrs'; +import { GauzyAIModule } from '@gauzy/integration-ai'; import { TenantModule } from './../../tenant/tenant.module'; import { Screenshot } from './screenshot.entity'; import { ScreenshotController } from './screenshot.controller'; import { ScreenshotService } from './screenshot.service'; -import { CommandHandlers } from './commands/handlers'; import { TimeSlotModule } from './../time-slot/time-slot.module'; import { UserModule } from './../../user/user.module'; +import { IntegrationTenantModule } from './../../integration-tenant/integration-tenant.module'; +import { CommandHandlers } from './commands/handlers'; @Module({ controllers: [ @@ -17,9 +19,11 @@ import { UserModule } from './../../user/user.module'; TypeOrmModule.forFeature([ Screenshot ]), + GauzyAIModule.forRoot(), TenantModule, forwardRef(() => TimeSlotModule), forwardRef(() => UserModule), + IntegrationTenantModule, CqrsModule ], providers: [ diff --git a/packages/core/src/time-tracking/screenshot/screenshot.service.ts b/packages/core/src/time-tracking/screenshot/screenshot.service.ts index bb5ccd7e860..32d4c40398c 100644 --- a/packages/core/src/time-tracking/screenshot/screenshot.service.ts +++ b/packages/core/src/time-tracking/screenshot/screenshot.service.ts @@ -1,17 +1,21 @@ import { ForbiddenException, Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { FindOptionsWhere, Repository } from 'typeorm'; -import { IScreenshot, PermissionsEnum } from '@gauzy/contracts'; +import { GauzyAIService, ImageAnalysisResult } from '@gauzy/integration-ai'; +import { IScreenshot, IntegrationEnum, PermissionsEnum, UploadedFile } from '@gauzy/contracts'; import { RequestContext } from './../../core/context'; import { TenantAwareCrudService } from './../../core/crud'; +import { IntegrationTenantService } from './../../integration-tenant/integration-tenant.service'; import { Screenshot } from './screenshot.entity'; @Injectable() export class ScreenshotService extends TenantAwareCrudService { constructor( - @InjectRepository(Screenshot) - protected readonly screenshotRepository: Repository + @InjectRepository(Screenshot) protected readonly screenshotRepository: Repository, + /** */ + private readonly _integrationTenantService: IntegrationTenantService, + private readonly _gauzyAIService: GauzyAIService, ) { super(screenshotRepository); } @@ -53,4 +57,46 @@ export class ScreenshotService extends TenantAwareCrudService { throw new ForbiddenException(); } } + + /** + * Analyze a screenshot using Gauzy AI service. + * @param input - The input options for the screenshot. + * @param data - The screenshot data. + * @param file - The screenshot file. + * @param callback - Optional callback function to handle the analysis result. + * @returns Promise + */ + async analyzeScreenshot( + input: IScreenshot, + data: Buffer, + file: UploadedFile, + callback?: (analysis: ImageAnalysisResult['data']['analysis']) => void + ): Promise { + try { + const { organizationId } = input; + const tenantId = RequestContext.currentTenantId() || input.tenantId; + + // Retrieve integration + const integration = await this._integrationTenantService.getIntegrationByOptions({ + organizationId, + tenantId, + name: IntegrationEnum.GAUZY_AI + }); + + // Check if integration exists + if (!!integration) { + // Analyze image using Gauzy AI service + const [analysis] = await this._gauzyAIService.analyzeImage(data, file); + + if (analysis.success && callback) { + // Call the callback function if provided + callback(analysis.data.analysis); + } + + return analysis; + } + } catch (error) { + // If needed, consider throwing or handling the error appropriately. + } + } } diff --git a/packages/core/src/time-tracking/screenshot/screenshot.subscriber.ts b/packages/core/src/time-tracking/screenshot/screenshot.subscriber.ts index a997f75aed7..ac298770fe9 100644 --- a/packages/core/src/time-tracking/screenshot/screenshot.subscriber.ts +++ b/packages/core/src/time-tracking/screenshot/screenshot.subscriber.ts @@ -1,7 +1,9 @@ -import { EntitySubscriberInterface, EventSubscriber, LoadEvent, RemoveEvent } from "typeorm"; +import { DataSourceOptions, EntitySubscriberInterface, EventSubscriber, InsertEvent, LoadEvent, RemoveEvent } from "typeorm"; import { IScreenshot } from "@gauzy/contracts"; import { Screenshot } from "./screenshot.entity"; import { FileStorage } from "./../../core/file-storage"; +import { getConfig } from "@gauzy/config"; +import { isJsObject } from "@gauzy/common"; @EventSubscriber() export class ScreenshotSubscriber implements EntitySubscriberInterface { @@ -13,6 +15,32 @@ export class ScreenshotSubscriber implements EntitySubscriberInterface): void | Promise { + try { + if (event) { + const options: Partial = event.connection.options || getConfig().dbConnectionOptions; + if (['sqlite', 'better-sqlite3'].includes(options.type)) { + const { entity } = event; + try { + if (isJsObject(entity.apps)) { + entity.apps = JSON.stringify(entity.apps); + } + } catch (error) { + console.log('Before Insert Screenshot Activity Error:', error); + entity.apps = JSON.stringify({}); + } + } + } + } catch (error) { + console.log(error); + } + } + /** * Called after entity is loaded from the database. * @@ -37,7 +65,7 @@ export class ScreenshotSubscriber implements EntitySubscriberInterface): Promise { + async afterRemove(event: RemoveEvent): Promise { try { if (event.entityId) { console.log(`BEFORE SCREENSHOT ENTITY WITH ID ${event.entityId} REMOVED`); @@ -57,4 +85,4 @@ export class ScreenshotSubscriber implements EntitySubscriberInterface ({ + // GraphQL endpoint for Gauzy AI gauzyAIGraphQLEndpoint: process.env.GAUZY_AI_GRAPHQL_ENDPOINT || null, + // REST endpoint for Gauzy AI gauzyAIRESTEndpoint: process.env.GAUZY_AI_REST_ENDPOINT || null, + // Request timeout for Gauzy AI in milliseconds gauzyAIRequestTimeout: parseInt(process.env.GAUZY_AI_REQUEST_TIMEOUT) || 60 * 5 * 1000, - // Gauzy AI API keys Pair gauzyAiApiKey: process.env.GAUZY_AI_API_KEY || null, gauzyAiApiSecret: process.env.GAUZY_AI_API_SECRET || null diff --git a/packages/plugins/integration-ai/src/gauzy-ai.service.ts b/packages/plugins/integration-ai/src/gauzy-ai.service.ts index 3222aab88ad..91ec3c29af2 100644 --- a/packages/plugins/integration-ai/src/gauzy-ai.service.ts +++ b/packages/plugins/integration-ai/src/gauzy-ai.service.ts @@ -22,6 +22,7 @@ import { import { TypedDocumentNode as DocumentNode } from '@graphql-typed-document-node/core'; import fetch from 'cross-fetch'; import * as chalk from 'chalk'; +import * as FormData from 'form-data'; import { ApolloClient, ApolloQueryResult, @@ -52,6 +53,20 @@ import { import { RequestConfigProvider } from './request-config.provider'; import { AxiosRequestHeaders, HttpMethodEnum } from './configuration.interface'; +export interface ImageAnalysisResult { + success: boolean; + data: { + mimetype: string; + filename: string; + analysis: Array<{ + work: boolean; + description: string; + apps: string[]; + }>; + message?: string; + }; +} + @Injectable() export class GauzyAIService { private readonly _logger = new Logger(GauzyAIService.name); @@ -84,63 +99,64 @@ export class GauzyAIService { } /** - * Send an HTTP request with dynamic configuration. - * - * @param path The URL path for the request. - * @param options Custom Axios request configuration. - * @param method The HTTP method (e.g., GET, POST). - * @returns An Observable that emits the response data or throws an error. - */ + * Send an HTTP request with dynamic configuration. + * + * @param path The URL path for the request. + * @param options Custom Axios request configuration. + * @param method The HTTP method (e.g., GET, POST). + * @returns An Observable that emits the response data or throws an error. + */ private sendRequest( path: string, options: AxiosRequestConfig = {}, method: string = HttpMethodEnum.GET, + defaultHeaders: AxiosRequestHeaders = { + 'Content-Type': 'application/json', // Define default headers + } ): Observable> { - const { - ApiKey, - ApiSecret, - ApiBearerToken, - ApiTenantId, - } = this._requestConfigProvider.getConfig(); - - // Define default headers - const defaultHeaders: AxiosRequestHeaders = { - 'Content-Type': 'application/json', - }; + /** */ + const { ApiKey, ApiSecret, ApiBearerToken, ApiTenantId } = this._requestConfigProvider.getConfig(); - // Add your custom headers here - const headers: AxiosRequestHeaders = { + // Add your custom headers + const customHeaders = (): AxiosRequestHeaders => ({ // Define default headers ...defaultHeaders, // Add your custom headers here 'X-APP-ID': this._configService.get('guazyAI.gauzyAiApiKey'), 'X-API-KEY': this._configService.get('guazyAI.gauzyAiApiSecret'), + /** */ ...(ApiKey ? { 'X-APP-ID': ApiKey } : {}), ...(ApiSecret ? { 'X-API-KEY': ApiSecret } : {}), + /** */ ...(ApiTenantId ? { 'Tenant-Id': ApiTenantId } : {}), ...(ApiBearerToken ? { 'Authorization': ApiBearerToken } : {}), - }; - console.log('Default AxiosRequestConfig Headers: %s', `${JSON.stringify(headers)}`); + }); + + /** */ + const headers: AxiosRequestHeaders = customHeaders(); + // console.log('Default AxiosRequestConfig Headers: %s', `${JSON.stringify(headers)}`); // Merge the provided options with the default options const mergedOptions: AxiosRequestConfig = { ...options, // Inside your sendRequest method, use qs.stringify for custom parameter serialization paramsSerializer: (params) => { - console.log('Customize the serialization of URL parameters', params); - // Customize the serialization of URL parameters as needed - return qs.stringify(params, { arrayFormat: 'repeat' }); + // console.log('Customize the serialization of URL parameters', params); + if (Object.keys(params).length > 0) { + // Customize the serialization of URL parameters as needed + return qs.stringify(params, { arrayFormat: 'repeat' }); + } } }; - console.log('Default AxiosRequestConfig Options: %s', `${JSON.stringify(mergedOptions)}`); + // console.log('Default AxiosRequestConfig Options: %s', `${JSON.stringify(mergedOptions)}`); try { return this._http.request({ ...mergedOptions, url: path, - method: method, + method, headers, }); } catch (error) { @@ -155,6 +171,43 @@ export class GauzyAIService { } } + /** + * Analyze an image/screenshot using Gauzy AI. + * + * @param files - Array of Buffers representing the uploaded images. + * @returns Promise - The analysis result for the image. + */ + public async analyzeImage(stream: Buffer, file: any): Promise { + // Create FormData and append the image data + const form = new FormData(); + + // Assuming you have an image file or buffer + form.append(`files`, stream, { + filename: file.filename, + contentType: 'application/octet-stream' + }); + + // Set custom headers + const headers = { + ...form.getHeaders(), + 'Content-Length': form.getLengthSync().toString(), + // Add any other headers you need + }; + + // Set request options + const options = { + data: form, // Set the request payload + params: {} + }; + + // Call the sendRequest function with the appropriate parameters + return await firstValueFrom( + this.sendRequest('image/process', options, HttpMethodEnum.POST, headers).pipe( + map((resp: AxiosResponse) => resp.data) + ) + ); + } + /** * Call pre process method to create new employee job application record. * @@ -170,9 +223,8 @@ export class GauzyAIService { // Call the sendRequest function with the appropriate parameters return await firstValueFrom( this.sendRequest('employee/job/application/pre-process', { - method: HttpMethodEnum.POST, // Set the HTTP method to POST data: params, // Set the request payload - }).pipe( + }, HttpMethodEnum.POST).pipe( tap((resp: AxiosResponse) => console.log(resp)), map((resp: AxiosResponse) => resp.data) ) @@ -190,9 +242,7 @@ export class GauzyAIService { ): Promise { // Call the sendRequest function with the appropriate parameters return await firstValueFrom( - this.sendRequest(`employee/job/application/generate-proposal/${employeeJobApplicationId}`, { - method: HttpMethodEnum.POST, // Set the HTTP method to POST - }).pipe( + this.sendRequest(`employee/job/application/generate-proposal/${employeeJobApplicationId}`, {}, HttpMethodEnum.POST).pipe( tap((resp: AxiosResponse) => console.log(resp)), map((resp: AxiosResponse) => resp.data) ) @@ -210,9 +260,7 @@ export class GauzyAIService { ): Promise { // Call the sendRequest function with the appropriate parameters return await firstValueFrom( - this.sendRequest(`employee/job/application/${employeeJobApplicationId}`, { - method: HttpMethodEnum.GET, // Set the HTTP method to GET - }).pipe( + this.sendRequest(`employee/job/application/${employeeJobApplicationId}`).pipe( tap((resp: AxiosResponse) => console.log(resp)), map((resp: AxiosResponse) => resp.data) )