diff --git a/apps/frontend-snippet/.env.example b/apps/frontend-snippet/.env.example index c2a0a9011..cf785b53a 100644 --- a/apps/frontend-snippet/.env.example +++ b/apps/frontend-snippet/.env.example @@ -1 +1,2 @@ -VITE_BACKEND_DOMAIN= \ No newline at end of file +VITE_BACKEND_DOMAIN= +VITE_ML_FRONTEND_URL= \ No newline at end of file diff --git a/apps/frontend-snippet/public/assets/ticketing/front.png b/apps/frontend-snippet/public/assets/ticketing/front.png new file mode 100644 index 000000000..0c13ebd26 Binary files /dev/null and b/apps/frontend-snippet/public/assets/ticketing/front.png differ diff --git a/apps/frontend-snippet/src/helpers/utils.ts b/apps/frontend-snippet/src/helpers/utils.ts index bd32a1e64..0db48b593 100644 --- a/apps/frontend-snippet/src/helpers/utils.ts +++ b/apps/frontend-snippet/src/helpers/utils.ts @@ -49,6 +49,20 @@ export const providersConfig: ProvidersConfig = { }, }, + 'Ticketing': { + 'front': { + clientId: '5f1d8d963c77285f339a', + scopes: '', + authBaseUrl: 'https://app.frontapp.com/oauth/authorize', + logoPath: 'assets/ticketing/front.png', + }, + 'zendesk_tcg': { + clientId: 'panora_ticketing', + scopes: 'read write', + authBaseUrl: 'https://panora3702.zendesk.com/oauth/authorizations/new', + logoPath: 'assets/crm/zendesk_logo.png', + }, + }, 'Accounting': { 'pennylane': { clientId: '', diff --git a/apps/frontend-snippet/src/hooks/useOAuth.ts b/apps/frontend-snippet/src/hooks/useOAuth.ts index fea82f946..e4f0d82f8 100644 --- a/apps/frontend-snippet/src/hooks/useOAuth.ts +++ b/apps/frontend-snippet/src/hooks/useOAuth.ts @@ -47,7 +47,9 @@ const useOAuth = ({ providerName, returnUrl, projectId, linkedUserId, onSuccess console.log(finalAuth); } else if(providerName == "zendesk"){ finalAuth = `${baseUrl}?client_id=${encodeURIComponent(clientId)}&response_type=code&redirect_uri=${encodedRedirectUrl}&state=${state}` - } else { + } else if(providerName == "zendesk_tcg" || providerName=="front") { + finalAuth = `${baseUrl}?client_id=${encodeURIComponent(clientId)}&response_type=code&redirect_uri=${encodedRedirectUrl}&scope=${encodeURIComponent(scopes)}&state=${state}` + }else{ finalAuth = addScope ? `${baseUrl}?client_id=${encodeURIComponent(clientId)}&redirect_uri=${encodedRedirectUrl}&scope=${encodeURIComponent(scopes)}&state=${state}` : `${baseUrl}?client_id=${encodeURIComponent(clientId)}&redirect_uri=${encodedRedirectUrl}&state=${state}`; diff --git a/apps/webapp/public/providers/crm/zendesk_tcg.png b/apps/webapp/public/providers/crm/zendesk_tcg.png new file mode 100644 index 000000000..697e340fb Binary files /dev/null and b/apps/webapp/public/providers/crm/zendesk_tcg.png differ diff --git a/packages/api/.env.example b/packages/api/.env.example index 3a82d2da0..04570920f 100644 --- a/packages/api/.env.example +++ b/packages/api/.env.example @@ -23,6 +23,8 @@ ZENDESK_SELL_CLIENT_SECRET= ZENDESK_TICKETING_SUBDOMAIN= ZENDESK_TICKETING_CLIENT_ID= ZENDESK_TICKETING_CLIENT_SECRET= +FRONT_CLIENT_ID= +FRONT_CLIENT_SECRET= OAUTH_REDIRECT_BASE='https://api-staging.panora.dev/' diff --git a/packages/api/prisma/schema.prisma b/packages/api/prisma/schema.prisma index 579d94ada..b8b0933c9 100644 --- a/packages/api/prisma/schema.prisma +++ b/packages/api/prisma/schema.prisma @@ -90,9 +90,8 @@ model crm_companies { created_at DateTime @db.Timestamp(6) modified_at DateTime @db.Timestamp(6) id_crm_user String? @db.Uuid - id_event String @db.Uuid + id_linked_user String? @db.Uuid crm_addresses crm_addresses[] - events events @relation(fields: [id_event], references: [id_event], onDelete: NoAction, onUpdate: NoAction, map: "fk_13") crm_users crm_users? @relation(fields: [id_crm_user], references: [id_crm_user], onDelete: NoAction, onUpdate: NoAction, map: "fk_24") crm_email_addresses crm_email_addresses[] crm_engagements crm_engagements[] @@ -101,7 +100,6 @@ model crm_companies { crm_tasks crm_tasks[] @@index([id_crm_user], map: "fk_crm_company_crm_userid") - @@index([id_event], map: "fk_crm_company_jobid") } model crm_contacts { @@ -110,18 +108,16 @@ model crm_contacts { last_name String created_at DateTime @db.Timestamp(6) modified_at DateTime @db.Timestamp(6) - remote_platform String remote_id String + remote_platform String id_crm_user String? @db.Uuid - id_event String @db.Uuid + id_linked_user String? @db.Uuid crm_addresses crm_addresses[] crm_users crm_users? @relation(fields: [id_crm_user], references: [id_crm_user], onDelete: NoAction, onUpdate: NoAction, map: "fk_23") - events events @relation(fields: [id_event], references: [id_event], onDelete: NoAction, onUpdate: NoAction, map: "job_id_crm_contact") crm_email_addresses crm_email_addresses[] crm_notes crm_notes[] crm_phone_numbers crm_phone_numbers[] - @@index([id_event], map: "crm_contact_id_job") @@index([id_crm_user], map: "fk_crm_contact_userid") } @@ -135,6 +131,7 @@ model crm_deals { modified_at DateTime @db.Timestamp(6) id_crm_user String? @db.Uuid id_crm_deals_stage String? @db.Uuid + id_linked_user String? @db.Uuid crm_deals_stages crm_deals_stages? @relation(fields: [id_crm_deals_stage], references: [id_crm_deals_stage], onDelete: NoAction, onUpdate: NoAction, map: "fk_21") crm_users crm_users? @relation(fields: [id_crm_user], references: [id_crm_user], onDelete: NoAction, onUpdate: NoAction, map: "fk_22") crm_notes crm_notes[] @@ -149,6 +146,7 @@ model crm_deals_stages { stage_name String? created_at DateTime @db.Timestamp(6) modified_at DateTime @db.Timestamp(6) + id_linked_user String? @db.Uuid crm_deals crm_deals[] } @@ -202,6 +200,7 @@ model crm_engagements { remote_id String? id_crm_engagement_type String @db.Uuid id_crm_company String? @db.Uuid + id_linked_user String? @db.Uuid crm_engagement_contacts crm_engagement_contacts[] crm_engagement_types crm_engagement_types @relation(fields: [id_crm_engagement_type], references: [id_crm_engagement_type], onDelete: NoAction, onUpdate: NoAction, map: "fk_28") crm_companies crm_companies? @relation(fields: [id_crm_company], references: [id_crm_company], onDelete: NoAction, onUpdate: NoAction, map: "fk_29") @@ -218,6 +217,7 @@ model crm_notes { id_crm_company String? @db.Uuid id_crm_contact String? @db.Uuid id_crm_deal String? @db.Uuid + id_linked_user String? @db.Uuid crm_companies crm_companies? @relation(fields: [id_crm_company], references: [id_crm_company], onDelete: NoAction, onUpdate: NoAction, map: "fk_18") crm_contacts crm_contacts? @relation(fields: [id_crm_contact], references: [id_crm_contact], onDelete: NoAction, onUpdate: NoAction, map: "fk_19") crm_deals crm_deals? @relation(fields: [id_crm_deal], references: [id_crm_deal], onDelete: NoAction, onUpdate: NoAction, map: "fk_20") @@ -256,6 +256,7 @@ model crm_tasks { id_crm_user String? @db.Uuid id_crm_company String? @db.Uuid id_crm_deal String? @db.Uuid + id_linked_user String? @db.Uuid crm_users crm_users? @relation(fields: [id_crm_user], references: [id_crm_user], onDelete: NoAction, onUpdate: NoAction, map: "fk_25") crm_companies crm_companies? @relation(fields: [id_crm_company], references: [id_crm_company], onDelete: NoAction, onUpdate: NoAction, map: "fk_26") crm_deals crm_deals? @relation(fields: [id_crm_deal], references: [id_crm_deal], onDelete: NoAction, onUpdate: NoAction, map: "fk_27") @@ -266,15 +267,16 @@ model crm_tasks { } model crm_users { - id_crm_user String @id(map: "pk_crm_users") @db.Uuid - name String? - email String? - created_at DateTime @db.Timestamp(6) - modified_at DateTime @db.Timestamp(6) - crm_companies crm_companies[] - crm_contacts crm_contacts[] - crm_deals crm_deals[] - crm_tasks crm_tasks[] + id_crm_user String @id(map: "pk_crm_users") @db.Uuid + name String? + email String? + created_at DateTime @db.Timestamp(6) + modified_at DateTime @db.Timestamp(6) + id_linked_user String? @db.Uuid + crm_companies crm_companies[] + crm_contacts crm_contacts[] + crm_deals crm_deals[] + crm_tasks crm_tasks[] } /// This model or at least one of its fields has comments in the database, and requires an additional setup for migrations: Read more: https://pris.ly/d/database-comments @@ -296,14 +298,8 @@ model events { url String provider String id_linked_user String @db.Uuid - crm_companies crm_companies[] - crm_contacts crm_contacts[] linked_users linked_users @relation(fields: [id_linked_user], references: [id_linked_user], onDelete: NoAction, onUpdate: NoAction, map: "fk_12") jobs_status_history jobs_status_history[] - tcg_comments tcg_comments[] - tcg_contacts tcg_contacts[] - tcg_tickets tcg_tickets[] - tcg_users tcg_users[] webhook_delivery_attempts webhook_delivery_attempts[] @@index([id_linked_user], map: "fk_linkeduserid_projectid") @@ -379,28 +375,28 @@ model remote_data { /// This model or at least one of its fields has comments in the database, and requires an additional setup for migrations: Read more: https://pris.ly/d/database-comments model tcg_comments { - id_tcg_comment String @id(map: "pk_tcg_comments") @db.Uuid - body String? - html_body String? - is_private Boolean? - created_at DateTime? @db.Timestamp(6) - modified_at DateTime? @db.Timestamp(6) - author_type String? @db.Uuid - remote_id String? - remote_platform String? - id_tcg_ticket String? @db.Uuid - id_tcg_contact String? @db.Uuid - id_tcg_user String? @db.Uuid - id_event String? @db.Uuid - tcg_tickets tcg_tickets? @relation(fields: [id_tcg_ticket], references: [id_tcg_ticket], onDelete: NoAction, onUpdate: NoAction, map: "fk_40_1") - tcg_contacts tcg_contacts? @relation(fields: [id_tcg_contact], references: [id_tcg_contact], onDelete: NoAction, onUpdate: NoAction, map: "fk_41") - tcg_users tcg_users? @relation(fields: [id_tcg_user], references: [id_tcg_user], onDelete: NoAction, onUpdate: NoAction, map: "fk_42") - events events? @relation(fields: [id_event], references: [id_event], onDelete: NoAction, onUpdate: NoAction, map: "fk_46") + id_tcg_comment String @id(map: "pk_tcg_comments") @db.Uuid + body String? + html_body String? + is_private Boolean? + remote_id String? + remote_platform String? + created_at DateTime? @db.Timestamp(6) + modified_at DateTime? @db.Timestamp(6) + creator_type String? + id_tcg_attachment String[] + id_tcg_ticket String? @db.Uuid + id_tcg_contact String? @db.Uuid + id_tcg_user String? @db.Uuid + id_linked_user String? @db.Uuid + tcg_attachments tcg_attachments[] + tcg_tickets tcg_tickets? @relation(fields: [id_tcg_ticket], references: [id_tcg_ticket], onDelete: NoAction, onUpdate: NoAction, map: "fk_40_1") + tcg_contacts tcg_contacts? @relation(fields: [id_tcg_contact], references: [id_tcg_contact], onDelete: NoAction, onUpdate: NoAction, map: "fk_41") + tcg_users tcg_users? @relation(fields: [id_tcg_user], references: [id_tcg_user], onDelete: NoAction, onUpdate: NoAction, map: "fk_42") @@index([id_tcg_contact], map: "fk_tcg_comment_tcg_contact") @@index([id_tcg_ticket], map: "fk_tcg_comment_tcg_ticket") @@index([id_tcg_user], map: "fk_tcg_comment_tcg_userid") - @@index([id_event], map: "fk_tcg_comments_eventid") } model tcg_contacts { @@ -409,39 +405,43 @@ model tcg_contacts { email_address String? phone_number String? details String? - created_at DateTime? @db.Timestamp(6) - modified_at DateTime? @db.Timestamp(6) remote_id String? remote_platform String? - id_event String? @db.Uuid + created_at DateTime? @db.Timestamp(6) + modified_at DateTime? @db.Timestamp(6) + id_tcg_account String? @db.Uuid + id_linked_user String? @db.Uuid tcg_comments tcg_comments[] - events events? @relation(fields: [id_event], references: [id_event], onDelete: NoAction, onUpdate: NoAction, map: "fk_43") + tcg_accounts tcg_accounts? @relation(fields: [id_tcg_account], references: [id_tcg_account], onDelete: NoAction, onUpdate: NoAction, map: "fk_49") - @@index([id_event], map: "fk_tcg_contact_event_id") + @@index([id_tcg_account], map: "fk_tcg_contact_tcg_account_id") } /// This model or at least one of its fields has comments in the database, and requires an additional setup for migrations: Read more: https://pris.ly/d/database-comments model tcg_tickets { - id_tcg_ticket String @id(map: "pk_tcg_tickets") @db.Uuid + id_tcg_ticket String @id(map: "pk_tcg_tickets") @db.Uuid name String? status String? description String? - due_date DateTime? @db.Timestamp(6) + due_date DateTime? @db.Timestamp(6) ticket_type String? - parent_ticket String? @db.Uuid - tags String? - completed_at DateTime? @db.Timestamp(6) + parent_ticket String? @db.Uuid + tags String[] + completed_at DateTime? @db.Timestamp(6) priority String? - created_at DateTime @db.Timestamp(6) - modified_at DateTime @db.Timestamp(6) + created_at DateTime @db.Timestamp(6) + modified_at DateTime @db.Timestamp(6) assigned_to String[] remote_id String? remote_platform String? - id_event String? @db.Uuid + creator_type String? + id_tcg_user String? @db.Uuid + id_linked_user String @db.Uuid + tcg_attachments tcg_attachments[] tcg_comments tcg_comments[] - events events? @relation(fields: [id_event], references: [id_event], onDelete: NoAction, onUpdate: NoAction, map: "fk_44") + tcg_tags tcg_tags[] - @@index([id_event], map: "fk_tcg_tickets_eventid") + @@index([id_tcg_user], map: "fk_tcg_ticket_tcg_user") } /// This model or at least one of its fields has comments in the database, and requires an additional setup for migrations: Read more: https://pris.ly/d/database-comments @@ -449,15 +449,13 @@ model tcg_users { id_tcg_user String @id(map: "pk_tcg_users") @db.Uuid name String? email_address String? - created_at DateTime? @db.Timestamp(6) - modified_at DateTime? @db.Timestamp(6) remote_id String? remote_platform String? - id_event String? @db.Uuid + teams String[] + created_at DateTime? @db.Timestamp(6) + modified_at DateTime? @db.Timestamp(6) + id_linked_user String? @db.Uuid tcg_comments tcg_comments[] - events events? @relation(fields: [id_event], references: [id_event], onDelete: NoAction, onUpdate: NoAction, map: "fk_45") - - @@index([id_event], map: "fk_tcg_users_event_id") } /// This model or at least one of its fields has comments in the database, and requires an additional setup for migrations: Read more: https://pris.ly/d/database-comments @@ -519,7 +517,7 @@ model webhook_endpoints { secret String active Boolean created_at DateTime @db.Timestamp(6) - scope String? + scope String[] id_project String @db.Uuid last_update DateTime? @db.Timestamp(6) webhook_delivery_attempts webhook_delivery_attempts[] @@ -538,3 +536,63 @@ model webhooks_reponses { http_status_code String webhook_delivery_attempts webhook_delivery_attempts[] } + +/// The underlying table does not contain a valid unique identifier and can therefore currently not be handled by Prisma Client. +/// This model or at least one of its fields has comments in the database, and requires an additional setup for migrations: Read more: https://pris.ly/d/database-comments +model tcg_accounts { + id_tcg_account String @id(map: "pk_tcg_account") @db.Uuid + remote_id String? + name String? + domains String[] + remote_platform String? + created_at DateTime @db.Timestamp(6) + modified_at DateTime @db.Timestamp(6) + id_linked_user String? @db.Uuid + tcg_contacts tcg_contacts[] +} + +/// The underlying table does not contain a valid unique identifier and can therefore currently not be handled by Prisma Client. +model tcg_teams { + id_tcg_team String @id(map: "pk_tcg_teams") @db.Uuid + remote_id String? + remote_platform String? + name String? + description String? + created_at DateTime @db.Timestamp(6) + modified_at DateTime @db.Timestamp(6) + id_linked_user String? @db.Uuid +} + +/// This model or at least one of its fields has comments in the database, and requires an additional setup for migrations: Read more: https://pris.ly/d/database-comments +model tcg_attachments { + id_tcg_attachment String @id(map: "pk_tcg_attachments") @db.Uuid + remote_id String? + remote_platform String? + file_name String? + file_url String? + uploader String @db.Uuid + created_at DateTime @db.Timestamp(6) + modified_at DateTime @db.Timestamp(6) + id_linked_user String? @db.Uuid + id_tcg_ticket String? @db.Uuid + id_tcg_comment String? @db.Uuid + tcg_tickets tcg_tickets? @relation(fields: [id_tcg_ticket], references: [id_tcg_ticket], onDelete: NoAction, onUpdate: NoAction, map: "fk_50") + tcg_comments tcg_comments? @relation(fields: [id_tcg_comment], references: [id_tcg_comment], onDelete: NoAction, onUpdate: NoAction, map: "fk_51") + + @@index([id_tcg_comment], map: "fk_tcg_attachment_tcg_commentid") + @@index([id_tcg_ticket], map: "fk_tcg_attachment_tcg_ticketid") +} + +model tcg_tags { + id_tcg_tag String @id(map: "pk_tcg_tags") @db.Uuid + name String? + remote_id String? + remote_platform String? + created_at DateTime @db.Timestamp(6) + modified_at DateTime @db.Timestamp(6) + id_tcg_ticket String? @db.Uuid + id_linked_user String? @db.Uuid + tcg_tickets tcg_tickets? @relation(fields: [id_tcg_ticket], references: [id_tcg_ticket], onDelete: NoAction, onUpdate: NoAction, map: "fk_48") + + @@index([id_tcg_ticket], map: "fk_tcg_tag_tcg_ticketid") +} diff --git a/packages/api/scripts/commonObject.sh b/packages/api/scripts/commonObject.sh index b9dcf9a72..29e19d00d 100755 --- a/packages/api/scripts/commonObject.sh +++ b/packages/api/scripts/commonObject.sh @@ -88,7 +88,7 @@ export class ${ObjectCap}Service { integrationId: string, linkedUserId: string, remote_data?: boolean, - ): Promise> { + ): Promise<${ObjectCap}Response> { return } @@ -97,14 +97,14 @@ export class ${ObjectCap}Service { integrationId: string, linkedUserId: string, remote_data?: boolean, - ): Promise> { + ): Promise<${ObjectCap}Response> { return; } async get${ObjectCap}( id_${VerticalLow}_${objectType}: string, remote_data?: boolean, - ): Promise> { + ): Promise<${ObjectCap}Response> { return; } @@ -112,14 +112,14 @@ export class ${ObjectCap}Service { integrationId: string, linkedUserId: string, remote_data?: boolean, - ): Promise> { + ): Promise<${ObjectCap}Response> { return; } async update${ObjectCap}( id: string, update${ObjectCap}Data: Partial, - ): Promise> { + ): Promise<${ObjectCap}Response> { return; } } @@ -352,7 +352,7 @@ export class ${ObjectCap}Controller { name: 'id', required: true, type: String, - description: 'id of the `${objectType}` you want to retrive.', + description: 'id of the ${objectType} you want to retrieve.', }) @ApiQuery({ name: 'remoteData', @@ -398,13 +398,13 @@ export class ${ObjectCap}Controller { //@ApiCustomResponse(${ObjectCap}Response) @Post() add${ObjectCap}( - @Body() unfied${ObjectCap}Data: Unified${ObjectCap}Input, + @Body() unified${ObjectCap}Data: Unified${ObjectCap}Input, @Headers('integrationId') integrationId: string, @Headers('linkedUserId') linkedUserId: string, @Query('remoteData') remote_data?: boolean, ) { return this.${objectType}Service.add${ObjectCap}( - unfied${ObjectCap}Data, + unified${ObjectCap}Data, integrationId, linkedUserId, remote_data, diff --git a/packages/api/scripts/seed.webapp.ts b/packages/api/scripts/seed.webapp.ts index 86d821ada..14b0bf6cb 100644 --- a/packages/api/scripts/seed.webapp.ts +++ b/packages/api/scripts/seed.webapp.ts @@ -6,19 +6,21 @@ const prisma = new PrismaClient(); async function main() { const org = await prisma.organizations.create({ data: { - id_organization: uuidv4(), + id_organization: `55222419-795d-4183-8478-361626363e58`, name: `Acme Inc`, - stripe_customer_id: `cust_stripe_acme_${uuidv4()}`, + stripe_customer_id: `cust_stripe_acme_56604f75-7bf8-4541-9ab4-5928aade4bb8`, }, }); await prisma.users.create({ data: { - id_user: uuidv4(), + id_user: `0ce39030-2901-4c56-8db0-5e326182ec6b`, email: 'audrey@aubry.io', - password_hash: 'password_hashed_her', + password_hash: + '$2b$10$Nxcp3x0yDaCrMrhZQ6IiNeqk0BxxDTnfn9iGG2UK5nWMh/UB6LgZu', first_name: 'audrey', last_name: 'aubry', + id_organization: '55222419-795d-4183-8478-361626363e58', }, }); diff --git a/packages/api/scripts/webhook.testing.ts b/packages/api/scripts/webhook.testing.ts index 3c6d88f7f..bc0975a33 100644 --- a/packages/api/scripts/webhook.testing.ts +++ b/packages/api/scripts/webhook.testing.ts @@ -20,7 +20,7 @@ async function main() { id_webhook_endpoint: 'a18682af-43f6-4ed2-8bde-b84298f51dde', }, data: { - scope: 'crm.contact.pulled', + scope: ['crm.contact.pulled'], }, }); } diff --git a/packages/api/src/@core/connections/connections.controller.ts b/packages/api/src/@core/connections/connections.controller.ts index 4a7dc8bd3..bb04720ed 100644 --- a/packages/api/src/@core/connections/connections.controller.ts +++ b/packages/api/src/@core/connections/connections.controller.ts @@ -6,12 +6,14 @@ import { NotFoundError, handleServiceError } from '@@core/utils/errors'; import { PrismaService } from '@@core/prisma/prisma.service'; import { ProviderVertical, getProviderVertical } from '@@core/utils/types'; import { ApiOperation, ApiQuery, ApiResponse, ApiTags } from '@nestjs/swagger'; +import { TicketingConnectionsService } from './ticketing/services/ticketing.connection.service'; @ApiTags('connections') @Controller('connections') export class ConnectionsController { constructor( private readonly crmConnectionsService: CrmConnectionsService, + private readonly ticketingConnectionsService: TicketingConnectionsService, private logger: LoggerService, private prisma: PrismaService, ) { @@ -67,6 +69,12 @@ export class ConnectionsController { case ProviderVertical.MarketingAutomation: break; case ProviderVertical.Ticketing: + this.ticketingConnectionsService.handleTicketingCallBack( + projectId, + linkedUserId, + providerName, + code, + ); break; case ProviderVertical.Unknown: break; diff --git a/packages/api/src/@core/connections/crm/crm.connection.module.ts b/packages/api/src/@core/connections/crm/crm.connection.module.ts index d0a3c4c04..8794afd28 100644 --- a/packages/api/src/@core/connections/crm/crm.connection.module.ts +++ b/packages/api/src/@core/connections/crm/crm.connection.module.ts @@ -23,6 +23,7 @@ import { PipedriveConnectionService } from './services/pipedrive/pipedrive.servi WebhookService, EnvironmentService, EncryptionService, + // PROVIDERS SERVICES FreshsalesConnectionService, HubspotConnectionService, ZohoConnectionService, diff --git a/packages/api/src/@core/connections/ticketing/services/front/front.service.ts b/packages/api/src/@core/connections/ticketing/services/front/front.service.ts new file mode 100644 index 000000000..5a984357e --- /dev/null +++ b/packages/api/src/@core/connections/ticketing/services/front/front.service.ts @@ -0,0 +1,151 @@ +import { Injectable } from '@nestjs/common'; +import axios from 'axios'; +import { PrismaService } from '@@core/prisma/prisma.service'; +import { Action, handleServiceError } from '@@core/utils/errors'; +import { LoggerService } from '@@core/logger/logger.service'; +import { v4 as uuidv4 } from 'uuid'; +import { EnvironmentService } from '@@core/environment/environment.service'; +import { EncryptionService } from '@@core/encryption/encryption.service'; +import { + CallbackParams, + RefreshParams, + ITicketingConnectionService, + FrontOAuthResponse, +} from '../../types'; +import { ServiceRegistry } from '../registry.service'; + +@Injectable() +export class FrontConnectionService implements ITicketingConnectionService { + constructor( + private prisma: PrismaService, + private logger: LoggerService, + private env: EnvironmentService, + private cryptoService: EncryptionService, + private registry: ServiceRegistry, + ) { + this.logger.setContext(FrontConnectionService.name); + this.registry.registerService('front', this); + } + + async handleCallback(opts: CallbackParams) { + try { + const { linkedUserId, projectId, code } = opts; + const isNotUnique = await this.prisma.connections.findFirst({ + where: { + id_linked_user: linkedUserId, + provider_slug: 'front', //TODO + }, + }); + + //reconstruct the redirect URI that was passed in the frontend it must be the same + const REDIRECT_URI = `${this.env.getOAuthRredirectBaseUrl()}/connections/oauth/callback`; + + const formData = new URLSearchParams({ + grant_type: 'authorization_code', + redirect_uri: REDIRECT_URI, + code: code, + }); + const res = await axios.post( + `https://app.frontapp.com/oauth/token`, + formData.toString(), + { + headers: { + 'Content-Type': 'application/x-www-form-urlencoded;charset=utf-8', + Authorization: `Basic ${Buffer.from( + `${this.env.getFrontSecret().CLIENT_ID}:${ + this.env.getFrontSecret().CLIENT_SECRET + }`, + ).toString('base64')}`, + }, + }, + ); + const data: FrontOAuthResponse = res.data; + this.logger.log( + 'OAuth credentials : front ticketing ' + JSON.stringify(data), + ); + + let db_res; + + if (isNotUnique) { + db_res = await this.prisma.connections.update({ + where: { + id_connection: isNotUnique.id_connection, + }, + data: { + access_token: this.cryptoService.encrypt(data.access_token), + refresh_token: this.cryptoService.encrypt(data.refresh_token), + expiration_timestamp: new Date( + new Date().getTime() + Number(data.expires_at) * 1000, + ), + status: 'valid', + created_at: new Date(), + }, + }); + } else { + db_res = await this.prisma.connections.create({ + data: { + id_connection: uuidv4(), + provider_slug: 'front', + token_type: 'oauth', + access_token: this.cryptoService.encrypt(data.access_token), + refresh_token: this.cryptoService.encrypt(data.refresh_token), + expiration_timestamp: new Date( + new Date().getTime() + Number(data.expires_at) * 1000, + ), + status: 'valid', + created_at: new Date(), + projects: { + connect: { id_project: projectId }, + }, + linked_users: { + connect: { id_linked_user: linkedUserId }, + }, + }, + }); + } + return db_res; + } catch (error) { + handleServiceError(error, this.logger, 'front', Action.oauthCallback); + } + } + + async handleTokenRefresh(opts: RefreshParams) { + try { + const { connectionId, refreshToken } = opts; + const formData = new URLSearchParams({ + grant_type: 'refresh_token', + refresh_token: this.cryptoService.decrypt(refreshToken), + }); + const res = await axios.post( + `https://app.frontapp.com/oauth/token`, + formData.toString(), + { + headers: { + 'Content-Type': 'application/x-www-form-urlencoded;charset=utf-8', + Authorization: `Basic ${Buffer.from( + `${this.env.getFrontSecret().CLIENT_ID}:${ + this.env.getFrontSecret().CLIENT_SECRET + }`, + ).toString('base64')}`, + }, + }, + ); + const data: FrontOAuthResponse = res.data; + await this.prisma.connections.update({ + where: { + id_connection: connectionId, + }, + data: { + access_token: this.cryptoService.encrypt(data.access_token), + refresh_token: this.cryptoService.encrypt(data.refresh_token), + expiration_timestamp: new Date( + new Date().getTime() + Number(data.expires_at) * 1000, + ), + }, + }); + this.logger.log('OAuth credentials updated : front '); + } catch (error) { + handleServiceError(error, this.logger, 'front', Action.oauthRefresh); + } + } +} diff --git a/packages/api/src/@core/connections/ticketing/services/github/github.service.ts b/packages/api/src/@core/connections/ticketing/services/github/github.service.ts new file mode 100644 index 000000000..bef9c245a --- /dev/null +++ b/packages/api/src/@core/connections/ticketing/services/github/github.service.ts @@ -0,0 +1,148 @@ +import { Injectable } from '@nestjs/common'; +import axios from 'axios'; +import { PrismaService } from '@@core/prisma/prisma.service'; +import { Action, handleServiceError } from '@@core/utils/errors'; +import { LoggerService } from '@@core/logger/logger.service'; +import { v4 as uuidv4 } from 'uuid'; +import { EnvironmentService } from '@@core/environment/environment.service'; +import { EncryptionService } from '@@core/encryption/encryption.service'; +import { + CallbackParams, + RefreshParams, + ITicketingConnectionService, + GithubOAuthResponse, +} from '../../types'; +import { ServiceRegistry } from '../registry.service'; + +@Injectable() +export class GithubConnectionService implements ITicketingConnectionService { + constructor( + private prisma: PrismaService, + private logger: LoggerService, + private env: EnvironmentService, + private cryptoService: EncryptionService, + private registry: ServiceRegistry, + ) { + this.logger.setContext(GithubConnectionService.name); + this.registry.registerService('github', this); + } + + async handleCallback(opts: CallbackParams) { + try { + const { linkedUserId, projectId, code } = opts; + const isNotUnique = await this.prisma.connections.findFirst({ + where: { + id_linked_user: linkedUserId, + provider_slug: 'github', //TODO + }, + }); + + //reconstruct the redirect URI that was passed in the githubend it must be the same + const REDIRECT_URI = `${this.env.getOAuthRredirectBaseUrl()}/connections/oauth/callback`; + + const formData = new URLSearchParams({ + client_id: this.env.getGithubSecret().CLIENT_ID, + client_secret: this.env.getGithubSecret().CLIENT_SECRET, + redirect_uri: REDIRECT_URI, + code: code, + //repository_id: todo + }); + const res = await axios.post( + `https://github.com/login/oauth/access_token`, + formData.toString(), + { + headers: { + 'Content-Type': 'application/x-www-form-urlencoded;charset=utf-8', + }, + }, + ); + const data: GithubOAuthResponse = res.data; + this.logger.log( + 'OAuth credentials : github ticketing ' + JSON.stringify(data), + ); + + let db_res; + + if (isNotUnique) { + db_res = await this.prisma.connections.update({ + where: { + id_connection: isNotUnique.id_connection, + }, + data: { + access_token: this.cryptoService.encrypt(data.access_token), + refresh_token: this.cryptoService.encrypt(data.refresh_token), + expiration_timestamp: new Date( + new Date().getTime() + Number(data.expires_in) * 1000, + ), + status: 'valid', + created_at: new Date(), + }, + }); + } else { + db_res = await this.prisma.connections.create({ + data: { + id_connection: uuidv4(), + provider_slug: 'github', + token_type: 'oauth', + access_token: this.cryptoService.encrypt(data.access_token), + refresh_token: this.cryptoService.encrypt(data.refresh_token), + expiration_timestamp: new Date( + new Date().getTime() + Number(data.expires_in) * 1000, + ), + status: 'valid', + created_at: new Date(), + projects: { + connect: { id_project: projectId }, + }, + linked_users: { + connect: { id_linked_user: linkedUserId }, + }, + }, + }); + } + return db_res; + } catch (error) { + handleServiceError(error, this.logger, 'github', Action.oauthCallback); + } + } + + async handleTokenRefresh(opts: RefreshParams) { + try { + const { connectionId, refreshToken } = opts; + const formData = new URLSearchParams({ + grant_type: 'refresh_token', + refresh_token: this.cryptoService.decrypt(refreshToken), + }); + const res = await axios.post( + `https://app.githubapp.com/oauth/token`, + formData.toString(), + { + headers: { + 'Content-Type': 'application/x-www-form-urlencoded;charset=utf-8', + Authorization: `Basic ${Buffer.from( + `${this.env.getGithubSecret().CLIENT_ID}:${ + this.env.getGithubSecret().CLIENT_SECRET + }`, + ).toString('base64')}`, + }, + }, + ); + const data: GithubOAuthResponse = res.data; + await this.prisma.connections.update({ + where: { + id_connection: connectionId, + }, + data: { + access_token: this.cryptoService.encrypt(data.access_token), + refresh_token: this.cryptoService.encrypt(data.refresh_token), + expiration_timestamp: new Date( + new Date().getTime() + Number(data.expires_in) * 1000, + ), + }, + }); + this.logger.log('OAuth credentials updated : github '); + } catch (error) { + handleServiceError(error, this.logger, 'github', Action.oauthRefresh); + } + } +} diff --git a/packages/api/src/@core/connections/ticketing/services/registry.service.ts b/packages/api/src/@core/connections/ticketing/services/registry.service.ts index df10c5f73..0de0f679d 100644 --- a/packages/api/src/@core/connections/ticketing/services/registry.service.ts +++ b/packages/api/src/@core/connections/ticketing/services/registry.service.ts @@ -1,22 +1,22 @@ import { Injectable } from '@nestjs/common'; import { ITicketingConnectionService } from '../types'; -import { ZendeskConnectionService } from './zendesk/zendesk.service'; @Injectable() -export class ServiceConnectionRegistry { +export class ServiceRegistry { private serviceMap: Map; - constructor(zendesk: ZendeskConnectionService) { + constructor() { this.serviceMap = new Map(); - this.serviceMap.set('zendesk_t', zendesk); + } + + registerService(serviceKey: string, service: ITicketingConnectionService) { + this.serviceMap.set(serviceKey, service); } getService(integrationId: string): ITicketingConnectionService { const service = this.serviceMap.get(integrationId); if (!service) { - throw new Error( - `Connection Service not found for integration ID: ${integrationId}`, - ); + throw new Error(`Service not found for integration ID: ${integrationId}`); } return service; } diff --git a/packages/api/src/@core/connections/ticketing/services/ticketing.connection.service.ts b/packages/api/src/@core/connections/ticketing/services/ticketing.connection.service.ts index e18158ee9..572496db2 100644 --- a/packages/api/src/@core/connections/ticketing/services/ticketing.connection.service.ts +++ b/packages/api/src/@core/connections/ticketing/services/ticketing.connection.service.ts @@ -6,12 +6,12 @@ import { connections as Connection } from '@prisma/client'; import { PrismaService } from '@@core/prisma/prisma.service'; import { v4 as uuidv4 } from 'uuid'; import { CallbackParams, RefreshParams } from '../types'; -import { ServiceConnectionRegistry } from './registry.service'; +import { ServiceRegistry } from './registry.service'; @Injectable() export class TicketingConnectionsService { constructor( - private serviceRegistry: ServiceConnectionRegistry, + private serviceRegistry: ServiceRegistry, private webhook: WebhookService, private logger: LoggerService, private prisma: PrismaService, diff --git a/packages/api/src/@core/connections/ticketing/services/zendesk/zendesk.service.ts b/packages/api/src/@core/connections/ticketing/services/zendesk/zendesk.service.ts index d2b6caf88..1b67d3c94 100644 --- a/packages/api/src/@core/connections/ticketing/services/zendesk/zendesk.service.ts +++ b/packages/api/src/@core/connections/ticketing/services/zendesk/zendesk.service.ts @@ -12,6 +12,7 @@ import { ITicketingConnectionService, ZendeskTicketingOAuthResponse, } from '../../types'; +import { ServiceRegistry } from '../registry.service'; @Injectable() export class ZendeskConnectionService implements ITicketingConnectionService { @@ -20,8 +21,10 @@ export class ZendeskConnectionService implements ITicketingConnectionService { private logger: LoggerService, private env: EnvironmentService, private cryptoService: EncryptionService, + private registry: ServiceRegistry, ) { this.logger.setContext(ZendeskConnectionService.name); + this.registry.registerService('zendesk_tcg', this); } async handleCallback(opts: CallbackParams) { @@ -30,7 +33,7 @@ export class ZendeskConnectionService implements ITicketingConnectionService { const isNotUnique = await this.prisma.connections.findFirst({ where: { id_linked_user: linkedUserId, - provider_slug: 'zendesk', //TODO + provider_slug: 'zendesk_tcg', }, }); @@ -78,7 +81,7 @@ export class ZendeskConnectionService implements ITicketingConnectionService { db_res = await this.prisma.connections.create({ data: { id_connection: uuidv4(), - provider_slug: 'zendesk', + provider_slug: 'zendesk_tcg', token_type: 'oauth', access_token: this.cryptoService.encrypt(data.access_token), refresh_token: '', @@ -96,11 +99,17 @@ export class ZendeskConnectionService implements ITicketingConnectionService { } return db_res; } catch (error) { - handleServiceError(error, this.logger, 'zendesk', Action.oauthCallback); + handleServiceError( + error, + this.logger, + 'zendesk_tcg', + Action.oauthCallback, + ); } } - //TODO + //todo: revoke ? + //ZENDESK TICKETING OAUTH TOKENS DONT EXPIRE BUT THEY MAY BE REVOKED async handleTokenRefresh(opts: RefreshParams): Promise { throw new Error('Method not implemented.'); } diff --git a/packages/api/src/@core/connections/ticketing/ticketing.connection.module.ts b/packages/api/src/@core/connections/ticketing/ticketing.connection.module.ts index dcccd026c..67e1b1455 100644 --- a/packages/api/src/@core/connections/ticketing/ticketing.connection.module.ts +++ b/packages/api/src/@core/connections/ticketing/ticketing.connection.module.ts @@ -7,20 +7,24 @@ import { WebhookModule } from '@@core/webhook/webhook.module'; import { EnvironmentService } from '@@core/environment/environment.service'; import { EncryptionService } from '@@core/encryption/encryption.service'; import { TicketingConnectionsService } from './services/ticketing.connection.service'; -import { ServiceConnectionRegistry } from './services/registry.service'; +import { ServiceRegistry } from './services/registry.service'; +import { FrontConnectionService } from './services/front/front.service'; +import { GithubConnectionService } from './services/github/github.service'; @Module({ imports: [WebhookModule], providers: [ TicketingConnectionsService, PrismaService, - ZendeskConnectionService, LoggerService, WebhookService, EnvironmentService, EncryptionService, - ServiceConnectionRegistry, + ServiceRegistry, + //PROVIDERS SERVICES ZendeskConnectionService, + FrontConnectionService, + GithubConnectionService, ], exports: [TicketingConnectionsService], }) diff --git a/packages/api/src/@core/connections/ticketing/types/index.ts b/packages/api/src/@core/connections/ticketing/types/index.ts index 9d39a9f06..c2e5a2ab4 100644 --- a/packages/api/src/@core/connections/ticketing/types/index.ts +++ b/packages/api/src/@core/connections/ticketing/types/index.ts @@ -5,6 +5,21 @@ export interface ZendeskTicketingOAuthResponse { token_type: string; scope: string; } +export interface FrontOAuthResponse { + access_token: string; + refresh_token: string; + expires_at: string; + token_type: string; +} + +export interface GithubOAuthResponse { + access_token: string; + refresh_token: string; + expires_in: string; + refresh_token_expires_in: string; //TODO + token_type: string; + scope: string; +} export type CallbackParams = { linkedUserId: string; diff --git a/packages/api/src/@core/environment/environment.service.ts b/packages/api/src/@core/environment/environment.service.ts index 80909fc69..480a429d6 100644 --- a/packages/api/src/@core/environment/environment.service.ts +++ b/packages/api/src/@core/environment/environment.service.ts @@ -37,6 +37,9 @@ export class EnvironmentService { getCryptoKey(): string { return this.configService.get('ENCRYPT_CRYPTO_SECRET_KEY'); } + + /* CRM */ + getHubspotAuth(): OAuth { return { CLIENT_ID: this.configService.get('HUBSPOT_CLIENT_ID'), @@ -58,6 +61,21 @@ export class EnvironmentService { }; } + getFreshsalesSecret(): OAuth { + return { + CLIENT_ID: this.configService.get('FRESHSALES_CLIENT_ID'), + CLIENT_SECRET: this.configService.get('FRESHSALES_CLIENT_SECRET'), + }; + } + getPipedriveSecret(): OAuth { + return { + CLIENT_ID: this.configService.get('PIPEDRIVE_CLIENT_ID'), + CLIENT_SECRET: this.configService.get('PIPEDRIVE_CLIENT_SECRET'), + }; + } + + /* TICKETING */ + getZendeskTicketingSecret(): OAuth { return { CLIENT_ID: this.configService.get('ZENDESK_TICKETING_CLIENT_ID'), @@ -71,16 +89,17 @@ export class EnvironmentService { return this.configService.get('ZENDESK_TICKETING_SUBDOMAIN'); } - getFreshsalesSecret(): OAuth { + getFrontSecret(): OAuth { return { - CLIENT_ID: this.configService.get('FRESHSALES_CLIENT_ID'), - CLIENT_SECRET: this.configService.get('FRESHSALES_CLIENT_SECRET'), + CLIENT_ID: this.configService.get('FRONT_CLIENT_ID'), + CLIENT_SECRET: this.configService.get('FRONT_CLIENT_SECRET'), }; } - getPipedriveSecret(): OAuth { + + getGithubSecret(): OAuth { return { - CLIENT_ID: this.configService.get('PIPEDRIVE_CLIENT_ID'), - CLIENT_SECRET: this.configService.get('PIPEDRIVE_CLIENT_SECRET'), + CLIENT_ID: this.configService.get('GITHUB_CLIENT_ID'), + CLIENT_SECRET: this.configService.get('GITHUB_CLIENT_SECRET'), }; } diff --git a/packages/api/src/@core/utils/errors.ts b/packages/api/src/@core/utils/errors.ts index 22052e6c5..bd7868373 100644 --- a/packages/api/src/@core/utils/errors.ts +++ b/packages/api/src/@core/utils/errors.ts @@ -4,6 +4,7 @@ import axios, { AxiosError } from 'axios'; import { Prisma } from '@prisma/client'; import { TargetObject } from './types'; import { PrismaClientKnownRequestError } from '@prisma/client/runtime/library'; +import { PinoLogger } from 'nestjs-pino'; type ServiceError = AxiosError | PrismaClientKnownRequestError | Error; diff --git a/packages/api/src/@core/utils/types/index.ts b/packages/api/src/@core/utils/types/index.ts index 998123644..7cf470ff2 100644 --- a/packages/api/src/@core/utils/types/index.ts +++ b/packages/api/src/@core/utils/types/index.ts @@ -90,7 +90,7 @@ export const CRM_PROVIDERS = [ export const HRIS_PROVIDERS = ['']; export const ATS_PROVIDERS = ['']; export const ACCOUNTING_PROVIDERS = ['']; -export const TICKETING_PROVIDERS = ['zendesk_t']; +export const TICKETING_PROVIDERS = ['zendesk_tcg', 'front']; //TODO: add github export const MARKETING_AUTOMATION_PROVIDERS = ['']; export const FILE_STORAGE_PROVIDERS = ['']; diff --git a/packages/api/src/@core/utils/types/original/original.ticketing.ts b/packages/api/src/@core/utils/types/original/original.ticketing.ts index 1be81d418..b9ce99b62 100644 --- a/packages/api/src/@core/utils/types/original/original.ticketing.ts +++ b/packages/api/src/@core/utils/types/original/original.ticketing.ts @@ -5,46 +5,190 @@ import { ZendeskTicketOutput, ZendeskCommentOutput, ZendeskUserOutput, - ZendeskAttachmentOutput, - ZendeskAttachmentInput, + ZendeskAccountInput, + ZendeskAccountOutput, + ZendeskContactInput, + ZendeskContactOutput, + ZendeskTagInput, + ZendeskTagOutput, + ZendeskTeamInput, + ZendeskTeamOutput, } from '@ticketing/@utils/@types'; +import { + FrontAccountInput, + FrontAccountOutput, +} from '@ticketing/account/services/front/types'; +import { + GithubAccountInput, + GithubAccountOutput, +} from '@ticketing/account/services/github/types'; +import { FrontAttachmentOutput } from '@ticketing/attachment/services/front/types'; +import { GithubAttachmentOutput } from '@ticketing/attachment/services/github/types'; +import { ZendeskAttachmentOutput } from '@ticketing/attachment/services/zendesk/types'; + +import { + FrontCommentInput, + FrontCommentOutput, +} from '@ticketing/comment/services/front/types'; +import { + GithubCommentInput, + GithubCommentOutput, +} from '@ticketing/comment/services/github/types'; +import { + HubspotCommentInput, + HubspotCommentOutput, +} from '@ticketing/comment/services/hubspot/types'; +import { + FrontContactInput, + FrontContactOutput, +} from '@ticketing/contact/services/front/types'; +import { + GithubContactInput, + GithubContactOutput, +} from '@ticketing/contact/services/github/types'; +import { + FrontTagInput, + FrontTagOutput, +} from '@ticketing/tag/services/front/types'; +import { + GithubTagInput, + GithubTagOutput, +} from '@ticketing/tag/services/github/types'; +import { + FrontTeamInput, + FrontTeamOutput, +} from '@ticketing/team/services/front/types'; +import { + GithubTeamInput, + GithubTeamOutput, +} from '@ticketing/team/services/github/types'; +import { + FrontTicketInput, + FrontTicketOutput, +} from '@ticketing/ticket/services/front/types'; +import { + GithubTicketInput, + GithubTicketOutput, +} from '@ticketing/ticket/services/github/types'; +import { + HubspotTicketInput, + HubspotTicketOutput, +} from '@ticketing/ticket/services/hubspot/types'; +import { + FrontUserInput, + FrontUserOutput, +} from '@ticketing/user/services/front/types'; +import { + GithubUserInput, + GithubUserOutput, +} from '@ticketing/user/services/github/types'; /* INPUT */ /* ticket */ -export type OriginalTicketInput = ZendeskTicketInput; +export type OriginalTicketInput = + | ZendeskTicketInput + | FrontTicketInput + | GithubTicketInput + | HubspotTicketInput; /* comment */ -export type OriginalCommentInput = ZendeskCommentInput; - +export type OriginalCommentInput = + | ZendeskCommentInput + | FrontCommentInput + | GithubCommentInput + | HubspotCommentInput; /* user */ -export type OriginalUserInput = ZendeskUserInput; +export type OriginalUserInput = + | ZendeskUserInput + | GithubUserInput + | FrontUserInput; +/* account */ +export type OriginalAccountInput = + | ZendeskAccountInput + | GithubAccountInput + | FrontAccountInput; +/* contact */ +export type OriginalContactInput = + | ZendeskContactInput + | GithubContactInput + | FrontContactInput; + +/* tag */ +export type OriginalTagInput = ZendeskTagInput | GithubTagInput | FrontTagInput; +/* team */ +export type OriginalTeamInput = + | ZendeskTeamInput + | GithubTeamInput + | FrontTeamInput; /* attachment */ -export type OriginalAttachmentInput = ZendeskAttachmentInput; +export type OriginalAttachmentInput = null; export type TicketingObjectInput = | OriginalTicketInput | OriginalCommentInput | OriginalUserInput - | OriginalAttachmentInput; + | OriginalAttachmentInput + | OriginalTagInput + | OriginalTeamInput + | OriginalContactInput + | OriginalAccountInput; /* OUTPUT */ /* ticket */ -export type OriginalTicketOutput = ZendeskTicketOutput; - +export type OriginalTicketOutput = + | ZendeskTicketOutput + | FrontTicketOutput + | GithubTicketOutput + | HubspotTicketOutput; /* comment */ -export type OriginalCommentOutput = ZendeskCommentOutput; - +export type OriginalCommentOutput = + | ZendeskCommentOutput + | FrontCommentOutput + | GithubCommentOutput + | HubspotCommentOutput; /* user */ -export type OriginalUserOutput = ZendeskUserOutput; +export type OriginalUserOutput = + | ZendeskUserOutput + | GithubUserOutput + | FrontUserOutput; + +/* account */ +export type OriginalAccountOutput = + | ZendeskAccountOutput + | GithubAccountOutput + | FrontAccountOutput; +/* contact */ +export type OriginalContactOutput = + | ZendeskContactOutput + | GithubContactOutput + | FrontContactOutput; + +/* tag */ +export type OriginalTagOutput = + | ZendeskTagOutput + | GithubTagOutput + | FrontTagOutput; +/* team */ +export type OriginalTeamOutput = + | ZendeskTeamOutput + | GithubTeamOutput + | FrontTeamOutput; /* attachment */ -export type OriginalAttachmentOutput = ZendeskAttachmentOutput; +export type OriginalAttachmentOutput = + | ZendeskAttachmentOutput + | FrontAttachmentOutput + | GithubAttachmentOutput; export type TicketingObjectOutput = | OriginalTicketOutput | OriginalCommentOutput | OriginalUserOutput - | OriginalAttachmentOutput; + | OriginalAttachmentOutput + | OriginalTeamOutput + | OriginalTagOutput + | OriginalContactOutput + | OriginalAccountOutput; diff --git a/packages/api/src/@core/webhook/webhook.service.ts b/packages/api/src/@core/webhook/webhook.service.ts index c6c0781f1..09e2995b2 100644 --- a/packages/api/src/@core/webhook/webhook.service.ts +++ b/packages/api/src/@core/webhook/webhook.service.ts @@ -46,7 +46,7 @@ export class WebhookService { active: true, created_at: new Date(), id_project: data.id_project, - scope: JSON.stringify(data.scope), + scope: data.scope, }, }); } catch (error) { @@ -73,7 +73,7 @@ export class WebhookService { if (!webhooks) return; const webhook = webhooks.find((wh) => { - const scopes = JSON.parse(wh.scope); + const scopes = wh.scope; return scopes.includes(eventType); }); diff --git a/packages/api/src/crm/contact/contact.controller.ts b/packages/api/src/crm/contact/contact.controller.ts index 30eb0ea69..32bfb5082 100644 --- a/packages/api/src/crm/contact/contact.controller.ts +++ b/packages/api/src/crm/contact/contact.controller.ts @@ -7,7 +7,7 @@ import { Patch, Param, UseGuards, - Headers + Headers, } from '@nestjs/common'; import { ContactService } from './services/contact.service'; import { LoggerService } from '@@core/logger/logger.service'; @@ -18,7 +18,7 @@ import { ApiParam, ApiQuery, ApiTags, - ApiHeader + ApiHeader, } from '@nestjs/swagger'; import { ApiKeyAuthGuard } from '@@core/auth/guards/api-key.guard'; @@ -35,9 +35,9 @@ export class ContactController { @ApiOperation({ operationId: 'getContacts', summary: 'List a batch of CRM Contacts', - }) + }) @ApiHeader({ name: 'integrationId', required: true }) - @ApiHeader({ name: 'linkedUserId', required: true}) + @ApiHeader({ name: 'linkedUserId', required: true }) @ApiQuery({ name: 'remoteData', required: false, diff --git a/packages/api/src/crm/contact/services/contact.service.ts b/packages/api/src/crm/contact/services/contact.service.ts index 8d8460d72..f2051d45d 100644 --- a/packages/api/src/crm/contact/services/contact.service.ts +++ b/packages/api/src/crm/contact/services/contact.service.ts @@ -35,7 +35,7 @@ export class ContactService { integrationId: string, linkedUserId: string, remote_data?: boolean, - ): Promise> { + ): Promise { try { const responses = await Promise.all( unifiedContactData.map((unifiedData) => @@ -48,21 +48,7 @@ export class ContactService { ), ); - const allContacts = responses.flatMap( - (response) => response.data.contacts, - ); - const allRemoteData = responses.flatMap( - (response) => response.data.remote_data || [], - ); - - return { - data: { - contacts: allContacts, - remote_data: allRemoteData, - }, - message: 'All contacts inserted successfully', - statusCode: 201, - }; + return responses; } catch (error) { handleServiceError(error, this.logger); } @@ -73,27 +59,13 @@ export class ContactService { integrationId: string, linkedUserId: string, remote_data?: boolean, - ): Promise> { + ): Promise { try { const linkedUser = await this.prisma.linked_users.findUnique({ where: { id_linked_user: linkedUserId, }, }); - const job_resp_create = await this.prisma.events.create({ - data: { - id_event: uuidv4(), - status: 'initialized', - type: 'crm.contact.created', //sync, push or pull - method: 'POST', - url: '/crm/contact', - provider: integrationId, - direction: '0', - timestamp: new Date(), - id_linked_user: linkedUserId, - }, - }); - const job_id = job_resp_create.id_event; // Retrieve custom field mappings // get potential fieldMappings and extract the original properties name @@ -142,9 +114,7 @@ export class ContactService { where: { remote_id: originId, remote_platform: integrationId, - events: { - id_linked_user: linkedUserId, - }, + id_linked_user: linkedUserId, }, include: { crm_email_addresses: true, crm_phone_numbers: true }, }); @@ -197,7 +167,7 @@ export class ContactService { last_name: target_contact.last_name || '', created_at: new Date(), modified_at: new Date(), - id_event: job_id, + id_linked_user: linkedUserId, remote_id: originId, remote_platform: integrationId, }; @@ -280,28 +250,32 @@ export class ContactService { }); } - ///// const result_contact = await this.getContact( unique_crm_contact_id, remote_data, ); const status_resp = resp.statusCode === 201 ? 'success' : 'fail'; - await this.prisma.events.update({ - where: { - id_event: job_id, - }, + const event = await this.prisma.events.create({ data: { + id_event: uuidv4(), status: status_resp, + type: 'crm.contact.created', //sync, push or pull + method: 'POST', + url: '/crm/contact', + provider: integrationId, + direction: '0', + timestamp: new Date(), + id_linked_user: linkedUserId, }, }); await this.webhook.handleWebhook( - result_contact.data.contacts, + result_contact, 'crm.contact.created', linkedUser.id_project, - job_id, + event.id_event, ); - return { ...resp, data: result_contact.data }; + return result_contact; } catch (error) { handleServiceError(error, this.logger); } @@ -310,7 +284,7 @@ export class ContactService { async getContact( id_crm_contact: string, remote_data?: boolean, - ): Promise> { + ): Promise { try { const contact = await this.prisma.crm_contacts.findUnique({ where: { @@ -362,10 +336,7 @@ export class ContactService { field_mappings: field_mappings, }; - let res: ContactResponse = { - contacts: [unifiedContact], - }; - + let res: UnifiedContactOutput = unifiedContact; if (remote_data) { const resp = await this.prisma.remote_data.findFirst({ where: { @@ -376,14 +347,11 @@ export class ContactService { res = { ...res, - remote_data: [remote_data], + remote_data: remote_data, }; } - return { - data: res, - statusCode: 200, - }; + return res; } catch (error) { handleServiceError(error, this.logger); } @@ -393,29 +361,14 @@ export class ContactService { integrationId: string, linkedUserId: string, remote_data?: boolean, - ): Promise> { + ): Promise { try { //TODO: handle case where data is not there (not synced) or old synced - const job_resp_create = await this.prisma.events.create({ - data: { - id_event: uuidv4(), - status: 'initialized', - type: 'crm.contact.pull', - method: 'GET', - url: '/crm/contact', - provider: integrationId, - direction: '0', - timestamp: new Date(), - id_linked_user: linkedUserId, - }, - }); - const job_id = job_resp_create.id_event; + const contacts = await this.prisma.crm_contacts.findMany({ where: { - remote_id: integrationId.toLowerCase(), - events: { - id_linked_user: linkedUserId, - }, + remote_platform: integrationId.toLowerCase(), + id_linked_user: linkedUserId, }, include: { crm_email_addresses: true, @@ -467,41 +420,37 @@ export class ContactService { }), ); - let res: ContactResponse = { - contacts: unifiedContacts, - }; + let res: UnifiedContactOutput[] = unifiedContacts; if (remote_data) { - const remote_array_data: Record[] = await Promise.all( - contacts.map(async (contact) => { + const remote_array_data: UnifiedContactOutput[] = await Promise.all( + res.map(async (contact) => { const resp = await this.prisma.remote_data.findFirst({ where: { - ressource_owner_id: contact.id_crm_contact, + ressource_owner_id: contact.id, }, }); const remote_data = JSON.parse(resp.data); - return remote_data; + return { ...contact, remote_data }; }), ); - res = { - ...res, - remote_data: remote_array_data, - }; + res = remote_array_data; } - await this.prisma.events.update({ - where: { - id_event: job_id, - }, + const event = await this.prisma.events.create({ data: { + id_event: uuidv4(), status: 'success', + type: 'crm.contact.pull', + method: 'GET', + url: '/crm/contact', + provider: integrationId, + direction: '0', + timestamp: new Date(), + id_linked_user: linkedUserId, }, }); - - return { - data: res, - statusCode: 200, - }; + return res; } catch (error) { handleServiceError(error, this.logger); } @@ -510,7 +459,7 @@ export class ContactService { async updateContact( id: string, updateContactData: Partial, - ): Promise> { + ): Promise { try { } catch (error) { handleServiceError(error, this.logger); diff --git a/packages/api/src/crm/contact/services/hubspot/types.ts b/packages/api/src/crm/contact/services/hubspot/types.ts index 7e9de18ef..208850ca0 100644 --- a/packages/api/src/crm/contact/services/hubspot/types.ts +++ b/packages/api/src/crm/contact/services/hubspot/types.ts @@ -16,6 +16,14 @@ export interface HubspotContactInput { [key: string]: any; } +export interface HubspotContactOutput { + id: string; + properties: HubspotPropertiesOuput; + createdAt: string; + updatedAt: string; + archived: boolean; +} + type HubspotPropertiesOuput = { createdate: string; email: string; @@ -35,10 +43,3 @@ export const commonHubspotProperties = { lastname: '', // Add any other common properties here }; -export interface HubspotContactOutput { - id: string; - properties: HubspotPropertiesOuput; - createdAt: string; - updatedAt: string; - archived: boolean; -} diff --git a/packages/api/src/crm/contact/sync/sync.service.ts b/packages/api/src/crm/contact/sync/sync.service.ts index 7deb67562..9f0774bec 100644 --- a/packages/api/src/crm/contact/sync/sync.service.ts +++ b/packages/api/src/crm/contact/sync/sync.service.ts @@ -90,7 +90,6 @@ export class SyncContactsService implements OnModuleInit { contacts: UnifiedContactOutput[], originIds: string[], originSource: string, - jobId: string, remote_data: Record[], ): Promise { try { @@ -107,9 +106,7 @@ export class SyncContactsService implements OnModuleInit { where: { remote_id: originId, remote_platform: originSource, - events: { - id_linked_user: linkedUserId, - }, + id_linked_user: linkedUserId, }, include: { crm_email_addresses: true, crm_phone_numbers: true }, }); @@ -164,7 +161,7 @@ export class SyncContactsService implements OnModuleInit { last_name: contact.last_name ? contact.last_name : '', created_at: new Date(), modified_at: new Date(), - id_event: jobId, + id_linked_user: linkedUserId, remote_id: originId, remote_platform: originSource, }; @@ -269,21 +266,7 @@ export class SyncContactsService implements OnModuleInit { provider_slug: integrationId, }, }); - if (!connection) return; - const job_resp_create = await this.prisma.events.create({ - data: { - id_event: uuidv4(), - status: 'initialized', - type: 'crm.contact.pulled', - method: 'PULL', - url: '/pull', - provider: integrationId, - direction: '0', - timestamp: new Date(), - id_linked_user: linkedUserId, - }, - }); - const job_id = job_resp_create.id_event; + if (!connection) throw new Error('connection not found'); // get potential fieldMappings and extract the original properties name const customFieldMappings = @@ -326,22 +309,26 @@ export class SyncContactsService implements OnModuleInit { unifiedObject, contactIds, integrationId, - job_id, sourceObject, ); - await this.prisma.events.update({ - where: { - id_event: job_id, - }, + const event = await this.prisma.events.create({ data: { + id_event: uuidv4(), status: 'success', + type: 'crm.contact.pulled', + method: 'PULL', + url: '/pull', + provider: integrationId, + direction: '0', + timestamp: new Date(), + id_linked_user: linkedUserId, }, }); await this.webhook.handleWebhook( contacts_data, 'crm.contact.pulled', id_project, - job_id, + event.id_event, ); } catch (error) { handleServiceError(error, this.logger); diff --git a/packages/api/src/crm/contact/types/model.unified.ts b/packages/api/src/crm/contact/types/model.unified.ts index e8004d954..c883d74f1 100644 --- a/packages/api/src/crm/contact/types/model.unified.ts +++ b/packages/api/src/crm/contact/types/model.unified.ts @@ -15,6 +15,11 @@ export class UnifiedContactInput { } export class UnifiedContactOutput extends UnifiedContactInput { - @ApiPropertyOptional() + @ApiPropertyOptional({ description: 'The id of the contact' }) id?: string; + @ApiPropertyOptional({ + description: 'The id of the contact in the context of the Crm software', + }) + remote_id?: string; + remote_data?: Record; } diff --git a/packages/api/src/crm/deal/deal.controller.ts b/packages/api/src/crm/deal/deal.controller.ts index f22663c67..5cf2ff386 100644 --- a/packages/api/src/crm/deal/deal.controller.ts +++ b/packages/api/src/crm/deal/deal.controller.ts @@ -42,8 +42,7 @@ export class DealController { name: 'remoteData', required: false, type: Boolean, - description: - 'Set to true to include data from the original Crm software.', + description: 'Set to true to include data from the original Crm software.', }) //@ApiCustomResponse(DealResponse) @Get() @@ -52,11 +51,7 @@ export class DealController { @Headers('linkedUserId') linkedUserId: string, @Query('remoteData') remote_data?: boolean, ) { - return this.dealService.getDeals( - integrationId, - linkedUserId, - remote_data, - ); + return this.dealService.getDeals(integrationId, linkedUserId, remote_data); } @ApiOperation({ @@ -74,15 +69,11 @@ export class DealController { name: 'remoteData', required: false, type: Boolean, - description: - 'Set to true to include data from the original Crm software.', + description: 'Set to true to include data from the original Crm software.', }) //@ApiCustomResponse(DealResponse) @Get(':id') - getDeal( - @Param('id') id: string, - @Query('remoteData') remote_data?: boolean, - ) { + getDeal(@Param('id') id: string, @Query('remoteData') remote_data?: boolean) { return this.dealService.getDeal(id, remote_data); } @@ -107,8 +98,7 @@ export class DealController { name: 'remoteData', required: false, type: Boolean, - description: - 'Set to true to include data from the original Crm software.', + description: 'Set to true to include data from the original Crm software.', }) @ApiBody({ type: UnifiedDealInput }) //@ApiCustomResponse(DealResponse) @@ -137,8 +127,7 @@ export class DealController { name: 'remoteData', required: false, type: Boolean, - description: - 'Set to true to include data from the original Crm software.', + description: 'Set to true to include data from the original Crm software.', }) @ApiBody({ type: UnifiedDealInput, isArray: true }) //@ApiCustomResponse(DealResponse) diff --git a/packages/api/src/crm/deal/services/deal.service.ts b/packages/api/src/crm/deal/services/deal.service.ts index cb9134e7c..493080ede 100644 --- a/packages/api/src/crm/deal/services/deal.service.ts +++ b/packages/api/src/crm/deal/services/deal.service.ts @@ -2,17 +2,11 @@ import { Injectable } from '@nestjs/common'; import { PrismaService } from '@@core/prisma/prisma.service'; import { LoggerService } from '@@core/logger/logger.service'; import { v4 as uuidv4 } from 'uuid'; -import { ApiResponse } from '@@core/utils/types'; -import { handleServiceError } from '@@core/utils/errors'; import { WebhookService } from '@@core/webhook/webhook.service'; -import { UnifiedDealInput, UnifiedDealOutput } from '../types/model.unified'; +import { UnifiedDealInput } from '../types/model.unified'; import { DealResponse } from '../types'; -import { desunify } from '@@core/utils/unification/desunify'; -import { CrmObject } from '@crm/@utils/@types'; import { FieldMappingService } from '@@core/field-mapping/field-mapping.service'; import { ServiceRegistry } from './registry.service'; -import { OriginalDealOutput } from '@@core/utils/types/original/original.crm'; -import { unify } from '@@core/utils/unification/unify'; @Injectable() export class DealService { @@ -31,23 +25,23 @@ export class DealService { integrationId: string, linkedUserId: string, remote_data?: boolean, - ): Promise> { - return + ): Promise { + return; } - async addDeal( + async addDeal( unifiedDealData: UnifiedDealInput, integrationId: string, linkedUserId: string, remote_data?: boolean, - ): Promise> { + ): Promise { return; } async getDeal( id_crm_deal: string, remote_data?: boolean, - ): Promise> { + ): Promise { return; } @@ -55,14 +49,14 @@ export class DealService { integrationId: string, linkedUserId: string, remote_data?: boolean, - ): Promise> { + ): Promise { return; } async updateDeal( id: string, updateDealData: Partial, - ): Promise> { + ): Promise { return; } } diff --git a/packages/api/src/ticketing/@utils/@types/index.ts b/packages/api/src/ticketing/@utils/@types/index.ts index 09c40be41..b303a219b 100644 --- a/packages/api/src/ticketing/@utils/@types/index.ts +++ b/packages/api/src/ticketing/@utils/@types/index.ts @@ -1,4 +1,15 @@ +import { contactUnificationMapping } from '@crm/contact/types/mappingsTypes'; +import { IAccountService } from '@ticketing/account/types'; +import { accountUnificationMapping } from '@ticketing/account/types/mappingsTypes'; +import { + UnifiedAccountInput, + UnifiedAccountOutput, +} from '@ticketing/account/types/model.unified'; import { IAttachmentService } from '@ticketing/attachment/types'; +import { + UnifiedAttachmentInput, + UnifiedAttachmentOutput, +} from '@ticketing/attachment/types/model.unified'; import { ICommentService } from '@ticketing/comment/types'; import { commentUnificationMapping } from '@ticketing/comment/types/mappingsTypes'; import { @@ -6,6 +17,22 @@ import { UnifiedCommentOutput, } from '@ticketing/comment/types/model.unified'; import { IContactService } from '@ticketing/contact/types'; +import { + UnifiedContactInput, + UnifiedContactOutput, +} from '@ticketing/contact/types/model.unified'; +import { ITagService } from '@ticketing/tag/types'; +import { tagUnificationMapping } from '@ticketing/tag/types/mappingsTypes'; +import { + UnifiedTagInput, + UnifiedTagOutput, +} from '@ticketing/tag/types/model.unified'; +import { ITeamService } from '@ticketing/team/types'; +import { teamUnificationMapping } from '@ticketing/team/types/mappingsTypes'; +import { + UnifiedTeamInput, + UnifiedTeamOutput, +} from '@ticketing/team/types/model.unified'; import { ITicketService } from '@ticketing/ticket/types'; import { ticketUnificationMapping } from '@ticketing/ticket/types/mappingsTypes'; import { @@ -13,24 +40,49 @@ import { UnifiedTicketOutput, } from '@ticketing/ticket/types/model.unified'; import { IUserService } from '@ticketing/user/types'; +import { userUnificationMapping } from '@ticketing/user/types/mappingsTypes'; +import { + UnifiedUserInput, + UnifiedUserOutput, +} from '@ticketing/user/types/model.unified'; export enum TicketingObject { ticket = 'ticket', comment = 'comment', user = 'user', - attachment = 'attachement', + attachment = 'attachment', contact = 'contact', + account = 'account', + tag = 'tag', + team = 'team', } export type UnifiedTicketing = | UnifiedTicketInput | UnifiedTicketOutput | UnifiedCommentInput - | UnifiedCommentOutput; + | UnifiedCommentOutput + | UnifiedUserInput + | UnifiedUserOutput + | UnifiedAccountInput + | UnifiedAccountOutput + | UnifiedContactInput + | UnifiedContactOutput + | UnifiedTeamInput + | UnifiedTeamOutput + | UnifiedTagInput + | UnifiedTagOutput + | UnifiedAttachmentInput + | UnifiedAttachmentOutput; export const unificationMapping = { [TicketingObject.ticket]: ticketUnificationMapping, [TicketingObject.comment]: commentUnificationMapping, + [TicketingObject.user]: userUnificationMapping, + [TicketingObject.account]: accountUnificationMapping, + [TicketingObject.contact]: contactUnificationMapping, + [TicketingObject.team]: teamUnificationMapping, + [TicketingObject.tag]: tagUnificationMapping, }; export type ITicketingService = @@ -38,10 +90,16 @@ export type ITicketingService = | ICommentService | IUserService | IAttachmentService - | IContactService; + | IContactService + | IAccountService + | ITeamService + | ITagService; +//TODO; export everything export * from '../../ticket/services/zendesk/types'; export * from '../../comment/services/zendesk/types'; export * from '../../user/services/zendesk/types'; export * from '../../contact/services/zendesk/types'; -export * from '../../attachment/services/zendesk/types'; +export * from '../../account/services/zendesk/types'; +export * from '../../team/services/zendesk/types'; +export * from '../../tag/services/zendesk/types'; diff --git a/packages/api/src/ticketing/account/account.controller.ts b/packages/api/src/ticketing/account/account.controller.ts new file mode 100644 index 000000000..ec414eac3 --- /dev/null +++ b/packages/api/src/ticketing/account/account.controller.ts @@ -0,0 +1,76 @@ +import { Controller, Query, Get, Param, Headers } from '@nestjs/common'; +import { LoggerService } from '@@core/logger/logger.service'; +import { + ApiOperation, + ApiParam, + ApiQuery, + ApiTags, + ApiHeader, +} from '@nestjs/swagger'; +import { ApiCustomResponse } from '@@core/utils/types'; +import { AccountService } from './services/account.service'; + +@ApiTags('ticketing/account') +@Controller('ticketing/account') +export class AccountController { + constructor( + private readonly accountService: AccountService, + private logger: LoggerService, + ) { + this.logger.setContext(AccountController.name); + } + + @ApiOperation({ + operationId: 'getAccounts', + summary: 'List a batch of Accounts', + }) + @ApiHeader({ name: 'integrationId', required: true }) + @ApiHeader({ name: 'linkedUserId', required: true }) + @ApiQuery({ + name: 'remoteData', + required: false, + type: Boolean, + description: + 'Set to true to include data from the original Ticketing software.', + }) + //@ApiCustomResponse(AccountResponse) + @Get() + getAccounts( + @Headers('integrationId') integrationId: string, + @Headers('linkedUserId') linkedUserId: string, + @Query('remoteData') remote_data?: boolean, + ) { + return this.accountService.getAccounts( + integrationId, + linkedUserId, + remote_data, + ); + } + + @ApiOperation({ + operationId: 'getAccount', + summary: 'Retrieve an Account', + description: 'Retrieve an account from any connected Ticketing software', + }) + @ApiParam({ + name: 'id', + required: true, + type: String, + description: 'id of the account you want to retrieve.', + }) + @ApiQuery({ + name: 'remoteData', + required: false, + type: Boolean, + description: + 'Set to true to include data from the original Ticketing software.', + }) + //@ApiCustomResponse(AccountResponse) + @Get(':id') + getAccount( + @Param('id') id: string, + @Query('remoteData') remote_data?: boolean, + ) { + return this.accountService.getAccount(id, remote_data); + } +} diff --git a/packages/api/src/ticketing/account/account.module.ts b/packages/api/src/ticketing/account/account.module.ts new file mode 100644 index 000000000..48f0d779b --- /dev/null +++ b/packages/api/src/ticketing/account/account.module.ts @@ -0,0 +1,35 @@ +import { Module } from '@nestjs/common'; +import { AccountController } from './account.controller'; +import { SyncService } from './sync/sync.service'; +import { LoggerService } from '@@core/logger/logger.service'; +import { AccountService } from './services/account.service'; +import { ServiceRegistry } from './services/registry.service'; +import { EncryptionService } from '@@core/encryption/encryption.service'; +import { FieldMappingService } from '@@core/field-mapping/field-mapping.service'; +import { PrismaService } from '@@core/prisma/prisma.service'; +import { WebhookService } from '@@core/webhook/webhook.service'; +import { BullModule } from '@nestjs/bull'; + +@Module({ + imports: [ + BullModule.registerQueue({ + name: 'webhookDelivery', + }), + ], + controllers: [AccountController], + providers: [ + AccountService, + PrismaService, + LoggerService, + SyncService, + WebhookService, + EncryptionService, + FieldMappingService, + ServiceRegistry, + /* PROVIDERS SERVICES */ + + ], + exports: [SyncService], +}) +export class AccountModule {} + diff --git a/packages/api/src/ticketing/account/services/account.service.ts b/packages/api/src/ticketing/account/services/account.service.ts new file mode 100644 index 000000000..e53ce14ed --- /dev/null +++ b/packages/api/src/ticketing/account/services/account.service.ts @@ -0,0 +1,166 @@ +import { Injectable } from '@nestjs/common'; +import { PrismaService } from '@@core/prisma/prisma.service'; +import { LoggerService } from '@@core/logger/logger.service'; +import { v4 as uuidv4 } from 'uuid'; +import { handleServiceError } from '@@core/utils/errors'; +import { UnifiedAccountOutput } from '../types/model.unified'; +import { AccountResponse } from '../types'; + +@Injectable() +export class AccountService { + constructor(private prisma: PrismaService, private logger: LoggerService) { + this.logger.setContext(AccountService.name); + } + + async getAccount( + id_ticketing_account: string, + remote_data?: boolean, + ): Promise { + try { + const account = await this.prisma.tcg_accounts.findUnique({ + where: { + id_tcg_account: id_ticketing_account, + }, + }); + + // Fetch field mappings for the ticket + const values = await this.prisma.value.findMany({ + where: { + entity: { + ressource_owner_id: account.id_tcg_account, + }, + }, + include: { + attribute: true, + }, + }); + + // Create a map to store unique field mappings + const fieldMappingsMap = new Map(); + + values.forEach((value) => { + fieldMappingsMap.set(value.attribute.slug, value.data); + }); + + // Convert the map to an array of objects + const field_mappings = Array.from(fieldMappingsMap, ([key, value]) => ({ + [key]: value, + })); + + // Transform to UnifiedAccountOutput format + const unifiedAccount: UnifiedAccountOutput = { + id: account.id_tcg_account, + name: account.name, + domains: account.domains, + field_mappings: field_mappings, + }; + + let res: UnifiedAccountOutput = unifiedAccount; + + if (remote_data) { + const resp = await this.prisma.remote_data.findFirst({ + where: { + ressource_owner_id: account.id_tcg_account, + }, + }); + const remote_data = JSON.parse(resp.data); + + res = { + ...res, + remote_data: remote_data, + }; + } + + return res; + } catch (error) { + handleServiceError(error, this.logger); + } + } + + async getAccounts( + integrationId: string, + linkedUserId: string, + remote_data?: boolean, + ): Promise { + try { + //TODO: handle case where data is not there (not synced) or old synced + + const accounts = await this.prisma.tcg_accounts.findMany({ + where: { + remote_platform: integrationId.toLowerCase(), + id_linked_user: linkedUserId, + }, + }); + + const unifiedAccounts: UnifiedAccountOutput[] = await Promise.all( + accounts.map(async (account) => { + // Fetch field mappings for the account + const values = await this.prisma.value.findMany({ + where: { + entity: { + ressource_owner_id: account.id_tcg_account, + }, + }, + include: { + attribute: true, + }, + }); + // Create a map to store unique field mappings + const fieldMappingsMap = new Map(); + + values.forEach((value) => { + fieldMappingsMap.set(value.attribute.slug, value.data); + }); + + // Convert the map to an array of objects + const field_mappings = Array.from( + fieldMappingsMap, + ([key, value]) => ({ [key]: value }), + ); + + // Transform to UnifiedAccountOutput format + return { + id: account.id_tcg_account, + name: account.name, + domains: account.domains, + field_mappings: field_mappings, + }; + }), + ); + + let res: UnifiedAccountOutput[] = unifiedAccounts; + + if (remote_data) { + const remote_array_data: UnifiedAccountOutput[] = await Promise.all( + res.map(async (account) => { + const resp = await this.prisma.remote_data.findFirst({ + where: { + ressource_owner_id: account.id, + }, + }); + const remote_data = JSON.parse(resp.data); + return { ...account, remote_data }; + }), + ); + + res = remote_array_data; + } + const event = await this.prisma.events.create({ + data: { + id_event: uuidv4(), + status: 'success', + type: 'ticketing.account.pull', + method: 'GET', + url: '/ticketing/account', + provider: integrationId, + direction: '0', + timestamp: new Date(), + id_linked_user: linkedUserId, + }, + }); + return res; + } catch (error) { + handleServiceError(error, this.logger); + } + } +} diff --git a/packages/api/src/ticketing/account/services/front/index.ts b/packages/api/src/ticketing/account/services/front/index.ts new file mode 100644 index 000000000..d1ed87b59 --- /dev/null +++ b/packages/api/src/ticketing/account/services/front/index.ts @@ -0,0 +1,63 @@ +import { Injectable } from '@nestjs/common'; +import { LoggerService } from '@@core/logger/logger.service'; +import { PrismaService } from '@@core/prisma/prisma.service'; +import { EncryptionService } from '@@core/encryption/encryption.service'; +import { TicketingObject } from '@ticketing/@utils/@types'; +import { ApiResponse } from '@@core/utils/types'; +import axios from 'axios'; +import { ActionType, handleServiceError } from '@@core/utils/errors'; +import { ServiceRegistry } from '../registry.service'; +import { IAccountService } from '@ticketing/account/types'; +import { FrontAccountOutput } from './types'; + +@Injectable() +export class FrontService implements IAccountService { + constructor( + private prisma: PrismaService, + private logger: LoggerService, + private cryptoService: EncryptionService, + private registry: ServiceRegistry, + ) { + this.logger.setContext( + TicketingObject.account.toUpperCase() + ':' + FrontService.name, + ); + this.registry.registerService('front', this); + } + + async syncAccounts( + linkedUserId: string, + ): Promise> { + try { + const connection = await this.prisma.connections.findFirst({ + where: { + id_linked_user: linkedUserId, + provider_slug: 'front', + }, + }); + + const resp = await axios.get('https://api2.frontapp.com/accounts', { + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${this.cryptoService.decrypt( + connection.access_token, + )}`, + }, + }); + this.logger.log(`Synced front accounts !`); + + return { + data: resp.data._results, + message: 'Front accounts retrieved', + statusCode: 200, + }; + } catch (error) { + handleServiceError( + error, + this.logger, + 'Front', + TicketingObject.account, + ActionType.GET, + ); + } + } +} diff --git a/packages/api/src/ticketing/account/services/front/mappers.ts b/packages/api/src/ticketing/account/services/front/mappers.ts new file mode 100644 index 000000000..cf4089ec3 --- /dev/null +++ b/packages/api/src/ticketing/account/services/front/mappers.ts @@ -0,0 +1,53 @@ +import { IAccountMapper } from '@ticketing/account/types'; +import { FrontAccountInput, FrontAccountOutput } from './types'; +import { + UnifiedAccountInput, + UnifiedAccountOutput, +} from '@ticketing/account/types/model.unified'; + +export class FrontAccountMapper implements IAccountMapper { + desunify( + source: UnifiedAccountInput, + customFieldMappings?: { + slug: string; + remote_id: string; + }[], + ): FrontAccountInput { + return; + } + + unify( + source: FrontAccountOutput | FrontAccountOutput[], + customFieldMappings?: { + slug: string; + remote_id: string; + }[], + ): UnifiedAccountOutput | UnifiedAccountOutput[] { + // If the source is not an array, convert it to an array for mapping + const sourcesArray = Array.isArray(source) ? source : [source]; + + return sourcesArray.map((account) => + this.mapSingleAccountToUnified(account, customFieldMappings), + ); + } + + private mapSingleAccountToUnified( + account: FrontAccountOutput, + customFieldMappings?: { + slug: string; + remote_id: string; + }[], + ): UnifiedAccountOutput { + const field_mappings = customFieldMappings?.map((mapping) => ({ + [mapping.slug]: account.custom_fields?.[mapping.remote_id], + })); + + const unifiedAccount: UnifiedAccountOutput = { + name: account.name, + domains: account.domains.flat(), + field_mappings: field_mappings, + }; + + return unifiedAccount; + } +} diff --git a/packages/api/src/ticketing/account/services/front/types.ts b/packages/api/src/ticketing/account/services/front/types.ts new file mode 100644 index 000000000..24fd1e68b --- /dev/null +++ b/packages/api/src/ticketing/account/services/front/types.ts @@ -0,0 +1,24 @@ +export type FrontAccountInput = { + id: string; +}; + +export type FrontAccountOutput = { + _links: { + self: string; + related: { + contacts: string; + }; + }; + id: string; + name: string; + logo_url: string; + description: string; + domains: string[][]; + external_id: number; + custom_fields: { + employees: number; + headquarters: string; + }; + created_at: number; + updated_at: number; +}; diff --git a/packages/api/src/ticketing/account/services/github/index.ts b/packages/api/src/ticketing/account/services/github/index.ts new file mode 100644 index 000000000..f9164f231 --- /dev/null +++ b/packages/api/src/ticketing/account/services/github/index.ts @@ -0,0 +1,64 @@ +import { Injectable } from '@nestjs/common'; +import { LoggerService } from '@@core/logger/logger.service'; +import { PrismaService } from '@@core/prisma/prisma.service'; +import { EncryptionService } from '@@core/encryption/encryption.service'; +import { TicketingObject } from '@ticketing/@utils/@types'; +import { ApiResponse } from '@@core/utils/types'; +import axios from 'axios'; +import { ActionType, handleServiceError } from '@@core/utils/errors'; +import { ServiceRegistry } from '../registry.service'; +import { IAccountService } from '@ticketing/account/types'; +import { GithubAccountOutput } from './types'; + +//TODO +@Injectable() +export class GithubService implements IAccountService { + constructor( + private prisma: PrismaService, + private logger: LoggerService, + private cryptoService: EncryptionService, + private registry: ServiceRegistry, + ) { + this.logger.setContext( + TicketingObject.account.toUpperCase() + ':' + GithubService.name, + ); + this.registry.registerService('github', this); + } + + async syncAccounts( + linkedUserId: string, + custom_properties?: string[], + ): Promise> { + try { + const connection = await this.prisma.connections.findFirst({ + where: { + id_linked_user: linkedUserId, + provider_slug: 'github', + }, + }); + const resp = await axios.get(`https://api.github.com/accounts`, { + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${this.cryptoService.decrypt( + connection.access_token, + )}`, + }, + }); + this.logger.log(`Synced github accounts !`); + + return { + data: resp.data, + message: 'Github accounts retrieved', + statusCode: 200, + }; + } catch (error) { + handleServiceError( + error, + this.logger, + 'Github', + TicketingObject.account, + ActionType.GET, + ); + } + } +} diff --git a/packages/api/src/ticketing/account/services/github/mappers.ts b/packages/api/src/ticketing/account/services/github/mappers.ts new file mode 100644 index 000000000..c666bb63f --- /dev/null +++ b/packages/api/src/ticketing/account/services/github/mappers.ts @@ -0,0 +1,28 @@ +import { IAccountMapper } from '@ticketing/account/types'; +import { GithubAccountInput, GithubAccountOutput } from './types'; +import { + UnifiedAccountInput, + UnifiedAccountOutput, +} from '@ticketing/account/types/model.unified'; + +export class GithubAccountMapper implements IAccountMapper { + desunify( + source: UnifiedAccountInput, + customFieldMappings?: { + slug: string; + remote_id: string; + }[], + ): GithubAccountInput { + return; + } + + unify( + source: GithubAccountOutput | GithubAccountOutput[], + customFieldMappings?: { + slug: string; + remote_id: string; + }[], + ): UnifiedAccountOutput | UnifiedAccountOutput[] { + return; + } +} diff --git a/packages/api/src/ticketing/account/services/github/types.ts b/packages/api/src/ticketing/account/services/github/types.ts new file mode 100644 index 000000000..7ad3a458d --- /dev/null +++ b/packages/api/src/ticketing/account/services/github/types.ts @@ -0,0 +1,6 @@ +export type GithubAccountInput = { + name: string; +}; + +//TODO +export type GithubAccountOutput = GithubAccountInput; diff --git a/packages/api/src/ticketing/account/services/registry.service.ts b/packages/api/src/ticketing/account/services/registry.service.ts new file mode 100644 index 000000000..c7c0928fa --- /dev/null +++ b/packages/api/src/ticketing/account/services/registry.service.ts @@ -0,0 +1,23 @@ +import { Injectable } from '@nestjs/common'; +import { IAccountService } from '../types'; + +@Injectable() +export class ServiceRegistry { + private serviceMap: Map; + + constructor() { + this.serviceMap = new Map(); + } + + registerService(serviceKey: string, service: IAccountService) { + this.serviceMap.set(serviceKey, service); + } + + getService(integrationId: string): IAccountService { + const service = this.serviceMap.get(integrationId); + if (!service) { + throw new Error(); + } + return service; + } +} diff --git a/packages/api/src/ticketing/account/services/zendesk/index.ts b/packages/api/src/ticketing/account/services/zendesk/index.ts new file mode 100644 index 000000000..584190c40 --- /dev/null +++ b/packages/api/src/ticketing/account/services/zendesk/index.ts @@ -0,0 +1,71 @@ +import { Injectable } from '@nestjs/common'; +import { LoggerService } from '@@core/logger/logger.service'; +import { PrismaService } from '@@core/prisma/prisma.service'; +import { EncryptionService } from '@@core/encryption/encryption.service'; +import { + TicketingObject, + ZendeskAccountOutput, +} from '@ticketing/@utils/@types'; +import { ApiResponse } from '@@core/utils/types'; +import axios from 'axios'; +import { ActionType, handleServiceError } from '@@core/utils/errors'; +import { EnvironmentService } from '@@core/environment/environment.service'; +import { ServiceRegistry } from '../registry.service'; +import { IAccountService } from '@ticketing/account/types'; + +@Injectable() +export class ZendeskService implements IAccountService { + constructor( + private prisma: PrismaService, + private logger: LoggerService, + private cryptoService: EncryptionService, + private env: EnvironmentService, + private registry: ServiceRegistry, + ) { + this.logger.setContext( + TicketingObject.account.toUpperCase() + ':' + ZendeskService.name, + ); + this.registry.registerService('zendesk_tcg', this); + } + + async syncAccounts( + linkedUserId: string, + custom_properties?: string[], + ): Promise> { + try { + const connection = await this.prisma.connections.findFirst({ + where: { + id_linked_user: linkedUserId, + provider_slug: 'zendesk_tcg', + }, + }); + + const resp = await axios.get( + `https://${this.env.getZendeskTicketingSubdomain()}.zendesk.com/api/v2/accounts`, + { + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${this.cryptoService.decrypt( + connection.access_token, + )}`, + }, + }, + ); + this.logger.log(`Synced zendesk accounts !`); + + return { + data: resp.data.accounts, + message: 'Zendesk accounts retrieved', + statusCode: 200, + }; + } catch (error) { + handleServiceError( + error, + this.logger, + 'Zendesk', + TicketingObject.account, + ActionType.GET, + ); + } + } +} diff --git a/packages/api/src/ticketing/account/services/zendesk/mappers.ts b/packages/api/src/ticketing/account/services/zendesk/mappers.ts new file mode 100644 index 000000000..47a524a92 --- /dev/null +++ b/packages/api/src/ticketing/account/services/zendesk/mappers.ts @@ -0,0 +1,43 @@ +import { IAccountMapper } from '@ticketing/account/types'; +import { ZendeskAccountInput, ZendeskAccountOutput } from './types'; +import { + UnifiedAccountInput, + UnifiedAccountOutput, +} from '@ticketing/account/types/model.unified'; + +export class ZendeskAccountMapper implements IAccountMapper { + desunify( + source: UnifiedAccountInput, + customFieldMappings?: { + slug: string; + remote_id: string; + }[], + ): ZendeskAccountInput { + return; + } + + unify( + source: ZendeskAccountOutput | ZendeskAccountOutput[], + customFieldMappings?: { + slug: string; + remote_id: string; + }[], + ): UnifiedAccountOutput | UnifiedAccountOutput[] { + if (!Array.isArray(source)) { + return this.mapSingleAccountToUnified(source, customFieldMappings); + } + return source.map((ticket) => + this.mapSingleAccountToUnified(ticket, customFieldMappings), + ); + } + + private mapSingleAccountToUnified( + ticket: ZendeskAccountOutput, + customFieldMappings?: { + slug: string; + remote_id: string; + }[], + ): UnifiedAccountOutput { + return; + } +} diff --git a/packages/api/src/ticketing/account/services/zendesk/types.ts b/packages/api/src/ticketing/account/services/zendesk/types.ts new file mode 100644 index 000000000..928814ea2 --- /dev/null +++ b/packages/api/src/ticketing/account/services/zendesk/types.ts @@ -0,0 +1,7 @@ +export type ZendeskAccountInput = { + _: string; +}; + +export type ZendeskAccountOutput = ZendeskAccountInput & { + id: number; // Read-only. Automatically assigned when the ticket is created. +}; diff --git a/packages/api/src/ticketing/account/sync/sync.service.ts b/packages/api/src/ticketing/account/sync/sync.service.ts new file mode 100644 index 000000000..5ef2f914d --- /dev/null +++ b/packages/api/src/ticketing/account/sync/sync.service.ts @@ -0,0 +1,292 @@ +import { Injectable, OnModuleInit } from '@nestjs/common'; +import { LoggerService } from '@@core/logger/logger.service'; +import { PrismaService } from '@@core/prisma/prisma.service'; +import { NotFoundError, handleServiceError } from '@@core/utils/errors'; +import { Cron } from '@nestjs/schedule'; +import { ApiResponse, TICKETING_PROVIDERS } from '@@core/utils/types'; +import { v4 as uuidv4 } from 'uuid'; +import { FieldMappingService } from '@@core/field-mapping/field-mapping.service'; +import { ServiceRegistry } from '../services/registry.service'; +import { unify } from '@@core/utils/unification/unify'; +import { TicketingObject } from '@ticketing/@utils/@types'; +import { WebhookService } from '@@core/webhook/webhook.service'; +import { UnifiedAccountOutput } from '../types/model.unified'; +import { IAccountService } from '../types'; +import { OriginalAccountOutput } from '@@core/utils/types/original/original.ticketing'; +import { tcg_accounts as TicketingAccount } from '@prisma/client'; + +@Injectable() +export class SyncService implements OnModuleInit { + constructor( + private prisma: PrismaService, + private logger: LoggerService, + private webhook: WebhookService, + private fieldMappingService: FieldMappingService, + private serviceRegistry: ServiceRegistry, + ) { + this.logger.setContext(SyncService.name); + } + + async onModuleInit() { + try { + //await this.syncAccounts(); + } catch (error) { + handleServiceError(error, this.logger); + } + } + + //@Cron('*/20 * * * *') + //function used by sync worker which populate our tcg_accounts table + //its role is to fetch all accounts from providers 3rd parties and save the info inside our db + async syncAccounts() { + try { + this.logger.log(`Syncing accounts....`); + const defaultOrg = await this.prisma.organizations.findFirst({ + where: { + name: 'Acme Inc', + }, + }); + + const defaultProject = await this.prisma.projects.findFirst({ + where: { + id_organization: defaultOrg.id_organization, + name: 'Project 1', + }, + }); + const id_project = defaultProject.id_project; + const linkedAccounts = await this.prisma.linked_users.findMany({ + where: { + id_project: id_project, + }, + }); + linkedAccounts.map(async (linkedAccount) => { + try { + const providers = TICKETING_PROVIDERS; + for (const provider of providers) { + try { + await this.syncAccountsForLinkedAccount( + provider, + linkedAccount.id_linked_user, + id_project, + ); + } catch (error) { + handleServiceError(error, this.logger); + } + } + } catch (error) { + handleServiceError(error, this.logger); + } + }); + } catch (error) { + handleServiceError(error, this.logger); + } + } + + //todo: HANDLE DATA REMOVED FROM PROVIDER + async syncAccountsForLinkedAccount( + integrationId: string, + linkedUserId: string, + id_project: string, + ) { + try { + this.logger.log( + `Syncing ${integrationId} accounts for linkedAccount ${linkedUserId}`, + ); + // check if linkedAccount has a connection if not just stop sync + const connection = await this.prisma.connections.findFirst({ + where: { + id_linked_user: linkedUserId, + provider_slug: integrationId, + }, + }); + if (!connection) throw new Error('connection not found'); + + // get potential fieldMappings and extract the original properties name + const customFieldMappings = + await this.fieldMappingService.getCustomFieldMappings( + integrationId, + linkedUserId, + 'account', + ); + const remoteProperties: string[] = customFieldMappings.map( + (mapping) => mapping.remote_id, + ); + + const service: IAccountService = + this.serviceRegistry.getService(integrationId); + const resp: ApiResponse = + await service.syncAccounts(linkedUserId, remoteProperties); + + const sourceObject: OriginalAccountOutput[] = resp.data; + //this.logger.log('SOURCE OBJECT DATA = ' + JSON.stringify(sourceObject)); + //unify the data according to the target obj wanted + const unifiedObject = (await unify({ + sourceObject, + targetType: TicketingObject.account, + providerName: integrationId, + customFieldMappings, + })) as UnifiedAccountOutput[]; + + //TODO + const accountIds = sourceObject.map((account) => + 'id' in account ? String(account.id) : undefined, + ); + + //insert the data in the DB with the fieldMappings (value table) + const account_data = await this.saveAccountsInDb( + linkedUserId, + unifiedObject, + accountIds, + integrationId, + sourceObject, + ); + const event = await this.prisma.events.create({ + data: { + id_event: uuidv4(), + status: 'success', + type: 'ticketing.account.pulled', + method: 'PULL', + url: '/pull', + provider: integrationId, + direction: '0', + timestamp: new Date(), + id_linked_user: linkedUserId, + }, + }); + await this.webhook.handleWebhook( + account_data, + 'ticketing.account.pulled', + id_project, + event.id_event, + ); + } catch (error) { + handleServiceError(error, this.logger); + } + } + + async saveAccountsInDb( + linkedUserId: string, + accounts: UnifiedAccountOutput[], + originIds: string[], + originSource: string, + remote_data: Record[], + ): Promise { + try { + let accounts_results: TicketingAccount[] = []; + for (let i = 0; i < accounts.length; i++) { + const account = accounts[i]; + const originId = originIds[i]; + + if (!originId || originId == '') { + throw new NotFoundError(`Origin id not there, found ${originId}`); + } + + const existingAccount = await this.prisma.tcg_accounts.findFirst({ + where: { + remote_id: originId, + remote_platform: originSource, + id_linked_user: linkedUserId, + }, + }); + + let unique_ticketing_account_id: string; + + if (existingAccount) { + // Update the existing ticket + const res = await this.prisma.tcg_accounts.update({ + where: { + id_tcg_account: existingAccount.id_tcg_account, + }, + data: { + name: existingAccount.name, + domains: existingAccount.domains, + modified_at: new Date(), + }, + }); + unique_ticketing_account_id = res.id_tcg_account; + accounts_results = [...accounts_results, res]; + } else { + // Create a new account + this.logger.log('not existing account ' + account.name); + const data = { + id_tcg_account: uuidv4(), + name: account.name, + domains: account.domains, + created_at: new Date(), + modified_at: new Date(), + id_linked_user: linkedUserId, + remote_id: originId, + remote_platform: originSource, + }; + const res = await this.prisma.tcg_accounts.create({ + data: data, + }); + accounts_results = [...accounts_results, res]; + unique_ticketing_account_id = res.id_tcg_account; + } + + // check duplicate or existing values + if (account.field_mappings && account.field_mappings.length > 0) { + const entity = await this.prisma.entity.create({ + data: { + id_entity: uuidv4(), + ressource_owner_id: unique_ticketing_account_id, + }, + }); + + for (const mapping of account.field_mappings) { + const attribute = await this.prisma.attribute.findFirst({ + where: { + slug: Object.keys(mapping)[0], + source: originSource, + id_consumer: linkedUserId, + }, + }); + + if (attribute) { + await this.prisma.value.create({ + data: { + id_value: uuidv4(), + data: Object.values(mapping)[0] + ? Object.values(mapping)[0] + : 'null', + attribute: { + connect: { + id_attribute: attribute.id_attribute, + }, + }, + entity: { + connect: { + id_entity: entity.id_entity, + }, + }, + }, + }); + } + } + } + + //insert remote_data in db + await this.prisma.remote_data.upsert({ + where: { + ressource_owner_id: unique_ticketing_account_id, + }, + create: { + id_remote_data: uuidv4(), + ressource_owner_id: unique_ticketing_account_id, + format: 'json', + data: JSON.stringify(remote_data[i]), + created_at: new Date(), + }, + update: { + data: JSON.stringify(remote_data[i]), + created_at: new Date(), + }, + }); + } + return accounts_results; + } catch (error) { + handleServiceError(error, this.logger); + } + } +} diff --git a/packages/api/src/ticketing/account/types/index.ts b/packages/api/src/ticketing/account/types/index.ts new file mode 100644 index 000000000..8a1b4fbde --- /dev/null +++ b/packages/api/src/ticketing/account/types/index.ts @@ -0,0 +1,38 @@ +import { DesunifyReturnType } from '@@core/utils/types/desunify.input'; +import { UnifiedAccountInput, UnifiedAccountOutput } from './model.unified'; +import { OriginalAccountOutput } from '@@core/utils/types/original/original.ticketing'; +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { ApiResponse } from '@@core/utils/types'; + +export interface IAccountService { + syncAccounts( + linkedUserId: string, + custom_properties?: string[], + ): Promise>; +} + +export interface IAccountMapper { + desunify( + source: UnifiedAccountInput, + customFieldMappings?: { + slug: string; + remote_id: string; + }[], + ): DesunifyReturnType; + + unify( + source: OriginalAccountOutput | OriginalAccountOutput[], + customFieldMappings?: { + slug: string; + remote_id: string; + }[], + ): UnifiedAccountOutput | UnifiedAccountOutput[]; +} + +export class AccountResponse { + @ApiProperty({ type: [UnifiedAccountOutput] }) + accounts: UnifiedAccountOutput[]; + + @ApiPropertyOptional({ type: [{}] }) + remote_data?: Record[]; // Data in original format +} diff --git a/packages/api/src/ticketing/account/types/mappingsTypes.ts b/packages/api/src/ticketing/account/types/mappingsTypes.ts new file mode 100644 index 000000000..75f712a42 --- /dev/null +++ b/packages/api/src/ticketing/account/types/mappingsTypes.ts @@ -0,0 +1,22 @@ +import { FrontAccountMapper } from '../services/front/mappers'; +import { GithubAccountMapper } from '../services/github/mappers'; +import { ZendeskAccountMapper } from '../services/zendesk/mappers'; + +const zendeskAccountMapper = new ZendeskAccountMapper(); +const frontAccountMapper = new FrontAccountMapper(); +const githubAccountMapper = new GithubAccountMapper(); + +export const accountUnificationMapping = { + zendesk_tcg: { + unify: zendeskAccountMapper.unify, + desunify: zendeskAccountMapper.desunify, + }, + front: { + unify: frontAccountMapper.unify, + desunify: frontAccountMapper.desunify, + }, + github: { + unify: githubAccountMapper.unify, + desunify: githubAccountMapper.desunify, + }, +}; diff --git a/packages/api/src/ticketing/account/types/model.unified.ts b/packages/api/src/ticketing/account/types/model.unified.ts new file mode 100644 index 000000000..d0ca96e5a --- /dev/null +++ b/packages/api/src/ticketing/account/types/model.unified.ts @@ -0,0 +1,11 @@ +export class UnifiedAccountInput { + name: string; + domains: string[]; + field_mappings?: Record[]; +} + +export class UnifiedAccountOutput extends UnifiedAccountInput { + id?: string; + remote_id?: string; + remote_data?: Record; +} diff --git a/packages/api/src/ticketing/account/utils/index.ts b/packages/api/src/ticketing/account/utils/index.ts new file mode 100644 index 000000000..f849788c1 --- /dev/null +++ b/packages/api/src/ticketing/account/utils/index.ts @@ -0,0 +1 @@ +/* PUT ALL UTILS FUNCTIONS USED IN YOUR OBJECT METHODS HERE */ diff --git a/packages/api/src/ticketing/attachment/attachment.controller.ts b/packages/api/src/ticketing/attachment/attachment.controller.ts index 6b16a04ef..2b19ef2ab 100644 --- a/packages/api/src/ticketing/attachment/attachment.controller.ts +++ b/packages/api/src/ticketing/attachment/attachment.controller.ts @@ -1,4 +1,184 @@ -import { Controller } from '@nestjs/common'; +import { + Controller, + Post, + Body, + Query, + Get, + Param, + Headers, +} from '@nestjs/common'; +import { LoggerService } from '@@core/logger/logger.service'; +import { + ApiBody, + ApiOperation, + ApiParam, + ApiQuery, + ApiTags, + ApiHeader, +} from '@nestjs/swagger'; +import { ApiCustomResponse } from '@@core/utils/types'; +import { AttachmentService } from './services/attachment.service'; +import { UnifiedAttachmentInput } from './types/model.unified'; -@Controller('attachment') -export class AttachmentController {} +@ApiTags('ticketing/attachment') +@Controller('ticketing/attachment') +export class AttachmentController { + constructor( + private readonly attachmentService: AttachmentService, + private logger: LoggerService, + ) { + this.logger.setContext(AttachmentController.name); + } + + @ApiOperation({ + operationId: 'getAttachments', + summary: 'List a batch of Attachments', + }) + @ApiHeader({ name: 'integrationId', required: true }) + @ApiHeader({ name: 'linkedUserId', required: true }) + @ApiQuery({ + name: 'remoteData', + required: false, + type: Boolean, + description: + 'Set to true to include data from the original Ticketing software.', + }) + //@ApiCustomResponse(AttachmentResponse) + @Get() + getAttachments( + @Headers('integrationId') integrationId: string, + @Headers('linkedUserId') linkedUserId: string, + @Query('remoteData') remote_data?: boolean, + ) { + return this.attachmentService.getAttachments( + integrationId, + linkedUserId, + remote_data, + ); + } + + @ApiOperation({ + operationId: 'getAttachment', + summary: 'Retrieve a Attachment', + description: 'Retrieve a attachment from any connected Ticketing software', + }) + @ApiParam({ + name: 'id', + required: true, + type: String, + description: 'id of the attachment you want to retrive.', + }) + @ApiQuery({ + name: 'remoteData', + required: false, + type: Boolean, + description: + 'Set to true to include data from the original Ticketing software.', + }) + //@ApiCustomResponse(AttachmentResponse) + @Get(':id') + getAttachment( + @Param('id') id: string, + @Query('remoteData') remote_data?: boolean, + ) { + return this.attachmentService.getAttachment(id, remote_data); + } + + @ApiOperation({ + operationId: 'downloadAttachment', + summary: 'Download a Attachment', + description: 'Download a attachment from any connected Ticketing software', + }) + @ApiParam({ + name: 'id', + required: true, + type: String, + description: 'id of the attachment you want to retrive.', + }) + @ApiQuery({ + name: 'remoteData', + required: false, + type: Boolean, + description: + 'Set to true to include data from the original Ticketing software.', + }) + //@ApiCustomResponse(AttachmentResponse) + @Get(':id/download') + downloadAttachment( + @Param('id') id: string, + @Query('remoteData') remote_data?: boolean, + ) { + return this.attachmentService.downloadAttachment(id, remote_data); + } + + @ApiOperation({ + operationId: 'addAttachment', + summary: 'Create a Attachment', + description: 'Create a attachment in any supported Ticketing software', + }) + @ApiHeader({ + name: 'integrationId', + required: true, + description: 'The integration ID', + example: '6aa2acf3-c244-4f85-848b-13a57e7abf55', + }) + @ApiHeader({ + name: 'linkedUserId', + required: true, + description: 'The linked user ID', + example: 'b008e199-eda9-4629-bd41-a01b6195864a', + }) + @ApiQuery({ + name: 'remoteData', + required: false, + type: Boolean, + description: + 'Set to true to include data from the original Ticketing software.', + }) + @ApiBody({ type: UnifiedAttachmentInput }) + //@ApiCustomResponse(AttachmentResponse) + @Post() + addAttachment( + @Body() unfiedAttachmentData: UnifiedAttachmentInput, + @Headers('integrationId') integrationId: string, + @Headers('linkedUserId') linkedUserId: string, + @Query('remoteData') remote_data?: boolean, + ) { + return this.attachmentService.addAttachment( + unfiedAttachmentData, + integrationId, + linkedUserId, + remote_data, + ); + } + + @ApiOperation({ + operationId: 'addAttachments', + summary: 'Add a batch of Attachments', + }) + @ApiHeader({ name: 'integrationId', required: true }) + @ApiHeader({ name: 'linkedUserId', required: true }) + @ApiQuery({ + name: 'remoteData', + required: false, + type: Boolean, + description: + 'Set to true to include data from the original Ticketing software.', + }) + @ApiBody({ type: UnifiedAttachmentInput, isArray: true }) + //@ApiCustomResponse(AttachmentResponse) + @Post('batch') + addAttachments( + @Body() unfiedAttachmentData: UnifiedAttachmentInput[], + @Headers('integrationId') integrationId: string, + @Headers('linkedUserId') linkedUserId: string, + @Query('remoteData') remote_data?: boolean, + ) { + return this.attachmentService.batchAddAttachments( + unfiedAttachmentData, + integrationId, + linkedUserId, + remote_data, + ); + } +} diff --git a/packages/api/src/ticketing/attachment/attachment.module.ts b/packages/api/src/ticketing/attachment/attachment.module.ts index 5cdf80a80..935a8bb6a 100644 --- a/packages/api/src/ticketing/attachment/attachment.module.ts +++ b/packages/api/src/ticketing/attachment/attachment.module.ts @@ -1,8 +1,6 @@ import { Module } from '@nestjs/common'; import { AttachmentController } from './attachment.controller'; -import { SyncService } from './sync/sync.service'; import { LoggerService } from '@@core/logger/logger.service'; -import { ZendeskService } from './services/zendesk'; import { AttachmentService } from './services/attachment.service'; import { ServiceRegistry } from './services/registry.service'; import { EncryptionService } from '@@core/encryption/encryption.service'; @@ -22,14 +20,11 @@ import { BullModule } from '@nestjs/bull'; AttachmentService, PrismaService, LoggerService, - SyncService, WebhookService, EncryptionService, FieldMappingService, ServiceRegistry, /* PROVIDERS SERVICES */ - ZendeskService, ], - exports: [SyncService], }) export class AttachmentModule {} diff --git a/packages/api/src/ticketing/attachment/services/attachment.service.ts b/packages/api/src/ticketing/attachment/services/attachment.service.ts index 2624418dd..fbfcdd2b9 100644 --- a/packages/api/src/ticketing/attachment/services/attachment.service.ts +++ b/packages/api/src/ticketing/attachment/services/attachment.service.ts @@ -10,11 +10,6 @@ import { UnifiedAttachmentOutput, } from '../types/model.unified'; import { AttachmentResponse } from '../types'; -import { desunify } from '@@core/utils/unification/desunify'; -import { TicketingObject } from '@ticketing/@utils/@types'; -import { FieldMappingService } from '@@core/field-mapping/field-mapping.service'; -import { unify } from '@@core/utils/unification/unify'; -import { ServiceRegistry } from './registry.service'; @Injectable() export class AttachmentService { @@ -22,11 +17,286 @@ export class AttachmentService { private prisma: PrismaService, private logger: LoggerService, private webhook: WebhookService, - private fieldMappingService: FieldMappingService, - private serviceRegistry: ServiceRegistry, ) { this.logger.setContext(AttachmentService.name); } - // Additional methods and logic + async batchAddAttachments( + unifiedAttachmentData: UnifiedAttachmentInput[], + integrationId: string, + linkedUserId: string, + remote_data?: boolean, + ): Promise { + try { + const responses = await Promise.all( + unifiedAttachmentData.map((unifiedData) => + this.addAttachment( + unifiedData, + integrationId.toLowerCase(), + linkedUserId, + remote_data, + ), + ), + ); + + return responses; + } catch (error) { + handleServiceError(error, this.logger); + } + } + + async addAttachment( + unifiedAttachmentData: UnifiedAttachmentInput, + integrationId: string, + linkedUserId: string, + remote_data?: boolean, + ): Promise { + try { + const linkedUser = await this.prisma.linked_users.findUnique({ + where: { + id_linked_user: linkedUserId, + }, + }); + if (!linkedUser) throw new Error('Linked User Not Found'); + + //EXCEPTION: for Attachments we directly store them inside our db (no raw call to the provider) + //the actual job to retrieve the attachment info would be done inside /comments + + // add the attachment inside our db + + const existingAttachment = await this.prisma.tcg_attachments.findFirst({ + where: { + file_name: unifiedAttachmentData.file_name, + remote_platform: integrationId, + id_linked_user: linkedUserId, + }, + }); + + let unique_ticketing_attachment_id: string; + + if (existingAttachment) { + // Update the existing attachment + const res = await this.prisma.tcg_attachments.update({ + where: { + id_tcg_attachment: existingAttachment.id_tcg_attachment, + }, + data: { + file_name: unifiedAttachmentData.file_name, + uploader: linkedUserId, + modified_at: new Date(), + }, + }); + unique_ticketing_attachment_id = res.id_tcg_attachment; + } else { + // Create a new attachment + this.logger.log('not existing attachment '); + const data = { + id_tcg_attachment: uuidv4(), + file_name: unifiedAttachmentData.file_name, + uploader: linkedUserId, + created_at: new Date(), + modified_at: new Date(), + id_linked_user: linkedUserId, + remote_platform: integrationId, + }; + + const res = await this.prisma.tcg_attachments.create({ + data: data, + }); + unique_ticketing_attachment_id = res.id_tcg_attachment; + } + + const result_attachment = await this.getAttachment( + unique_ticketing_attachment_id, + remote_data, + ); + + const event = await this.prisma.events.create({ + data: { + id_event: uuidv4(), + status: 'success', + type: 'ticketing.attachment.push', //sync, push or pull + method: 'POST', + url: '/ticketing/attachment', + provider: integrationId, + direction: '0', + timestamp: new Date(), + id_linked_user: linkedUserId, + }, + }); + + await this.webhook.handleWebhook( + result_attachment, + 'ticketing.attachment.created', + linkedUser.id_project, + event.id_event, + ); + return result_attachment; + } catch (error) { + handleServiceError(error, this.logger); + } + } + + async getAttachment( + id_ticketing_attachment: string, + remote_data?: boolean, + ): Promise { + try { + const attachment = await this.prisma.tcg_attachments.findUnique({ + where: { + id_tcg_attachment: id_ticketing_attachment, + }, + }); + + // Fetch field mappings for the attachment + const values = await this.prisma.value.findMany({ + where: { + entity: { + ressource_owner_id: attachment.id_tcg_attachment, + }, + }, + include: { + attribute: true, + }, + }); + + // Create a map to store unique field mappings + const fieldMappingsMap = new Map(); + + values.forEach((value) => { + fieldMappingsMap.set(value.attribute.slug, value.data); + }); + + // Convert the map to an array of objects + const field_mappings = Array.from(fieldMappingsMap, ([key, value]) => ({ + [key]: value, + })); + + // Transform to UnifiedAttachmentOutput format + const unifiedAttachment: UnifiedAttachmentOutput = { + id: attachment.id_tcg_attachment, + file_name: attachment.file_name, + file_url: attachment.file_url, + uploader: attachment.uploader, + field_mappings: field_mappings, + }; + + let res: UnifiedAttachmentOutput = unifiedAttachment; + + if (remote_data) { + const resp = await this.prisma.remote_data.findFirst({ + where: { + ressource_owner_id: attachment.id_tcg_attachment, + }, + }); + const remote_data = JSON.parse(resp.data); + + res = { + ...res, + remote_data: remote_data, + }; + } + + return res; + } catch (error) { + handleServiceError(error, this.logger); + } + } + + async getAttachments( + integrationId: string, + linkedUserId: string, + remote_data?: boolean, + ): Promise { + try { + //TODO: handle case where data is not there (not synced) or old synced + const attachments = await this.prisma.tcg_attachments.findMany({ + where: { + remote_platform: integrationId.toLowerCase(), + id_linked_user: linkedUserId, + }, + }); + + const unifiedAttachments: UnifiedAttachmentOutput[] = await Promise.all( + attachments.map(async (attachment) => { + // Fetch field mappings for the attachment + const values = await this.prisma.value.findMany({ + where: { + entity: { + ressource_owner_id: attachment.id_tcg_attachment, + }, + }, + include: { + attribute: true, + }, + }); + // Create a map to store unique field mappings + const fieldMappingsMap = new Map(); + + values.forEach((value) => { + fieldMappingsMap.set(value.attribute.slug, value.data); + }); + + // Convert the map to an array of objects + const field_mappings = Array.from( + fieldMappingsMap, + ([key, value]) => ({ [key]: value }), + ); + + // Transform to UnifiedAttachmentOutput format + return { + id: attachment.id_tcg_attachment, + file_name: attachment.file_name, + file_url: attachment.file_url, + uploader: attachment.uploader, //TODO + field_mappings: field_mappings, + }; + }), + ); + + let res: UnifiedAttachmentOutput[] = unifiedAttachments; + + if (remote_data) { + const remote_array_data: UnifiedAttachmentOutput[] = await Promise.all( + res.map(async (attachment) => { + const resp = await this.prisma.remote_data.findFirst({ + where: { + ressource_owner_id: attachment.id, + }, + }); + const remote_data = JSON.parse(resp.data); + return { ...attachment, remote_data }; + }), + ); + + res = remote_array_data; + } + + const event = await this.prisma.events.create({ + data: { + id_event: uuidv4(), + status: 'success', + type: 'ticketing.attachment.pull', + method: 'GET', + url: '/ticketing/attachment', + provider: integrationId, + direction: '0', + timestamp: new Date(), + id_linked_user: linkedUserId, + }, + }); + + return res; + } catch (error) { + handleServiceError(error, this.logger); + } + } + + //TODO + async downloadAttachment( + id_ticketing_attachment: string, + remote_data?: boolean, + ): Promise { + return; + } } diff --git a/packages/api/src/ticketing/attachment/services/front/mappers.ts b/packages/api/src/ticketing/attachment/services/front/mappers.ts new file mode 100644 index 000000000..3c6ddda0a --- /dev/null +++ b/packages/api/src/ticketing/attachment/services/front/mappers.ts @@ -0,0 +1,47 @@ +import { IAttachmentMapper } from '@ticketing/attachment/types'; +import { + UnifiedAttachmentInput, + UnifiedAttachmentOutput, +} from '@ticketing/attachment/types/model.unified'; +import { FrontAttachmentOutput } from './types'; + +export class FrontAttachmentMapper implements IAttachmentMapper { + async desunify( + source: UnifiedAttachmentInput, + customFieldMappings?: { + slug: string; + remote_id: string; + }[], + ): Promise { + return; + } + + unify( + source: FrontAttachmentOutput | FrontAttachmentOutput[], + customFieldMappings?: { + slug: string; + remote_id: string; + }[], + ): UnifiedAttachmentOutput | UnifiedAttachmentOutput[] { + if (!Array.isArray(source)) { + return this.mapSingleAttachmentToUnified(source, customFieldMappings); + } + return source.map((attachment) => + this.mapSingleAttachmentToUnified(attachment, customFieldMappings), + ); + } + + private mapSingleAttachmentToUnified( + attachment: FrontAttachmentOutput, + customFieldMappings?: { + slug: string; + remote_id: string; + }[], + ): UnifiedAttachmentOutput { + return { + remote_id: attachment.id, + file_name: attachment.filename, + file_url: attachment.url, + }; + } +} diff --git a/packages/api/src/ticketing/attachment/services/front/types.ts b/packages/api/src/ticketing/attachment/services/front/types.ts new file mode 100644 index 000000000..1fd21c1bc --- /dev/null +++ b/packages/api/src/ticketing/attachment/services/front/types.ts @@ -0,0 +1,13 @@ +export type FrontAttachmentOutput = { + id: string; + filename: string; + url: string; + content_type: string; + size: number; + metadata: AttachmentMetadata; +}; + +type AttachmentMetadata = { + is_inline: boolean; + cid: string; +}; diff --git a/packages/api/src/ticketing/attachment/services/github/mappers.ts b/packages/api/src/ticketing/attachment/services/github/mappers.ts new file mode 100644 index 000000000..0fd35df71 --- /dev/null +++ b/packages/api/src/ticketing/attachment/services/github/mappers.ts @@ -0,0 +1,44 @@ +import { IAttachmentMapper } from '@ticketing/attachment/types'; +import { + UnifiedAttachmentInput, + UnifiedAttachmentOutput, +} from '@ticketing/attachment/types/model.unified'; +import { GithubAttachmentOutput } from './types'; + +export class GithubAttachmentMapper implements IAttachmentMapper { + async desunify( + source: UnifiedAttachmentInput, + customFieldMappings?: { + slug: string; + remote_id: string; + }[], + ): Promise { + return; + } + + unify( + source: GithubAttachmentOutput | GithubAttachmentOutput[], + customFieldMappings?: { + slug: string; + remote_id: string; + }[], + ): UnifiedAttachmentOutput | UnifiedAttachmentOutput[] { + if (!Array.isArray(source)) { + return this.mapSingleAttachmentToUnified(source, customFieldMappings); + } + return source.map((attachment) => + this.mapSingleAttachmentToUnified(attachment, customFieldMappings), + ); + } + + //TODO; + private mapSingleAttachmentToUnified( + attachment: GithubAttachmentOutput, + customFieldMappings?: { + slug: string; + remote_id: string; + }[], + ): UnifiedAttachmentOutput { + return; + } +} diff --git a/packages/api/src/ticketing/attachment/services/github/types.ts b/packages/api/src/ticketing/attachment/services/github/types.ts new file mode 100644 index 000000000..39b76d9cf --- /dev/null +++ b/packages/api/src/ticketing/attachment/services/github/types.ts @@ -0,0 +1,3 @@ +export type GithubAttachmentOutput = { + id: string; +}; diff --git a/packages/api/src/ticketing/attachment/services/zendesk/index.ts b/packages/api/src/ticketing/attachment/services/zendesk/index.ts deleted file mode 100644 index 2dbf562fc..000000000 --- a/packages/api/src/ticketing/attachment/services/zendesk/index.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { EncryptionService } from '@@core/encryption/encryption.service'; -import { EnvironmentService } from '@@core/environment/environment.service'; -import { LoggerService } from '@@core/logger/logger.service'; -import { PrismaService } from '@@core/prisma/prisma.service'; -import { ApiResponse } from '@@core/utils/types'; -import { DesunifyReturnType } from '@@core/utils/types/desunify.input'; -import { Injectable } from '@nestjs/common'; -import { - TicketingObject, - ZendeskAttachmentInput, -} from '@ticketing/@utils/@types'; -import { IAttachmentService } from '@ticketing/attachment/types'; -import { ServiceRegistry } from '../registry.service'; - -@Injectable() -export class ZendeskService implements IAttachmentService { - constructor( - private prisma: PrismaService, - private logger: LoggerService, - private cryptoService: EncryptionService, - private env: EnvironmentService, - private registry: ServiceRegistry, - ) { - this.logger.setContext( - TicketingObject.attachment.toUpperCase() + ':' + ZendeskService.name, - ); - this.registry.registerService('zendesk_t', this); - } - addAttachment( - attachmentData: DesunifyReturnType, - linkedUserId: string, - ): Promise> { - throw new Error('Method not implemented.'); - } - syncAttachments( - linkedUserId: string, - custom_properties?: string[], - ): Promise> { - throw new Error('Method not implemented.'); - } -} diff --git a/packages/api/src/ticketing/attachment/services/zendesk/mappers.ts b/packages/api/src/ticketing/attachment/services/zendesk/mappers.ts index e69de29bb..99a391c4c 100644 --- a/packages/api/src/ticketing/attachment/services/zendesk/mappers.ts +++ b/packages/api/src/ticketing/attachment/services/zendesk/mappers.ts @@ -0,0 +1,47 @@ +import { IAttachmentMapper } from '@ticketing/attachment/types'; +import { + UnifiedAttachmentInput, + UnifiedAttachmentOutput, +} from '@ticketing/attachment/types/model.unified'; +import { ZendeskAttachmentOutput } from './types'; + +export class ZendeskAttachmentMapper implements IAttachmentMapper { + async desunify( + source: UnifiedAttachmentInput, + customFieldMappings?: { + slug: string; + remote_id: string; + }[], + ): Promise { + return; + } + + unify( + source: ZendeskAttachmentOutput | ZendeskAttachmentOutput[], + customFieldMappings?: { + slug: string; + remote_id: string; + }[], + ): UnifiedAttachmentOutput | UnifiedAttachmentOutput[] { + if (!Array.isArray(source)) { + return this.mapSingleAttachmentToUnified(source, customFieldMappings); + } + return source.map((attachment) => + this.mapSingleAttachmentToUnified(attachment, customFieldMappings), + ); + } + + private mapSingleAttachmentToUnified( + attachment: ZendeskAttachmentOutput, + customFieldMappings?: { + slug: string; + remote_id: string; + }[], + ): UnifiedAttachmentOutput { + return { + remote_id: String(attachment.id), + file_name: attachment.file_name, + file_url: attachment.url, + }; + } +} diff --git a/packages/api/src/ticketing/attachment/services/zendesk/types.ts b/packages/api/src/ticketing/attachment/services/zendesk/types.ts index 417c82d83..06933e01a 100644 --- a/packages/api/src/ticketing/attachment/services/zendesk/types.ts +++ b/packages/api/src/ticketing/attachment/services/zendesk/types.ts @@ -1,5 +1,20 @@ -export type ZendeskAttachmentInput = { - id: string; +export type ZendeskAttachmentOutput = { + content_type: string; // The content type of the image, e.g., "image/png". + content_url: string; // A full URL where the attachment image file can be downloaded. + deleted: boolean; // If true, the attachment has been deleted. + file_name: string; // The name of the image file. + height: string | null; // The height of the image file in pixels, or null if unknown. + id: number; // Automatically assigned when created. + inline: boolean; // If true, the attachment is excluded from the attachment list. + malware_access_override: boolean; // If true, you can download an attachment flagged as malware. + malware_scan_result: + | 'malware_found' + | 'malware_not_found' + | 'failed_to_scan' + | 'not_scanned'; // The result of the malware scan. + mapped_content_url: string; // The URL the attachment image file has been mapped to. + size: number; // The size of the image file in bytes. + thumbnails: ZendeskAttachmentOutput[]; // An array of attachment objects. + url: string; // A URL to access the attachment details. + width: string | null; // The width of the image file in pixels, or null if unknown. }; - -export type ZendeskAttachmentOutput = ZendeskAttachmentInput; diff --git a/packages/api/src/ticketing/attachment/sync/sync.service.ts b/packages/api/src/ticketing/attachment/sync/sync.service.ts deleted file mode 100644 index 5caadfabc..000000000 --- a/packages/api/src/ticketing/attachment/sync/sync.service.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { Injectable, OnModuleInit } from '@nestjs/common'; -import { LoggerService } from '@@core/logger/logger.service'; -import { PrismaService } from '@@core/prisma/prisma.service'; -import { NotFoundError, handleServiceError } from '@@core/utils/errors'; -import { Cron } from '@nestjs/schedule'; -import { ApiResponse, TICKETING_PROVIDERS } from '@@core/utils/types'; -import { v4 as uuidv4 } from 'uuid'; -import { FieldMappingService } from '@@core/field-mapping/field-mapping.service'; -import { unify } from '@@core/utils/unification/unify'; -import { TicketingObject } from '@ticketing/@utils/@types'; -import { WebhookService } from '@@core/webhook/webhook.service'; -import { UnifiedAttachmentOutput } from '../types/model.unified'; -import { IAttachmentService } from '../types'; -import { ServiceRegistry } from '../services/registry.service'; - -@Injectable() -export class SyncService implements OnModuleInit { - constructor( - private prisma: PrismaService, - private logger: LoggerService, - private webhook: WebhookService, - private fieldMappingService: FieldMappingService, - private serviceRegistry: ServiceRegistry, - ) { - this.logger.setContext(SyncService.name); - } - - async onModuleInit() { - // Initialization logic - } - - // Additional methods and logic -} diff --git a/packages/api/src/ticketing/attachment/types/mappingsTypes.ts b/packages/api/src/ticketing/attachment/types/mappingsTypes.ts index d08c20aaa..68ffceec2 100644 --- a/packages/api/src/ticketing/attachment/types/mappingsTypes.ts +++ b/packages/api/src/ticketing/attachment/types/mappingsTypes.ts @@ -1 +1,22 @@ -// Content for mappingsTypes.ts +import { FrontAttachmentMapper } from '../services/front/mappers'; +import { GithubAttachmentMapper } from '../services/github/mappers'; +import { ZendeskAttachmentMapper } from '../services/zendesk/mappers'; + +const zendeskAttachmentMapper = new ZendeskAttachmentMapper(); +const githubAttachmentMapper = new GithubAttachmentMapper(); +const frontAttachmentMapper = new FrontAttachmentMapper(); + +export const commentUnificationMapping = { + zendesk_tcg: { + unify: zendeskAttachmentMapper.unify.bind(zendeskAttachmentMapper), + desunify: zendeskAttachmentMapper.desunify, + }, + front: { + unify: frontAttachmentMapper.unify.bind(frontAttachmentMapper), + desunify: frontAttachmentMapper.desunify, + }, + github: { + unify: githubAttachmentMapper.unify.bind(githubAttachmentMapper), + desunify: githubAttachmentMapper.desunify, + }, +}; diff --git a/packages/api/src/ticketing/attachment/types/model.unified.ts b/packages/api/src/ticketing/attachment/types/model.unified.ts index b452a5c8d..dd3e2e447 100644 --- a/packages/api/src/ticketing/attachment/types/model.unified.ts +++ b/packages/api/src/ticketing/attachment/types/model.unified.ts @@ -1,3 +1,23 @@ -export class UnifiedAttachmentInput {} +import { ApiPropertyOptional } from '@nestjs/swagger'; -export class UnifiedAttachmentOutput extends UnifiedAttachmentInput {} +export class UnifiedAttachmentInput { + file_name: string; + file_url: string; + uploader?: string; + field_mappings?: Record[]; +} + +export class UnifiedAttachmentOutput extends UnifiedAttachmentInput { + @ApiPropertyOptional({ + description: 'The id of the attachment', + type: String, + }) + id?: string; + @ApiPropertyOptional({ + description: + 'The id of the attachment in the context of the Ticketing software', + type: String, + }) + remote_id?: string; + remote_data?: Record; +} diff --git a/packages/api/src/ticketing/comment/comment.controller.ts b/packages/api/src/ticketing/comment/comment.controller.ts index b1e49e3a6..cfd466a94 100644 --- a/packages/api/src/ticketing/comment/comment.controller.ts +++ b/packages/api/src/ticketing/comment/comment.controller.ts @@ -4,7 +4,6 @@ import { Body, Query, Get, - Patch, Param, Headers, } from '@nestjs/common'; @@ -46,8 +45,8 @@ export class CommentController { //@ApiCustomResponse(CommentResponse) @Get() getComments( - @Query('integrationId') integrationId: string, - @Query('linkedUserId') linkedUserId: string, + @Headers('integrationId') integrationId: string, + @Headers('linkedUserId') linkedUserId: string, @Query('remoteData') remote_data?: boolean, ) { return this.commentService.getComments( @@ -60,13 +59,13 @@ export class CommentController { @ApiOperation({ operationId: 'getComment', summary: 'Retrieve a Comment', - description: 'Retrieve a ticket from any connected Ticketing software', + description: 'Retrieve a comment from any connected Ticketing software', }) @ApiParam({ name: 'id', required: true, type: String, - description: 'id of the `ticket` you want to retrive.', + description: 'id of the `comment` you want to retrive.', }) @ApiQuery({ name: 'remoteData', @@ -87,7 +86,7 @@ export class CommentController { @ApiOperation({ operationId: 'addComment', summary: 'Create a Comment', - description: 'Create a ticket in any supported Ticketing software', + description: 'Create a comment in any supported Ticketing software', }) @ApiHeader({ name: 'integrationId', @@ -112,13 +111,13 @@ export class CommentController { //@ApiCustomResponse(CommentResponse) @Post() addComment( - @Body() unfiedContactData: UnifiedCommentInput, + @Body() unfiedCommentData: UnifiedCommentInput, @Headers('integrationId') integrationId: string, @Headers('linkedUserId') linkedUserId: string, @Query('remoteData') remote_data?: boolean, ) { return this.commentService.addComment( - unfiedContactData, + unfiedCommentData, integrationId, linkedUserId, remote_data, @@ -142,28 +141,16 @@ export class CommentController { //@ApiCustomResponse(CommentResponse) @Post('batch') addComments( - @Body() unfiedContactData: UnifiedCommentInput[], + @Body() unfiedCommentData: UnifiedCommentInput[], @Headers('integrationId') integrationId: string, @Headers('linkedUserId') linkedUserId: string, @Query('remoteData') remote_data?: boolean, ) { return this.commentService.batchAddComments( - unfiedContactData, + unfiedCommentData, integrationId, linkedUserId, remote_data, ); } - - @ApiOperation({ - operationId: 'updateComment', - summary: 'Update a Comment', - }) - @Patch() - updateComment( - @Query('id') id: string, - @Body() updateCommentData: Partial, - ) { - return this.commentService.updateComment(id, updateCommentData); - } } diff --git a/packages/api/src/ticketing/comment/comment.module.ts b/packages/api/src/ticketing/comment/comment.module.ts index 0e9f8e6c7..e33d3d1ae 100644 --- a/packages/api/src/ticketing/comment/comment.module.ts +++ b/packages/api/src/ticketing/comment/comment.module.ts @@ -10,6 +10,9 @@ import { CommentController } from './comment.controller'; import { CommentService } from './services/comment.service'; import { FieldMappingService } from '@@core/field-mapping/field-mapping.service'; import { ServiceRegistry } from './services/registry.service'; +import { GithubService } from './services/github'; +import { FrontService } from './services/front'; +import { HubspotService } from './services/hubspot'; @Module({ imports: [ @@ -29,6 +32,9 @@ import { ServiceRegistry } from './services/registry.service'; ServiceRegistry, /* PROVIDERS SERVICES */ ZendeskService, + HubspotService, + FrontService, + GithubService, ], exports: [SyncService], }) diff --git a/packages/api/src/ticketing/comment/services/comment.service.ts b/packages/api/src/ticketing/comment/services/comment.service.ts index 6ba8ab2f1..99b9c1290 100644 --- a/packages/api/src/ticketing/comment/services/comment.service.ts +++ b/packages/api/src/ticketing/comment/services/comment.service.ts @@ -1,6 +1,5 @@ import { Injectable } from '@nestjs/common'; import { PrismaService } from '@@core/prisma/prisma.service'; -import { ZendeskService } from './zendesk'; import { LoggerService } from '@@core/logger/logger.service'; import { v4 as uuidv4 } from 'uuid'; import { ApiResponse } from '@@core/utils/types'; @@ -10,21 +9,19 @@ import { UnifiedCommentInput, UnifiedCommentOutput, } from '../types/model.unified'; -import { CommentResponse } from '../types'; +import { ICommentService } from '../types'; import { desunify } from '@@core/utils/unification/desunify'; import { TicketingObject } from '@ticketing/@utils/@types'; -import { FieldMappingService } from '@@core/field-mapping/field-mapping.service'; import { unify } from '@@core/utils/unification/unify'; import { ServiceRegistry } from './registry.service'; +import { OriginalCommentOutput } from '@@core/utils/types/original/original.ticketing'; @Injectable() export class CommentService { constructor( private prisma: PrismaService, - private zendesk: ZendeskService, private logger: LoggerService, private webhook: WebhookService, - private fieldMappingService: FieldMappingService, private serviceRegistry: ServiceRegistry, ) { this.logger.setContext(CommentService.name); @@ -35,8 +32,23 @@ export class CommentService { integrationId: string, linkedUserId: string, remote_data?: boolean, - ): Promise> { - return; + ): Promise { + try { + const responses = await Promise.all( + unifiedCommentData.map((unifiedData) => + this.addComment( + unifiedData, + integrationId.toLowerCase(), + linkedUserId, + remote_data, + ), + ), + ); + + return responses; + } catch (error) { + handleServiceError(error, this.logger); + } } async addComment( @@ -44,37 +56,406 @@ export class CommentService { integrationId: string, linkedUserId: string, remote_data?: boolean, - ): Promise> { - return; + ): Promise { + try { + const linkedUser = await this.prisma.linked_users.findUnique({ + where: { + id_linked_user: linkedUserId, + }, + }); + + //CHECKS + if (!linkedUser) throw new Error('Linked User Not Found'); + const tick = unifiedCommentData.ticket_id; + //check if contact_id and account_id refer to real uuids + if (tick) { + const search = await this.prisma.tcg_tickets.findUnique({ + where: { + id_tcg_ticket: tick, + }, + }); + if (!search) + throw new Error('You inserted a ticket_id which does not exist'); + } + + const contact = unifiedCommentData.contact_id; + //check if contact_id and account_id refer to real uuids + if (contact) { + const search = await this.prisma.tcg_contacts.findUnique({ + where: { + id_tcg_contact: contact, + }, + }); + if (!search) + throw new Error('You inserted a contact_id which does not exist'); + } + const user = unifiedCommentData.user_id; + //check if contact_id and account_id refer to real uuids + if (user) { + const search = await this.prisma.tcg_users.findUnique({ + where: { + id_tcg_user: user, + }, + }); + if (!search) + throw new Error('You inserted a user_id which does not exist'); + } + + const attachmts = unifiedCommentData.attachments; + //CHEK IF attachments contains valid Attachment uuids + if (attachmts && attachmts.length > 0) { + attachmts.map(async (attachmt) => { + const search = await this.prisma.tcg_attachments.findUnique({ + where: { + id_tcg_attachment: attachmt, + }, + }); + if (!search) + throw new Error( + 'You inserted an attachment_id which does not exist', + ); + }); + } + + //desunify the data according to the target obj wanted + const desunifiedObject = await desunify({ + sourceObject: unifiedCommentData, + targetType: TicketingObject.comment, + providerName: integrationId, + customFieldMappings: [], + }); + + const service: ICommentService = + this.serviceRegistry.getService(integrationId); + //get remote_id of the ticket so the comment is inserted successfully + const ticket = await this.prisma.tcg_tickets.findUnique({ + where: { + id_tcg_ticket: unifiedCommentData.ticket_id, + }, + select: { + remote_id: true, + }, + }); + if (!ticket) + throw new Error( + 'ticket does not exist for the comment you try to create', + ); + const resp: ApiResponse = await service.addComment( + desunifiedObject, + linkedUserId, + ticket.remote_id, + ); + + //unify the data according to the target obj wanted + const unifiedObject = (await unify({ + sourceObject: [resp.data], + targetType: TicketingObject.comment, + providerName: integrationId, + customFieldMappings: [], + })) as UnifiedCommentOutput[]; + + // add the comment inside our db + const source_comment = resp.data; + const target_comment = unifiedObject[0]; + const originId = + 'id' in source_comment ? String(source_comment.id) : undefined; //TODO + + const existingComment = await this.prisma.tcg_comments.findFirst({ + where: { + remote_id: originId, + remote_platform: integrationId, + id_linked_user: linkedUserId, + }, + }); + + let unique_ticketing_comment_id: string; + const opts = + target_comment.creator_type === 'contact' + ? { + id_tcg_contact: unifiedCommentData.contact_id, + } + : target_comment.creator_type === 'user' + ? { + id_tcg_user: unifiedCommentData.user_id, + } + : {}; //case where nothing is passed for creator or a not authorized value; + + if (existingComment) { + // Update the existing comment + let data: any = { + id_tcg_ticket: unifiedCommentData.ticket_id, + modified_at: new Date(), + }; + if (target_comment.body) { + data = { ...data, body: target_comment.body }; + } + if (target_comment.html_body) { + data = { ...data, html_body: target_comment.html_body }; + } + if (target_comment.is_private) { + data = { ...data, is_private: target_comment.is_private }; + } + if (target_comment.creator_type) { + data = { ...data, creator_type: target_comment.creator_type }; + } + data = { ...data, ...opts }; + + const res = await this.prisma.tcg_comments.update({ + where: { + id_tcg_comment: existingComment.id_tcg_comment, + }, + data: data, + }); + unique_ticketing_comment_id = res.id_tcg_comment; + } else { + // Create a new comment + this.logger.log('comment not exists'); + let data: any = { + id_tcg_comment: uuidv4(), + created_at: new Date(), + modified_at: new Date(), + id_tcg_ticket: unifiedCommentData.ticket_id, + id_linked_user: linkedUserId, + remote_id: originId, + remote_platform: integrationId, + }; + + if (target_comment.body) { + data = { ...data, body: target_comment.body }; + } + if (target_comment.html_body) { + data = { ...data, html_body: target_comment.html_body }; + } + if (target_comment.is_private) { + data = { ...data, is_private: target_comment.is_private }; + } + if (target_comment.creator_type) { + data = { ...data, creator_type: target_comment.creator_type }; + } + data = { ...data, ...opts }; + + const res = await this.prisma.tcg_comments.create({ + data: data, + }); + unique_ticketing_comment_id = res.id_tcg_comment; + } + + if (remote_data) { + //insert remote_data in db + await this.prisma.remote_data.upsert({ + where: { + ressource_owner_id: unique_ticketing_comment_id, + }, + create: { + id_remote_data: uuidv4(), + ressource_owner_id: unique_ticketing_comment_id, + format: 'json', + data: JSON.stringify(source_comment), + created_at: new Date(), + }, + update: { + data: JSON.stringify(source_comment), + created_at: new Date(), + }, + }); + } + + const result_comment = await this.getComment( + unique_ticketing_comment_id, + remote_data, + ); + + const status_resp = resp.statusCode === 201 ? 'success' : 'fail'; + + const event = await this.prisma.events.create({ + data: { + id_event: uuidv4(), + status: status_resp, + type: 'ticketing.comment.push', //sync, push or pull + method: 'POST', + url: '/ticketing/comment', + provider: integrationId, + direction: '0', + timestamp: new Date(), + id_linked_user: linkedUserId, + }, + }); + await this.webhook.handleWebhook( + result_comment, + 'ticketing.comment.created', + linkedUser.id_project, + event.id_event, + ); + return result_comment; + } catch (error) { + handleServiceError(error, this.logger); + } } + //TODO: return attachments if specified in param async getComment( id_commenting_comment: string, remote_data?: boolean, - ): Promise> { - return; + ): Promise { + try { + const comment = await this.prisma.tcg_comments.findUnique({ + where: { + id_tcg_comment: id_commenting_comment, + }, + }); + + // WE SHOULDNT HAVE FIELD MAPPINGS TO COMMENT + + // Fetch field mappings for the comment + /*const values = await this.prisma.value.findMany({ + where: { + entity: { + ressource_owner_id: comment.id_tcg_comment, + }, + }, + include: { + attribute: true, + }, + }); + + Create a map to store unique field mappings + const fieldMappingsMap = new Map(); + + values.forEach((value) => { + fieldMappingsMap.set(value.attribute.slug, value.data); + }); + + // Convert the map to an array of objects + const field_mappings = Array.from(fieldMappingsMap, ([key, value]) => ({ + [key]: value, + }));*/ + + // Transform to UnifiedCommentOutput format + const unifiedComment: UnifiedCommentOutput = { + id: comment.id_tcg_comment, + body: comment.body, + html_body: comment.html_body, + is_private: comment.is_private, + creator_type: comment.creator_type, + ticket_id: comment.id_tcg_ticket, + contact_id: comment.id_tcg_contact, // uuid of Contact object + user_id: comment.id_tcg_user, // uuid of User object + }; + + let res: UnifiedCommentOutput = { + ...unifiedComment, + }; + + if (remote_data) { + const resp = await this.prisma.remote_data.findFirst({ + where: { + ressource_owner_id: comment.id_tcg_comment, + }, + }); + const remote_data = JSON.parse(resp.data); + + res = { + ...res, + remote_data: remote_data, + }; + } + + return res; + } catch (error) { + handleServiceError(error, this.logger); + } } + //TODO: return attachments if specified in param + async getComments( integrationId: string, linkedUserId: string, remote_data?: boolean, - ): Promise> { - return; - } - //TODO - async updateComment( - id: string, - updateCommentData: Partial, - ): Promise> { + ): Promise { try { + const comments = await this.prisma.tcg_comments.findMany({ + where: { + remote_platform: integrationId.toLowerCase(), + id_linked_user: linkedUserId, + }, + }); + + const unifiedComments: UnifiedCommentOutput[] = await Promise.all( + comments.map(async (comment) => { + //WE SHOULDNT HAVE FIELD MAPPINGS FOR COMMENT + // Fetch field mappings for the ticket + /*const values = await this.prisma.value.findMany({ + where: { + entity: { + ressource_owner_id: comment.id_tcg_ticket, + }, + }, + include: { + attribute: true, + }, + }); + // Create a map to store unique field mappings + const fieldMappingsMap = new Map(); + + values.forEach((value) => { + fieldMappingsMap.set(value.attribute.slug, value.data); + }); + + // Convert the map to an array of objects + const field_mappings = Array.from( + fieldMappingsMap, + ([key, value]) => ({ [key]: value }), + );*/ + + // Transform to UnifiedCommentOutput format + return { + id: comment.id_tcg_comment, + body: comment.body, + html_body: comment.html_body, + is_private: comment.is_private, + creator_type: comment.creator_type, + ticket_id: comment.id_tcg_ticket, + contact_id: comment.id_tcg_contact, // uuid of Contact object + user_id: comment.id_tcg_user, // uuid of User object + }; + }), + ); + + let res: UnifiedCommentOutput[] = unifiedComments; + + if (remote_data) { + const remote_array_data: UnifiedCommentOutput[] = await Promise.all( + res.map(async (comment) => { + const resp = await this.prisma.remote_data.findFirst({ + where: { + ressource_owner_id: comment.id, + }, + }); + const remote_data = JSON.parse(resp.data); + return { ...comment, remote_data }; + }), + ); + res = remote_array_data; + } + + const event = await this.prisma.events.create({ + data: { + id_event: uuidv4(), + status: 'success', + type: 'ticketing.comment.pulled', + method: 'GET', + url: '/ticketing/comment', + provider: integrationId, + direction: '0', + timestamp: new Date(), + id_linked_user: linkedUserId, + }, + }); + + return res; } catch (error) { handleServiceError(error, this.logger); } - // TODO: fetch the comment from the database using 'id' - // TODO: update the comment with 'updateCommentData' - // TODO: save the updated comment back to the database - // TODO: return the updated comment - return; } } diff --git a/packages/api/src/ticketing/comment/services/front/index.ts b/packages/api/src/ticketing/comment/services/front/index.ts new file mode 100644 index 000000000..b33c09fd3 --- /dev/null +++ b/packages/api/src/ticketing/comment/services/front/index.ts @@ -0,0 +1,192 @@ +import { Injectable } from '@nestjs/common'; +import { LoggerService } from '@@core/logger/logger.service'; +import { PrismaService } from '@@core/prisma/prisma.service'; +import { EncryptionService } from '@@core/encryption/encryption.service'; +import { ApiResponse } from '@@core/utils/types'; +import axios from 'axios'; +import { ActionType, handleServiceError } from '@@core/utils/errors'; +import { ICommentService } from '@ticketing/comment/types'; +import { TicketingObject } from '@ticketing/@utils/@types'; +import { FrontCommentInput, FrontCommentOutput } from './types'; +import { ServiceRegistry } from '../registry.service'; +import { Utils } from '@ticketing/comment/utils'; + +@Injectable() +export class FrontService implements ICommentService { + constructor( + private prisma: PrismaService, + private logger: LoggerService, + private cryptoService: EncryptionService, + private registry: ServiceRegistry, + ) { + this.logger.setContext( + TicketingObject.comment.toUpperCase() + ':' + FrontService.name, + ); + this.registry.registerService('front', this); + } + private readonly utils = new Utils(); + + async addComment( + commentData: FrontCommentInput, + linkedUserId: string, + remoteIdTicket: string, + ): Promise> { + try { + // Check required scope => crm.objects.contacts.write + const connection = await this.prisma.connections.findFirst({ + where: { + id_linked_user: linkedUserId, + provider_slug: 'front', + }, + }); + + let dataBody = commentData; + + //first we retrieve the right author_id (it must be either a User or a Cntact) + const author_id = commentData.author_id; //uuid of either a User or a Contact + let author_data; + + if (author_id) { + // Retrieve the right user for author + const user = await this.prisma.tcg_users.findUnique({ + where: { + id_tcg_user: commentData.author_id, + }, + select: { remote_id: true }, + }); + if (!user) { + throw new Error('author_id is invalid, it must be a valid User'); + } + author_data = user; //it might be undefined but if it is i insert the right data below + dataBody = { ...dataBody, author_id: user.remote_id }; + } + + // Process attachments + let uploads = []; + const uuids = commentData.attachments; + if (uuids && uuids.length > 0) { + uploads = await Promise.all( + uuids.map(async (uuid) => { + const attachment = await this.prisma.tcg_attachments.findUnique({ + where: { + id_tcg_attachment: uuid, + }, + }); + if (!attachment) { + throw new Error(`tcg_attachment not found for uuid ${uuid}`); + } + // TODO: Construct the right binary attachment + // Get the AWS S3 right file + // TODO: Check how to send a stream of a URL + return await this.utils.fetchFileStreamFromURL(attachment.file_url); + }), + ); + } + + // Prepare request data + let resp; + if (uploads.length > 0) { + const formData = new FormData(); + if (author_data) { + formData.append('author_id', author_data.remote_id); + } + formData.append('body', commentData.body); + uploads.forEach((fileStream, index) => { + formData.append(`attachments[${index}]`, fileStream); + }); + + // Send request with attachments + resp = await axios.post( + `https://api2.frontapp.com/conversations/${remoteIdTicket}/comments`, + formData, + { + headers: { + 'Content-Type': 'multipart/form-data', + Authorization: `Bearer ${this.cryptoService.decrypt( + connection.access_token, + )}`, + }, + }, + ); + } else { + // Send request without attachments + resp = await axios.post( + `https://api2.frontapp.com/conversations/${remoteIdTicket}/comments`, + JSON.stringify(dataBody), + { + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${this.cryptoService.decrypt( + connection.access_token, + )}`, + }, + }, + ); + } + + // Return response + return { + data: resp.data, + message: 'Front comment created', + statusCode: 201, + }; + } catch (error) { + handleServiceError( + error, + this.logger, + 'Front', + TicketingObject.comment, + ActionType.POST, + ); + } + } + async syncComments( + linkedUserId: string, + id_ticket: string, + ): Promise> { + try { + const connection = await this.prisma.connections.findFirst({ + where: { + id_linked_user: linkedUserId, + provider_slug: 'front', + }, + }); + //retrieve ticket remote id so we can retrieve the comments in the original software + const ticket = await this.prisma.tcg_tickets.findUnique({ + where: { + id_tcg_ticket: id_ticket, + }, + select: { + remote_id: true, + }, + }); + + const resp = await axios.get( + `https://api2.frontapp.com/conversations/${ticket.remote_id}/comments`, + { + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${this.cryptoService.decrypt( + connection.access_token, + )}`, + }, + }, + ); + this.logger.log(`Synced front comments !`); + + return { + data: resp.data._results, + message: 'Front comments retrieved', + statusCode: 200, + }; + } catch (error) { + handleServiceError( + error, + this.logger, + 'Front', + TicketingObject.comment, + ActionType.GET, + ); + } + } +} diff --git a/packages/api/src/ticketing/comment/services/front/mappers.ts b/packages/api/src/ticketing/comment/services/front/mappers.ts new file mode 100644 index 000000000..c3ae0c4fd --- /dev/null +++ b/packages/api/src/ticketing/comment/services/front/mappers.ts @@ -0,0 +1,98 @@ +import { ICommentMapper } from '@ticketing/comment/types'; +import { + UnifiedCommentInput, + UnifiedCommentOutput, +} from '@ticketing/comment/types/model.unified'; +import { FrontCommentInput, FrontCommentOutput } from './types'; +import { UnifiedAttachmentOutput } from '@ticketing/attachment/types/model.unified'; +import { TicketingObject } from '@ticketing/@utils/@types'; +import { unify } from '@@core/utils/unification/unify'; +import { OriginalAttachmentOutput } from '@@core/utils/types/original/original.ticketing'; +import { Utils } from '@ticketing/comment/utils'; + +export class FrontCommentMapper implements ICommentMapper { + private readonly utils = new Utils(); + + async desunify( + source: UnifiedCommentInput, + customFieldMappings?: { + slug: string; + remote_id: string; + }[], + ): Promise { + const result: FrontCommentInput = { + body: source.body, + // for author and attachments + // we let the Panora uuids on purpose (it will be modified in the given service on the fly where we'll retrieve the actual remote id for the given uuid!) + // either one must be passed + author_id: source.user_id || source.contact_id, // for Front it must be a User + attachments: source.attachments, + }; + return result; + } + + async unify( + source: FrontCommentOutput | FrontCommentOutput[], + customFieldMappings?: { + slug: string; + remote_id: string; + }[], + ): Promise { + if (!Array.isArray(source)) { + return await this.mapSingleCommentToUnified(source, customFieldMappings); + } + return Promise.all( + source.map((comment) => + this.mapSingleCommentToUnified(comment, customFieldMappings), + ), + ); + } + + private async mapSingleCommentToUnified( + comment: FrontCommentOutput, + customFieldMappings?: { + slug: string; + remote_id: string; + }[], + ): Promise { + //map the front attachment to our unified version of attachment + //unifying the original attachment object coming from Front + let opts; + + if (comment.attachments && comment.attachments.length > 0) { + const unifiedObject = (await unify({ + sourceObject: comment.attachments, + targetType: TicketingObject.attachment, + providerName: 'front', + customFieldMappings: [], + })) as UnifiedAttachmentOutput[]; + + opts = { ...opts, attachments: unifiedObject }; + } + + if (comment.author.id) { + const user_id = await this.utils.getUserUuidFromRemoteId( + String(comment.author.id), + 'zendesk_tcg', + ); + + if (user_id) { + // we must always fall here for Front + opts = { user_id: user_id, creator_type: 'user' }; + } else { + const contact_id = await this.utils.getContactUuidFromRemoteId( + String(comment.author.id), + 'zendesk_tcg', + ); + opts = { creator_type: 'contact', contact_id: contact_id }; + } + } + + const res = { + body: comment.body, + ...opts, + }; + + return res; + } +} diff --git a/packages/api/src/ticketing/comment/services/front/types.ts b/packages/api/src/ticketing/comment/services/front/types.ts new file mode 100644 index 000000000..83298fada --- /dev/null +++ b/packages/api/src/ticketing/comment/services/front/types.ts @@ -0,0 +1,50 @@ +export type FrontCommentInput = { + author_id?: string; + body: string; + attachments?: string[]; +}; + +export type FrontCommentOutput = { + _links: Links; + id: string; + author?: Author; + body: string; + posted_at: number; + attachments?: Attachment[]; +}; + +type Links = { + self: string; + related?: Record; +}; + +type CustomFields = { + [key: string]: string | boolean | number; +}; + +type Author = { + _links: Links; + id: string; + email: string; + username: string; + first_name: string; + last_name: string; + is_admin: boolean; + is_available: boolean; + is_blocked: boolean; + custom_fields: CustomFields; +}; + +type AttachmentMetadata = { + is_inline: boolean; + cid: string; +}; + +type Attachment = { + id: string; + filename: string; + url: string; + content_type: string; + size: number; + metadata: AttachmentMetadata; +}; diff --git a/packages/api/src/ticketing/comment/services/github/index.ts b/packages/api/src/ticketing/comment/services/github/index.ts new file mode 100644 index 000000000..8c1dae630 --- /dev/null +++ b/packages/api/src/ticketing/comment/services/github/index.ts @@ -0,0 +1,120 @@ +import { Injectable } from '@nestjs/common'; +import { LoggerService } from '@@core/logger/logger.service'; +import { PrismaService } from '@@core/prisma/prisma.service'; +import { EncryptionService } from '@@core/encryption/encryption.service'; +import { ApiResponse } from '@@core/utils/types'; +import { DesunifyReturnType } from '@@core/utils/types/desunify.input'; +import axios from 'axios'; +import { ActionType, handleServiceError } from '@@core/utils/errors'; +import { ICommentService } from '@ticketing/comment/types'; +import { TicketingObject } from '@ticketing/@utils/@types'; +import { OriginalCommentOutput } from '@@core/utils/types/original/original.ticketing'; +import { ServiceRegistry } from '../registry.service'; +import { GithubCommentInput, GithubCommentOutput } from './types'; + +@Injectable() +export class GithubService implements ICommentService { + constructor( + private prisma: PrismaService, + private logger: LoggerService, + private cryptoService: EncryptionService, + private registry: ServiceRegistry, + ) { + this.logger.setContext( + TicketingObject.comment.toUpperCase() + ':' + GithubService.name, + ); + this.registry.registerService('github', this); + } + async addComment( + commentData: GithubCommentInput, + linkedUserId: string, + remoteIdTicket: string, + ): Promise> { + try { + //TODO: check required scope => crm.objects.contacts.write + const connection = await this.prisma.connections.findFirst({ + where: { + id_linked_user: linkedUserId, + provider_slug: 'github', + }, + }); + const dataBody = { + comment: commentData, + }; + const resp = await axios.post( + `https://api2.frontapp.com/conversations/${remoteIdTicket}/comments`, + JSON.stringify(dataBody), + { + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${this.cryptoService.decrypt( + connection.access_token, + )}`, + }, + }, + ); + return { + data: resp.data, + message: 'Github comment created', + statusCode: 201, + }; + } catch (error) { + handleServiceError( + error, + this.logger, + 'Github', + TicketingObject.comment, + ActionType.POST, + ); + } + } + async syncComments( + linkedUserId: string, + id_ticket: string, + ): Promise> { + try { + const connection = await this.prisma.connections.findFirst({ + where: { + id_linked_user: linkedUserId, + provider_slug: 'github', + }, + }); + //retrieve ticket remote id so we can retrieve the comments in the original software + const ticket = await this.prisma.tcg_tickets.findUnique({ + where: { + id_tcg_ticket: id_ticket, + }, + select: { + remote_id: true, + }, + }); + + const resp = await axios.get( + `https://api2.frontapp.com/conversations/${ticket.remote_id}/comments`, + { + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${this.cryptoService.decrypt( + connection.access_token, + )}`, + }, + }, + ); + this.logger.log(`Synced github comments !`); + + return { + data: resp.data._results, + message: 'Front github retrieved', + statusCode: 200, + }; + } catch (error) { + handleServiceError( + error, + this.logger, + 'Github', + TicketingObject.comment, + ActionType.GET, + ); + } + } +} diff --git a/packages/api/src/ticketing/comment/services/github/mappers.ts b/packages/api/src/ticketing/comment/services/github/mappers.ts new file mode 100644 index 000000000..048fe09d1 --- /dev/null +++ b/packages/api/src/ticketing/comment/services/github/mappers.ts @@ -0,0 +1,47 @@ +import { ICommentMapper } from '@ticketing/comment/types'; +import { + UnifiedCommentInput, + UnifiedCommentOutput, +} from '@ticketing/comment/types/model.unified'; +import { GithubCommentInput, GithubCommentOutput } from './types'; + +export class GithubCommentMapper implements ICommentMapper { + desunify( + source: UnifiedCommentInput, + customFieldMappings?: { + slug: string; + remote_id: string; + }[], + ): GithubCommentInput { + //TODO + return; + } + + async unify( + source: GithubCommentOutput | GithubCommentOutput[], + customFieldMappings?: { + slug: string; + remote_id: string; + }[], + ): Promise { + if (!Array.isArray(source)) { + return this.mapSingleCommentToUnified(source, customFieldMappings); + } + return source.map((comment) => + this.mapSingleCommentToUnified(comment, customFieldMappings), + ); + } + + private mapSingleCommentToUnified( + comment: GithubCommentOutput, + customFieldMappings?: { + slug: string; + remote_id: string; + }[], + ): UnifiedCommentOutput { + /*TODO const field_mappings = customFieldMappings.map((mapping) => ({ + [mapping.slug]: comment.custom_fields[mapping.remote_id], + }));*/ + return; + } +} diff --git a/packages/api/src/ticketing/comment/services/github/types.ts b/packages/api/src/ticketing/comment/services/github/types.ts new file mode 100644 index 000000000..bcc9d9ec5 --- /dev/null +++ b/packages/api/src/ticketing/comment/services/github/types.ts @@ -0,0 +1,5 @@ +export type GithubCommentInput = { + id: string; +}; + +export type GithubCommentOutput = GithubCommentInput; diff --git a/packages/api/src/ticketing/comment/services/hubspot/index.ts b/packages/api/src/ticketing/comment/services/hubspot/index.ts new file mode 100644 index 000000000..d993b0e44 --- /dev/null +++ b/packages/api/src/ticketing/comment/services/hubspot/index.ts @@ -0,0 +1,122 @@ +import { Injectable } from '@nestjs/common'; +import { LoggerService } from '@@core/logger/logger.service'; +import { PrismaService } from '@@core/prisma/prisma.service'; +import { EncryptionService } from '@@core/encryption/encryption.service'; +import { ApiResponse } from '@@core/utils/types'; +import { DesunifyReturnType } from '@@core/utils/types/desunify.input'; +import axios from 'axios'; +import { ActionType, handleServiceError } from '@@core/utils/errors'; +import { ICommentService } from '@ticketing/comment/types'; +import { TicketingObject } from '@ticketing/@utils/@types'; +import { OriginalCommentOutput } from '@@core/utils/types/original/original.ticketing'; +import { ServiceRegistry } from '../registry.service'; +import { HubspotCommentInput, HubspotCommentOutput } from './types'; + +@Injectable() +export class HubspotService implements ICommentService { + constructor( + private prisma: PrismaService, + private logger: LoggerService, + private cryptoService: EncryptionService, + private registry: ServiceRegistry, + ) { + this.logger.setContext( + TicketingObject.comment.toUpperCase() + ':' + HubspotService.name, + ); + this.registry.registerService('hubspot_t', this); + } + async addComment( + commentData: HubspotCommentInput, + linkedUserId: string, + remoteIdTicket: string, + ): Promise> { + try { + //TODO: check required scope => crm.objects.contacts.write + /*const connection = await this.prisma.connections.findFirst({ + where: { + id_linked_user: linkedUserId, + provider_slug: 'hubspot_t', + }, + }); + const dataBody = { + comment: commentData, + }; + const resp = await axios.post( + `https://api2.frontapp.com/conversations/${remoteIdTicket}/comments`, + JSON.stringify(dataBody), + { + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${this.cryptoService.decrypt( + connection.access_token, + )}`, + }, + }, + ); + return { + data: resp.data, + message: 'Hubspot comment created', + statusCode: 201, + };*/ + return; + } catch (error) { + handleServiceError( + error, + this.logger, + 'Hubspot', + TicketingObject.comment, + ActionType.POST, + ); + } + } + async syncComments( + linkedUserId: string, + id_ticket: string, + ): Promise> { + try { + /*const connection = await this.prisma.connections.findFirst({ + where: { + id_linked_user: linkedUserId, + provider_slug: 'hubspot_t', + }, + }); + //retrieve ticket remote id so we can retrieve the comments in the original software + const ticket = await this.prisma.tcg_tickets.findUnique({ + where: { + id_tcg_ticket: id_ticket, + }, + select: { + remote_id: true, + }, + }); + + const resp = await axios.get( + `https://api2.frontapp.com/conversations/${ticket.remote_id}/comments`, + { + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${this.cryptoService.decrypt( + connection.access_token, + )}`, + }, + }, + ); + this.logger.log(`Synced hubspot comments !`); + + return { + data: resp.data._results, + message: 'Hubspot comments retrieved', + statusCode: 200, + };*/ + return; + } catch (error) { + handleServiceError( + error, + this.logger, + 'Hubspot', + TicketingObject.comment, + ActionType.GET, + ); + } + } +} diff --git a/packages/api/src/ticketing/comment/services/hubspot/mappers.ts b/packages/api/src/ticketing/comment/services/hubspot/mappers.ts new file mode 100644 index 000000000..e6b306658 --- /dev/null +++ b/packages/api/src/ticketing/comment/services/hubspot/mappers.ts @@ -0,0 +1,48 @@ +import { ICommentMapper } from '@ticketing/comment/types'; +import { + UnifiedCommentInput, + UnifiedCommentOutput, +} from '@ticketing/comment/types/model.unified'; +import { HubspotCommentInput, HubspotCommentOutput } from './types'; + +//TODO: HUBSPOT DOES NOT HAVE A COMMENT ENDPOINT +export class HubspotCommentMapper implements ICommentMapper { + desunify( + source: UnifiedCommentInput, + customFieldMappings?: { + slug: string; + remote_id: string; + }[], + ): HubspotCommentInput { + //TODO + return; + } + + async unify( + source: HubspotCommentOutput | HubspotCommentOutput[], + customFieldMappings?: { + slug: string; + remote_id: string; + }[], + ): Promise { + if (!Array.isArray(source)) { + return this.mapSingleCommentToUnified(source, customFieldMappings); + } + return source.map((comment) => + this.mapSingleCommentToUnified(comment, customFieldMappings), + ); + } + + private mapSingleCommentToUnified( + comment: HubspotCommentOutput, + customFieldMappings?: { + slug: string; + remote_id: string; + }[], + ): UnifiedCommentOutput { + /*TODO const field_mappings = customFieldMappings.map((mapping) => ({ + [mapping.slug]: comment.custom_fields[mapping.remote_id], + }));*/ + return; + } +} diff --git a/packages/api/src/ticketing/comment/services/hubspot/types.ts b/packages/api/src/ticketing/comment/services/hubspot/types.ts new file mode 100644 index 000000000..d3ec77b6f --- /dev/null +++ b/packages/api/src/ticketing/comment/services/hubspot/types.ts @@ -0,0 +1,5 @@ +export type HubspotCommentInput = { + id: string; +}; + +export type HubspotCommentOutput = HubspotCommentInput; diff --git a/packages/api/src/ticketing/comment/services/zendesk/index.ts b/packages/api/src/ticketing/comment/services/zendesk/index.ts index df5ab8ff6..82aed3d51 100644 --- a/packages/api/src/ticketing/comment/services/zendesk/index.ts +++ b/packages/api/src/ticketing/comment/services/zendesk/index.ts @@ -3,48 +3,133 @@ import { LoggerService } from '@@core/logger/logger.service'; import { PrismaService } from '@@core/prisma/prisma.service'; import { EncryptionService } from '@@core/encryption/encryption.service'; import { ApiResponse } from '@@core/utils/types'; -import { DesunifyReturnType } from '@@core/utils/types/desunify.input'; import axios from 'axios'; import { ActionType, handleServiceError } from '@@core/utils/errors'; -import { EnvironmentService } from '@@core/environment/environment.service'; import { ICommentService } from '@ticketing/comment/types'; import { TicketingObject } from '@ticketing/@utils/@types'; -import { ZendeskCommentOutput } from './types'; import { OriginalCommentOutput } from '@@core/utils/types/original/original.ticketing'; import { ServiceRegistry } from '../registry.service'; - +import { ZendeskCommentInput, ZendeskCommentOutput } from './types'; +import { EnvironmentService } from '@@core/environment/environment.service'; @Injectable() export class ZendeskService implements ICommentService { constructor( private prisma: PrismaService, private logger: LoggerService, - private cryptoService: EncryptionService, private env: EnvironmentService, + private cryptoService: EncryptionService, private registry: ServiceRegistry, ) { this.logger.setContext( TicketingObject.comment.toUpperCase() + ':' + ZendeskService.name, ); - this.registry.registerService('zendesk_t', this); + this.registry.registerService('zendesk_tcg', this); } + async addComment( - commentData: DesunifyReturnType, + commentData: ZendeskCommentInput, linkedUserId: string, + remoteIdTicket: string, ): Promise> { try { - //TODO: check required scope => crm.objects.contacts.write const connection = await this.prisma.connections.findFirst({ where: { id_linked_user: linkedUserId, - provider_slug: 'zendesk_t', + provider_slug: 'zendesk_tcg', }, }); - const dataBody = { - comment: commentData, + + let dataBody = { + ticket: { + comment: commentData, + }, }; - const ticketOriginalId = 0; //TODO: check if it exists first and retrieve it properly - const resp = await axios.post( - `https://${this.env.getZendeskTicketingSubdomain()}.zendesk.com/api/v2/tickets/${ticketOriginalId}.json`, + + //first we retrieve the right author_id (it must be either a User or a Cntact) + const author_id = commentData.author_id; //uuid of either a User or a Contact + let author_data; + + if (author_id) { + const res_user = await this.prisma.tcg_users.findUnique({ + where: { + id_tcg_user: String(author_id), + }, + select: { remote_id: true }, + }); + author_data = res_user; //it might be undefined but if it is i insert the right data below + + if (!res_user) { + //try to see if there is a contact for this uuid + const res_contact = await this.prisma.tcg_contacts.findUnique({ + where: { + id_tcg_contact: String(author_id), + }, + select: { remote_id: true }, + }); + if (!res_contact) { + throw new Error( + 'author_id is invalid, it must be a valid User or Contact', + ); + } + author_data = res_contact; + } + + const finalData = { + ticket: { + comment: { + ...commentData, + author_id: author_data.remote_id, + }, + }, + }; + dataBody = finalData; + } + + // We must fetch tokens from zendesk with the commentData.uploads array of Attachment uuids + const uuids = commentData.uploads; + let uploads = []; + if (uuids && uuids.length > 0) { + await Promise.all( + uuids.map(async (uuid) => { + const res = await this.prisma.tcg_attachments.findUnique({ + where: { + id_tcg_attachment: uuid, + }, + }); + if (!res) + throw new Error(`tcg_attachment not found for uuid ${uuid}`); + + //TODO:; fetch the right file from AWS s3 + const s3File = ''; + const url = `https://${this.env.getZendeskTicketingSubdomain()}.zendesk.com/api/v2/uploads.json?filename=${ + res.file_name + }`; + + const resp = await axios.get(url, { + headers: { + 'Content-Type': 'image/png', //TODO: get the right content-type given a file name extension + Authorization: `Bearer ${this.cryptoService.decrypt( + connection.access_token, + )}`, + }, + }); + uploads = [...uploads, resp.data.upload.token]; + }), + ); + const finalData = { + ticket: { + comment: { + ...commentData, + uploads: uploads, + }, + }, + }; + dataBody = finalData; + } + + //to add a comment on Zendesk you must update a ticket using the Ticket API + const resp = await axios.put( + `https://${this.env.getZendeskTicketingSubdomain()}.zendesk.com/api/v2/tickets/${remoteIdTicket}.json`, JSON.stringify(dataBody), { headers: { @@ -55,8 +140,11 @@ export class ZendeskService implements ICommentService { }, }, ); + const pre_res = resp.data.audit.events.find((obj) => + obj.hasOwnProperty('body'), + ); return { - data: resp.data, + data: pre_res, message: 'Zendesk comment created', statusCode: 201, }; @@ -70,10 +158,55 @@ export class ZendeskService implements ICommentService { ); } } - syncComments( + async syncComments( linkedUserId: string, - custom_properties?: string[], + id_ticket: string, ): Promise> { - throw new Error('Method not implemented.'); + try { + const connection = await this.prisma.connections.findFirst({ + where: { + id_linked_user: linkedUserId, + provider_slug: 'zendesk_tcg', + }, + }); + //retrieve ticket remote id so we can retrieve the comments in the original software + const ticket = await this.prisma.tcg_tickets.findUnique({ + where: { + id_tcg_ticket: id_ticket, + }, + select: { + remote_id: true, + }, + }); + + const resp = await axios.get( + `https://${this.env.getZendeskTicketingSubdomain()}.zendesk.com/api/v2/tickets/${ + ticket.remote_id + }/comments.json`, + { + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${this.cryptoService.decrypt( + connection.access_token, + )}`, + }, + }, + ); + this.logger.log(`Synced zendesk comments !`); + + return { + data: resp.data.comments, + message: 'Zendesk comments retrieved', + statusCode: 200, + }; + } catch (error) { + handleServiceError( + error, + this.logger, + 'Zendesk', + TicketingObject.comment, + ActionType.GET, + ); + } } } diff --git a/packages/api/src/ticketing/comment/services/zendesk/mappers.ts b/packages/api/src/ticketing/comment/services/zendesk/mappers.ts index 4d464cf33..cf9dfc4dc 100644 --- a/packages/api/src/ticketing/comment/services/zendesk/mappers.ts +++ b/packages/api/src/ticketing/comment/services/zendesk/mappers.ts @@ -4,44 +4,99 @@ import { UnifiedCommentInput, UnifiedCommentOutput, } from '@ticketing/comment/types/model.unified'; +import { UnifiedAttachmentOutput } from '@ticketing/attachment/types/model.unified'; +import { unify } from '@@core/utils/unification/unify'; +import { TicketingObject } from '@ticketing/@utils/@types'; +import { OriginalAttachmentOutput } from '@@core/utils/types/original/original.ticketing'; +import { Utils } from '@ticketing/comment/utils'; export class ZendeskCommentMapper implements ICommentMapper { - desunify( + private readonly utils = new Utils(); + async desunify( source: UnifiedCommentInput, customFieldMappings?: { slug: string; remote_id: string; }[], - ): ZendeskCommentInput { - //TODO - return; + ): Promise { + const result: ZendeskCommentInput = { + body: source.body, + html_body: source.html_body, + public: !source.is_private, + author_id: source.user_id + ? parseInt(source.user_id) + : parseInt(source.contact_id), + // we let the Panora uuids on purpose (it will be modified in the given service on the fly where we'll retrieve the actual remote id for the given uuid!) + // either one must be passed + type: 'Comment', + uploads: source.attachments, //we let the array of uuids on purpose (it will be modified in the given service on the fly!) + }; + + return result; } - unify( + async unify( source: ZendeskCommentOutput | ZendeskCommentOutput[], customFieldMappings?: { slug: string; remote_id: string; }[], - ): UnifiedCommentOutput | UnifiedCommentOutput[] { + ): Promise { if (!Array.isArray(source)) { return this.mapSingleCommentToUnified(source, customFieldMappings); } - return source.map((comment) => - this.mapSingleCommentToUnified(comment, customFieldMappings), + return Promise.all( + source.map((comment) => + this.mapSingleCommentToUnified(comment, customFieldMappings), + ), ); } - private mapSingleCommentToUnified( + private async mapSingleCommentToUnified( comment: ZendeskCommentOutput, customFieldMappings?: { slug: string; remote_id: string; }[], - ): UnifiedCommentOutput { - /*TODO const field_mappings = customFieldMappings.map((mapping) => ({ - [mapping.slug]: comment.custom_fields[mapping.remote_id], - }));*/ - return; + ): Promise { + let opts; + + if (comment.attachments && comment.attachments.length > 0) { + const unifiedObject = (await unify({ + sourceObject: comment.attachments, + targetType: TicketingObject.attachment, + providerName: 'zendesk_tcg', + customFieldMappings: [], + })) as UnifiedAttachmentOutput[]; + + opts = { ...opts, attachments: unifiedObject }; + } + + /*TODO: uncomment when test for sync of users/contacts is done as right now we dont have any real users nor contacts inside our db + if (comment.author_id) { + const user_id = await this.utils.getUserUuidFromRemoteId( + String(comment.author_id), + 'zendesk_tcg', + ); + + if (user_id) { + opts = { user_id: user_id, creator_type: 'user' }; + } else { + const contact_id = await this.utils.getContactUuidFromRemoteId( + String(comment.author_id), + 'zendesk_tcg', + ); + opts = { creator_type: 'contact', contact_id: contact_id }; + } + }*/ + + const res = { + body: comment.body || '', + html_body: comment.html_body || '', + is_private: !comment.public, + ...opts, + }; + + return res; } } diff --git a/packages/api/src/ticketing/comment/services/zendesk/types.ts b/packages/api/src/ticketing/comment/services/zendesk/types.ts index f404340b1..7d7f647d2 100644 --- a/packages/api/src/ticketing/comment/services/zendesk/types.ts +++ b/packages/api/src/ticketing/comment/services/zendesk/types.ts @@ -1,5 +1,192 @@ -export type ZendeskCommentInput = { +export type ZendeskCommentInput = Partial; + +export type ZendeskCommentOutput = ZendeskCommentInput & { + id: number; +}; + +type BaseComment = { + attachments: Attachment[]; // Read-only. Attachments, if any. + audit_id: number; // Read-only. The id of the ticket audit record. + author_id?: number; // The id of the comment author. + body?: string; // The comment string. + created_at: string; // Read-only. The time the comment was created. + html_body?: string; // The comment formatted as HTML. + metadata: Metadata; // Read-only. System information and comment flags. + plain_body: string; // Read-only. The comment presented as plain text. + public?: boolean; // true if a public comment; false if an internal note. + type: 'Comment' | 'VoiceComment'; // Read-only. Either 'Comment' or 'VoiceComment'. + uploads?: string[]; // List of tokens for comment attachments. + via?: Via; // Describes how the object was created. +}; + +export type Attachment = { + content_type: string; // The content type of the image, e.g., "image/png". + content_url: string; // A full URL where the attachment image file can be downloaded. + deleted: boolean; // If true, the attachment has been deleted. + file_name: string; // The name of the image file. + height: string | null; // The height of the image file in pixels, or null if unknown. + id: number; // Automatically assigned when created. + inline: boolean; // If true, the attachment is excluded from the attachment list. + malware_access_override: boolean; // If true, you can download an attachment flagged as malware. + malware_scan_result: + | 'malware_found' + | 'malware_not_found' + | 'failed_to_scan' + | 'not_scanned'; // The result of the malware scan. + mapped_content_url: string; // The URL the attachment image file has been mapped to. + size: number; // The size of the image file in bytes. + thumbnails: Attachment[]; // An array of attachment objects. + url: string; // A URL to access the attachment details. + width: string | null; // The width of the image file in pixels, or null if unknown. +}; + +export type CustomField_ = { id: string; + value: any; +}; + +//48 ccs MAX otherwise 404 error +type EmailCc = + | { + user_email: string; + user_name?: string; + user_id?: never; + action?: 'put' | 'delete'; + } + | { + user_email?: never; + user_name?: never; + user_id: string; + action?: 'put' | 'delete'; + }; + +type Metadata = Record; + +type Via = { + channel: string; + source: + | EmailSource + | WebSource + | ZendeskWidgetSource + | FeedbackTabSource + | MobileSource + | ApiSource + | FollowUpSource + | BusinessRuleTriggerSource + | BusinessRuleAutomationSource + | ForumTopicSource + | SocialMediaSource + | ChatSource + | ChatOfflineMessageSource + | CallSource + | FacebookSource + | SystemMergedSource + | SystemFollowUpSource + | SystemSuspendedTicketSource + | SystemProblemTicketSolvedSource + | AnyChannelSource; + rel?: string; +}; + +type EmailSource = { + from: EmailDetails; + to: EmailDetails; + original_recipients?: string[]; +}; + +type EmailDetails = { + address: string; + name?: string; + email_ccs?: EmailCc[]; +}; + +type WebSource = any; // "Submit a request" on website has no additional details + +type ZendeskWidgetSource = { + zendesk_widget: any; // Replace 'any' with actual structure if available +}; + +type FeedbackTabSource = { + feedback_tab: any; // Replace 'any' with actual structure if available +}; + +type MobileSource = { + mobile: any; // Replace 'any' with actual structure if available +}; + +type ApiSource = { + api: any; // Replace 'any' with actual structure if available +}; + +type FollowUpSource = { + ticket_id: number; + subject: string; +}; + +type BusinessRuleTriggerSource = { + id: number; + title: string; + deleted?: boolean; + revision_id?: number; // Enterprise +}; + +type BusinessRuleAutomationSource = { + id: number; + title: string; + deleted?: boolean; +}; + +type ForumTopicSource = { + topic_id: number; + topic_name: string; }; -export type ZendeskCommentOutput = ZendeskCommentInput; +type SocialMediaSource = { + profile_url: string; + username: string; + name?: string; +}; + +type ChatSource = any; // Chat has no additional details + +type ChatOfflineMessageSource = { + chat_offline_message: any; // Replace 'any' with actual structure if available +}; + +type CallSource = { + phone: string; + formatted_phone: string; + name?: string; +}; + +type FacebookSource = { + name: string; + profile_url: string; + facebook_id: string; +}; + +type SystemMergedSource = { + ticket_id: number; + subject: string; +}; + +type SystemFollowUpSource = { + ticket_id: number; + subject: string; +}; + +type SystemSuspendedTicketSource = { + suspended_ticket_id: number; +}; + +type SystemProblemTicketSolvedSource = { + ticket_id: number; + subject: string; +}; + +type AnyChannelSource = { + service_info: any; // Replace 'any' with actual structure if available + supports_channelback: boolean; + supports_clickthrough: boolean; + registered_integration_service_name: string; +}; diff --git a/packages/api/src/ticketing/comment/sync/sync.service.ts b/packages/api/src/ticketing/comment/sync/sync.service.ts index 3a5998844..e405c13e0 100644 --- a/packages/api/src/ticketing/comment/sync/sync.service.ts +++ b/packages/api/src/ticketing/comment/sync/sync.service.ts @@ -29,7 +29,7 @@ export class SyncService implements OnModuleInit { async onModuleInit() { try { - await this.syncComments(); + //await this.syncComments(); } catch (error) { handleServiceError(error, this.logger); } @@ -64,11 +64,21 @@ export class SyncService implements OnModuleInit { const providers = TICKETING_PROVIDERS; for (const provider of providers) { try { - await this.syncCommentsForLinkedUser( - provider, - linkedUser.id_linked_user, - id_project, - ); + //call the sync comments for every ticket of the linkedUser (a comment is tied to a ticket) + const tickets = await this.prisma.tcg_tickets.findMany({ + where: { + remote_platform: provider, + id_linked_user: linkedUser.id_linked_user, + }, + }); + for (const ticket of tickets) { + await this.syncCommentsForLinkedUser( + provider, + linkedUser.id_linked_user, + id_project, + ticket.id_tcg_ticket, + ); + } } catch (error) { handleServiceError(error, this.logger); } @@ -87,6 +97,7 @@ export class SyncService implements OnModuleInit { integrationId: string, linkedUserId: string, id_project: string, + id_ticket: string, ) { try { this.logger.log( @@ -99,21 +110,7 @@ export class SyncService implements OnModuleInit { provider_slug: integrationId, }, }); - if (!connection) return; - const job_resp_create = await this.prisma.events.create({ - data: { - id_event: uuidv4(), - status: 'initialized', - type: 'ticketing.comment.pulled', - method: 'PULL', - url: '/pull', - provider: integrationId, - direction: '0', - timestamp: new Date(), - id_linked_user: linkedUserId, - }, - }); - const job_id = job_resp_create.id_event; + if (!connection) throw new Error('connection not found'); // get potential fieldMappings and extract the original properties name const customFieldMappings = @@ -129,7 +126,7 @@ export class SyncService implements OnModuleInit { const service: ICommentService = this.serviceRegistry.getService(integrationId); const resp: ApiResponse = - await service.syncComments(linkedUserId, remoteProperties); + await service.syncComments(linkedUserId, id_ticket, remoteProperties); const sourceObject: OriginalCommentOutput[] = resp.data; //this.logger.log('SOURCE OBJECT DATA = ' + JSON.stringify(sourceObject)); @@ -145,29 +142,34 @@ export class SyncService implements OnModuleInit { const commentsIds = sourceObject.map((comment) => 'id' in comment ? String(comment.id) : undefined, ); - //insert the data in the DB with the fieldMappings (value table) const comments_data = await this.saveCommentsInDb( linkedUserId, unifiedObject, commentsIds, integrationId, - job_id, + id_ticket, sourceObject, ); - await this.prisma.events.update({ - where: { - id_event: job_id, - }, + + const event = await this.prisma.events.create({ data: { + id_event: uuidv4(), status: 'success', + type: 'ticketing.comment.synced', + method: 'SYNC', + url: '/sync', + provider: integrationId, + direction: '0', + timestamp: new Date(), + id_linked_user: linkedUserId, }, }); await this.webhook.handleWebhook( comments_data, - 'ticketing.comment.pulled', + 'ticketing.comment.synced', id_project, - job_id, + event.id_event, ); } catch (error) { handleServiceError(error, this.logger); @@ -176,12 +178,198 @@ export class SyncService implements OnModuleInit { async saveCommentsInDb( linkedUserId: string, - tickets: UnifiedCommentOutput[], + comments: UnifiedCommentOutput[], originIds: string[], originSource: string, - jobId: string, + id_ticket: string, remote_data: Record[], ): Promise { - return; + try { + let comments_results: TicketingComment[] = []; + for (let i = 0; i < comments.length; i++) { + const comment = comments[i]; + const originId = originIds[i]; + + if (!originId || originId == '') { + throw new NotFoundError(`Origin id not there, found ${originId}`); + } + + const existingComment = await this.prisma.tcg_comments.findFirst({ + where: { + remote_id: originId, + remote_platform: originSource, + id_linked_user: linkedUserId, + }, + }); + + let unique_ticketing_comment_id: string; + const opts = + comment.creator_type === 'contact' + ? { + id_tcg_contact: comment.contact_id, + } + : comment.creator_type === 'user' + ? { + id_tcg_user: comment.user_id, + } + : {}; //case where nothing is passed for creator or a not authorized value; + + if (existingComment) { + // Update the existing comment + let data: any = { + id_tcg_ticket: comment.ticket_id, + modified_at: new Date(), + }; + if (comment.body) { + data = { ...data, body: comment.body }; + } + if (comment.html_body) { + data = { ...data, html_body: comment.html_body }; + } + if (comment.is_private) { + data = { ...data, is_private: comment.is_private }; + } + if (comment.creator_type) { + data = { ...data, creator_type: comment.creator_type }; + } + data = { ...data, ...opts }; + const res = await this.prisma.tcg_comments.update({ + where: { + id_tcg_comment: existingComment.id_tcg_comment, + }, + data: data, + }); + unique_ticketing_comment_id = res.id_tcg_comment; + comments_results = [...comments_results, res]; + } else { + // Create a new comment + this.logger.log('comment not exists'); + let data: any = { + id_tcg_comment: uuidv4(), + created_at: new Date(), + modified_at: new Date(), + id_tcg_ticket: comment.ticket_id, + id_linked_user: linkedUserId, + remote_id: originId, + remote_platform: originSource, + }; + + if (comment.body) { + data = { ...data, body: comment.body }; + } + if (comment.html_body) { + data = { ...data, html_body: comment.html_body }; + } + if (comment.is_private) { + data = { ...data, is_private: comment.is_private }; + } + if (comment.creator_type) { + data = { ...data, creator_type: comment.creator_type }; + } + data = { ...data, ...opts }; + + const res = await this.prisma.tcg_comments.create({ + data: data, + }); + comments_results = [...comments_results, res]; + unique_ticketing_comment_id = res.id_tcg_comment; + } + + // now insert the attachment of the comment inside tcg_attachments + // we should already have at least initial data (as it must have been inserted by the end linked user before adding comment) + // though we might sync comments that have been also directly been added to the provider without passing through Panora + // in this case just create a new attachment row ! + if (comment.attachments && comment.attachments.length > 0) { + for (const attchmt of comment.attachments) { + let unique_ticketing_attachmt_id: string; + + const existingAttachmt = + await this.prisma.tcg_attachments.findFirst({ + where: { + remote_platform: originSource, + id_linked_user: linkedUserId, + file_name: attchmt.file_name, + }, + }); + + if (existingAttachmt) { + // Update the existing attachmt + const res = await this.prisma.tcg_attachments.update({ + where: { + id_tcg_attachment: existingAttachmt.id_tcg_attachment, + }, + data: { + remote_id: attchmt.id, + file_url: attchmt.file_url, + id_tcg_comment: unique_ticketing_comment_id, + id_tcg_ticket: id_ticket, + modified_at: new Date(), + }, + }); + unique_ticketing_attachmt_id = res.id_tcg_attachment; + } else { + // Create a new attachment + this.logger.log('attchmt not exists'); + const data = { + id_tcg_attachment: uuidv4(), + remote_id: attchmt.id, + file_name: attchmt.file_name, + file_url: attchmt.file_url, + id_tcg_comment: unique_ticketing_comment_id, + created_at: new Date(), + modified_at: new Date(), + uploader: linkedUserId, //TODO + id_tcg_ticket: id_ticket, + id_linked_user: linkedUserId, + remote_platform: originSource, + }; + const res = await this.prisma.tcg_attachments.create({ + data: data, + }); + unique_ticketing_attachmt_id = res.id_tcg_attachment; + } + + //TODO: insert remote_data in db i dont know the type of remote_data to extract the right source attachment object + /*await this.prisma.remote_data.upsert({ + where: { + ressource_owner_id: unique_ticketing_attachmt_id, + }, + create: { + id_remote_data: uuidv4(), + ressource_owner_id: unique_ticketing_attachmt_id, + format: 'json', + data: JSON.stringify(remote_data[i]), + created_at: new Date(), + }, + update: { + data: JSON.stringify(remote_data[i]), + created_at: new Date(), + }, + });*/ + } + } + + //insert remote_data in db + await this.prisma.remote_data.upsert({ + where: { + ressource_owner_id: unique_ticketing_comment_id, + }, + create: { + id_remote_data: uuidv4(), + ressource_owner_id: unique_ticketing_comment_id, + format: 'json', + data: JSON.stringify(remote_data[i]), + created_at: new Date(), + }, + update: { + data: JSON.stringify(remote_data[i]), + created_at: new Date(), + }, + }); + } + return comments_results; + } catch (error) { + handleServiceError(error, this.logger); + } } } diff --git a/packages/api/src/ticketing/comment/types/index.ts b/packages/api/src/ticketing/comment/types/index.ts index 546489de2..69a95465e 100644 --- a/packages/api/src/ticketing/comment/types/index.ts +++ b/packages/api/src/ticketing/comment/types/index.ts @@ -8,10 +8,12 @@ export interface ICommentService { addComment( commentData: DesunifyReturnType, linkedUserId: string, + remoteIdTicket: string, ): Promise>; syncComments( linkedUserId: string, + idTicket: string, custom_properties?: string[], ): Promise>; } @@ -30,7 +32,7 @@ export interface ICommentMapper { slug: string; remote_id: string; }[], - ): UnifiedCommentOutput | UnifiedCommentOutput[]; + ): Promise; } export type Comment = { diff --git a/packages/api/src/ticketing/comment/types/mappingsTypes.ts b/packages/api/src/ticketing/comment/types/mappingsTypes.ts index 5e12ec50a..a01408950 100644 --- a/packages/api/src/ticketing/comment/types/mappingsTypes.ts +++ b/packages/api/src/ticketing/comment/types/mappingsTypes.ts @@ -1,10 +1,22 @@ +import { FrontCommentMapper } from '../services/front/mappers'; +import { GithubCommentMapper } from '../services/github/mappers'; import { ZendeskCommentMapper } from '../services/zendesk/mappers'; const zendeskCommentMapper = new ZendeskCommentMapper(); +const githubCommentMapper = new GithubCommentMapper(); +const frontCommentMapper = new FrontCommentMapper(); export const commentUnificationMapping = { - zendesk: { - unify: zendeskCommentMapper.unify, + zendesk_tcg: { + unify: zendeskCommentMapper.unify.bind(zendeskCommentMapper), desunify: zendeskCommentMapper.desunify, }, + front: { + unify: frontCommentMapper.unify.bind(frontCommentMapper), + desunify: frontCommentMapper.desunify, + }, + github: { + unify: githubCommentMapper.unify.bind(githubCommentMapper), + desunify: githubCommentMapper.desunify, + }, }; diff --git a/packages/api/src/ticketing/comment/types/model.unified.ts b/packages/api/src/ticketing/comment/types/model.unified.ts index f2dc53fcc..088e70ca1 100644 --- a/packages/api/src/ticketing/comment/types/model.unified.ts +++ b/packages/api/src/ticketing/comment/types/model.unified.ts @@ -1,6 +1,35 @@ import { ApiPropertyOptional } from '@nestjs/swagger'; -import { Comment } from '@ticketing/comment/types'; +import { UnifiedAttachmentOutput } from '@ticketing/attachment/types/model.unified'; -export class UnifiedCommentInput {} +export class UnifiedCommentInput { + body: string; + html_body?: string; + is_private?: boolean; + creator_type: 'user' | 'contact' | null | string; + ticket_id?: string; // uuid of Ticket object + contact_id?: string; // uuid of Contact object + user_id?: string; // uuid of User object + attachments?: string[]; //uuids of Attachments objects +} -export class UnifiedCommentOutput extends UnifiedCommentInput {} +export class UnifiedCommentOutput { + @ApiPropertyOptional({ description: 'The id of the comment', type: String }) + id?: string; + @ApiPropertyOptional({ + description: + 'The id of the comment in the context of the Ticketing software', + type: String, + }) + remote_id?: string; + remote_data?: Record; + body: string; + html_body?: string; + is_private?: boolean; + created_at?: Date; + modified_at?: Date; + creator_type: 'user' | 'contact' | null | string; + ticket_id?: string; // uuid of Ticket object + contact_id?: string; // uuid of Contact object + user_id?: string; // uuid of User object + attachments?: UnifiedAttachmentOutput[]; // Attachments objects +} diff --git a/packages/api/src/ticketing/comment/utils/index.ts b/packages/api/src/ticketing/comment/utils/index.ts index e69de29bb..90172977e 100644 --- a/packages/api/src/ticketing/comment/utils/index.ts +++ b/packages/api/src/ticketing/comment/utils/index.ts @@ -0,0 +1,48 @@ +import { PrismaClient } from '@prisma/client'; + +export class Utils { + private readonly prisma: PrismaClient; + constructor() { + this.prisma = new PrismaClient(); + } + + async fetchFileStreamFromURL(file_url: string) { + //TODO; + return; + } + + async getUserUuidFromRemoteId(remote_id: string, remote_platform: string) { + try { + const res = await this.prisma.tcg_users.findFirst({ + where: { + remote_id: remote_id, + remote_platform: remote_platform, + }, + }); + if (!res) + throw new Error( + `tcg_user not found for remote_id ${remote_id} and integration ${remote_platform}`, + ); + return res.id_tcg_user; + } catch (error) { + throw new Error(error); + } + } + async getContactUuidFromRemoteId(remote_id: string, remote_platform: string) { + try { + const res = await this.prisma.tcg_contacts.findFirst({ + where: { + remote_id: remote_id, + remote_platform: remote_platform, + }, + }); + if (!res) + throw new Error( + `tcg_contact not found for remote_id ${remote_id} and integration ${remote_platform}`, + ); + return res.id_tcg_contact; + } catch (error) { + throw new Error(error); + } + } +} diff --git a/packages/api/src/ticketing/contact/contact.controller.ts b/packages/api/src/ticketing/contact/contact.controller.ts index a1a8c3389..9db580672 100644 --- a/packages/api/src/ticketing/contact/contact.controller.ts +++ b/packages/api/src/ticketing/contact/contact.controller.ts @@ -1,24 +1,13 @@ -import { - Controller, - Post, - Body, - Query, - Get, - Patch, - Param, - Headers, -} from '@nestjs/common'; +import { Controller, Query, Get, Param, Headers } from '@nestjs/common'; import { LoggerService } from '@@core/logger/logger.service'; import { - ApiBody, ApiOperation, ApiParam, ApiQuery, - ApiTags, ApiHeader, + ApiTags, } from '@nestjs/swagger'; import { ContactService } from './services/contact.service'; -import { UnifiedContactInput } from './types/model.unified'; @ApiTags('ticketing/contact') @Controller('ticketing/contact') @@ -29,4 +18,58 @@ export class ContactController { ) { this.logger.setContext(ContactController.name); } + + @ApiOperation({ + operationId: 'getContacts', + summary: 'List a batch of Contacts', + }) + @ApiHeader({ name: 'integrationId', required: true }) + @ApiHeader({ name: 'linkedUserId', required: true }) + @ApiQuery({ + name: 'remoteData', + required: false, + type: Boolean, + description: + 'Set to true to include data from the original Ticketing software.', + }) + //@ApiCustomResponse(ContactResponse) + @Get() + getContacts( + @Headers('integrationId') integrationId: string, + @Headers('linkedUserId') linkedUserId: string, + @Query('remoteData') remote_data?: boolean, + ) { + return this.contactService.getContacts( + integrationId, + linkedUserId, + remote_data, + ); + } + + @ApiOperation({ + operationId: 'getContact', + summary: 'Retrieve a Contact', + description: 'Retrieve a contact from any connected Ticketing software', + }) + @ApiParam({ + name: 'id', + required: true, + type: String, + description: 'id of the contact you want to retrieve.', + }) + @ApiQuery({ + name: 'remoteData', + required: false, + type: Boolean, + description: + 'Set to true to include data from the original Ticketing software.', + }) + //@ApiCustomResponse(ContactResponse) + @Get(':id') + getContact( + @Param('id') id: string, + @Query('remoteData') remote_data?: boolean, + ) { + return this.contactService.getContact(id, remote_data); + } } diff --git a/packages/api/src/ticketing/contact/contact.module.ts b/packages/api/src/ticketing/contact/contact.module.ts index cba0c9133..a6c3a9c79 100644 --- a/packages/api/src/ticketing/contact/contact.module.ts +++ b/packages/api/src/ticketing/contact/contact.module.ts @@ -10,6 +10,8 @@ import { FieldMappingService } from '@@core/field-mapping/field-mapping.service' import { ServiceRegistry } from './services/registry.service'; import { ContactService } from './services/contact.service'; import { ContactController } from './contact.controller'; +import { FrontService } from './services/front'; +import { GithubService } from './services/github'; @Module({ imports: [ @@ -29,6 +31,8 @@ import { ContactController } from './contact.controller'; ServiceRegistry, /* PROVIDERS SERVICES */ ZendeskService, + FrontService, + GithubService, ], exports: [SyncService], }) diff --git a/packages/api/src/ticketing/contact/services/contact.service.ts b/packages/api/src/ticketing/contact/services/contact.service.ts index a03b682b5..2077d6b94 100644 --- a/packages/api/src/ticketing/contact/services/contact.service.ts +++ b/packages/api/src/ticketing/contact/services/contact.service.ts @@ -4,27 +4,168 @@ import { LoggerService } from '@@core/logger/logger.service'; import { v4 as uuidv4 } from 'uuid'; import { ApiResponse } from '@@core/utils/types'; import { handleServiceError } from '@@core/utils/errors'; -import { WebhookService } from '@@core/webhook/webhook.service'; -import { - UnifiedContactInput, - UnifiedContactOutput, -} from '../types/model.unified'; +import { UnifiedContactOutput } from '../types/model.unified'; import { ContactResponse } from '../types'; -import { desunify } from '@@core/utils/unification/desunify'; -import { TicketingObject } from '@ticketing/@utils/@types'; -import { FieldMappingService } from '@@core/field-mapping/field-mapping.service'; -import { unify } from '@@core/utils/unification/unify'; -import { ServiceRegistry } from './registry.service'; @Injectable() export class ContactService { - constructor( - private prisma: PrismaService, - private logger: LoggerService, - private webhook: WebhookService, - private fieldMappingService: FieldMappingService, - private serviceRegistry: ServiceRegistry, - ) { + constructor(private prisma: PrismaService, private logger: LoggerService) { this.logger.setContext(ContactService.name); } + + async getContact( + id_ticketing_contact: string, + remote_data?: boolean, + ): Promise { + try { + const contact = await this.prisma.tcg_contacts.findUnique({ + where: { + id_tcg_contact: id_ticketing_contact, + }, + }); + + // Fetch field mappings for the ticket + const values = await this.prisma.value.findMany({ + where: { + entity: { + ressource_owner_id: contact.id_tcg_contact, + }, + }, + include: { + attribute: true, + }, + }); + + // Create a map to store unique field mappings + const fieldMappingsMap = new Map(); + + values.forEach((value) => { + fieldMappingsMap.set(value.attribute.slug, value.data); + }); + + // Convert the map to an array of objects + const field_mappings = Array.from(fieldMappingsMap, ([key, value]) => ({ + [key]: value, + })); + + // Transform to UnifiedContactOutput format + const unifiedContact: UnifiedContactOutput = { + id: contact.id_tcg_contact, + email_address: contact.email_address, + name: contact.name, + details: contact.details, + phone_number: contact.phone_number, + field_mappings: field_mappings, + }; + + let res: UnifiedContactOutput = unifiedContact; + + if (remote_data) { + const resp = await this.prisma.remote_data.findFirst({ + where: { + ressource_owner_id: contact.id_tcg_contact, + }, + }); + const remote_data = JSON.parse(resp.data); + + res = { + ...res, + remote_data: remote_data, + }; + } + + return res; + } catch (error) { + handleServiceError(error, this.logger); + } + } + + async getContacts( + integrationId: string, + linkedUserId: string, + remote_data?: boolean, + ): Promise { + try { + //TODO: handle case where data is not there (not synced) or old synced + const contacts = await this.prisma.tcg_contacts.findMany({ + where: { + remote_platform: integrationId.toLowerCase(), + id_linked_user: linkedUserId, + }, + }); + + const unifiedContacts: UnifiedContactOutput[] = await Promise.all( + contacts.map(async (contact) => { + // Fetch field mappings for the contact + const values = await this.prisma.value.findMany({ + where: { + entity: { + ressource_owner_id: contact.id_tcg_contact, + }, + }, + include: { + attribute: true, + }, + }); + // Create a map to store unique field mappings + const fieldMappingsMap = new Map(); + + values.forEach((value) => { + fieldMappingsMap.set(value.attribute.slug, value.data); + }); + + // Convert the map to an array of objects + const field_mappings = Array.from( + fieldMappingsMap, + ([key, value]) => ({ [key]: value }), + ); + + // Transform to UnifiedContactOutput format + return { + id: contact.id_tcg_contact, + email_address: contact.email_address, + name: contact.name, + details: contact.details, + phone_number: contact.phone_number, + field_mappings: field_mappings, + }; + }), + ); + + let res: UnifiedContactOutput[] = unifiedContacts; + + if (remote_data) { + const remote_array_data: UnifiedContactOutput[] = await Promise.all( + res.map(async (contact) => { + const resp = await this.prisma.remote_data.findFirst({ + where: { + ressource_owner_id: contact.id, + }, + }); + const remote_data = JSON.parse(resp.data); + return { ...contact, remote_data }; + }), + ); + + res = remote_array_data; + } + const event = await this.prisma.events.create({ + data: { + id_event: uuidv4(), + status: 'success', + type: 'ticketing.contact.pull', + method: 'GET', + url: '/ticketing/contact', + provider: integrationId, + direction: '0', + timestamp: new Date(), + id_linked_user: linkedUserId, + }, + }); + + return res; + } catch (error) { + handleServiceError(error, this.logger); + } + } } diff --git a/packages/api/src/ticketing/contact/services/front/index.ts b/packages/api/src/ticketing/contact/services/front/index.ts new file mode 100644 index 000000000..3fb172356 --- /dev/null +++ b/packages/api/src/ticketing/contact/services/front/index.ts @@ -0,0 +1,63 @@ +import { Injectable } from '@nestjs/common'; +import { LoggerService } from '@@core/logger/logger.service'; +import { PrismaService } from '@@core/prisma/prisma.service'; +import { EncryptionService } from '@@core/encryption/encryption.service'; +import { TicketingObject } from '@ticketing/@utils/@types'; +import { ApiResponse } from '@@core/utils/types'; +import axios from 'axios'; +import { ActionType, handleServiceError } from '@@core/utils/errors'; +import { ServiceRegistry } from '../registry.service'; +import { IContactService } from '@ticketing/contact/types'; +import { FrontContactOutput } from './types'; + +@Injectable() +export class FrontService implements IContactService { + constructor( + private prisma: PrismaService, + private logger: LoggerService, + private cryptoService: EncryptionService, + private registry: ServiceRegistry, + ) { + this.logger.setContext( + TicketingObject.contact.toUpperCase() + ':' + FrontService.name, + ); + this.registry.registerService('front', this); + } + + async syncContacts( + linkedUserId: string, + ): Promise> { + try { + const connection = await this.prisma.connections.findFirst({ + where: { + id_linked_user: linkedUserId, + provider_slug: 'front', + }, + }); + + const resp = await axios.get('https://api2.frontapp.com/teammates', { + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${this.cryptoService.decrypt( + connection.access_token, + )}`, + }, + }); + this.logger.log(`Synced front contacts !`); + + return { + data: resp.data._results, + message: 'Front contacts retrieved', + statusCode: 200, + }; + } catch (error) { + handleServiceError( + error, + this.logger, + 'Front', + TicketingObject.contact, + ActionType.GET, + ); + } + } +} diff --git a/packages/api/src/ticketing/contact/services/front/mappers.ts b/packages/api/src/ticketing/contact/services/front/mappers.ts new file mode 100644 index 000000000..b3f604ae6 --- /dev/null +++ b/packages/api/src/ticketing/contact/services/front/mappers.ts @@ -0,0 +1,60 @@ +import { IContactMapper } from '@ticketing/contact/types'; +import { FrontContactInput, FrontContactOutput } from './types'; +import { + UnifiedContactInput, + UnifiedContactOutput, +} from '@ticketing/contact/types/model.unified'; + +export class FrontContactMapper implements IContactMapper { + desunify( + source: UnifiedContactInput, + customFieldMappings?: { + slug: string; + remote_id: string; + }[], + ): FrontContactInput { + return; + } + + unify( + source: FrontContactOutput | FrontContactOutput[], + customFieldMappings?: { + slug: string; + remote_id: string; + }[], + ): UnifiedContactOutput | UnifiedContactOutput[] { + // If the source is not an array, convert it to an array for mapping + const sourcesArray = Array.isArray(source) ? source : [source]; + + return sourcesArray.map((contact) => + this.mapSingleContactToUnified(contact, customFieldMappings), + ); + } + + private mapSingleContactToUnified( + contact: FrontContactOutput, + customFieldMappings?: { + slug: string; + remote_id: string; + }[], + ): UnifiedContactOutput { + const field_mappings = customFieldMappings?.map((mapping) => ({ + [mapping.slug]: contact.custom_fields?.[mapping.remote_id], + })); + const emailHandle = contact.handles.find( + (handle) => handle.source === 'email', + ); + const phoneHandle = contact.handles.find( + (handle) => handle.source === 'phone', + ); + + const unifiedContact: UnifiedContactOutput = { + name: contact.name, + email_address: emailHandle.handle || '', + phone_number: phoneHandle.handle || '', + field_mappings: field_mappings, + }; + + return unifiedContact; + } +} diff --git a/packages/api/src/ticketing/contact/services/front/types.ts b/packages/api/src/ticketing/contact/services/front/types.ts new file mode 100644 index 000000000..d8c60213a --- /dev/null +++ b/packages/api/src/ticketing/contact/services/front/types.ts @@ -0,0 +1,40 @@ +export type FrontContactInput = { + id: string; +}; + +export type FrontContactOutput = { + _links: ContactLink; + id: string; + name: string; + description: string; + avatar_url: string; + is_spammer: boolean; + links: string[][]; + groups: Group[]; + handles: Handle[]; + custom_fields: { + [key: string]: string | boolean; + }; + is_private: boolean; +}; + +type ContactLink = { + self: string; + related: { + notes?: string; + conversations?: string; + owner?: string | null; + }; +}; + +type Group = { + _links: ContactLink; + id: string; + name: string; + is_private: boolean; +}; + +type Handle = { + handle: string; + source: string; +}; diff --git a/packages/api/src/ticketing/contact/services/github/index.ts b/packages/api/src/ticketing/contact/services/github/index.ts new file mode 100644 index 000000000..54dff8476 --- /dev/null +++ b/packages/api/src/ticketing/contact/services/github/index.ts @@ -0,0 +1,64 @@ +import { Injectable } from '@nestjs/common'; +import { LoggerService } from '@@core/logger/logger.service'; +import { PrismaService } from '@@core/prisma/prisma.service'; +import { EncryptionService } from '@@core/encryption/encryption.service'; +import { TicketingObject } from '@ticketing/@utils/@types'; +import { ApiResponse } from '@@core/utils/types'; +import axios from 'axios'; +import { ActionType, handleServiceError } from '@@core/utils/errors'; +import { ServiceRegistry } from '../registry.service'; +import { IContactService } from '@ticketing/contact/types'; +import { GithubContactOutput } from './types'; + +//TODO +@Injectable() +export class GithubService implements IContactService { + constructor( + private prisma: PrismaService, + private logger: LoggerService, + private cryptoService: EncryptionService, + private registry: ServiceRegistry, + ) { + this.logger.setContext( + TicketingObject.contact.toUpperCase() + ':' + GithubService.name, + ); + this.registry.registerService('github', this); + } + + async syncContacts( + linkedUserId: string, + custom_properties?: string[], + ): Promise> { + try { + const connection = await this.prisma.connections.findFirst({ + where: { + id_linked_user: linkedUserId, + provider_slug: 'github', + }, + }); + const resp = await axios.get(`https://api.github.com/contacts`, { + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${this.cryptoService.decrypt( + connection.access_token, + )}`, + }, + }); + this.logger.log(`Synced github contacts !`); + + return { + data: resp.data, + message: 'Github contacts retrieved', + statusCode: 200, + }; + } catch (error) { + handleServiceError( + error, + this.logger, + 'Github', + TicketingObject.contact, + ActionType.GET, + ); + } + } +} diff --git a/packages/api/src/ticketing/contact/services/github/mappers.ts b/packages/api/src/ticketing/contact/services/github/mappers.ts new file mode 100644 index 000000000..cd5d22f33 --- /dev/null +++ b/packages/api/src/ticketing/contact/services/github/mappers.ts @@ -0,0 +1,28 @@ +import { IContactMapper } from '@ticketing/contact/types'; +import { GithubContactInput, GithubContactOutput } from './types'; +import { + UnifiedContactInput, + UnifiedContactOutput, +} from '@ticketing/contact/types/model.unified'; + +export class GithubContactMapper implements IContactMapper { + desunify( + source: UnifiedContactInput, + customFieldMappings?: { + slug: string; + remote_id: string; + }[], + ): GithubContactInput { + return; + } + + unify( + source: GithubContactOutput | GithubContactOutput[], + customFieldMappings?: { + slug: string; + remote_id: string; + }[], + ): UnifiedContactOutput | UnifiedContactOutput[] { + return; + } +} diff --git a/packages/api/src/ticketing/contact/services/github/types.ts b/packages/api/src/ticketing/contact/services/github/types.ts new file mode 100644 index 000000000..422136439 --- /dev/null +++ b/packages/api/src/ticketing/contact/services/github/types.ts @@ -0,0 +1,6 @@ +export type GithubContactInput = { + name: string; +}; + +//TODO +export type GithubContactOutput = GithubContactInput; diff --git a/packages/api/src/ticketing/contact/services/zendesk/index.ts b/packages/api/src/ticketing/contact/services/zendesk/index.ts index 8d00a3c68..9fd7d4abf 100644 --- a/packages/api/src/ticketing/contact/services/zendesk/index.ts +++ b/packages/api/src/ticketing/contact/services/zendesk/index.ts @@ -1,14 +1,17 @@ -import { EncryptionService } from '@@core/encryption/encryption.service'; -import { EnvironmentService } from '@@core/environment/environment.service'; +import { Injectable } from '@nestjs/common'; import { LoggerService } from '@@core/logger/logger.service'; import { PrismaService } from '@@core/prisma/prisma.service'; +import { EncryptionService } from '@@core/encryption/encryption.service'; +import { + TicketingObject, + ZendeskContactOutput, +} from '@ticketing/@utils/@types'; import { ApiResponse } from '@@core/utils/types'; -import { DesunifyReturnType } from '@@core/utils/types/desunify.input'; -import { OriginalContactOutput } from '@@core/utils/types/original/original.crm'; -import { Injectable } from '@nestjs/common'; -import { TicketingObject } from '@ticketing/@utils/@types'; -import { IContactService } from '@ticketing/contact/types'; +import axios from 'axios'; +import { ActionType, handleServiceError } from '@@core/utils/errors'; +import { EnvironmentService } from '@@core/environment/environment.service'; import { ServiceRegistry } from '../registry.service'; +import { IContactService } from '@ticketing/contact/types'; @Injectable() export class ZendeskService implements IContactService { @@ -22,18 +25,47 @@ export class ZendeskService implements IContactService { this.logger.setContext( TicketingObject.contact.toUpperCase() + ':' + ZendeskService.name, ); - this.registry.registerService('zendesk_t', this); - } - addContact( - contactData: DesunifyReturnType, - linkedUserId: string, - ): Promise> { - throw new Error('Method not implemented.'); + this.registry.registerService('zendesk_tcg', this); } - syncContacts( + + async syncContacts( linkedUserId: string, custom_properties?: string[], - ): Promise> { - throw new Error('Method not implemented.'); + ): Promise> { + try { + const connection = await this.prisma.connections.findFirst({ + where: { + id_linked_user: linkedUserId, + provider_slug: 'zendesk_tcg', + }, + }); + + const resp = await axios.get( + `https://${this.env.getZendeskTicketingSubdomain()}.zendesk.com/api/v2/contacts`, + { + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${this.cryptoService.decrypt( + connection.access_token, + )}`, + }, + }, + ); + this.logger.log(`Synced zendesk contacts !`); + + return { + data: resp.data.contacts, + message: 'Zendesk contacts retrieved', + statusCode: 200, + }; + } catch (error) { + handleServiceError( + error, + this.logger, + 'Zendesk', + TicketingObject.contact, + ActionType.GET, + ); + } } } diff --git a/packages/api/src/ticketing/contact/services/zendesk/mappers.ts b/packages/api/src/ticketing/contact/services/zendesk/mappers.ts index e69de29bb..516b69efe 100644 --- a/packages/api/src/ticketing/contact/services/zendesk/mappers.ts +++ b/packages/api/src/ticketing/contact/services/zendesk/mappers.ts @@ -0,0 +1,43 @@ +import { IContactMapper } from '@ticketing/contact/types'; +import { ZendeskContactInput, ZendeskContactOutput } from './types'; +import { + UnifiedContactInput, + UnifiedContactOutput, +} from '@ticketing/contact/types/model.unified'; + +export class ZendeskContactMapper implements IContactMapper { + desunify( + source: UnifiedContactInput, + customFieldMappings?: { + slug: string; + remote_id: string; + }[], + ): ZendeskContactInput { + return; + } + + unify( + source: ZendeskContactOutput | ZendeskContactOutput[], + customFieldMappings?: { + slug: string; + remote_id: string; + }[], + ): UnifiedContactOutput | UnifiedContactOutput[] { + if (!Array.isArray(source)) { + return this.mapSingleContactToUnified(source, customFieldMappings); + } + return source.map((ticket) => + this.mapSingleContactToUnified(ticket, customFieldMappings), + ); + } + + private mapSingleContactToUnified( + ticket: ZendeskContactOutput, + customFieldMappings?: { + slug: string; + remote_id: string; + }[], + ): UnifiedContactOutput { + return; + } +} diff --git a/packages/api/src/ticketing/contact/services/zendesk/types.ts b/packages/api/src/ticketing/contact/services/zendesk/types.ts index fea981f1c..093356801 100644 --- a/packages/api/src/ticketing/contact/services/zendesk/types.ts +++ b/packages/api/src/ticketing/contact/services/zendesk/types.ts @@ -1,5 +1,7 @@ export type ZendeskContactInput = { - id: string; + _: string; }; -export type ZendeskContactOutput = ZendeskContactInput; +export type ZendeskContactOutput = ZendeskContactInput & { + id: number; // Read-only. Automatically assigned when the ticket is created. +}; diff --git a/packages/api/src/ticketing/contact/sync/sync.service.ts b/packages/api/src/ticketing/contact/sync/sync.service.ts index 6e094ef7f..4985a6aed 100644 --- a/packages/api/src/ticketing/contact/sync/sync.service.ts +++ b/packages/api/src/ticketing/contact/sync/sync.service.ts @@ -12,6 +12,8 @@ import { WebhookService } from '@@core/webhook/webhook.service'; import { UnifiedContactOutput } from '../types/model.unified'; import { IContactService } from '../types'; import { ServiceRegistry } from '../services/registry.service'; +import { tcg_contacts as TicketingContact } from '@prisma/client'; +import { OriginalContactOutput } from '@@core/utils/types/original/original.ticketing'; @Injectable() export class SyncService implements OnModuleInit { @@ -26,6 +28,269 @@ export class SyncService implements OnModuleInit { } async onModuleInit() { - // Initialization logic + try { + //await this.syncContacts(); + } catch (error) { + handleServiceError(error, this.logger); + } + } + + //@Cron('*/20 * * * *') + //function used by sync worker which populate our tcg_contacts table + //its role is to fetch all contacts from providers 3rd parties and save the info inside our db + async syncContacts() { + try { + this.logger.log(`Syncing contacts....`); + const defaultOrg = await this.prisma.organizations.findFirst({ + where: { + name: 'Acme Inc', + }, + }); + + const defaultProject = await this.prisma.projects.findFirst({ + where: { + id_organization: defaultOrg.id_organization, + name: 'Project 1', + }, + }); + const id_project = defaultProject.id_project; + const linkedContacts = await this.prisma.linked_users.findMany({ + where: { + id_project: id_project, + }, + }); + linkedContacts.map(async (linkedContact) => { + try { + const providers = TICKETING_PROVIDERS; + for (const provider of providers) { + try { + await this.syncContactsForLinkedContact( + provider, + linkedContact.id_linked_user, + id_project, + ); + } catch (error) { + handleServiceError(error, this.logger); + } + } + } catch (error) { + handleServiceError(error, this.logger); + } + }); + } catch (error) { + handleServiceError(error, this.logger); + } + } + + //todo: HANDLE DATA REMOVED FROM PROVIDER + async syncContactsForLinkedContact( + integrationId: string, + linkedUserId: string, + id_project: string, + ) { + try { + this.logger.log( + `Syncing ${integrationId} contacts for linkedContact ${linkedUserId}`, + ); + // check if linkedContact has a connection if not just stop sync + const connection = await this.prisma.connections.findFirst({ + where: { + id_linked_user: linkedUserId, + provider_slug: integrationId, + }, + }); + if (!connection) throw new Error('connection not found'); + + // get potential fieldMappings and extract the original properties name + const customFieldMappings = + await this.fieldMappingService.getCustomFieldMappings( + integrationId, + linkedUserId, + 'contact', + ); + const remoteProperties: string[] = customFieldMappings.map( + (mapping) => mapping.remote_id, + ); + + const service: IContactService = + this.serviceRegistry.getService(integrationId); + const resp: ApiResponse = + await service.syncContacts(linkedUserId, remoteProperties); + + const sourceObject: OriginalContactOutput[] = resp.data; + //this.logger.log('SOURCE OBJECT DATA = ' + JSON.stringify(sourceObject)); + //unify the data according to the target obj wanted + const unifiedObject = (await unify({ + sourceObject, + targetType: TicketingObject.contact, + providerName: integrationId, + customFieldMappings, + })) as UnifiedContactOutput[]; + + //TODO + const contactIds = sourceObject.map((contact) => + 'id' in contact ? String(contact.id) : undefined, + ); + + //insert the data in the DB with the fieldMappings (value table) + const contact_data = await this.saveContactsInDb( + linkedUserId, + unifiedObject, + contactIds, + integrationId, + sourceObject, + ); + const event = await this.prisma.events.create({ + data: { + id_event: uuidv4(), + status: 'success', + type: 'ticketing.contact.pulled', + method: 'PULL', + url: '/pull', + provider: integrationId, + direction: '0', + timestamp: new Date(), + id_linked_user: linkedUserId, + }, + }); + await this.webhook.handleWebhook( + contact_data, + 'ticketing.contact.pulled', + id_project, + event.id_event, + ); + } catch (error) { + handleServiceError(error, this.logger); + } + } + + async saveContactsInDb( + linkedUserId: string, + contacts: UnifiedContactOutput[], + originIds: string[], + originSource: string, + remote_data: Record[], + ): Promise { + try { + let contacts_results: TicketingContact[] = []; + for (let i = 0; i < contacts.length; i++) { + const contact = contacts[i]; + const originId = originIds[i]; + + if (!originId || originId == '') { + throw new NotFoundError(`Origin id not there, found ${originId}`); + } + + const existingContact = await this.prisma.tcg_contacts.findFirst({ + where: { + remote_id: originId, + remote_platform: originSource, + id_linked_user: linkedUserId, + }, + }); + + let unique_ticketing_contact_id: string; + + if (existingContact) { + // Update the existing ticket + const res = await this.prisma.tcg_contacts.update({ + where: { + id_tcg_contact: existingContact.id_tcg_contact, + }, + data: { + name: existingContact.name, + email_address: existingContact.email_address, + phone_number: existingContact.phone_number, + details: existingContact.details, + modified_at: new Date(), + }, + }); + unique_ticketing_contact_id = res.id_tcg_contact; + contacts_results = [...contacts_results, res]; + } else { + // Create a new contact + this.logger.log('not existing contact ' + contact.name); + const data = { + id_tcg_contact: uuidv4(), + name: contact.name, + email_address: contact.email_address, + phone_number: contact.phone_number, + details: contact.details, + created_at: new Date(), + modified_at: new Date(), + id_linked_user: linkedUserId, + remote_id: originId, + remote_platform: originSource, + }; + const res = await this.prisma.tcg_contacts.create({ + data: data, + }); + contacts_results = [...contacts_results, res]; + unique_ticketing_contact_id = res.id_tcg_contact; + } + + // check duplicate or existing values + if (contact.field_mappings && contact.field_mappings.length > 0) { + const entity = await this.prisma.entity.create({ + data: { + id_entity: uuidv4(), + ressource_owner_id: unique_ticketing_contact_id, + }, + }); + + for (const mapping of contact.field_mappings) { + const attribute = await this.prisma.attribute.findFirst({ + where: { + slug: Object.keys(mapping)[0], + source: originSource, + id_consumer: linkedUserId, + }, + }); + + if (attribute) { + await this.prisma.value.create({ + data: { + id_value: uuidv4(), + data: Object.values(mapping)[0] + ? Object.values(mapping)[0] + : 'null', + attribute: { + connect: { + id_attribute: attribute.id_attribute, + }, + }, + entity: { + connect: { + id_entity: entity.id_entity, + }, + }, + }, + }); + } + } + } + + //insert remote_data in db + await this.prisma.remote_data.upsert({ + where: { + ressource_owner_id: unique_ticketing_contact_id, + }, + create: { + id_remote_data: uuidv4(), + ressource_owner_id: unique_ticketing_contact_id, + format: 'json', + data: JSON.stringify(remote_data[i]), + created_at: new Date(), + }, + update: { + data: JSON.stringify(remote_data[i]), + created_at: new Date(), + }, + }); + } + return contacts_results; + } catch (error) { + handleServiceError(error, this.logger); + } } } diff --git a/packages/api/src/ticketing/contact/types/index.ts b/packages/api/src/ticketing/contact/types/index.ts index 0b5848f0c..05de6b4ac 100644 --- a/packages/api/src/ticketing/contact/types/index.ts +++ b/packages/api/src/ticketing/contact/types/index.ts @@ -2,14 +2,9 @@ import { DesunifyReturnType } from '@@core/utils/types/desunify.input'; import { UnifiedContactInput, UnifiedContactOutput } from './model.unified'; import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { ApiResponse } from '@@core/utils/types'; -import { OriginalContactOutput } from '@@core/utils/types/original/original.crm'; +import { OriginalContactOutput } from '@@core/utils/types/original/original.ticketing'; export interface IContactService { - addContact( - contactData: DesunifyReturnType, - linkedUserId: string, - ): Promise>; - syncContacts( linkedUserId: string, custom_properties?: string[], diff --git a/packages/api/src/ticketing/contact/types/mappingsTypes.ts b/packages/api/src/ticketing/contact/types/mappingsTypes.ts index d08c20aaa..96f7d9a85 100644 --- a/packages/api/src/ticketing/contact/types/mappingsTypes.ts +++ b/packages/api/src/ticketing/contact/types/mappingsTypes.ts @@ -1 +1,22 @@ -// Content for mappingsTypes.ts +import { FrontContactMapper } from '../services/front/mappers'; +import { GithubContactMapper } from '../services/github/mappers'; +import { ZendeskContactMapper } from '../services/zendesk/mappers'; + +const zendeskContactMapper = new ZendeskContactMapper(); +const frontContactMapper = new FrontContactMapper(); +const githubContactMapper = new GithubContactMapper(); + +export const accountUnificationMapping = { + zendesk_tcg: { + unify: zendeskContactMapper.unify, + desunify: zendeskContactMapper.desunify, + }, + front: { + unify: frontContactMapper.unify, + desunify: frontContactMapper.desunify, + }, + github: { + unify: githubContactMapper.unify, + desunify: githubContactMapper.desunify, + }, +}; diff --git a/packages/api/src/ticketing/contact/types/model.unified.ts b/packages/api/src/ticketing/contact/types/model.unified.ts index ba42f6609..20aa78319 100644 --- a/packages/api/src/ticketing/contact/types/model.unified.ts +++ b/packages/api/src/ticketing/contact/types/model.unified.ts @@ -1,3 +1,13 @@ -export class UnifiedContactInput {} +export class UnifiedContactInput { + name: string; + email_address: string; + phone_number?: string; + details?: string; + field_mappings?: Record[]; +} -export class UnifiedContactOutput extends UnifiedContactInput {} +export class UnifiedContactOutput extends UnifiedContactInput { + id?: string; + remote_id?: string; + remote_data?: Record; +} diff --git a/packages/api/src/ticketing/tag/services/front/index.ts b/packages/api/src/ticketing/tag/services/front/index.ts new file mode 100644 index 000000000..32fac9bee --- /dev/null +++ b/packages/api/src/ticketing/tag/services/front/index.ts @@ -0,0 +1,77 @@ +import { Injectable } from '@nestjs/common'; +import { LoggerService } from '@@core/logger/logger.service'; +import { PrismaService } from '@@core/prisma/prisma.service'; +import { EncryptionService } from '@@core/encryption/encryption.service'; +import { TicketingObject } from '@ticketing/@utils/@types'; +import { ApiResponse } from '@@core/utils/types'; +import axios from 'axios'; +import { ActionType, handleServiceError } from '@@core/utils/errors'; +import { ServiceRegistry } from '../registry.service'; +import { ITagService } from '@ticketing/tag/types'; +import { FrontTagOutput } from './types'; + +@Injectable() +export class FrontService implements ITagService { + constructor( + private prisma: PrismaService, + private logger: LoggerService, + private cryptoService: EncryptionService, + private registry: ServiceRegistry, + ) { + this.logger.setContext( + TicketingObject.tag.toUpperCase() + ':' + FrontService.name, + ); + this.registry.registerService('front', this); + } + + async syncTags( + linkedUserId: string, + id_ticket: string, + ): Promise> { + try { + const connection = await this.prisma.connections.findFirst({ + where: { + id_linked_user: linkedUserId, + provider_slug: 'front', + }, + }); + + const ticket = await this.prisma.tcg_tickets.findUnique({ + where: { + id_tcg_ticket: id_ticket, + }, + select: { + remote_id: true, + }, + }); + + const resp = await axios.get('https://api2.frontapp.com/conversations', { + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${this.cryptoService.decrypt( + connection.access_token, + )}`, + }, + }); + this.logger.log(`Synced front tags !`); + + const conversation = resp.data._results.find( + (c) => c.id === ticket.remote_id, + ); + + return { + data: conversation.tags, + message: 'Front tags retrieved', + statusCode: 200, + }; + } catch (error) { + handleServiceError( + error, + this.logger, + 'Front', + TicketingObject.tag, + ActionType.GET, + ); + } + } +} diff --git a/packages/api/src/ticketing/tag/services/front/mappers.ts b/packages/api/src/ticketing/tag/services/front/mappers.ts new file mode 100644 index 000000000..1ab02aef1 --- /dev/null +++ b/packages/api/src/ticketing/tag/services/front/mappers.ts @@ -0,0 +1,47 @@ +import { ITagMapper } from '@ticketing/tag/types'; +import { FrontTagInput, FrontTagOutput } from './types'; +import { + UnifiedTagInput, + UnifiedTagOutput, +} from '@ticketing/tag/types/model.unified'; + +export class FrontTagMapper implements ITagMapper { + desunify( + source: UnifiedTagInput, + customFieldMappings?: { + slug: string; + remote_id: string; + }[], + ): FrontTagInput { + return; + } + + unify( + source: FrontTagOutput | FrontTagOutput[], + customFieldMappings?: { + slug: string; + remote_id: string; + }[], + ): UnifiedTagOutput | UnifiedTagOutput[] { + // If the source is not an array, convert it to an array for mapping + const sourcesArray = Array.isArray(source) ? source : [source]; + + return sourcesArray.map((tag) => + this.mapSingleTagToUnified(tag, customFieldMappings), + ); + } + + private mapSingleTagToUnified( + tag: FrontTagOutput, + customFieldMappings?: { + slug: string; + remote_id: string; + }[], + ): UnifiedTagOutput { + const unifiedTag: UnifiedTagOutput = { + name: tag.name, + }; + + return unifiedTag; + } +} diff --git a/packages/api/src/ticketing/tag/services/front/types.ts b/packages/api/src/ticketing/tag/services/front/types.ts new file mode 100644 index 000000000..39c04ce83 --- /dev/null +++ b/packages/api/src/ticketing/tag/services/front/types.ts @@ -0,0 +1,25 @@ +export type FrontTagInput = { + id: string; +}; + +export type FrontTagOutput = { + _links: TagLink; + id: string; + name: string; + description: string; + highlight: string | null; + is_private: boolean; + is_visible_in_conversation_lists: boolean; + created_at: number; + updated_at: number; +}; + +interface TagLink { + self: string; + related: { + conversations: string; + owner: string; + parent_tag: string; + children: string; + }; +} diff --git a/packages/api/src/ticketing/tag/services/github/index.ts b/packages/api/src/ticketing/tag/services/github/index.ts new file mode 100644 index 000000000..d7dee0208 --- /dev/null +++ b/packages/api/src/ticketing/tag/services/github/index.ts @@ -0,0 +1,64 @@ +import { Injectable } from '@nestjs/common'; +import { LoggerService } from '@@core/logger/logger.service'; +import { PrismaService } from '@@core/prisma/prisma.service'; +import { EncryptionService } from '@@core/encryption/encryption.service'; +import { TicketingObject } from '@ticketing/@utils/@types'; +import { ApiResponse } from '@@core/utils/types'; +import axios from 'axios'; +import { ActionType, handleServiceError } from '@@core/utils/errors'; +import { ServiceRegistry } from '../registry.service'; +import { ITagService } from '@ticketing/tag/types'; +import { GithubTagOutput } from './types'; + +//TODO +@Injectable() +export class GithubService implements ITagService { + constructor( + private prisma: PrismaService, + private logger: LoggerService, + private cryptoService: EncryptionService, + private registry: ServiceRegistry, + ) { + this.logger.setContext( + TicketingObject.tag.toUpperCase() + ':' + GithubService.name, + ); + this.registry.registerService('github', this); + } + + async syncTags( + linkedUserId: string, + id_ticket: string, + ): Promise> { + try { + const connection = await this.prisma.connections.findFirst({ + where: { + id_linked_user: linkedUserId, + provider_slug: 'github', + }, + }); + const resp = await axios.get(`https://api.github.com/tags`, { + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${this.cryptoService.decrypt( + connection.access_token, + )}`, + }, + }); + this.logger.log(`Synced github tags !`); + + return { + data: resp.data, + message: 'Github tags retrieved', + statusCode: 200, + }; + } catch (error) { + handleServiceError( + error, + this.logger, + 'Github', + TicketingObject.tag, + ActionType.GET, + ); + } + } +} diff --git a/packages/api/src/ticketing/tag/services/github/mappers.ts b/packages/api/src/ticketing/tag/services/github/mappers.ts new file mode 100644 index 000000000..bea26ee1a --- /dev/null +++ b/packages/api/src/ticketing/tag/services/github/mappers.ts @@ -0,0 +1,28 @@ +import { ITagMapper } from '@ticketing/tag/types'; +import { GithubTagInput, GithubTagOutput } from './types'; +import { + UnifiedTagInput, + UnifiedTagOutput, +} from '@ticketing/tag/types/model.unified'; + +export class GithubTagMapper implements ITagMapper { + desunify( + source: UnifiedTagInput, + customFieldMappings?: { + slug: string; + remote_id: string; + }[], + ): GithubTagInput { + return; + } + + unify( + source: GithubTagOutput | GithubTagOutput[], + customFieldMappings?: { + slug: string; + remote_id: string; + }[], + ): UnifiedTagOutput | UnifiedTagOutput[] { + return; + } +} diff --git a/packages/api/src/ticketing/tag/services/github/types.ts b/packages/api/src/ticketing/tag/services/github/types.ts new file mode 100644 index 000000000..9598acae2 --- /dev/null +++ b/packages/api/src/ticketing/tag/services/github/types.ts @@ -0,0 +1,6 @@ +export type GithubTagInput = { + name: string; +}; + +//TODO +export type GithubTagOutput = GithubTagInput; diff --git a/packages/api/src/ticketing/tag/services/registry.service.ts b/packages/api/src/ticketing/tag/services/registry.service.ts new file mode 100644 index 000000000..c68895086 --- /dev/null +++ b/packages/api/src/ticketing/tag/services/registry.service.ts @@ -0,0 +1,23 @@ +import { Injectable } from '@nestjs/common'; +import { ITagService } from '../types'; + +@Injectable() +export class ServiceRegistry { + private serviceMap: Map; + + constructor() { + this.serviceMap = new Map(); + } + + registerService(serviceKey: string, service: ITagService) { + this.serviceMap.set(serviceKey, service); + } + + getService(integrationId: string): ITagService { + const service = this.serviceMap.get(integrationId); + if (!service) { + throw new Error(); + } + return service; + } +} diff --git a/packages/api/src/ticketing/tag/services/tag.service.ts b/packages/api/src/ticketing/tag/services/tag.service.ts new file mode 100644 index 000000000..e39262ee8 --- /dev/null +++ b/packages/api/src/ticketing/tag/services/tag.service.ts @@ -0,0 +1,165 @@ +import { Injectable } from '@nestjs/common'; +import { PrismaService } from '@@core/prisma/prisma.service'; +import { LoggerService } from '@@core/logger/logger.service'; +import { v4 as uuidv4 } from 'uuid'; +import { ApiResponse } from '@@core/utils/types'; +import { handleServiceError } from '@@core/utils/errors'; +import { UnifiedTagOutput } from '../types/model.unified'; +import { TagResponse } from '../types'; + +@Injectable() +export class TagService { + constructor(private prisma: PrismaService, private logger: LoggerService) { + this.logger.setContext(TagService.name); + } + + async getTag( + id_ticketing_tag: string, + remote_data?: boolean, + ): Promise { + try { + const tag = await this.prisma.tcg_tags.findUnique({ + where: { + id_tcg_tag: id_ticketing_tag, + }, + }); + + // Fetch field mappings for the ticket + const values = await this.prisma.value.findMany({ + where: { + entity: { + ressource_owner_id: tag.id_tcg_tag, + }, + }, + include: { + attribute: true, + }, + }); + + // Create a map to store unique field mappings + const fieldMappingsMap = new Map(); + + values.forEach((value) => { + fieldMappingsMap.set(value.attribute.slug, value.data); + }); + + // Convert the map to an array of objects + const field_mappings = Array.from(fieldMappingsMap, ([key, value]) => ({ + [key]: value, + })); + + // Transform to UnifiedTagOutput format + const unifiedTag: UnifiedTagOutput = { + id: tag.id_tcg_tag, + name: tag.name, + field_mappings: field_mappings, + }; + + let res: UnifiedTagOutput = unifiedTag; + + if (remote_data) { + const resp = await this.prisma.remote_data.findFirst({ + where: { + ressource_owner_id: tag.id_tcg_tag, + }, + }); + const remote_data = JSON.parse(resp.data); + + res = { + ...res, + remote_data: remote_data, + }; + } + + return res; + } catch (error) { + handleServiceError(error, this.logger); + } + } + + async getTags( + integrationId: string, + linkedUserId: string, + remote_data?: boolean, + ): Promise { + try { + //TODO: handle case where data is not there (not synced) or old synced + const tags = await this.prisma.tcg_tags.findMany({ + where: { + remote_platform: integrationId.toLowerCase(), + id_linked_user: linkedUserId, + }, + }); + + const unifiedTags: UnifiedTagOutput[] = await Promise.all( + tags.map(async (tag) => { + // Fetch field mappings for the tag + const values = await this.prisma.value.findMany({ + where: { + entity: { + ressource_owner_id: tag.id_tcg_tag, + }, + }, + include: { + attribute: true, + }, + }); + // Create a map to store unique field mappings + const fieldMappingsMap = new Map(); + + values.forEach((value) => { + fieldMappingsMap.set(value.attribute.slug, value.data); + }); + + // Convert the map to an array of objects + const field_mappings = Array.from( + fieldMappingsMap, + ([key, value]) => ({ [key]: value }), + ); + + // Transform to UnifiedTagOutput format + return { + id: tag.id_tcg_tag, + name: tag.name, + field_mappings: field_mappings, + }; + }), + ); + + let res: UnifiedTagOutput[] = unifiedTags; + + if (remote_data) { + const remote_array_data: UnifiedTagOutput[] = await Promise.all( + res.map(async (tag) => { + const resp = await this.prisma.remote_data.findFirst({ + where: { + ressource_owner_id: tag.id, + }, + }); + const remote_data = JSON.parse(resp.data); + return { ...tag, remote_data }; + }), + ); + + res = remote_array_data; + } + const event = await this.prisma.events.create({ + data: { + id_event: uuidv4(), + status: 'success', + type: 'ticketing.tag.pull', + method: 'GET', + url: '/ticketing/tag', + provider: integrationId, + direction: '0', + timestamp: new Date(), + id_linked_user: linkedUserId, + }, + }); + + return res; + } catch (error) { + handleServiceError(error, this.logger); + } + } +} diff --git a/packages/api/src/ticketing/tag/services/zendesk/index.ts b/packages/api/src/ticketing/tag/services/zendesk/index.ts new file mode 100644 index 000000000..e7f9baf25 --- /dev/null +++ b/packages/api/src/ticketing/tag/services/zendesk/index.ts @@ -0,0 +1,79 @@ +import { Injectable } from '@nestjs/common'; +import { LoggerService } from '@@core/logger/logger.service'; +import { PrismaService } from '@@core/prisma/prisma.service'; +import { EncryptionService } from '@@core/encryption/encryption.service'; +import { TicketingObject, ZendeskTagOutput } from '@ticketing/@utils/@types'; +import { ApiResponse } from '@@core/utils/types'; +import axios from 'axios'; +import { ActionType, handleServiceError } from '@@core/utils/errors'; +import { EnvironmentService } from '@@core/environment/environment.service'; +import { ServiceRegistry } from '../registry.service'; +import { ITagService } from '@ticketing/tag/types'; + +@Injectable() +export class ZendeskService implements ITagService { + constructor( + private prisma: PrismaService, + private logger: LoggerService, + private cryptoService: EncryptionService, + private env: EnvironmentService, + private registry: ServiceRegistry, + ) { + this.logger.setContext( + TicketingObject.tag.toUpperCase() + ':' + ZendeskService.name, + ); + this.registry.registerService('zendesk_tcg', this); + } + + async syncTags( + linkedUserId: string, + id_ticket: string, + ): Promise> { + try { + const connection = await this.prisma.connections.findFirst({ + where: { + id_linked_user: linkedUserId, + provider_slug: 'zendesk_tcg', + }, + }); + + const ticket = await this.prisma.tcg_tickets.findUnique({ + where: { + id_tcg_ticket: id_ticket, + }, + select: { + remote_id: true, + }, + }); + + const resp = await axios.get( + `https://${this.env.getZendeskTicketingSubdomain()}.zendesk.com/api/v2/tickets/${ + ticket.remote_id + }/tags`, + { + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${this.cryptoService.decrypt( + connection.access_token, + )}`, + }, + }, + ); + this.logger.log(`Synced zendesk tags !`); + + return { + data: resp.data.tags, + message: 'Zendesk tags retrieved', + statusCode: 200, + }; + } catch (error) { + handleServiceError( + error, + this.logger, + 'Zendesk', + TicketingObject.tag, + ActionType.GET, + ); + } + } +} diff --git a/packages/api/src/ticketing/tag/services/zendesk/mappers.ts b/packages/api/src/ticketing/tag/services/zendesk/mappers.ts new file mode 100644 index 000000000..2e2639479 --- /dev/null +++ b/packages/api/src/ticketing/tag/services/zendesk/mappers.ts @@ -0,0 +1,46 @@ +import { ITagMapper } from '@ticketing/tag/types'; +import { ZendeskTagInput, ZendeskTagOutput } from './types'; +import { + UnifiedTagInput, + UnifiedTagOutput, +} from '@ticketing/tag/types/model.unified'; + +export class ZendeskTagMapper implements ITagMapper { + desunify( + source: UnifiedTagInput, + customFieldMappings?: { + slug: string; + remote_id: string; + }[], + ): ZendeskTagInput { + return; + } + + unify( + source: ZendeskTagOutput, + customFieldMappings?: { + slug: string; + remote_id: string; + }[], + ): UnifiedTagOutput | UnifiedTagOutput[] { + const tags = source.tags; + + return tags.map((tag) => + this.mapSingleTagToUnified(tag, customFieldMappings), + ); + } + + private mapSingleTagToUnified( + tag: string, + customFieldMappings?: { + slug: string; + remote_id: string; + }[], + ): UnifiedTagOutput { + const unifiedTag: UnifiedTagOutput = { + name: tag, + }; + + return unifiedTag; + } +} diff --git a/packages/api/src/ticketing/tag/services/zendesk/types.ts b/packages/api/src/ticketing/tag/services/zendesk/types.ts new file mode 100644 index 000000000..51532d488 --- /dev/null +++ b/packages/api/src/ticketing/tag/services/zendesk/types.ts @@ -0,0 +1,7 @@ +export type ZendeskTagInput = { + _: string; +}; + +export type ZendeskTagOutput = { + tags: string[]; +}; diff --git a/packages/api/src/ticketing/tag/sync/sync.service.ts b/packages/api/src/ticketing/tag/sync/sync.service.ts new file mode 100644 index 000000000..0fc88060d --- /dev/null +++ b/packages/api/src/ticketing/tag/sync/sync.service.ts @@ -0,0 +1,303 @@ +import { Injectable, OnModuleInit } from '@nestjs/common'; +import { LoggerService } from '@@core/logger/logger.service'; +import { PrismaService } from '@@core/prisma/prisma.service'; +import { NotFoundError, handleServiceError } from '@@core/utils/errors'; +import { Cron } from '@nestjs/schedule'; +import { ApiResponse, TICKETING_PROVIDERS } from '@@core/utils/types'; +import { v4 as uuidv4 } from 'uuid'; +import { FieldMappingService } from '@@core/field-mapping/field-mapping.service'; +import { ServiceRegistry } from '../services/registry.service'; +import { unify } from '@@core/utils/unification/unify'; +import { TicketingObject } from '@ticketing/@utils/@types'; +import { WebhookService } from '@@core/webhook/webhook.service'; +import { UnifiedTagOutput } from '../types/model.unified'; +import { ITagService } from '../types'; +import { OriginalTagOutput } from '@@core/utils/types/original/original.ticketing'; +import { tcg_tags as TicketingTag } from '@prisma/client'; + +@Injectable() +export class SyncService implements OnModuleInit { + constructor( + private prisma: PrismaService, + private logger: LoggerService, + private webhook: WebhookService, + private fieldMappingService: FieldMappingService, + private serviceRegistry: ServiceRegistry, + ) { + this.logger.setContext(SyncService.name); + } + + async onModuleInit() { + try { + //await this.syncTags(); + } catch (error) { + handleServiceError(error, this.logger); + } + } + + //@Cron('*/20 * * * *') + //function used by sync worker which populate our tcg_tags table + //its role is to fetch all tags from providers 3rd parties and save the info inside our db + async syncTags() { + try { + this.logger.log(`Syncing tags....`); + const defaultOrg = await this.prisma.organizations.findFirst({ + where: { + name: 'Acme Inc', + }, + }); + + const defaultProject = await this.prisma.projects.findFirst({ + where: { + id_organization: defaultOrg.id_organization, + name: 'Project 1', + }, + }); + const id_project = defaultProject.id_project; + const linkedUsers = await this.prisma.linked_users.findMany({ + where: { + id_project: id_project, + }, + }); + linkedUsers.map(async (linkedUser) => { + try { + const providers = TICKETING_PROVIDERS; + for (const provider of providers) { + try { + //call the sync comments for every ticket of the linkedUser (a comment is tied to a ticket) + const tickets = await this.prisma.tcg_tickets.findMany({ + where: { + remote_platform: provider, + id_linked_user: linkedUser.id_linked_user, + }, + }); + for (const ticket of tickets) { + await this.syncTagsForLinkedUser( + provider, + linkedUser.id_linked_user, + id_project, + ticket.id_tcg_ticket, + ); + } + } catch (error) { + handleServiceError(error, this.logger); + } + } + } catch (error) { + handleServiceError(error, this.logger); + } + }); + } catch (error) { + handleServiceError(error, this.logger); + } + } + + //todo: HANDLE DATA REMOVED FROM PROVIDER + async syncTagsForLinkedUser( + integrationId: string, + linkedUserId: string, + id_project: string, + id_ticket: string, + ) { + try { + this.logger.log( + `Syncing ${integrationId} tags for linkedTag ${linkedUserId}`, + ); + // check if linkedTag has a connection if not just stop sync + const connection = await this.prisma.connections.findFirst({ + where: { + id_linked_user: linkedUserId, + provider_slug: integrationId, + }, + }); + if (!connection) throw new Error('connection not found'); + + // get potential fieldMappings and extract the original properties name + const customFieldMappings = + await this.fieldMappingService.getCustomFieldMappings( + integrationId, + linkedUserId, + 'tag', + ); + + const service: ITagService = + this.serviceRegistry.getService(integrationId); + const resp: ApiResponse = await service.syncTags( + linkedUserId, + id_ticket, + ); + + const sourceObject: OriginalTagOutput[] = resp.data; + //this.logger.log('SOURCE OBJECT DATA = ' + JSON.stringify(sourceObject)); + //unify the data according to the target obj wanted + const unifiedObject = (await unify({ + sourceObject, + targetType: TicketingObject.tag, + providerName: integrationId, + customFieldMappings, + })) as UnifiedTagOutput[]; + + //TODO + const tagIds = sourceObject.map((tag) => + 'id' in tag ? String(tag.id) : undefined, + ); + + //insert the data in the DB with the fieldMappings (value table) + const tag_data = await this.saveTagsInDb( + linkedUserId, + unifiedObject, + tagIds, + integrationId, + id_ticket, + sourceObject, + ); + const event = await this.prisma.events.create({ + data: { + id_event: uuidv4(), + status: 'success', + type: 'ticketing.tag.pulled', + method: 'PULL', + url: '/pull', + provider: integrationId, + direction: '0', + timestamp: new Date(), + id_linked_user: linkedUserId, + }, + }); + await this.webhook.handleWebhook( + tag_data, + 'ticketing.tag.pulled', + id_project, + event.id_event, + ); + } catch (error) { + handleServiceError(error, this.logger); + } + } + + async saveTagsInDb( + linkedUserId: string, + tags: UnifiedTagOutput[], + originIds: string[], + originSource: string, + id_ticket: string, + remote_data: Record[], + ): Promise { + try { + let tags_results: TicketingTag[] = []; + for (let i = 0; i < tags.length; i++) { + const tag = tags[i]; + let originId = originIds[i]; + + if (!originId || originId == '') { + originId = 'zendesk_id_tag'; //zendesk does not return a uuid so we put that as default value + } + + const existingTag = await this.prisma.tcg_tags.findFirst({ + where: { + remote_id: originId, + remote_platform: originSource, + id_linked_user: linkedUserId, + }, + }); + + let unique_ticketing_tag_id: string; + + if (existingTag) { + // Update the existing ticket + const res = await this.prisma.tcg_tags.update({ + where: { + id_tcg_tag: existingTag.id_tcg_tag, + }, + data: { + name: existingTag.name, + modified_at: new Date(), + }, + }); + unique_ticketing_tag_id = res.id_tcg_tag; + tags_results = [...tags_results, res]; + } else { + // Create a new tag + this.logger.log('not existing tag ' + tag.name); + const data = { + id_tcg_tag: uuidv4(), + name: tag.name, + created_at: new Date(), + modified_at: new Date(), + id_tcg_ticket: id_ticket, + id_linked_users: linkedUserId, + remote_id: originId, + remote_platform: originSource, + }; + const res = await this.prisma.tcg_tags.create({ + data: data, + }); + tags_results = [...tags_results, res]; + unique_ticketing_tag_id = res.id_tcg_tag; + } + + // check duplicate or existing values + if (tag.field_mappings && tag.field_mappings.length > 0) { + const entity = await this.prisma.entity.create({ + data: { + id_entity: uuidv4(), + ressource_owner_id: unique_ticketing_tag_id, + }, + }); + + for (const mapping of tag.field_mappings) { + const attribute = await this.prisma.attribute.findFirst({ + where: { + slug: Object.keys(mapping)[0], + source: originSource, + id_consumer: linkedUserId, + }, + }); + + if (attribute) { + await this.prisma.value.create({ + data: { + id_value: uuidv4(), + data: Object.values(mapping)[0] + ? Object.values(mapping)[0] + : 'null', + attribute: { + connect: { + id_attribute: attribute.id_attribute, + }, + }, + entity: { + connect: { + id_entity: entity.id_entity, + }, + }, + }, + }); + } + } + } + + //insert remote_data in db + await this.prisma.remote_data.upsert({ + where: { + ressource_owner_id: unique_ticketing_tag_id, + }, + create: { + id_remote_data: uuidv4(), + ressource_owner_id: unique_ticketing_tag_id, + format: 'json', + data: JSON.stringify(remote_data[i]), + created_at: new Date(), + }, + update: { + data: JSON.stringify(remote_data[i]), + created_at: new Date(), + }, + }); + } + return tags_results; + } catch (error) { + handleServiceError(error, this.logger); + } + } +} diff --git a/packages/api/src/ticketing/tag/tag.controller.ts b/packages/api/src/ticketing/tag/tag.controller.ts new file mode 100644 index 000000000..2514b2b03 --- /dev/null +++ b/packages/api/src/ticketing/tag/tag.controller.ts @@ -0,0 +1,81 @@ +import { + Controller, + Post, + Body, + Query, + Get, + Patch, + Param, + Headers, +} from '@nestjs/common'; +import { LoggerService } from '@@core/logger/logger.service'; +import { + ApiBody, + ApiOperation, + ApiParam, + ApiQuery, + ApiTags, + ApiHeader, +} from '@nestjs/swagger'; +import { ApiCustomResponse } from '@@core/utils/types'; +import { TagService } from './services/tag.service'; +import { TagResponse } from './types'; +import { UnifiedTagInput } from './types/model.unified'; + +@ApiTags('ticketing/tag') +@Controller('ticketing/tag') +export class TagController { + constructor( + private readonly tagService: TagService, + private logger: LoggerService, + ) { + this.logger.setContext(TagController.name); + } + + @ApiOperation({ + operationId: 'getTags', + summary: 'List a batch of Tags', + }) + @ApiHeader({ name: 'integrationId', required: true }) + @ApiHeader({ name: 'linkedUserId', required: true }) + @ApiQuery({ + name: 'remoteData', + required: false, + type: Boolean, + description: + 'Set to true to include data from the original Ticketing software.', + }) + //@ApiCustomResponse(TagResponse) + @Get() + getTags( + @Headers('integrationId') integrationId: string, + @Headers('linkedUserId') linkedUserId: string, + @Query('remoteData') remote_data?: boolean, + ) { + return this.tagService.getTags(integrationId, linkedUserId, remote_data); + } + + @ApiOperation({ + operationId: 'getTag', + summary: 'Retrieve a Tag', + description: 'Retrieve a tag from any connected Ticketing software', + }) + @ApiParam({ + name: 'id', + required: true, + type: String, + description: 'id of the tag you want to retrieve.', + }) + @ApiQuery({ + name: 'remoteData', + required: false, + type: Boolean, + description: + 'Set to true to include data from the original Ticketing software.', + }) + //@ApiCustomResponse(TagResponse) + @Get(':id') + getTag(@Param('id') id: string, @Query('remoteData') remote_data?: boolean) { + return this.tagService.getTag(id, remote_data); + } +} diff --git a/packages/api/src/ticketing/tag/tag.module.ts b/packages/api/src/ticketing/tag/tag.module.ts new file mode 100644 index 000000000..03e41f0f9 --- /dev/null +++ b/packages/api/src/ticketing/tag/tag.module.ts @@ -0,0 +1,39 @@ +import { Module } from '@nestjs/common'; +import { TagController } from './tag.controller'; +import { SyncService } from './sync/sync.service'; +import { LoggerService } from '@@core/logger/logger.service'; +import { TagService } from './services/tag.service'; +import { ServiceRegistry } from './services/registry.service'; +import { EncryptionService } from '@@core/encryption/encryption.service'; +import { FieldMappingService } from '@@core/field-mapping/field-mapping.service'; +import { PrismaService } from '@@core/prisma/prisma.service'; +import { WebhookService } from '@@core/webhook/webhook.service'; +import { BullModule } from '@nestjs/bull'; +import { FrontService } from './services/front'; +import { GithubService } from './services/github'; +import { ZendeskService } from './services/zendesk'; + +@Module({ + imports: [ + BullModule.registerQueue({ + name: 'webhookDelivery', + }), + ], + controllers: [TagController], + providers: [ + TagService, + PrismaService, + LoggerService, + SyncService, + WebhookService, + EncryptionService, + FieldMappingService, + ServiceRegistry, + /* PROVIDERS SERVICES */ + ZendeskService, + FrontService, + GithubService, + ], + exports: [SyncService], +}) +export class TagModule {} diff --git a/packages/api/src/ticketing/tag/types/index.ts b/packages/api/src/ticketing/tag/types/index.ts new file mode 100644 index 000000000..7cfe6f28a --- /dev/null +++ b/packages/api/src/ticketing/tag/types/index.ts @@ -0,0 +1,38 @@ +import { DesunifyReturnType } from '@@core/utils/types/desunify.input'; +import { UnifiedTagInput, UnifiedTagOutput } from './model.unified'; +import { OriginalTagOutput } from '@@core/utils/types/original/original.ticketing'; +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { ApiResponse } from '@@core/utils/types'; + +export interface ITagService { + syncTags( + linkedUserId: string, + id_ticket: string, + ): Promise>; +} + +export interface ITagMapper { + desunify( + source: UnifiedTagInput, + customFieldMappings?: { + slug: string; + remote_id: string; + }[], + ): DesunifyReturnType; + + unify( + source: OriginalTagOutput | OriginalTagOutput[], + customFieldMappings?: { + slug: string; + remote_id: string; + }[], + ): UnifiedTagOutput | UnifiedTagOutput[]; +} + +export class TagResponse { + @ApiProperty({ type: [UnifiedTagOutput] }) + tags: UnifiedTagOutput[]; + + @ApiPropertyOptional({ type: [{}] }) + remote_data?: Record[]; // Data in original format +} diff --git a/packages/api/src/ticketing/tag/types/mappingsTypes.ts b/packages/api/src/ticketing/tag/types/mappingsTypes.ts new file mode 100644 index 000000000..b098ba23a --- /dev/null +++ b/packages/api/src/ticketing/tag/types/mappingsTypes.ts @@ -0,0 +1,22 @@ +import { FrontTagMapper } from '../services/front/mappers'; +import { GithubTagMapper } from '../services/github/mappers'; +import { ZendeskTagMapper } from '../services/zendesk/mappers'; + +const zendeskTagMapper = new ZendeskTagMapper(); +const frontTagMapper = new FrontTagMapper(); +const githubTagMapper = new GithubTagMapper(); + +export const tagUnificationMapping = { + zendesk_tcg: { + unify: zendeskTagMapper.unify, + desunify: zendeskTagMapper.desunify, + }, + front: { + unify: frontTagMapper.unify, + desunify: frontTagMapper.desunify, + }, + github: { + unify: githubTagMapper.unify, + desunify: githubTagMapper.desunify, + }, +}; diff --git a/packages/api/src/ticketing/tag/types/model.unified.ts b/packages/api/src/ticketing/tag/types/model.unified.ts new file mode 100644 index 000000000..ed5005e5d --- /dev/null +++ b/packages/api/src/ticketing/tag/types/model.unified.ts @@ -0,0 +1,10 @@ +export class UnifiedTagInput { + name: string; + field_mappings?: Record[]; +} + +export class UnifiedTagOutput extends UnifiedTagInput { + id?: string; + remote_id?: string; + remote_data?: Record; +} diff --git a/packages/api/src/ticketing/tag/utils/index.ts b/packages/api/src/ticketing/tag/utils/index.ts new file mode 100644 index 000000000..f849788c1 --- /dev/null +++ b/packages/api/src/ticketing/tag/utils/index.ts @@ -0,0 +1 @@ +/* PUT ALL UTILS FUNCTIONS USED IN YOUR OBJECT METHODS HERE */ diff --git a/packages/api/src/ticketing/team/services/front/index.ts b/packages/api/src/ticketing/team/services/front/index.ts new file mode 100644 index 000000000..f28d17b50 --- /dev/null +++ b/packages/api/src/ticketing/team/services/front/index.ts @@ -0,0 +1,63 @@ +import { Injectable } from '@nestjs/common'; +import { LoggerService } from '@@core/logger/logger.service'; +import { PrismaService } from '@@core/prisma/prisma.service'; +import { EncryptionService } from '@@core/encryption/encryption.service'; +import { TicketingObject } from '@ticketing/@utils/@types'; +import { ApiResponse } from '@@core/utils/types'; +import axios from 'axios'; +import { ActionType, handleServiceError } from '@@core/utils/errors'; +import { ServiceRegistry } from '../registry.service'; +import { ITeamService } from '@ticketing/team/types'; +import { FrontTeamOutput } from './types'; + +@Injectable() +export class FrontService implements ITeamService { + constructor( + private prisma: PrismaService, + private logger: LoggerService, + private cryptoService: EncryptionService, + private registry: ServiceRegistry, + ) { + this.logger.setContext( + TicketingObject.team.toUpperCase() + ':' + FrontService.name, + ); + this.registry.registerService('front', this); + } + + async syncTeams( + linkedUserId: string, + ): Promise> { + try { + const connection = await this.prisma.connections.findFirst({ + where: { + id_linked_user: linkedUserId, + provider_slug: 'front', + }, + }); + + const resp = await axios.get('https://api2.frontapp.com/teams', { + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${this.cryptoService.decrypt( + connection.access_token, + )}`, + }, + }); + this.logger.log(`Synced front teams !`); + + return { + data: resp.data._results, + message: 'Front teams retrieved', + statusCode: 200, + }; + } catch (error) { + handleServiceError( + error, + this.logger, + 'Front', + TicketingObject.team, + ActionType.GET, + ); + } + } +} diff --git a/packages/api/src/ticketing/team/services/front/mappers.ts b/packages/api/src/ticketing/team/services/front/mappers.ts new file mode 100644 index 000000000..e746bf085 --- /dev/null +++ b/packages/api/src/ticketing/team/services/front/mappers.ts @@ -0,0 +1,47 @@ +import { ITeamMapper } from '@ticketing/team/types'; +import { FrontTeamInput, FrontTeamOutput } from './types'; +import { + UnifiedTeamInput, + UnifiedTeamOutput, +} from '@ticketing/team/types/model.unified'; + +export class FrontTeamMapper implements ITeamMapper { + desunify( + source: UnifiedTeamInput, + customFieldMappings?: { + slug: string; + remote_id: string; + }[], + ): FrontTeamInput { + return; + } + + unify( + source: FrontTeamOutput | FrontTeamOutput[], + customFieldMappings?: { + slug: string; + remote_id: string; + }[], + ): UnifiedTeamOutput | UnifiedTeamOutput[] { + // If the source is not an array, convert it to an array for mapping + const sourcesArray = Array.isArray(source) ? source : [source]; + + return sourcesArray.map((team) => + this.mapSingleTeamToUnified(team, customFieldMappings), + ); + } + + private mapSingleTeamToUnified( + team: FrontTeamOutput, + customFieldMappings?: { + slug: string; + remote_id: string; + }[], + ): UnifiedTeamOutput { + const unifiedTeam: UnifiedTeamOutput = { + name: team.name, + }; + + return unifiedTeam; + } +} diff --git a/packages/api/src/ticketing/team/services/front/types.ts b/packages/api/src/ticketing/team/services/front/types.ts new file mode 100644 index 000000000..91651377b --- /dev/null +++ b/packages/api/src/ticketing/team/services/front/types.ts @@ -0,0 +1,46 @@ +export type FrontTeamInput = { + id: string; +}; + +export type FrontTeamOutput = { + _links: TeamLink; + id: string; + name: string; + inboxes: Inbox[]; + members: TeamMember[]; +}; + +type TeamLink = { + self: string; + related?: { + teammates?: string; + conversations?: string; + channels?: string; + owner?: string; + }; +}; + +type CustomFields = { + [key: string]: string | boolean | number | null; +}; + +type Inbox = { + _links: TeamLink; + id: string; + name: string; + is_private: boolean; + custom_fields: CustomFields; +}; + +type TeamMember = { + _links: TeamLink; + id: string; + email: string; + username: string; + first_name: string; + last_name: string; + is_admin: boolean; + is_available: boolean; + is_blocked: boolean; + custom_fields: CustomFields; +}; diff --git a/packages/api/src/ticketing/team/services/github/index.ts b/packages/api/src/ticketing/team/services/github/index.ts new file mode 100644 index 000000000..620741056 --- /dev/null +++ b/packages/api/src/ticketing/team/services/github/index.ts @@ -0,0 +1,64 @@ +import { Injectable } from '@nestjs/common'; +import { LoggerService } from '@@core/logger/logger.service'; +import { PrismaService } from '@@core/prisma/prisma.service'; +import { EncryptionService } from '@@core/encryption/encryption.service'; +import { TicketingObject } from '@ticketing/@utils/@types'; +import { ApiResponse } from '@@core/utils/types'; +import axios from 'axios'; +import { ActionType, handleServiceError } from '@@core/utils/errors'; +import { ServiceRegistry } from '../registry.service'; +import { ITeamService } from '@ticketing/team/types'; +import { GithubTeamOutput } from './types'; + +//TODO +@Injectable() +export class GithubService implements ITeamService { + constructor( + private prisma: PrismaService, + private logger: LoggerService, + private cryptoService: EncryptionService, + private registry: ServiceRegistry, + ) { + this.logger.setContext( + TicketingObject.team.toUpperCase() + ':' + GithubService.name, + ); + this.registry.registerService('github', this); + } + + async syncTeams( + linkedUserId: string, + custom_properties?: string[], + ): Promise> { + try { + const connection = await this.prisma.connections.findFirst({ + where: { + id_linked_user: linkedUserId, + provider_slug: 'github', + }, + }); + const resp = await axios.get(`https://api.github.com/teams`, { + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${this.cryptoService.decrypt( + connection.access_token, + )}`, + }, + }); + this.logger.log(`Synced github teams !`); + + return { + data: resp.data, + message: 'Github teams retrieved', + statusCode: 200, + }; + } catch (error) { + handleServiceError( + error, + this.logger, + 'Github', + TicketingObject.team, + ActionType.GET, + ); + } + } +} diff --git a/packages/api/src/ticketing/team/services/github/mappers.ts b/packages/api/src/ticketing/team/services/github/mappers.ts new file mode 100644 index 000000000..69bd3ea70 --- /dev/null +++ b/packages/api/src/ticketing/team/services/github/mappers.ts @@ -0,0 +1,28 @@ +import { ITeamMapper } from '@ticketing/team/types'; +import { GithubTeamInput, GithubTeamOutput } from './types'; +import { + UnifiedTeamInput, + UnifiedTeamOutput, +} from '@ticketing/team/types/model.unified'; + +export class GithubTeamMapper implements ITeamMapper { + desunify( + source: UnifiedTeamInput, + customFieldMappings?: { + slug: string; + remote_id: string; + }[], + ): GithubTeamInput { + return; + } + + unify( + source: GithubTeamOutput | GithubTeamOutput[], + customFieldMappings?: { + slug: string; + remote_id: string; + }[], + ): UnifiedTeamOutput | UnifiedTeamOutput[] { + return; + } +} diff --git a/packages/api/src/ticketing/team/services/github/types.ts b/packages/api/src/ticketing/team/services/github/types.ts new file mode 100644 index 000000000..f92dda4f8 --- /dev/null +++ b/packages/api/src/ticketing/team/services/github/types.ts @@ -0,0 +1,6 @@ +export type GithubTeamInput = { + name: string; +}; + +//TODO +export type GithubTeamOutput = GithubTeamInput; diff --git a/packages/api/src/ticketing/team/services/registry.service.ts b/packages/api/src/ticketing/team/services/registry.service.ts new file mode 100644 index 000000000..b895071ae --- /dev/null +++ b/packages/api/src/ticketing/team/services/registry.service.ts @@ -0,0 +1,23 @@ +import { Injectable } from '@nestjs/common'; +import { ITeamService } from '../types'; + +@Injectable() +export class ServiceRegistry { + private serviceMap: Map; + + constructor() { + this.serviceMap = new Map(); + } + + registerService(serviceKey: string, service: ITeamService) { + this.serviceMap.set(serviceKey, service); + } + + getService(integrationId: string): ITeamService { + const service = this.serviceMap.get(integrationId); + if (!service) { + throw new Error(); + } + return service; + } +} diff --git a/packages/api/src/ticketing/team/services/team.service.ts b/packages/api/src/ticketing/team/services/team.service.ts new file mode 100644 index 000000000..d55d3ab5e --- /dev/null +++ b/packages/api/src/ticketing/team/services/team.service.ts @@ -0,0 +1,168 @@ +import { Injectable } from '@nestjs/common'; +import { PrismaService } from '@@core/prisma/prisma.service'; +import { LoggerService } from '@@core/logger/logger.service'; +import { v4 as uuidv4 } from 'uuid'; +import { ApiResponse } from '@@core/utils/types'; +import { handleServiceError } from '@@core/utils/errors'; +import { UnifiedTeamOutput } from '../types/model.unified'; +import { TeamResponse } from '../types'; + +@Injectable() +export class TeamService { + constructor(private prisma: PrismaService, private logger: LoggerService) { + this.logger.setContext(TeamService.name); + } + + async getTeam( + id_ticketing_team: string, + remote_data?: boolean, + ): Promise { + try { + const team = await this.prisma.tcg_teams.findUnique({ + where: { + id_tcg_team: id_ticketing_team, + }, + }); + + // Fetch field mappings for the ticket + const values = await this.prisma.value.findMany({ + where: { + entity: { + ressource_owner_id: team.id_tcg_team, + }, + }, + include: { + attribute: true, + }, + }); + + // Create a map to store unique field mappings + const fieldMappingsMap = new Map(); + + values.forEach((value) => { + fieldMappingsMap.set(value.attribute.slug, value.data); + }); + + // Convert the map to an array of objects + const field_mappings = Array.from(fieldMappingsMap, ([key, value]) => ({ + [key]: value, + })); + + // Transform to UnifiedTeamOutput format + const unifiedTeam: UnifiedTeamOutput = { + id: team.id_tcg_team, + name: team.name, + description: team.description, + field_mappings: field_mappings, + }; + + let res: UnifiedTeamOutput = unifiedTeam; + + if (remote_data) { + const resp = await this.prisma.remote_data.findFirst({ + where: { + ressource_owner_id: team.id_tcg_team, + }, + }); + const remote_data = JSON.parse(resp.data); + + res = { + ...res, + remote_data: remote_data, + }; + } + + return res; + } catch (error) { + handleServiceError(error, this.logger); + } + } + + async getTeams( + integrationId: string, + linkedUserId: string, + remote_data?: boolean, + ): Promise { + try { + //TODO: handle case where data is not there (not synced) or old synced + + const teams = await this.prisma.tcg_teams.findMany({ + where: { + remote_platform: integrationId.toLowerCase(), + id_linked_user: linkedUserId, + }, + }); + + const unifiedTeams: UnifiedTeamOutput[] = await Promise.all( + teams.map(async (team) => { + // Fetch field mappings for the team + const values = await this.prisma.value.findMany({ + where: { + entity: { + ressource_owner_id: team.id_tcg_team, + }, + }, + include: { + attribute: true, + }, + }); + // Create a map to store unique field mappings + const fieldMappingsMap = new Map(); + + values.forEach((value) => { + fieldMappingsMap.set(value.attribute.slug, value.data); + }); + + // Convert the map to an array of objects + const field_mappings = Array.from( + fieldMappingsMap, + ([key, value]) => ({ [key]: value }), + ); + + // Transform to UnifiedTeamOutput format + return { + id: team.id_tcg_team, + name: team.name, + description: team.description, + field_mappings: field_mappings, + }; + }), + ); + + let res: UnifiedTeamOutput[] = unifiedTeams; + + if (remote_data) { + const remote_array_data: UnifiedTeamOutput[] = await Promise.all( + res.map(async (team) => { + const resp = await this.prisma.remote_data.findFirst({ + where: { + ressource_owner_id: team.id, + }, + }); + const remote_data = JSON.parse(resp.data); + return { ...team, remote_data }; + }), + ); + + res = remote_array_data; + } + const event = await this.prisma.events.create({ + data: { + id_event: uuidv4(), + status: 'success', + type: 'ticketing.team.pull', + method: 'GET', + url: '/ticketing/team', + provider: integrationId, + direction: '0', + timestamp: new Date(), + id_linked_user: linkedUserId, + }, + }); + + return res; + } catch (error) { + handleServiceError(error, this.logger); + } + } +} diff --git a/packages/api/src/ticketing/team/services/zendesk/index.ts b/packages/api/src/ticketing/team/services/zendesk/index.ts new file mode 100644 index 000000000..ac0362b48 --- /dev/null +++ b/packages/api/src/ticketing/team/services/zendesk/index.ts @@ -0,0 +1,68 @@ +import { Injectable } from '@nestjs/common'; +import { LoggerService } from '@@core/logger/logger.service'; +import { PrismaService } from '@@core/prisma/prisma.service'; +import { EncryptionService } from '@@core/encryption/encryption.service'; +import { TicketingObject, ZendeskTeamOutput } from '@ticketing/@utils/@types'; +import { ApiResponse } from '@@core/utils/types'; +import axios from 'axios'; +import { ActionType, handleServiceError } from '@@core/utils/errors'; +import { EnvironmentService } from '@@core/environment/environment.service'; +import { ServiceRegistry } from '../registry.service'; +import { ITeamService } from '@ticketing/team/types'; + +@Injectable() +export class ZendeskService implements ITeamService { + constructor( + private prisma: PrismaService, + private logger: LoggerService, + private cryptoService: EncryptionService, + private env: EnvironmentService, + private registry: ServiceRegistry, + ) { + this.logger.setContext( + TicketingObject.team.toUpperCase() + ':' + ZendeskService.name, + ); + this.registry.registerService('zendesk_tcg', this); + } + + async syncTeams( + linkedUserId: string, + custom_properties?: string[], + ): Promise> { + try { + const connection = await this.prisma.connections.findFirst({ + where: { + id_linked_user: linkedUserId, + provider_slug: 'zendesk_tcg', + }, + }); + + const resp = await axios.get( + `https://${this.env.getZendeskTicketingSubdomain()}.zendesk.com/api/v2/teams`, + { + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${this.cryptoService.decrypt( + connection.access_token, + )}`, + }, + }, + ); + this.logger.log(`Synced zendesk teams !`); + + return { + data: resp.data.teams, + message: 'Zendesk teams retrieved', + statusCode: 200, + }; + } catch (error) { + handleServiceError( + error, + this.logger, + 'Zendesk', + TicketingObject.team, + ActionType.GET, + ); + } + } +} diff --git a/packages/api/src/ticketing/team/services/zendesk/mappers.ts b/packages/api/src/ticketing/team/services/zendesk/mappers.ts new file mode 100644 index 000000000..83f8b1688 --- /dev/null +++ b/packages/api/src/ticketing/team/services/zendesk/mappers.ts @@ -0,0 +1,43 @@ +import { ITeamMapper } from '@ticketing/team/types'; +import { ZendeskTeamInput, ZendeskTeamOutput } from './types'; +import { + UnifiedTeamInput, + UnifiedTeamOutput, +} from '@ticketing/team/types/model.unified'; + +export class ZendeskTeamMapper implements ITeamMapper { + desunify( + source: UnifiedTeamInput, + customFieldMappings?: { + slug: string; + remote_id: string; + }[], + ): ZendeskTeamInput { + return; + } + + unify( + source: ZendeskTeamOutput | ZendeskTeamOutput[], + customFieldMappings?: { + slug: string; + remote_id: string; + }[], + ): UnifiedTeamOutput | UnifiedTeamOutput[] { + if (!Array.isArray(source)) { + return this.mapSingleTeamToUnified(source, customFieldMappings); + } + return source.map((ticket) => + this.mapSingleTeamToUnified(ticket, customFieldMappings), + ); + } + + private mapSingleTeamToUnified( + ticket: ZendeskTeamOutput, + customFieldMappings?: { + slug: string; + remote_id: string; + }[], + ): UnifiedTeamOutput { + return; + } +} diff --git a/packages/api/src/ticketing/team/services/zendesk/types.ts b/packages/api/src/ticketing/team/services/zendesk/types.ts new file mode 100644 index 000000000..2a62d1e26 --- /dev/null +++ b/packages/api/src/ticketing/team/services/zendesk/types.ts @@ -0,0 +1,7 @@ +export type ZendeskTeamInput = { + _: string; +}; + +export type ZendeskTeamOutput = ZendeskTeamInput & { + id: number; // Read-only. Automatically assigned when the ticket is created. +}; diff --git a/packages/api/src/ticketing/team/sync/sync.service.ts b/packages/api/src/ticketing/team/sync/sync.service.ts new file mode 100644 index 000000000..8810127eb --- /dev/null +++ b/packages/api/src/ticketing/team/sync/sync.service.ts @@ -0,0 +1,296 @@ +import { Injectable, OnModuleInit } from '@nestjs/common'; +import { LoggerService } from '@@core/logger/logger.service'; +import { PrismaService } from '@@core/prisma/prisma.service'; +import { NotFoundError, handleServiceError } from '@@core/utils/errors'; +import { Cron } from '@nestjs/schedule'; +import { ApiResponse, TICKETING_PROVIDERS } from '@@core/utils/types'; +import { v4 as uuidv4 } from 'uuid'; +import { FieldMappingService } from '@@core/field-mapping/field-mapping.service'; +import { ServiceRegistry } from '../services/registry.service'; +import { unify } from '@@core/utils/unification/unify'; +import { TicketingObject } from '@ticketing/@utils/@types'; +import { WebhookService } from '@@core/webhook/webhook.service'; +import { UnifiedTeamOutput } from '../types/model.unified'; +import { ITeamService } from '../types'; +import { tcg_teams as TicketingTeam } from '@prisma/client'; +import { OriginalTeamOutput } from '@@core/utils/types/original/original.ticketing'; + +@Injectable() +export class SyncService implements OnModuleInit { + constructor( + private prisma: PrismaService, + private logger: LoggerService, + private webhook: WebhookService, + private fieldMappingService: FieldMappingService, + private serviceRegistry: ServiceRegistry, + ) { + this.logger.setContext(SyncService.name); + } + + async onModuleInit() { + try { + //await this.syncTeams(); + } catch (error) { + handleServiceError(error, this.logger); + } + } + + //@Cron('*/20 * * * *') + //function used by sync worker which populate our tcg_teams table + //its role is to fetch all teams from providers 3rd parties and save the info inside our db + async syncTeams() { + try { + this.logger.log(`Syncing teams....`); + const defaultOrg = await this.prisma.organizations.findFirst({ + where: { + name: 'Acme Inc', + }, + }); + + const defaultProject = await this.prisma.projects.findFirst({ + where: { + id_organization: defaultOrg.id_organization, + name: 'Project 1', + }, + }); + const id_project = defaultProject.id_project; + const linkedTeams = await this.prisma.linked_users.findMany({ + where: { + id_project: id_project, + }, + }); + linkedTeams.map(async (linkedTeam) => { + try { + const providers = TICKETING_PROVIDERS; + for (const provider of providers) { + try { + await this.syncTeamsForLinkedTeam( + provider, + linkedTeam.id_linked_user, + id_project, + ); + } catch (error) { + handleServiceError(error, this.logger); + } + } + } catch (error) { + handleServiceError(error, this.logger); + } + }); + } catch (error) { + handleServiceError(error, this.logger); + } + } + + //todo: HANDLE DATA REMOVED FROM PROVIDER + async syncTeamsForLinkedTeam( + integrationId: string, + linkedUserId: string, + id_project: string, + ) { + try { + this.logger.log( + `Syncing ${integrationId} teams for linkedTeam ${linkedUserId}`, + ); + // check if linkedTeam has a connection if not just stop sync + const connection = await this.prisma.connections.findFirst({ + where: { + id_linked_user: linkedUserId, + provider_slug: integrationId, + }, + }); + if (!connection) throw new Error('connection not found'); + + // get potential fieldMappings and extract the original properties name + const customFieldMappings = + await this.fieldMappingService.getCustomFieldMappings( + integrationId, + linkedUserId, + 'team', + ); + const remoteProperties: string[] = customFieldMappings.map( + (mapping) => mapping.remote_id, + ); + + const service: ITeamService = + this.serviceRegistry.getService(integrationId); + const resp: ApiResponse = await service.syncTeams( + linkedUserId, + remoteProperties, + ); + + const sourceObject: OriginalTeamOutput[] = resp.data; + //this.logger.log('SOURCE OBJECT DATA = ' + JSON.stringify(sourceObject)); + //unify the data according to the target obj wanted + const unifiedObject = (await unify({ + sourceObject, + targetType: TicketingObject.team, + providerName: integrationId, + customFieldMappings, + })) as UnifiedTeamOutput[]; + + //TODO + const teamIds = sourceObject.map((team) => + 'id' in team ? String(team.id) : undefined, + ); + + //insert the data in the DB with the fieldMappings (value table) + const team_data = await this.saveTeamsInDb( + linkedUserId, + unifiedObject, + teamIds, + integrationId, + sourceObject, + ); + + const event = await this.prisma.events.create({ + data: { + id_event: uuidv4(), + status: 'success', + type: 'ticketing.team.pulled', + method: 'PULL', + url: '/pull', + provider: integrationId, + direction: '0', + timestamp: new Date(), + id_linked_user: linkedUserId, + }, + }); + + await this.webhook.handleWebhook( + team_data, + 'ticketing.team.pulled', + id_project, + event.id_event, + ); + } catch (error) { + handleServiceError(error, this.logger); + } + } + + async saveTeamsInDb( + linkedUserId: string, + teams: UnifiedTeamOutput[], + originIds: string[], + originSource: string, + remote_data: Record[], + ): Promise { + try { + let teams_results: TicketingTeam[] = []; + for (let i = 0; i < teams.length; i++) { + const team = teams[i]; + const originId = originIds[i]; + + if (!originId || originId == '') { + throw new NotFoundError(`Origin id not there, found ${originId}`); + } + + const existingTeam = await this.prisma.tcg_teams.findFirst({ + where: { + remote_id: originId, + remote_platform: originSource, + id_linked_user: linkedUserId, + }, + }); + + let unique_ticketing_team_id: string; + + if (existingTeam) { + // Update the existing ticket + const res = await this.prisma.tcg_teams.update({ + where: { + id_tcg_team: existingTeam.id_tcg_team, + }, + data: { + name: existingTeam.name, + description: team.description, + modified_at: new Date(), + }, + }); + unique_ticketing_team_id = res.id_tcg_team; + teams_results = [...teams_results, res]; + } else { + // Create a new team + this.logger.log('not existing team ' + team.name); + const data = { + id_tcg_team: uuidv4(), + name: team.name, + description: team.description, + created_at: new Date(), + modified_at: new Date(), + id_linked_user: linkedUserId, + remote_id: originId, + remote_platform: originSource, + }; + const res = await this.prisma.tcg_teams.create({ + data: data, + }); + teams_results = [...teams_results, res]; + unique_ticketing_team_id = res.id_tcg_team; + } + + // check duplicate or existing values + if (team.field_mappings && team.field_mappings.length > 0) { + const entity = await this.prisma.entity.create({ + data: { + id_entity: uuidv4(), + ressource_owner_id: unique_ticketing_team_id, + }, + }); + + for (const mapping of team.field_mappings) { + const attribute = await this.prisma.attribute.findFirst({ + where: { + slug: Object.keys(mapping)[0], + source: originSource, + id_consumer: linkedUserId, + }, + }); + + if (attribute) { + await this.prisma.value.create({ + data: { + id_value: uuidv4(), + data: Object.values(mapping)[0] + ? Object.values(mapping)[0] + : 'null', + attribute: { + connect: { + id_attribute: attribute.id_attribute, + }, + }, + entity: { + connect: { + id_entity: entity.id_entity, + }, + }, + }, + }); + } + } + } + + //insert remote_data in db + await this.prisma.remote_data.upsert({ + where: { + ressource_owner_id: unique_ticketing_team_id, + }, + create: { + id_remote_data: uuidv4(), + ressource_owner_id: unique_ticketing_team_id, + format: 'json', + data: JSON.stringify(remote_data[i]), + created_at: new Date(), + }, + update: { + data: JSON.stringify(remote_data[i]), + created_at: new Date(), + }, + }); + } + return teams_results; + } catch (error) { + handleServiceError(error, this.logger); + } + } +} diff --git a/packages/api/src/ticketing/team/team.controller.ts b/packages/api/src/ticketing/team/team.controller.ts new file mode 100644 index 000000000..30c75ee59 --- /dev/null +++ b/packages/api/src/ticketing/team/team.controller.ts @@ -0,0 +1,69 @@ +import { Controller, Query, Get, Param, Headers } from '@nestjs/common'; +import { LoggerService } from '@@core/logger/logger.service'; +import { + ApiOperation, + ApiParam, + ApiQuery, + ApiTags, + ApiHeader, +} from '@nestjs/swagger'; +import { ApiCustomResponse } from '@@core/utils/types'; +import { TeamService } from './services/team.service'; + +@ApiTags('ticketing/team') +@Controller('ticketing/team') +export class TeamController { + constructor( + private readonly teamService: TeamService, + private logger: LoggerService, + ) { + this.logger.setContext(TeamController.name); + } + + @ApiOperation({ + operationId: 'getTeams', + summary: 'List a batch of Teams', + }) + @ApiHeader({ name: 'integrationId', required: true }) + @ApiHeader({ name: 'linkedUserId', required: true }) + @ApiQuery({ + name: 'remoteData', + required: false, + type: Boolean, + description: + 'Set to true to include data from the original Ticketing software.', + }) + //@ApiCustomResponse(TeamResponse) + @Get() + getTeams( + @Headers('integrationId') integrationId: string, + @Headers('linkedUserId') linkedUserId: string, + @Query('remoteData') remote_data?: boolean, + ) { + return this.teamService.getTeams(integrationId, linkedUserId, remote_data); + } + + @ApiOperation({ + operationId: 'getTeam', + summary: 'Retrieve a Team', + description: 'Retrieve a team from any connected Ticketing software', + }) + @ApiParam({ + name: 'id', + required: true, + type: String, + description: 'id of the team you want to retrieve.', + }) + @ApiQuery({ + name: 'remoteData', + required: false, + type: Boolean, + description: + 'Set to true to include data from the original Ticketing software.', + }) + //@ApiCustomResponse(TeamResponse) + @Get(':id') + getTeam(@Param('id') id: string, @Query('remoteData') remote_data?: boolean) { + return this.teamService.getTeam(id, remote_data); + } +} diff --git a/packages/api/src/ticketing/team/team.module.ts b/packages/api/src/ticketing/team/team.module.ts new file mode 100644 index 000000000..5aada8f6e --- /dev/null +++ b/packages/api/src/ticketing/team/team.module.ts @@ -0,0 +1,39 @@ +import { Module } from '@nestjs/common'; +import { TeamController } from './team.controller'; +import { SyncService } from './sync/sync.service'; +import { LoggerService } from '@@core/logger/logger.service'; +import { TeamService } from './services/team.service'; +import { ServiceRegistry } from './services/registry.service'; +import { EncryptionService } from '@@core/encryption/encryption.service'; +import { FieldMappingService } from '@@core/field-mapping/field-mapping.service'; +import { PrismaService } from '@@core/prisma/prisma.service'; +import { WebhookService } from '@@core/webhook/webhook.service'; +import { BullModule } from '@nestjs/bull'; +import { FrontService } from './services/front'; +import { GithubService } from './services/github'; +import { ZendeskService } from './services/zendesk'; + +@Module({ + imports: [ + BullModule.registerQueue({ + name: 'webhookDelivery', + }), + ], + controllers: [TeamController], + providers: [ + TeamService, + PrismaService, + LoggerService, + SyncService, + WebhookService, + EncryptionService, + FieldMappingService, + ServiceRegistry, + /* PROVIDERS SERVICES */ + ZendeskService, + FrontService, + GithubService, + ], + exports: [SyncService], +}) +export class TeamModule {} diff --git a/packages/api/src/ticketing/team/types/index.ts b/packages/api/src/ticketing/team/types/index.ts new file mode 100644 index 000000000..c96093e61 --- /dev/null +++ b/packages/api/src/ticketing/team/types/index.ts @@ -0,0 +1,38 @@ +import { DesunifyReturnType } from '@@core/utils/types/desunify.input'; +import { UnifiedTeamInput, UnifiedTeamOutput } from './model.unified'; +import { OriginalTeamOutput } from '@@core/utils/types/original/original.ticketing'; +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { ApiResponse } from '@@core/utils/types'; + +export interface ITeamService { + syncTeams( + linkedUserId: string, + custom_properties?: string[], + ): Promise>; +} + +export interface ITeamMapper { + desunify( + source: UnifiedTeamInput, + customFieldMappings?: { + slug: string; + remote_id: string; + }[], + ): DesunifyReturnType; + + unify( + source: OriginalTeamOutput | OriginalTeamOutput[], + customFieldMappings?: { + slug: string; + remote_id: string; + }[], + ): UnifiedTeamOutput | UnifiedTeamOutput[]; +} + +export class TeamResponse { + @ApiProperty({ type: [UnifiedTeamOutput] }) + teams: UnifiedTeamOutput[]; + + @ApiPropertyOptional({ type: [{}] }) + remote_data?: Record[]; // Data in original format +} diff --git a/packages/api/src/ticketing/team/types/mappingsTypes.ts b/packages/api/src/ticketing/team/types/mappingsTypes.ts new file mode 100644 index 000000000..b43fbada8 --- /dev/null +++ b/packages/api/src/ticketing/team/types/mappingsTypes.ts @@ -0,0 +1,22 @@ +import { FrontTeamMapper } from '../services/front/mappers'; +import { GithubTeamMapper } from '../services/github/mappers'; +import { ZendeskTeamMapper } from '../services/zendesk/mappers'; + +const zendeskTeamMapper = new ZendeskTeamMapper(); +const frontTeamMapper = new FrontTeamMapper(); +const githubTeamMapper = new GithubTeamMapper(); + +export const teamUnificationMapping = { + zendesk_tcg: { + unify: zendeskTeamMapper.unify, + desunify: zendeskTeamMapper.desunify, + }, + front: { + unify: frontTeamMapper.unify, + desunify: frontTeamMapper.desunify, + }, + github: { + unify: githubTeamMapper.unify, + desunify: githubTeamMapper.desunify, + }, +}; diff --git a/packages/api/src/ticketing/team/types/model.unified.ts b/packages/api/src/ticketing/team/types/model.unified.ts new file mode 100644 index 000000000..f823be0d7 --- /dev/null +++ b/packages/api/src/ticketing/team/types/model.unified.ts @@ -0,0 +1,11 @@ +export class UnifiedTeamInput { + name: string; + description?: string; + field_mappings?: Record[]; +} + +export class UnifiedTeamOutput extends UnifiedTeamInput { + id?: string; + remote_id?: string; + remote_data?: Record; +} diff --git a/packages/api/src/ticketing/team/utils/index.ts b/packages/api/src/ticketing/team/utils/index.ts new file mode 100644 index 000000000..f849788c1 --- /dev/null +++ b/packages/api/src/ticketing/team/utils/index.ts @@ -0,0 +1 @@ +/* PUT ALL UTILS FUNCTIONS USED IN YOUR OBJECT METHODS HERE */ diff --git a/packages/api/src/ticketing/ticket/services/front/index.ts b/packages/api/src/ticketing/ticket/services/front/index.ts new file mode 100644 index 000000000..b2d2415d9 --- /dev/null +++ b/packages/api/src/ticketing/ticket/services/front/index.ts @@ -0,0 +1,197 @@ +import { Injectable } from '@nestjs/common'; +import { LoggerService } from '@@core/logger/logger.service'; +import { PrismaService } from '@@core/prisma/prisma.service'; +import { EncryptionService } from '@@core/encryption/encryption.service'; +import { TicketingObject } from '@ticketing/@utils/@types'; +import { ITicketService } from '@ticketing/ticket/types'; +import { ApiResponse } from '@@core/utils/types'; +import axios from 'axios'; +import { ActionType, handleServiceError } from '@@core/utils/errors'; +import { ServiceRegistry } from '../registry.service'; +import { FrontTicketInput, FrontTicketOutput } from './types'; +import { Utils } from '@ticketing/comment/utils'; + +@Injectable() +export class FrontService implements ITicketService { + constructor( + private prisma: PrismaService, + private logger: LoggerService, + private cryptoService: EncryptionService, + private registry: ServiceRegistry, + ) { + this.logger.setContext( + TicketingObject.ticket.toUpperCase() + ':' + FrontService.name, + ); + this.registry.registerService('front', this); + } + private readonly utils = new Utils(); + + async addTicket( + ticketData: FrontTicketInput, + linkedUserId: string, + ): Promise> { + try { + const connection = await this.prisma.connections.findFirst({ + where: { + id_linked_user: linkedUserId, + provider_slug: 'front', + }, + }); + + //We deconstruct as tags must be added separately + const { tags, custom_fields, ...restOfTicketData } = ticketData; + + let uploads = []; + const uuids = restOfTicketData.comment.attachments; + if (uuids && uuids.length > 0) { + for (const uuid of uuids) { + const res = await this.prisma.tcg_attachments.findUnique({ + where: { + id_tcg_attachment: uuid, + }, + }); + if (!res) + throw new Error(`tcg_attachment not found for uuid ${uuid}`); + //TODO: construct the right binary attachment + //get the AWS s3 right file + //TODO: check how to send a stream of a url + const fileStream = await this.utils.fetchFileStreamFromURL( + res.file_url, + ); + + uploads = [...uploads, fileStream]; + } + } + + let resp; + if (uploads.length > 0) { + const dataBody = { + ...restOfTicketData, + comment: { ...restOfTicketData.comment, attachments: uploads }, + }; + const formData = new FormData(); + + formData.append('type', dataBody.type); + + if (dataBody.inbox_id) { + formData.append('inbox_id', dataBody.inbox_id); + } + if (dataBody.teammate_ids && dataBody.teammate_ids.length > 0) { + for (let i = 0; i < dataBody.teammate_ids.length; i++) { + const item = dataBody.teammate_ids[i]; + formData.append(`teammate_ids[${i}]`, item); + } + } + if (dataBody.comment.author_id) { + formData.append('comment[author_id]', dataBody.comment.author_id); + } + formData.append('comment[body]', dataBody.comment.body); + + for (let i = 0; i < uploads.length; i++) { + const up = uploads[i]; + formData.append(`comment[attachments][${i}]`, up); + } + + resp = await axios.post( + `https://api2.frontapp.com/conversations`, + formData, + { + headers: { + 'Content-Type': 'multipart/form-data', + Authorization: `Bearer ${this.cryptoService.decrypt( + connection.access_token, + )}`, + }, + }, + ); + } else { + resp = await axios.post( + `https://api2.frontapp.com/conversations`, + JSON.stringify(restOfTicketData), + { + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${this.cryptoService.decrypt( + connection.access_token, + )}`, + }, + }, + ); + } + + //now we can add tags and/or custom fields to the conversation we just created + if (tags && tags.length > 0) { + let final: any = { + tag_ids: tags, + }; + if (custom_fields) { + final = { ...final, custom_fields: custom_fields }; + } + const tag_resp = await axios.patch( + `https://api2.frontapp.com/conversations/${resp.data.id}`, + JSON.stringify(final), + { + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${this.cryptoService.decrypt( + connection.access_token, + )}`, + }, + }, + ); + } + + //now we can insert + + return { + data: resp.data, + message: 'Front ticket created', + statusCode: 201, + }; + } catch (error) { + handleServiceError( + error, + this.logger, + 'Front', + TicketingObject.ticket, + ActionType.POST, + ); + } + } + async syncTickets( + linkedUserId: string, + ): Promise> { + try { + const connection = await this.prisma.connections.findFirst({ + where: { + id_linked_user: linkedUserId, + provider_slug: 'front', + }, + }); + + const resp = await axios.get('https://api2.frontapp.com/conversations', { + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${this.cryptoService.decrypt( + connection.access_token, + )}`, + }, + }); + this.logger.log(`Synced front tickets !`); + + return { + data: resp.data._results, + message: 'Front tickets retrieved', + statusCode: 200, + }; + } catch (error) { + handleServiceError( + error, + this.logger, + 'Front', + TicketingObject.ticket, + ActionType.GET, + ); + } + } +} diff --git a/packages/api/src/ticketing/ticket/services/front/mappers.ts b/packages/api/src/ticketing/ticket/services/front/mappers.ts new file mode 100644 index 000000000..0ef897aba --- /dev/null +++ b/packages/api/src/ticketing/ticket/services/front/mappers.ts @@ -0,0 +1,124 @@ +import { ITicketMapper } from '@ticketing/ticket/types'; +import { FrontTicketInput, FrontTicketOutput } from './types'; +import { + UnifiedTicketInput, + UnifiedTicketOutput, +} from '@ticketing/ticket/types/model.unified'; +import { Utils } from '@ticketing/ticket/utils'; + +export class FrontTicketMapper implements ITicketMapper { + private readonly utils = new Utils(); + + async desunify( + source: UnifiedTicketInput, + customFieldMappings?: { + slug: string; + remote_id: string; + }[], + ): Promise { + let result: FrontTicketInput = { + type: 'discussion', // Assuming 'discussion' as a default type for Front conversations + subject: source.name, + teammate_ids: source.assigned_to, + comment: { + body: source.comment.body, + author_id: + source.comment.creator_type === 'user' + ? source.comment.user_id + : source.comment.contact_id, + attachments: source.comment.attachments, + }, + }; + + if (source.assigned_to && source.assigned_to.length > 0) { + const res: string[] = []; + for (const assignee of source.assigned_to) { + res.push( + await this.utils.getAsigneeRemoteIdFromUserUuid(assignee, 'front'), + ); + } + result = { + ...result, + teammate_ids: res, + }; + } + + if (source.tags) { + result = { + ...result, + tags: source.tags, + }; + } + + if (customFieldMappings && source.field_mappings) { + for (const fieldMapping of source.field_mappings) { + for (const key in fieldMapping) { + const mapping = customFieldMappings.find( + (mapping) => mapping.slug === key, + ); + if (mapping) { + result['custom_fields'][mapping.remote_id] = fieldMapping[key]; + } + } + } + } + + return result; + } + + async unify( + source: FrontTicketOutput | FrontTicketOutput[], + customFieldMappings?: { + slug: string; + remote_id: string; + }[], + ): Promise { + // If the source is not an array, convert it to an array for mapping + const sourcesArray = Array.isArray(source) ? source : [source]; + + return Promise.all( + sourcesArray.map((ticket) => + this.mapSingleTicketToUnified(ticket, customFieldMappings), + ), + ); + } + + private async mapSingleTicketToUnified( + ticket: FrontTicketOutput, + customFieldMappings?: { + slug: string; + remote_id: string; + }[], + ): Promise { + const field_mappings = customFieldMappings?.map((mapping) => ({ + [mapping.slug]: ticket.custom_fields?.[mapping.remote_id], + })); + + let opts: any; + + if (ticket.assignee) { + //fetch the right assignee uuid from remote id + const user_id = await this.utils.getUserUuidFromRemoteId( + String(ticket.assignee.id), + 'front', + ); + if (user_id) { + opts = { assigned_to: [user_id] }; + } else { + throw new Error('user id not found for this ticket'); + } + } + + const unifiedTicket: UnifiedTicketOutput = { + name: ticket.subject, + status: ticket.status, + description: ticket.subject, // todo: ? + due_date: new Date(ticket.created_at), // todo ? + tags: ticket.tags?.map((tag) => tag.name), + field_mappings: field_mappings, + ...opts, + }; + + return unifiedTicket; + } +} diff --git a/packages/api/src/ticketing/ticket/services/front/types.ts b/packages/api/src/ticketing/ticket/services/front/types.ts new file mode 100644 index 000000000..954138722 --- /dev/null +++ b/packages/api/src/ticketing/ticket/services/front/types.ts @@ -0,0 +1,104 @@ +export type FrontTicketInput = { + type: 'discussion'; + inbox_id?: string; + teammate_ids?: string[]; + subject: string; + comment: Comment; + custom_fields?: CustomFields; + tags?: string[]; +}; + +type Comment = { + author_id?: string; + body: string; + attachments?: string[]; //TODO: maybe wrong type +}; + +export type FrontTicketOutput = Partial; + +type Conversation = { + _links: Link; + id: string; + subject: string; + status: string; + assignee: Assignee; + recipient: Recipient; + tags: Tag[]; + links: LinkItem[]; + custom_fields: CustomFields; + created_at: number; + is_private: boolean; + scheduled_reminders: ScheduledReminder[]; + metadata: Metadata; +}; + +type Link = { + self: string; + related?: { + [key: string]: string; + }; +}; + +type CustomFields = { + [key: string]: string | boolean | number | null; +}; + +type Assignee = { + _links: Link; + id: string; + email: string; + username: string; + first_name: string; + last_name: string; + is_admin: boolean; + is_available: boolean; + is_blocked: boolean; + custom_fields: CustomFields; +}; + +type Recipient = { + _links: { + related: { + contact: string; + }; + }; + name: string; + handle: string; + role: string; +}; + +type Tag = { + _links: Link; + id: string; + name: string; + description: string; + highlight: null | string; + is_private: boolean; + is_visible_in_conversation_lists: boolean; + created_at: number; + updated_at: number; +}; + +type LinkItem = { + _links: Link; + id: string; + name: string; + type: string; + external_url: string; + custom_fields: CustomFields; +}; + +type ScheduledReminder = { + _links: { + related: { + owner: string; + }; + }; + created_at: number; + scheduled_at: number; + updated_at: number; +}; + +type Metadata = { + external_conversation_ids: string[]; +}; diff --git a/packages/api/src/ticketing/ticket/services/github/index.ts b/packages/api/src/ticketing/ticket/services/github/index.ts new file mode 100644 index 000000000..d95aacaf5 --- /dev/null +++ b/packages/api/src/ticketing/ticket/services/github/index.ts @@ -0,0 +1,106 @@ +import { Injectable } from '@nestjs/common'; +import { LoggerService } from '@@core/logger/logger.service'; +import { PrismaService } from '@@core/prisma/prisma.service'; +import { EncryptionService } from '@@core/encryption/encryption.service'; +import { TicketingObject } from '@ticketing/@utils/@types'; +import { ITicketService } from '@ticketing/ticket/types'; +import { ApiResponse } from '@@core/utils/types'; +import axios from 'axios'; +import { ActionType, handleServiceError } from '@@core/utils/errors'; +import { ServiceRegistry } from '../registry.service'; +import { GithubTicketInput, GithubTicketOutput } from './types'; + +//TODO +@Injectable() +export class GithubService implements ITicketService { + constructor( + private prisma: PrismaService, + private logger: LoggerService, + private cryptoService: EncryptionService, + private registry: ServiceRegistry, + ) { + this.logger.setContext( + TicketingObject.ticket.toUpperCase() + ':' + GithubService.name, + ); + this.registry.registerService('github', this); + } + async addTicket( + ticketData: GithubTicketInput, + linkedUserId: string, + ): Promise> { + try { + const connection = await this.prisma.connections.findFirst({ + where: { + id_linked_user: linkedUserId, + provider_slug: 'github', + }, + }); + const dataBody = ticketData; + const owner = ''; + const repo = ''; + const resp = await axios.post( + `https://api.github.com/repos/${owner}/${repo}/issues`, + JSON.stringify(dataBody), + { + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${this.cryptoService.decrypt( + connection.access_token, + )}`, + }, + }, + ); + return { + data: resp.data, + message: 'Github ticket created', + statusCode: 201, + }; + } catch (error) { + handleServiceError( + error, + this.logger, + 'Github', + TicketingObject.ticket, + ActionType.POST, + ); + } + } + async syncTickets( + linkedUserId: string, + custom_properties?: string[], + ): Promise> { + try { + const connection = await this.prisma.connections.findFirst({ + where: { + id_linked_user: linkedUserId, + provider_slug: 'github', + }, + }); + const owner = ''; + const repo = ''; + const resp = await axios.get(`https://api.github.com/repos/issues`, { + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${this.cryptoService.decrypt( + connection.access_token, + )}`, + }, + }); + this.logger.log(`Synced github tickets !`); + + return { + data: resp.data, + message: 'Github tickets retrieved', + statusCode: 200, + }; + } catch (error) { + handleServiceError( + error, + this.logger, + 'Github', + TicketingObject.ticket, + ActionType.GET, + ); + } + } +} diff --git a/packages/api/src/ticketing/ticket/services/github/mappers.ts b/packages/api/src/ticketing/ticket/services/github/mappers.ts new file mode 100644 index 000000000..bf9da57fb --- /dev/null +++ b/packages/api/src/ticketing/ticket/services/github/mappers.ts @@ -0,0 +1,28 @@ +import { ITicketMapper } from '@ticketing/ticket/types'; +import { GithubTicketInput, GithubTicketOutput } from './types'; +import { + UnifiedTicketInput, + UnifiedTicketOutput, +} from '@ticketing/ticket/types/model.unified'; + +export class GithubTicketMapper implements ITicketMapper { + desunify( + source: UnifiedTicketInput, + customFieldMappings?: { + slug: string; + remote_id: string; + }[], + ): GithubTicketInput { + return; + } + + async unify( + source: GithubTicketOutput | GithubTicketOutput[], + customFieldMappings?: { + slug: string; + remote_id: string; + }[], + ): Promise { + return; + } +} diff --git a/packages/api/src/ticketing/ticket/services/github/types.ts b/packages/api/src/ticketing/ticket/services/github/types.ts new file mode 100644 index 000000000..de468bb89 --- /dev/null +++ b/packages/api/src/ticketing/ticket/services/github/types.ts @@ -0,0 +1,195 @@ +export type GithubTicketInput = { + title: string; + body?: string; + assignee?: string | null; + milestone?: string | number | null; + labels?: string[]; + assignees?: string[]; +}; + +export type GithubTicketOutput = { + id: number; + node_id: string; + url: string; + repository_url: string; + labels_url: string; + comments_url: string; + events_url: string; + html_url: string; + number: number; + state: string; + title: string; + body: string; + user: GitHubUser; + labels: GitHubLabel[]; + assignee: GitHubUser; + assignees: GitHubUser[]; + milestone: GitHubMilestone; + locked: boolean; + active_lock_reason: string; + comments: number; + pull_request: GitHubPullRequest; + closed_at: string | null; + created_at: string; + updated_at: string; + repository: GitHubRepository; + author_association: string; +}; + +type GitHubUser = { + login: string; + id: number; + node_id: string; + avatar_url: string; + gravatar_id: string; + url: string; + html_url: string; + followers_url: string; + following_url: string; + gists_url: string; + starred_url: string; + subscriptions_url: string; + organizations_url: string; + repos_url: string; + events_url: string; + received_events_url: string; + type: string; + site_admin: boolean; +}; + +type GitHubLabel = { + id: number; + node_id: string; + url: string; + name: string; + description: string; + color: string; + default: boolean; +}; + +type GitHubMilestone = { + url: string; + html_url: string; + labels_url: string; + id: number; + node_id: string; + number: number; + state: string; + title: string; + description: string; + creator: GitHubUser; + open_issues: number; + closed_issues: number; + created_at: string; + updated_at: string; + closed_at: string | null; + due_on: string | null; +}; + +type GitHubPullRequest = { + url: string; + html_url: string; + diff_url: string; + patch_url: string; +}; + +type GitHubRepository = { + id: number; + node_id: string; + name: string; + full_name: string; + owner: GitHubUser; + private: boolean; + html_url: string; + description: string; + fork: boolean; + url: string; + archive_url: string; + assignees_url: string; + blobs_url: string; + branches_url: string; + collaborators_url: string; + comments_url: string; + commits_url: string; + compare_url: string; + contents_url: string; + contributors_url: string; + deployments_url: string; + downloads_url: string; + events_url: string; + forks_url: string; + git_commits_url: string; + git_refs_url: string; + git_tags_url: string; + git_url: string; + issue_comment_url: string; + issue_events_url: string; + issues_url: string; + keys_url: string; + labels_url: string; + languages_url: string; + merges_url: string; + milestones_url: string; + notifications_url: string; + pulls_url: string; + releases_url: string; + ssh_url: string; + stargazers_url: string; + statuses_url: string; + subscribers_url: string; + subscription_url: string; + tags_url: string; + teams_url: string; + trees_url: string; + clone_url: string; + mirror_url: string | null; + hooks_url: string; + svn_url: string; + homepage: string | null; + language: string | null; + forks_count: number; + stargazers_count: number; + watchers_count: number; + size: number; + default_branch: string; + open_issues_count: number; + is_template: boolean; + topics: string[]; + has_issues: boolean; + has_projects: boolean; + has_wiki: boolean; + has_pages: boolean; + has_downloads: boolean; + archived: boolean; + disabled: boolean; + visibility: string; + pushed_at: string; + created_at: string; + updated_at: string; + permissions: GitHubPermissions; + allow_rebase_merge: boolean; + template_repository: any; // Replace 'any' with the actual structure, if available. + temp_clone_token: string; + allow_squash_merge: boolean; + allow_auto_merge: boolean; + delete_branch_on_merge: boolean; + allow_merge_commit: boolean; + subscribers_count: number; + network_count: number; + license: GitHubLicense; + forks: number; +}; +type GitHubLicense = { + key: string; + name: string; + url: string | null; + spdx_id: string | null; + node_id: string; + html_url: string; +}; + +type GitHubPermissions = { + admin: boolean; + push: boolean; + pull: boolean; +}; diff --git a/packages/api/src/ticketing/ticket/services/hubspot/index.ts b/packages/api/src/ticketing/ticket/services/hubspot/index.ts new file mode 100644 index 000000000..c566eb1a1 --- /dev/null +++ b/packages/api/src/ticketing/ticket/services/hubspot/index.ts @@ -0,0 +1,115 @@ +import { Injectable } from '@nestjs/common'; +import { LoggerService } from '@@core/logger/logger.service'; +import { PrismaService } from '@@core/prisma/prisma.service'; +import { EncryptionService } from '@@core/encryption/encryption.service'; +import { TicketingObject } from '@ticketing/@utils/@types'; +import { ITicketService } from '@ticketing/ticket/types'; +import { ApiResponse } from '@@core/utils/types'; +import axios from 'axios'; +import { ActionType, handleServiceError } from '@@core/utils/errors'; +import { ServiceRegistry } from '../registry.service'; +import { + HubspotTicketInput, + HubspotTicketOutput, + commonHubspotProperties, +} from './types'; + +@Injectable() +export class HubspotService implements ITicketService { + constructor( + private prisma: PrismaService, + private logger: LoggerService, + private cryptoService: EncryptionService, + private registry: ServiceRegistry, + ) { + this.logger.setContext( + TicketingObject.ticket.toUpperCase() + ':' + HubspotService.name, + ); + this.registry.registerService('hubspot_t', this); + } + async addTicket( + ticketData: HubspotTicketInput, + linkedUserId: string, + ): Promise> { + try { + const connection = await this.prisma.connections.findFirst({ + where: { + id_linked_user: linkedUserId, + provider_slug: 'hubspot_t', + }, + }); + const dataBody = { properties: ticketData }; + const resp = await axios.post( + `https://api.hubapi.com/crm/v3/objects/tickets`, + JSON.stringify(dataBody), + { + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${this.cryptoService.decrypt( + connection.access_token, + )}`, + }, + }, + ); + return { + data: resp.data, + message: 'Hubspot ticket created', + statusCode: 201, + }; + } catch (error) { + handleServiceError( + error, + this.logger, + 'Hubspot', + TicketingObject.ticket, + ActionType.POST, + ); + } + } + async syncTickets( + linkedUserId: string, + custom_properties?: string[], + ): Promise> { + try { + const connection = await this.prisma.connections.findFirst({ + where: { + id_linked_user: linkedUserId, + provider_slug: 'hubspot_t', + }, + }); + + const commonPropertyNames = Object.keys(commonHubspotProperties); + const allProperties = [...commonPropertyNames, ...custom_properties]; + const baseURL = 'https://api.hubapi.com/crm/v3/objects/tickets/'; + + const queryString = allProperties + .map((prop) => `properties=${encodeURIComponent(prop)}`) + .join('&'); + + const url = `${baseURL}?${queryString}`; + const resp = await axios.get(url, { + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${this.cryptoService.decrypt( + connection.access_token, + )}`, + }, + }); + this.logger.log(`Synced hubspot tickets !`); + + return { + data: resp.data.results, + message: 'Hubspot tickets retrieved', + statusCode: 200, + }; + } catch (error) { + handleServiceError( + error, + this.logger, + 'Hubspot', + TicketingObject.ticket, + ActionType.GET, + ); + } + } +} diff --git a/packages/api/src/ticketing/ticket/services/hubspot/mappers.ts b/packages/api/src/ticketing/ticket/services/hubspot/mappers.ts new file mode 100644 index 000000000..6fb5e3ea9 --- /dev/null +++ b/packages/api/src/ticketing/ticket/services/hubspot/mappers.ts @@ -0,0 +1,79 @@ +import { ITicketMapper } from '@ticketing/ticket/types'; +import { HubspotTicketInput, HubspotTicketOutput } from './types'; +import { + UnifiedTicketInput, + UnifiedTicketOutput, +} from '@ticketing/ticket/types/model.unified'; + +export class HubspotTicketMapper implements ITicketMapper { + desunify( + source: UnifiedTicketInput, + customFieldMappings?: { + slug: string; + remote_id: string; + }[], + ): HubspotTicketInput { + const result = { + subject: source.name, + hs_pipeline: source.type, + hubspot_owner_id: '', // TODO Replace 'default' with actual owner ID + hs_pipeline_stage: source.status, + hs_ticket_priority: source.priority || 'MEDIUM', + }; + + if (customFieldMappings && source.field_mappings) { + for (const fieldMapping of source.field_mappings) { + for (const key in fieldMapping) { + const mapping = customFieldMappings.find( + (mapping) => mapping.slug === key, + ); + if (mapping) { + result[mapping.remote_id] = fieldMapping[key]; + } + } + } + } + + return result; + } + + async unify( + source: HubspotTicketOutput | HubspotTicketOutput[], + customFieldMappings?: { + slug: string; + remote_id: string; + }[], + ): Promise { + // If the source is not an array, convert it to an array for mapping + const sourcesArray = Array.isArray(source) ? source : [source]; + + return sourcesArray.map((ticket) => + this.mapSingleTicketToUnified(ticket, customFieldMappings), + ); + } + + private mapSingleTicketToUnified( + ticket: HubspotTicketOutput, + customFieldMappings?: { + slug: string; + remote_id: string; + }[], + ): UnifiedTicketOutput { + const field_mappings = customFieldMappings.map((mapping) => ({ + [mapping.slug]: ticket.properties[mapping.remote_id], + })); + + return { + name: ticket.properties.name, //TODO + status: ticket.properties.hs_pipeline_stage, + description: ticket.properties.description, //TODO + due_date: new Date(ticket.properties.createdate), + type: ticket.properties.hs_pipeline, + parent_ticket: '', // Define how you determine the parent ticket + completed_at: new Date(ticket.properties.hs_lastmodifieddate), + priority: ticket.properties.hs_ticket_priority, + assigned_to: [ticket.properties.hubspot_owner_id], // Define how you determine assigned users + field_mappings: field_mappings, + }; + } +} diff --git a/packages/api/src/ticketing/ticket/services/hubspot/types.ts b/packages/api/src/ticketing/ticket/services/hubspot/types.ts new file mode 100644 index 000000000..20585ae86 --- /dev/null +++ b/packages/api/src/ticketing/ticket/services/hubspot/types.ts @@ -0,0 +1,37 @@ +export type HubspotTicketInput = { + subject: string; + hs_pipeline: string; + hubspot_owner_id: string; + hs_pipeline_stage: string; + hs_ticket_priority: string; + [key: string]: any; +}; + +export type HubspotTicketOutput = { + id: string; + properties: TicketProperties; + createdAt: string; + updatedAt: string; + archived: boolean; +}; + +export type TicketProperties = { + createdate: string; + hs_lastmodifieddate: string; + hs_pipeline: string; + hs_pipeline_stage: string; + hs_ticket_priority: string; + hubspot_owner_id: string; + subject: string; + [key: string]: string; +}; + +export const commonHubspotProperties = { + createdate: '', + hs_lastmodifieddate: '', + hs_pipeline: '', + hs_pipeline_stage: '', + hs_ticket_priority: '', + hubspot_owner_id: '', + subject: '', +}; diff --git a/packages/api/src/ticketing/ticket/services/ticket.service.ts b/packages/api/src/ticketing/ticket/services/ticket.service.ts index af7b5e754..52edcef97 100644 --- a/packages/api/src/ticketing/ticket/services/ticket.service.ts +++ b/packages/api/src/ticketing/ticket/services/ticket.service.ts @@ -14,16 +14,13 @@ import { desunify } from '@@core/utils/unification/desunify'; import { TicketingObject } from '@ticketing/@utils/@types'; import { FieldMappingService } from '@@core/field-mapping/field-mapping.service'; import { unify } from '@@core/utils/unification/unify'; -import { normalizeComments } from '../utils'; import { OriginalTicketOutput } from '@@core/utils/types/original/original.ticketing'; import { ServiceRegistry } from './registry.service'; -import { ZendeskService } from './zendesk'; @Injectable() export class TicketService { constructor( private prisma: PrismaService, - private zendesk: ZendeskService, private logger: LoggerService, private webhook: WebhookService, private fieldMappingService: FieldMappingService, @@ -37,7 +34,7 @@ export class TicketService { integrationId: string, linkedUserId: string, remote_data?: boolean, - ): Promise> { + ): Promise { try { const responses = await Promise.all( unifiedTicketData.map((unifiedData) => @@ -49,20 +46,7 @@ export class TicketService { ), ), ); - - const allTickets = responses.flatMap((response) => response.data.tickets); - const allRemoteData = responses.flatMap( - (response) => response.data.remote_data || [], - ); - - return { - data: { - tickets: allTickets, - remote_data: allRemoteData, - }, - message: 'All tickets inserted successfully', - statusCode: 201, - }; + return responses; } catch (error) { handleServiceError(error, this.logger); } @@ -73,29 +57,51 @@ export class TicketService { integrationId: string, linkedUserId: string, remote_data?: boolean, - ): Promise> { + ): Promise { try { const linkedUser = await this.prisma.linked_users.findUnique({ where: { id_linked_user: linkedUserId, }, }); - const job_resp_create = await this.prisma.events.create({ - data: { - id_event: uuidv4(), - status: 'initialized', - type: 'ticketing.ticket.created', //sync, push or pull - method: 'POST', - url: '/ticketing/ticket', - provider: integrationId, - direction: '0', - timestamp: new Date(), - id_linked_user: linkedUserId, - }, - }); - const job_id = job_resp_create.id_event; + //CHECKS + if (!linkedUser) throw new Error('Linked User Not Found'); + const acc = unifiedTicketData.account_id; + //check if contact_id and account_id refer to real uuids + if (acc) { + const search = await this.prisma.tcg_accounts.findUnique({ + where: { + id_tcg_account: acc, + }, + }); + if (!search) + throw new Error('You inserted an account_id which does not exist'); + } - //TODO + const contact = unifiedTicketData.contact_id; + //check if contact_id and account_id refer to real uuids + if (contact) { + const search = await this.prisma.tcg_contacts.findUnique({ + where: { + id_tcg_contact: contact, + }, + }); + if (!search) + throw new Error('You inserted a contact_id which does not exist'); + } + const assignees = unifiedTicketData.assigned_to; + //CHEK IF assigned_to contains valid Users uuids + if (assignees && assignees.length > 0) { + assignees.map(async (assignee) => { + const search = await this.prisma.tcg_users.findUnique({ + where: { + id_tcg_user: assignee, + }, + }); + if (!search) + throw new Error('You inserted an assignee which does not exist'); + }); + } // Retrieve custom field mappings // get potential fieldMappings and extract the original properties name const customFieldMappings = @@ -139,76 +145,91 @@ export class TicketService { where: { remote_id: originId, remote_platform: integrationId, - events: { - id_linked_user: linkedUserId, - }, + id_linked_user: linkedUserId, }, - include: { tcg_comments: true }, }); - const { normalizedComments } = normalizeComments(target_ticket.comments); - let unique_ticketing_ticket_id: string; if (existingTicket) { // Update the existing ticket + let data: any = { + id_tcg_ticket: uuidv4(), + modified_at: new Date(), + }; + if (target_ticket.name) { + data = { ...data, name: target_ticket.name }; + } + if (target_ticket.status) { + data = { ...data, status: target_ticket.status }; + } + if (target_ticket.description) { + data = { ...data, description: target_ticket.description }; + } + if (target_ticket.type) { + data = { ...data, ticket_type: target_ticket.type }; + } + if (target_ticket.tags) { + data = { ...data, tags: target_ticket.tags }; + } + if (target_ticket.priority) { + data = { ...data, priority: target_ticket.priority }; + } + if (target_ticket.completed_at) { + data = { ...data, completed_at: target_ticket.completed_at }; + } + if (target_ticket.assigned_to) { + data = { ...data, assigned_to: target_ticket.assigned_to }; + } + /* + parent_ticket: target_ticket.parent_ticket || 'd', + */ const res = await this.prisma.tcg_tickets.update({ where: { id_tcg_ticket: existingTicket.id_tcg_ticket, }, - data: { - name: target_ticket.name || '', - status: target_ticket.status || '', - description: target_ticket.description || '', - due_date: target_ticket.due_date || '', - ticket_type: target_ticket.type || '', - parent_ticket: target_ticket.parent_ticket || '', - tags: target_ticket.tags || '', - completed_at: target_ticket.completed_at || '', - priority: target_ticket.priority || '', - assigned_to: target_ticket.assigned_to || [], - modified_at: new Date(), - tcg_comments: { - update: normalizedComments.map((comment, index) => ({ - where: { - id_tcg_ticket: - existingTicket.tcg_comments[index].id_tcg_ticket, - id_tcg_comment: - existingTicket.tcg_comments[index].id_tcg_comment, - }, - data: comment, - })), - }, - }, + data: data, }); unique_ticketing_ticket_id = res.id_tcg_ticket; } else { // Create a new ticket this.logger.log('not existing ticket ' + target_ticket.name); - const data = { + + let data: any = { id_tcg_ticket: uuidv4(), - name: target_ticket.name || '', - status: target_ticket.status || '', - description: target_ticket.description || '', - due_date: target_ticket.due_date || '', - ticket_type: target_ticket.type || '', - parent_ticket: target_ticket.parent_ticket || '', - tags: target_ticket.tags || '', - completed_at: target_ticket.completed_at || '', - priority: target_ticket.priority || '', - assigned_to: target_ticket.assigned_to || [], created_at: new Date(), modified_at: new Date(), - id_event: job_id, + id_linked_user: linkedUserId, remote_id: originId, remote_platform: integrationId, }; - - if (normalizedComments) { - data['tcg_comments'] = { - create: normalizedComments, - }; + if (target_ticket.name) { + data = { ...data, name: target_ticket.name }; } + if (target_ticket.status) { + data = { ...data, status: target_ticket.status }; + } + if (target_ticket.description) { + data = { ...data, description: target_ticket.description }; + } + if (target_ticket.type) { + data = { ...data, ticket_type: target_ticket.type }; + } + if (target_ticket.tags) { + data = { ...data, tags: target_ticket.tags }; + } + if (target_ticket.priority) { + data = { ...data, priority: target_ticket.priority }; + } + if (target_ticket.completed_at) { + data = { ...data, completed_at: target_ticket.completed_at }; + } + if (target_ticket.assigned_to) { + data = { ...data, assigned_to: target_ticket.assigned_to }; + } + /* + parent_ticket: target_ticket.parent_ticket || 'd', + */ const res = await this.prisma.tcg_tickets.create({ data: data, @@ -277,45 +298,47 @@ export class TicketService { }); } - ///// const result_ticket = await this.getTicket( unique_ticketing_ticket_id, remote_data, ); const status_resp = resp.statusCode === 201 ? 'success' : 'fail'; - await this.prisma.events.update({ - where: { - id_event: job_id, - }, + const event = await this.prisma.events.create({ data: { + id_event: uuidv4(), status: status_resp, + type: 'ticketing.ticket.push', //sync, push or pull + method: 'PUSH', + url: '/ticketing/ticket', + provider: integrationId, + direction: '0', + timestamp: new Date(), + id_linked_user: linkedUserId, }, }); await this.webhook.handleWebhook( - result_ticket.data.tickets, + result_ticket, 'ticketing.ticket.created', linkedUser.id_project, - job_id, + event.id_event, ); - return { ...resp, data: result_ticket.data }; + return result_ticket; } catch (error) { handleServiceError(error, this.logger); } } + //TODO: given params return attachments and comments async getTicket( id_ticketing_ticket: string, remote_data?: boolean, - ): Promise> { + ): Promise { try { const ticket = await this.prisma.tcg_tickets.findUnique({ where: { id_tcg_ticket: id_ticketing_ticket, }, - include: { - tcg_comments: true, - }, }); // Fetch field mappings for the ticket @@ -351,21 +374,15 @@ export class TicketService { due_date: ticket.due_date || null, type: ticket.ticket_type || '', parent_ticket: ticket.parent_ticket || '', - tags: ticket.tags || '', + tags: ticket.tags || [], completed_at: ticket.completed_at || null, priority: ticket.priority || '', assigned_to: ticket.assigned_to || [], - comments: ticket.tcg_comments.map((comment) => ({ - remote_id: comment.remote_id, - body: comment.body, - html_body: comment.html_body, - is_private: comment.is_private, - })), field_mappings: field_mappings, }; - let res: TicketResponse = { - tickets: [unifiedTicket], + let res: UnifiedTicketOutput = { + ...unifiedTicket, }; if (remote_data) { @@ -378,14 +395,11 @@ export class TicketService { res = { ...res, - remote_data: [remote_data], + remote_data: remote_data, }; } - return { - data: res, - statusCode: 200, - }; + return res; } catch (error) { handleServiceError(error, this.logger); } @@ -395,33 +409,18 @@ export class TicketService { integrationId: string, linkedUserId: string, remote_data?: boolean, - ): Promise> { + ): Promise { try { //TODO: handle case where data is not there (not synced) or old synced - const job_resp_create = await this.prisma.events.create({ - data: { - id_event: uuidv4(), - status: 'initialized', - type: 'ticketing.ticket.pull', - method: 'GET', - url: '/ticketing/ticket', - provider: integrationId, - direction: '0', - timestamp: new Date(), - id_linked_user: linkedUserId, - }, - }); - const job_id = job_resp_create.id_event; const tickets = await this.prisma.tcg_tickets.findMany({ where: { - remote_id: integrationId.toLowerCase(), - events: { - id_linked_user: linkedUserId, - }, + remote_platform: integrationId.toLowerCase(), + id_linked_user: linkedUserId, }, + /* TODO: only if params include: { tcg_comments: true, - }, + },*/ }); const unifiedTickets: UnifiedTicketOutput[] = await Promise.all( @@ -454,62 +453,55 @@ export class TicketService { return { id: ticket.id_tcg_ticket, name: ticket.name || '', - remote_id: ticket.remote_id || '', status: ticket.status || '', description: ticket.description || '', due_date: ticket.due_date || null, type: ticket.ticket_type || '', parent_ticket: ticket.parent_ticket || '', - tags: ticket.tags || '', + tags: ticket.tags || [], completed_at: ticket.completed_at || null, priority: ticket.priority || '', assigned_to: ticket.assigned_to || [], - comments: ticket.tcg_comments.map((comment) => ({ - remote_id: comment.remote_id, - body: comment.body, - html_body: comment.html_body, - is_private: comment.is_private, - })), field_mappings: field_mappings, }; }), ); - let res: TicketResponse = { - tickets: unifiedTickets, - }; - + let res: UnifiedTicketOutput[] = unifiedTickets; if (remote_data) { - const remote_array_data: Record[] = await Promise.all( - tickets.map(async (ticket) => { + const remote_array_data: UnifiedTicketOutput[] = await Promise.all( + res.map(async (ticket) => { const resp = await this.prisma.remote_data.findFirst({ where: { - ressource_owner_id: ticket.id_tcg_ticket, + ressource_owner_id: ticket.id, }, }); - const remote_data = JSON.parse(resp.data); - return remote_data; + //TODO: + let remote_data: any; + if (resp && resp.data) { + remote_data = JSON.parse(resp.data); + } + return { ...ticket, remote_data }; }), ); - - res = { - ...res, - remote_data: remote_array_data, - }; + res = remote_array_data; } - await this.prisma.events.update({ - where: { - id_event: job_id, - }, + + const event = await this.prisma.events.create({ data: { + id_event: uuidv4(), status: 'success', + type: 'ticketing.ticket.pulled', + method: 'GET', + url: '/ticketing/ticket', + provider: integrationId, + direction: '0', + timestamp: new Date(), + id_linked_user: linkedUserId, }, }); - return { - data: res, - statusCode: 200, - }; + return res; } catch (error) { handleServiceError(error, this.logger); } @@ -518,7 +510,7 @@ export class TicketService { async updateTicket( id: string, updateTicketData: Partial, - ): Promise> { + ): Promise { try { } catch (error) { handleServiceError(error, this.logger); diff --git a/packages/api/src/ticketing/ticket/services/zendesk/index.ts b/packages/api/src/ticketing/ticket/services/zendesk/index.ts index 61454b754..6585e97e1 100644 --- a/packages/api/src/ticketing/ticket/services/zendesk/index.ts +++ b/packages/api/src/ticketing/ticket/services/zendesk/index.ts @@ -2,14 +2,16 @@ import { Injectable } from '@nestjs/common'; import { LoggerService } from '@@core/logger/logger.service'; import { PrismaService } from '@@core/prisma/prisma.service'; import { EncryptionService } from '@@core/encryption/encryption.service'; -import { TicketingObject, ZendeskTicketOutput } from '@ticketing/@utils/@types'; +import { + TicketingObject, + ZendeskTicketInput, + ZendeskTicketOutput, +} from '@ticketing/@utils/@types'; import { ITicketService } from '@ticketing/ticket/types'; import { ApiResponse } from '@@core/utils/types'; -import { DesunifyReturnType } from '@@core/utils/types/desunify.input'; import axios from 'axios'; import { ActionType, handleServiceError } from '@@core/utils/errors'; import { EnvironmentService } from '@@core/environment/environment.service'; -import { OriginalTicketOutput } from '@@core/utils/types/original/original.ticketing'; import { ServiceRegistry } from '../registry.service'; @Injectable() @@ -24,23 +26,65 @@ export class ZendeskService implements ITicketService { this.logger.setContext( TicketingObject.ticket.toUpperCase() + ':' + ZendeskService.name, ); - this.registry.registerService('zendesk_t', this); + this.registry.registerService('zendesk_tcg', this); } async addTicket( - ticketData: DesunifyReturnType, + ticketData: ZendeskTicketInput, linkedUserId: string, ): Promise> { try { - //TODO: check required scope => crm.objects.contacts.write const connection = await this.prisma.connections.findFirst({ where: { id_linked_user: linkedUserId, - provider_slug: 'zendesk_t', + provider_slug: 'zendesk_tcg', }, }); - const dataBody = { + let dataBody = { ticket: ticketData, }; + // We must fetch tokens from zendesk with the commentData.uploads array of Attachment uuids + const uuids = ticketData.comment.uploads; + let uploads = []; + if (uuids && uuids.length > 0) { + await Promise.all( + uuids.map(async (uuid) => { + const res = await this.prisma.tcg_attachments.findUnique({ + where: { + id_tcg_attachment: uuid, + }, + }); + if (!res) + throw new Error(`tcg_attachment not found for uuid ${uuid}`); + + //TODO:; fetch the right file from AWS s3 + const s3File = ''; + const url = `https://${this.env.getZendeskTicketingSubdomain()}.zendesk.com/api/v2/uploads.json?filename=${ + res.file_name + }`; + + const resp = await axios.get(url, { + headers: { + 'Content-Type': 'image/png', //TODO: get the right content-type given a file name extension + Authorization: `Bearer ${this.cryptoService.decrypt( + connection.access_token, + )}`, + }, + }); + uploads = [...uploads, resp.data.upload.token]; + }), + ); + const finalData = { + ...ticketData, + comment: { + ...ticketData.comment, + uploads: uploads, + }, + }; + dataBody = { + ticket: finalData, + }; + } + const resp = await axios.post( `https://${this.env.getZendeskTicketingSubdomain()}.zendesk.com/api/v2/tickets.json`, JSON.stringify(dataBody), @@ -54,7 +98,7 @@ export class ZendeskService implements ITicketService { }, ); return { - data: resp.data, + data: resp.data.ticket, message: 'Zendesk ticket created', statusCode: 201, }; @@ -68,10 +112,44 @@ export class ZendeskService implements ITicketService { ); } } - syncTickets( + async syncTickets( linkedUserId: string, custom_properties?: string[], - ): Promise> { - throw new Error('Method not implemented.'); + ): Promise> { + try { + const connection = await this.prisma.connections.findFirst({ + where: { + id_linked_user: linkedUserId, + provider_slug: 'zendesk_tcg', + }, + }); + + const resp = await axios.get( + `https://${this.env.getZendeskTicketingSubdomain()}.zendesk.com/api/v2/tickets.json`, + { + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${this.cryptoService.decrypt( + connection.access_token, + )}`, + }, + }, + ); + this.logger.log(`Synced zendesk tickets !`); + + return { + data: resp.data.tickets, + message: 'Zendesk tickets retrieved', + statusCode: 200, + }; + } catch (error) { + handleServiceError( + error, + this.logger, + 'Zendesk', + TicketingObject.ticket, + ActionType.GET, + ); + } } } diff --git a/packages/api/src/ticketing/ticket/services/zendesk/mappers.ts b/packages/api/src/ticketing/ticket/services/zendesk/mappers.ts index 08c18bf6f..0ad5b254a 100644 --- a/packages/api/src/ticketing/ticket/services/zendesk/mappers.ts +++ b/packages/api/src/ticketing/ticket/services/zendesk/mappers.ts @@ -4,39 +4,74 @@ import { UnifiedTicketInput, UnifiedTicketOutput, } from '@ticketing/ticket/types/model.unified'; +import { Utils } from '@ticketing/ticket/utils'; export class ZendeskTicketMapper implements ITicketMapper { - desunify( + private readonly utils = new Utils(); + + async desunify( source: UnifiedTicketInput, customFieldMappings?: { slug: string; remote_id: string; }[], - ): ZendeskTicketInput { - const result: ZendeskTicketInput = { - assignee_email: source.assigned_to?.[0], // Assuming the first assigned_to is the assignee email - created_at: source.completed_at?.toISOString(), - custom_fields: undefined, // Custom field mapping logic needed TODO + ): Promise { + let result: ZendeskTicketInput = { description: source.description, - due_at: source.due_date?.toISOString(), - priority: source.priority as 'urgent' | 'high' | 'normal' | 'low', - status: source.status as - | 'new' - | 'open' - | 'pending' - | 'hold' - | 'solved' - | 'closed', + priority: 'high', + status: 'new', subject: source.name, - tags: [source.tags], - type: source.type as 'problem' | 'incident' | 'question' | 'task', - updated_at: source.completed_at?.toISOString(), comment: { - body: source.comments[0].body, - html_body: source.comments[0].html_body, - public: !source.comments[0].is_private, + body: source.comment.body, + html_body: source.comment.html_body || null, + public: !source.comment.is_private || true, + uploads: source.comment.attachments, //fetch token attachments for this uuid, would be done on the fly in dest service }, }; + if (source.assigned_to && source.assigned_to.length > 0) { + result = { + ...result, + assignee_email: await this.utils.getAssigneeMetadataFromUuid( + source.assigned_to?.[0], + ), // get the mail of the uuid + }; + } + if (source.due_date) { + result = { + ...result, + due_at: source.due_date?.toISOString(), + }; + } + if (source.priority) { + result = { + ...result, + priority: source.priority as 'urgent' | 'high' | 'normal' | 'low', + }; + } + if (source.status) { + result = { + ...result, + status: source.status as + | 'new' + | 'open' + | 'pending' + | 'hold' + | 'solved' + | 'closed', + }; + } + if (source.tags) { + result = { + ...result, + tags: source.tags, + }; + } + if (source.type) { + result = { + ...result, + type: source.type as 'problem' | 'incident' | 'question' | 'task', + }; + } if (customFieldMappings && source.field_mappings) { let res: CustomField[] = []; @@ -46,8 +81,7 @@ export class ZendeskTicketMapper implements ITicketMapper { (mapping) => mapping.slug === key, ); if (mapping) { - const obj = { id: mapping.remote_id, value: fieldMapping[key] }; //TODO - //result[custom_fields][mapping.remote_id] = fieldMapping[key]; + const obj = { id: mapping.remote_id, value: fieldMapping[key] }; res = [...res, obj]; } } @@ -57,31 +91,56 @@ export class ZendeskTicketMapper implements ITicketMapper { return result; } - unify( + async unify( source: ZendeskTicketOutput | ZendeskTicketOutput[], customFieldMappings?: { slug: string; remote_id: string; }[], - ): UnifiedTicketOutput | UnifiedTicketOutput[] { + ): Promise { if (!Array.isArray(source)) { return this.mapSingleTicketToUnified(source, customFieldMappings); } - return source.map((ticket) => - this.mapSingleTicketToUnified(ticket, customFieldMappings), + return Promise.all( + source.map((ticket) => + this.mapSingleTicketToUnified(ticket, customFieldMappings), + ), ); } - private mapSingleTicketToUnified( + private async mapSingleTicketToUnified( ticket: ZendeskTicketOutput, customFieldMappings?: { slug: string; remote_id: string; }[], - ): UnifiedTicketOutput { - /*TODO const field_mappings = customFieldMappings.map((mapping) => ({ - [mapping.slug]: ticket.custom_fields[mapping.remote_id], - }));*/ + ): Promise> { + const field_mappings = customFieldMappings.reduce((acc, mapping) => { + const customField = ticket.custom_fields.find( + (field) => field.id === mapping.remote_id, + ); + if (customField) { + acc.push({ [mapping.slug]: customField.value }); + } + return acc; + }, [] as Record[]); + let opts: any; + + /* TODO: uncomment when test for sync of users/contacts is done as right now we dont have any real users nor contacts inside our db + + if (ticket.assignee_id) { + //fetch the right assignee uuid from remote id + const user_id = await this.utils.getUserUuidFromRemoteId( + String(ticket.assignee_id), + 'zendesk_tcg', + ); + if (user_id) { + opts = { assigned_to: [user_id] }; + } else { + //TODO: in future we must throw an error ? + //throw new Error('user id not found for this ticket'); + } + }*/ const unifiedTicket: UnifiedTicketOutput = { name: ticket.subject, @@ -90,22 +149,11 @@ export class ZendeskTicketMapper implements ITicketMapper { due_date: ticket.due_at ? new Date(ticket.due_at) : undefined, type: ticket.type, parent_ticket: undefined, // If available, add logic to map parent ticket - tags: JSON.stringify(ticket.tags), //TODO - completed_at: undefined, // If available, add logic to determine the completed date + tags: ticket.tags, + completed_at: new Date(ticket.updated_at), priority: ticket.priority, - assigned_to: undefined, // If available, add logic to map assigned users - comments: ticket.comment - ? [ - { - remote_id: ticket.comment.id.toString(), - body: ticket.comment.body, - html_body: ticket.comment.html_body, - is_private: !ticket.comment.public, - }, - ] - : undefined, - field_mappings: undefined, // Add logic to map custom fields if available - id: ticket.id.toString(), + field_mappings: field_mappings, + ...opts, }; return unifiedTicket; diff --git a/packages/api/src/ticketing/ticket/sync/sync.service.ts b/packages/api/src/ticketing/ticket/sync/sync.service.ts index bf2f048fc..d2df76233 100644 --- a/packages/api/src/ticketing/ticket/sync/sync.service.ts +++ b/packages/api/src/ticketing/ticket/sync/sync.service.ts @@ -11,7 +11,6 @@ import { TicketingObject } from '@ticketing/@utils/@types'; import { UnifiedTicketOutput } from '../types/model.unified'; import { WebhookService } from '@@core/webhook/webhook.service'; import { tcg_tickets as TicketingTicket } from '@prisma/client'; -import { normalizeComments } from '../utils'; import { ITicketService } from '../types'; import { OriginalTicketOutput } from '@@core/utils/types/original/original.ticketing'; import { ServiceRegistry } from '../services/registry.service'; @@ -30,7 +29,7 @@ export class SyncService implements OnModuleInit { async onModuleInit() { try { - await this.syncTickets(); + //await this.syncTickets(); } catch (error) { handleServiceError(error, this.logger); } @@ -101,20 +100,6 @@ export class SyncService implements OnModuleInit { }, }); if (!connection) return; - const job_resp_create = await this.prisma.events.create({ - data: { - id_event: uuidv4(), - status: 'initialized', - type: 'ticketing.ticket.pulled', - method: 'PULL', - url: '/pull', - provider: integrationId, - direction: '0', - timestamp: new Date(), - id_linked_user: linkedUserId, - }, - }); - const job_id = job_resp_create.id_event; // get potential fieldMappings and extract the original properties name const customFieldMappings = @@ -142,7 +127,7 @@ export class SyncService implements OnModuleInit { customFieldMappings, })) as UnifiedTicketOutput[]; - //TODO + //remote Ids in provider's tools const ticketIds = sourceObject.map((ticket) => 'id' in ticket ? String(ticket.id) : undefined, ); @@ -153,22 +138,28 @@ export class SyncService implements OnModuleInit { unifiedObject, ticketIds, integrationId, - job_id, sourceObject, ); - await this.prisma.events.update({ - where: { - id_event: job_id, - }, + + const event = await this.prisma.events.create({ data: { + id_event: uuidv4(), status: 'success', + type: 'ticketing.ticket.synced', + method: 'SYNC', + url: '/sync', + provider: integrationId, + direction: '0', + timestamp: new Date(), + id_linked_user: linkedUserId, }, }); + await this.webhook.handleWebhook( tickets_data, - 'ticketing.ticket.pulled', + 'ticketing.ticket.synced', id_project, - job_id, + event.id_event, ); } catch (error) { handleServiceError(error, this.logger); @@ -180,7 +171,6 @@ export class SyncService implements OnModuleInit { tickets: UnifiedTicketOutput[], originIds: string[], originSource: string, - jobId: string, remote_data: Record[], ): Promise { try { @@ -197,82 +187,98 @@ export class SyncService implements OnModuleInit { where: { remote_id: originId, remote_platform: originSource, - events: { - id_linked_user: linkedUserId, - }, + id_linked_user: linkedUserId, }, - include: { tcg_comments: true }, }); - const { normalizedComments } = normalizeComments(ticket.comments); - let unique_ticketing_ticket_id: string; if (existingTicket) { // Update the existing ticket + let data: any = { + id_tcg_ticket: uuidv4(), + modified_at: new Date(), + }; + if (ticket.name) { + data = { ...data, name: ticket.name }; + } + if (ticket.status) { + data = { ...data, status: ticket.status }; + } + if (ticket.description) { + data = { ...data, description: ticket.description }; + } + if (ticket.type) { + data = { ...data, ticket_type: ticket.type }; + } + if (ticket.tags) { + data = { ...data, tags: ticket.tags }; + } + if (ticket.priority) { + data = { ...data, priority: ticket.priority }; + } + if (ticket.completed_at) { + data = { ...data, completed_at: ticket.completed_at }; + } + if (ticket.assigned_to) { + data = { ...data, assigned_to: ticket.assigned_to }; + } + /* + parent_ticket: ticket.parent_ticket || 'd', + */ const res = await this.prisma.tcg_tickets.update({ where: { id_tcg_ticket: existingTicket.id_tcg_ticket, }, - data: { - name: ticket.name || '', - status: ticket.status || '', - description: ticket.description || '', - due_date: ticket.due_date || '', - ticket_type: ticket.type || '', - parent_ticket: ticket.parent_ticket || '', - tags: ticket.tags || '', - completed_at: ticket.completed_at || '', - priority: ticket.priority || '', - assigned_to: ticket.assigned_to || [], - modified_at: new Date(), - tcg_comments: { - update: normalizedComments.map((comment, index) => ({ - where: { - id_tcg_ticket: - existingTicket.tcg_comments[index].id_tcg_ticket, - id_tcg_comment: - existingTicket.tcg_comments[index].id_tcg_comment, - }, - data: comment, - })), - }, - }, + data: data, }); unique_ticketing_ticket_id = res.id_tcg_ticket; tickets_results = [...tickets_results, res]; } else { // Create a new ticket this.logger.log('not existing ticket ' + ticket.name); - const data = { + + let data: any = { id_tcg_ticket: uuidv4(), - name: ticket.name || '', - status: ticket.status || '', - description: ticket.description || '', - due_date: ticket.due_date || '', - ticket_type: ticket.type || '', - parent_ticket: ticket.parent_ticket || '', - tags: ticket.tags || '', - completed_at: ticket.completed_at || '', - priority: ticket.priority || '', - assigned_to: ticket.assigned_to || [], created_at: new Date(), modified_at: new Date(), - id_event: jobId, + id_linked_user: linkedUserId, remote_id: originId, remote_platform: originSource, }; - - if (normalizedComments) { - data['tcg_comments'] = { - create: normalizedComments, - }; + if (ticket.name) { + data = { ...data, name: ticket.name }; + } + if (ticket.status) { + data = { ...data, status: ticket.status }; + } + if (ticket.description) { + data = { ...data, description: ticket.description }; + } + if (ticket.type) { + data = { ...data, ticket_type: ticket.type }; + } + if (ticket.tags) { + data = { ...data, tags: ticket.tags }; + } + if (ticket.priority) { + data = { ...data, priority: ticket.priority }; } + if (ticket.completed_at) { + data = { ...data, completed_at: ticket.completed_at }; + } + if (ticket.assigned_to) { + data = { ...data, assigned_to: ticket.assigned_to }; + } + /* + parent_ticket: ticket.parent_ticket || 'd', + */ + const res = await this.prisma.tcg_tickets.create({ data: data, }); - tickets_results = [...tickets_results, res]; unique_ticketing_ticket_id = res.id_tcg_ticket; + tickets_results = [...tickets_results, res]; } // check duplicate or existing values diff --git a/packages/api/src/ticketing/ticket/ticket.controller.ts b/packages/api/src/ticketing/ticket/ticket.controller.ts index fb5e972b5..7fbfd0579 100644 --- a/packages/api/src/ticketing/ticket/ticket.controller.ts +++ b/packages/api/src/ticketing/ticket/ticket.controller.ts @@ -114,13 +114,13 @@ export class TicketController { //@ApiCustomResponse(TicketResponse) @Post() addTicket( - @Body() unfiedContactData: UnifiedTicketInput, + @Body() unfiedTicketData: UnifiedTicketInput, @Headers('integrationId') integrationId: string, @Headers('linkedUserId') linkedUserId: string, @Query('remoteData') remote_data?: boolean, ) { return this.ticketService.addTicket( - unfiedContactData, + unfiedTicketData, integrationId, linkedUserId, remote_data, diff --git a/packages/api/src/ticketing/ticket/ticket.module.ts b/packages/api/src/ticketing/ticket/ticket.module.ts index 9fd39e2f2..fbe503b50 100644 --- a/packages/api/src/ticketing/ticket/ticket.module.ts +++ b/packages/api/src/ticketing/ticket/ticket.module.ts @@ -10,6 +10,9 @@ import { ZendeskService } from './services/zendesk'; import { BullModule } from '@nestjs/bull'; import { FieldMappingService } from '@@core/field-mapping/field-mapping.service'; import { ServiceRegistry } from './services/registry.service'; +import { HubspotService } from './services/hubspot'; +import { FrontService } from './services/front'; +import { GithubService } from './services/github'; @Module({ imports: [ @@ -29,6 +32,9 @@ import { ServiceRegistry } from './services/registry.service'; ServiceRegistry, /* PROVIDERS SERVICES */ ZendeskService, + HubspotService, + FrontService, + GithubService, ], exports: [SyncService], }) diff --git a/packages/api/src/ticketing/ticket/types/index.ts b/packages/api/src/ticketing/ticket/types/index.ts index 98d495abc..3fbb662ed 100644 --- a/packages/api/src/ticketing/ticket/types/index.ts +++ b/packages/api/src/ticketing/ticket/types/index.ts @@ -30,7 +30,7 @@ export interface ITicketMapper { slug: string; remote_id: string; }[], - ): UnifiedTicketOutput | UnifiedTicketOutput[]; + ): Promise; } export type Comment = { diff --git a/packages/api/src/ticketing/ticket/types/mappingsTypes.ts b/packages/api/src/ticketing/ticket/types/mappingsTypes.ts index ce6600804..91d90131b 100644 --- a/packages/api/src/ticketing/ticket/types/mappingsTypes.ts +++ b/packages/api/src/ticketing/ticket/types/mappingsTypes.ts @@ -1,10 +1,28 @@ +import { FrontTicketMapper } from '../services/front/mappers'; +import { GithubTicketMapper } from '../services/github/mappers'; +import { HubspotTicketMapper } from '../services/hubspot/mappers'; import { ZendeskTicketMapper } from '../services/zendesk/mappers'; const zendeskTicketMapper = new ZendeskTicketMapper(); +const frontTicketMapper = new FrontTicketMapper(); +const githubTicketMapper = new GithubTicketMapper(); +const hubspotTicketMapper = new HubspotTicketMapper(); export const ticketUnificationMapping = { - zendesk: { - unify: zendeskTicketMapper.unify, + zendesk_tcg: { + unify: zendeskTicketMapper.unify.bind(zendeskTicketMapper), desunify: zendeskTicketMapper.desunify, }, + front: { + unify: frontTicketMapper.unify.bind(frontTicketMapper), + desunify: frontTicketMapper.desunify, + }, + github: { + unify: githubTicketMapper.unify.bind(githubTicketMapper), + desunify: githubTicketMapper.desunify, + }, + hubspot: { + unify: hubspotTicketMapper.unify.bind(hubspotTicketMapper), + desunify: hubspotTicketMapper.desunify, + }, }; diff --git a/packages/api/src/ticketing/ticket/types/model.unified.ts b/packages/api/src/ticketing/ticket/types/model.unified.ts index e6ca0ca16..133f8e564 100644 --- a/packages/api/src/ticketing/ticket/types/model.unified.ts +++ b/packages/api/src/ticketing/ticket/types/model.unified.ts @@ -1,5 +1,5 @@ -import { ApiPropertyOptional } from '@nestjs/swagger'; -import { Comment } from '@ticketing/ticket/types'; +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { UnifiedCommentInput } from '@ticketing/comment/types/model.unified'; export class UnifiedTicketInput { name: string; @@ -8,16 +8,23 @@ export class UnifiedTicketInput { due_date?: Date; type?: string; parent_ticket?: string; - tags?: string; + tags?: string[]; // tags names completed_at?: Date; priority?: string; - assigned_to?: string[]; - comments?: Comment[]; - @ApiPropertyOptional({ type: [{}] }) + assigned_to?: string[]; //uuid of Users objects ? + comment?: UnifiedCommentInput; + account_id?: string; + contact_id?: string; field_mappings?: Record[]; } - export class UnifiedTicketOutput extends UnifiedTicketInput { - @ApiPropertyOptional() + @ApiPropertyOptional({ description: 'The id of the ticket', type: String }) id?: string; + @ApiPropertyOptional({ + description: + 'The id of the ticket in the context of the Ticketing software', + type: String, + }) + remote_id?: string; + remote_data?: Record; } diff --git a/packages/api/src/ticketing/ticket/utils/index.ts b/packages/api/src/ticketing/ticket/utils/index.ts index e50c7be29..73b748e99 100644 --- a/packages/api/src/ticketing/ticket/utils/index.ts +++ b/packages/api/src/ticketing/ticket/utils/index.ts @@ -1,14 +1,58 @@ -import { v4 as uuidv4 } from 'uuid'; -import { Comment } from '@ticketing/ticket/types'; - -export function normalizeComments(comments: Comment[]) { - const normalizedComments = comments.map((comment) => ({ - ...comment, - created_at: new Date(), - modified_at: new Date(), - id_tcg_comment: uuidv4(), - })); - return { - normalizedComments, - }; +import { PrismaClient } from '@prisma/client'; + +export class Utils { + private readonly prisma: PrismaClient; + constructor() { + this.prisma = new PrismaClient(); + } + + async getUserUuidFromRemoteId(remote_id: string, remote_platform: string) { + try { + const res = await this.prisma.tcg_users.findFirst({ + where: { + remote_id: remote_id, + remote_platform: remote_platform, + }, + }); + if (!res) + throw new Error( + `tcg_user not found for remote_id ${remote_id} and integration ${remote_platform}`, + ); + return res.id_tcg_user; + } catch (error) { + throw new Error(error); + } + } + + async getAsigneeRemoteIdFromUserUuid(uuid: string, remote_platform: string) { + try { + const res = await this.prisma.tcg_users.findFirst({ + where: { + id_tcg_user: uuid, + remote_platform: remote_platform, + }, + }); + if (!res) + throw new Error( + `tcg_user not found for uuid ${uuid} and integration ${remote_platform}`, + ); + return res.remote_id; + } catch (error) { + throw new Error(error); + } + } + + async getAssigneeMetadataFromUuid(uuid: string) { + try { + const res = await this.prisma.tcg_users.findUnique({ + where: { + id_tcg_user: uuid, + }, + }); + if (!res) throw new Error(`tcg_user not found for uuid ${uuid}`); + return res.email_address; + } catch (error) { + throw new Error(error); + } + } } diff --git a/packages/api/src/ticketing/ticketing.module.ts b/packages/api/src/ticketing/ticketing.module.ts index ee4575e4d..4e1cd7302 100644 --- a/packages/api/src/ticketing/ticketing.module.ts +++ b/packages/api/src/ticketing/ticketing.module.ts @@ -4,6 +4,9 @@ import { CommentModule } from './comment/comment.module'; import { UserModule } from './user/user.module'; import { AttachmentModule } from './attachment/attachment.module'; import { ContactModule } from './contact/contact.module'; +import { AccountModule } from './account/account.module'; +import { TagModule } from './tag/tag.module'; +import { TeamModule } from './team/team.module'; @Module({ imports: [ @@ -12,15 +15,12 @@ import { ContactModule } from './contact/contact.module'; UserModule, AttachmentModule, ContactModule, + AccountModule, + TagModule, + TeamModule, ], providers: [], controllers: [], - exports: [ - TicketModule, - CommentModule, - UserModule, - AttachmentModule, - ContactModule, - ], + exports: [UserModule, AttachmentModule, ContactModule], }) export class TicketingModule {} diff --git a/packages/api/src/ticketing/user/services/front/index.ts b/packages/api/src/ticketing/user/services/front/index.ts new file mode 100644 index 000000000..a2b5148df --- /dev/null +++ b/packages/api/src/ticketing/user/services/front/index.ts @@ -0,0 +1,63 @@ +import { Injectable } from '@nestjs/common'; +import { LoggerService } from '@@core/logger/logger.service'; +import { PrismaService } from '@@core/prisma/prisma.service'; +import { EncryptionService } from '@@core/encryption/encryption.service'; +import { TicketingObject } from '@ticketing/@utils/@types'; +import { ApiResponse } from '@@core/utils/types'; +import axios from 'axios'; +import { ActionType, handleServiceError } from '@@core/utils/errors'; +import { ServiceRegistry } from '../registry.service'; +import { IUserService } from '@ticketing/user/types'; +import { FrontUserOutput } from './types'; + +@Injectable() +export class FrontService implements IUserService { + constructor( + private prisma: PrismaService, + private logger: LoggerService, + private cryptoService: EncryptionService, + private registry: ServiceRegistry, + ) { + this.logger.setContext( + TicketingObject.user.toUpperCase() + ':' + FrontService.name, + ); + this.registry.registerService('front', this); + } + + async syncUsers( + linkedUserId: string, + ): Promise> { + try { + const connection = await this.prisma.connections.findFirst({ + where: { + id_linked_user: linkedUserId, + provider_slug: 'front', + }, + }); + + const resp = await axios.get('https://api2.frontapp.com/teammates', { + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${this.cryptoService.decrypt( + connection.access_token, + )}`, + }, + }); + this.logger.log(`Synced front users !`); + + return { + data: resp.data._results, + message: 'Front users retrieved', + statusCode: 200, + }; + } catch (error) { + handleServiceError( + error, + this.logger, + 'Front', + TicketingObject.user, + ActionType.GET, + ); + } + } +} diff --git a/packages/api/src/ticketing/user/services/front/mappers.ts b/packages/api/src/ticketing/user/services/front/mappers.ts new file mode 100644 index 000000000..cfb94b288 --- /dev/null +++ b/packages/api/src/ticketing/user/services/front/mappers.ts @@ -0,0 +1,53 @@ +import { IUserMapper } from '@ticketing/user/types'; +import { + UnifiedUserInput, + UnifiedUserOutput, +} from '@ticketing/user/types/model.unified'; +import { FrontUserInput, FrontUserOutput } from './types'; + +export class FrontUserMapper implements IUserMapper { + desunify( + source: UnifiedUserInput, + customFieldMappings?: { + slug: string; + remote_id: string; + }[], + ): FrontUserInput { + return; + } + + unify( + source: FrontUserOutput | FrontUserOutput[], + customFieldMappings?: { + slug: string; + remote_id: string; + }[], + ): UnifiedUserOutput | UnifiedUserOutput[] { + // If the source is not an array, convert it to an array for mapping + const sourcesArray = Array.isArray(source) ? source : [source]; + + return sourcesArray.map((user) => + this.mapSingleUserToUnified(user, customFieldMappings), + ); + } + + private mapSingleUserToUnified( + user: FrontUserOutput, + customFieldMappings?: { + slug: string; + remote_id: string; + }[], + ): UnifiedUserOutput { + const field_mappings = customFieldMappings?.map((mapping) => ({ + [mapping.slug]: user.custom_fields?.[mapping.remote_id], + })); + + const unifiedUser: UnifiedUserOutput = { + name: `${user.last_name} ${user.last_name}`, + email_address: user.email, + field_mappings: field_mappings, + }; + + return unifiedUser; + } +} diff --git a/packages/api/src/ticketing/user/services/front/types.ts b/packages/api/src/ticketing/user/services/front/types.ts new file mode 100644 index 000000000..b421d1e68 --- /dev/null +++ b/packages/api/src/ticketing/user/services/front/types.ts @@ -0,0 +1,28 @@ +export type FrontUserInput = { + id: string; +}; + +export type FrontUserOutput = { + _links: TeammateLink; + id: string; + email: string; + username: string; + first_name: string; + last_name: string; + is_admin: boolean; + is_available: boolean; + is_blocked: boolean; + custom_fields: CustomFields; +}; + +type TeammateLink = { + self: string; + related: { + inboxes: string; + conversations: string; + }; +}; + +type CustomFields = { + [key: string]: string | boolean | number | null; +}; diff --git a/packages/api/src/ticketing/user/services/github/index.ts b/packages/api/src/ticketing/user/services/github/index.ts new file mode 100644 index 000000000..4e995e168 --- /dev/null +++ b/packages/api/src/ticketing/user/services/github/index.ts @@ -0,0 +1,64 @@ +import { Injectable } from '@nestjs/common'; +import { LoggerService } from '@@core/logger/logger.service'; +import { PrismaService } from '@@core/prisma/prisma.service'; +import { EncryptionService } from '@@core/encryption/encryption.service'; +import { TicketingObject } from '@ticketing/@utils/@types'; +import { ApiResponse } from '@@core/utils/types'; +import axios from 'axios'; +import { ActionType, handleServiceError } from '@@core/utils/errors'; +import { ServiceRegistry } from '../registry.service'; +import { IUserService } from '@ticketing/user/types'; +import { GithubUserOutput } from './types'; + +//TODO +@Injectable() +export class GithubService implements IUserService { + constructor( + private prisma: PrismaService, + private logger: LoggerService, + private cryptoService: EncryptionService, + private registry: ServiceRegistry, + ) { + this.logger.setContext( + TicketingObject.user.toUpperCase() + ':' + GithubService.name, + ); + this.registry.registerService('github', this); + } + + async syncUsers( + linkedUserId: string, + custom_properties?: string[], + ): Promise> { + try { + const connection = await this.prisma.connections.findFirst({ + where: { + id_linked_user: linkedUserId, + provider_slug: 'github', + }, + }); + const resp = await axios.get(`https://api.github.com/users`, { + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${this.cryptoService.decrypt( + connection.access_token, + )}`, + }, + }); + this.logger.log(`Synced github users !`); + + return { + data: resp.data, + message: 'Github users retrieved', + statusCode: 200, + }; + } catch (error) { + handleServiceError( + error, + this.logger, + 'Github', + TicketingObject.user, + ActionType.GET, + ); + } + } +} diff --git a/packages/api/src/ticketing/user/services/github/mappers.ts b/packages/api/src/ticketing/user/services/github/mappers.ts new file mode 100644 index 000000000..653d692ea --- /dev/null +++ b/packages/api/src/ticketing/user/services/github/mappers.ts @@ -0,0 +1,28 @@ +import { IUserMapper } from '@ticketing/user/types'; +import { GithubUserInput, GithubUserOutput } from './types'; +import { + UnifiedUserInput, + UnifiedUserOutput, +} from '@ticketing/user/types/model.unified'; + +export class GithubUserMapper implements IUserMapper { + desunify( + source: UnifiedUserInput, + customFieldMappings?: { + slug: string; + remote_id: string; + }[], + ): GithubUserInput { + return; + } + + unify( + source: GithubUserOutput | GithubUserOutput[], + customFieldMappings?: { + slug: string; + remote_id: string; + }[], + ): UnifiedUserOutput | UnifiedUserOutput[] { + return; + } +} diff --git a/packages/api/src/ticketing/user/services/github/types.ts b/packages/api/src/ticketing/user/services/github/types.ts new file mode 100644 index 000000000..1f644a57b --- /dev/null +++ b/packages/api/src/ticketing/user/services/github/types.ts @@ -0,0 +1,6 @@ +export type GithubUserInput = { + name: string; +}; + +//TODO +export type GithubUserOutput = GithubUserInput; diff --git a/packages/api/src/ticketing/user/services/registry.service.ts b/packages/api/src/ticketing/user/services/registry.service.ts index 342dc3645..2959ac489 100644 --- a/packages/api/src/ticketing/user/services/registry.service.ts +++ b/packages/api/src/ticketing/user/services/registry.service.ts @@ -16,7 +16,7 @@ export class ServiceRegistry { getService(integrationId: string): IUserService { const service = this.serviceMap.get(integrationId); if (!service) { - throw new Error(`Service not found for integration ID: ${integrationId}`); + throw new Error(); } return service; } diff --git a/packages/api/src/ticketing/user/services/user.service.ts b/packages/api/src/ticketing/user/services/user.service.ts index c34125d25..91bc5c1ab 100644 --- a/packages/api/src/ticketing/user/services/user.service.ts +++ b/packages/api/src/ticketing/user/services/user.service.ts @@ -4,26 +4,167 @@ import { LoggerService } from '@@core/logger/logger.service'; import { v4 as uuidv4 } from 'uuid'; import { ApiResponse } from '@@core/utils/types'; import { handleServiceError } from '@@core/utils/errors'; -import { WebhookService } from '@@core/webhook/webhook.service'; -import { UnifiedUserInput, UnifiedUserOutput } from '../types/model.unified'; +import { UnifiedUserOutput } from '../types/model.unified'; import { UserResponse } from '../types'; -import { desunify } from '@@core/utils/unification/desunify'; -import { TicketingObject } from '@ticketing/@utils/@types'; -import { FieldMappingService } from '@@core/field-mapping/field-mapping.service'; -import { unify } from '@@core/utils/unification/unify'; -import { ServiceRegistry } from './registry.service'; @Injectable() export class UserService { - constructor( - private prisma: PrismaService, - private logger: LoggerService, - private webhook: WebhookService, - private fieldMappingService: FieldMappingService, - private serviceRegistry: ServiceRegistry, - ) { + constructor(private prisma: PrismaService, private logger: LoggerService) { this.logger.setContext(UserService.name); } - // Additional methods and logic + async getUser( + id_ticketing_user: string, + remote_data?: boolean, + ): Promise { + try { + const user = await this.prisma.tcg_users.findUnique({ + where: { + id_tcg_user: id_ticketing_user, + }, + }); + + // Fetch field mappings for the ticket + const values = await this.prisma.value.findMany({ + where: { + entity: { + ressource_owner_id: user.id_tcg_user, + }, + }, + include: { + attribute: true, + }, + }); + + // Create a map to store unique field mappings + const fieldMappingsMap = new Map(); + + values.forEach((value) => { + fieldMappingsMap.set(value.attribute.slug, value.data); + }); + + // Convert the map to an array of objects + const field_mappings = Array.from(fieldMappingsMap, ([key, value]) => ({ + [key]: value, + })); + + // Transform to UnifiedUserOutput format + const unifiedUser: UnifiedUserOutput = { + id: user.id_tcg_user, + email_address: user.email_address, + name: user.name, + teams: user.teams, + field_mappings: field_mappings, + }; + + let res: UnifiedUserOutput = unifiedUser; + + if (remote_data) { + const resp = await this.prisma.remote_data.findFirst({ + where: { + ressource_owner_id: user.id_tcg_user, + }, + }); + const remote_data = JSON.parse(resp.data); + + res = { + ...res, + remote_data: remote_data, + }; + } + + return res; + } catch (error) { + handleServiceError(error, this.logger); + } + } + + async getUsers( + integrationId: string, + linkedUserId: string, + remote_data?: boolean, + ): Promise { + try { + //TODO: handle case where data is not there (not synced) or old synced + const users = await this.prisma.tcg_users.findMany({ + where: { + remote_platform: integrationId.toLowerCase(), + id_linked_user: linkedUserId, + }, + }); + + const unifiedUsers: UnifiedUserOutput[] = await Promise.all( + users.map(async (user) => { + // Fetch field mappings for the user + const values = await this.prisma.value.findMany({ + where: { + entity: { + ressource_owner_id: user.id_tcg_user, + }, + }, + include: { + attribute: true, + }, + }); + // Create a map to store unique field mappings + const fieldMappingsMap = new Map(); + + values.forEach((value) => { + fieldMappingsMap.set(value.attribute.slug, value.data); + }); + + // Convert the map to an array of objects + const field_mappings = Array.from( + fieldMappingsMap, + ([key, value]) => ({ [key]: value }), + ); + + // Transform to UnifiedUserOutput format + return { + id: user.id_tcg_user, + email_address: user.email_address, + name: user.name, + teams: user.teams, + field_mappings: field_mappings, + }; + }), + ); + + let res: UnifiedUserOutput[] = unifiedUsers; + + if (remote_data) { + const remote_array_data: UnifiedUserOutput[] = await Promise.all( + res.map(async (user) => { + const resp = await this.prisma.remote_data.findFirst({ + where: { + ressource_owner_id: user.id, + }, + }); + const remote_data = JSON.parse(resp.data); + return { ...user, remote_data }; + }), + ); + + res = remote_array_data; + } + + const event = await this.prisma.events.create({ + data: { + id_event: uuidv4(), + status: 'success', + type: 'ticketing.user.pull', + method: 'GET', + url: '/ticketing/user', + provider: integrationId, + direction: '0', + timestamp: new Date(), + id_linked_user: linkedUserId, + }, + }); + + return res; + } catch (error) { + handleServiceError(error, this.logger); + } + } } diff --git a/packages/api/src/ticketing/user/services/zendesk/index.ts b/packages/api/src/ticketing/user/services/zendesk/index.ts index fa222dadd..7d6dc2a37 100644 --- a/packages/api/src/ticketing/user/services/zendesk/index.ts +++ b/packages/api/src/ticketing/user/services/zendesk/index.ts @@ -1,13 +1,14 @@ -import { EncryptionService } from '@@core/encryption/encryption.service'; -import { EnvironmentService } from '@@core/environment/environment.service'; +import { Injectable } from '@nestjs/common'; import { LoggerService } from '@@core/logger/logger.service'; import { PrismaService } from '@@core/prisma/prisma.service'; +import { EncryptionService } from '@@core/encryption/encryption.service'; +import { TicketingObject, ZendeskUserOutput } from '@ticketing/@utils/@types'; import { ApiResponse } from '@@core/utils/types'; -import { DesunifyReturnType } from '@@core/utils/types/desunify.input'; -import { Injectable } from '@nestjs/common'; -import { TicketingObject, ZendeskUserInput } from '@ticketing/@utils/@types'; -import { IUserService } from '@ticketing/user/types'; +import axios from 'axios'; +import { ActionType, handleServiceError } from '@@core/utils/errors'; +import { EnvironmentService } from '@@core/environment/environment.service'; import { ServiceRegistry } from '../registry.service'; +import { IUserService } from '@ticketing/user/types'; @Injectable() export class ZendeskService implements IUserService { @@ -21,18 +22,47 @@ export class ZendeskService implements IUserService { this.logger.setContext( TicketingObject.user.toUpperCase() + ':' + ZendeskService.name, ); - this.registry.registerService('zendesk_t', this); - } - addUser( - userData: DesunifyReturnType, - linkedUserId: string, - ): Promise> { - throw new Error('Method not implemented.'); + this.registry.registerService('zendesk_tcg', this); } - syncUsers( + + async syncUsers( linkedUserId: string, custom_properties?: string[], - ): Promise> { - throw new Error('Method not implemented.'); + ): Promise> { + try { + const connection = await this.prisma.connections.findFirst({ + where: { + id_linked_user: linkedUserId, + provider_slug: 'zendesk_tcg', + }, + }); + + const resp = await axios.get( + `https://${this.env.getZendeskTicketingSubdomain()}.zendesk.com/api/v2/users`, + { + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${this.cryptoService.decrypt( + connection.access_token, + )}`, + }, + }, + ); + this.logger.log(`Synced zendesk users !`); + + return { + data: resp.data.users, + message: 'Zendesk users retrieved', + statusCode: 200, + }; + } catch (error) { + handleServiceError( + error, + this.logger, + 'Zendesk', + TicketingObject.user, + ActionType.GET, + ); + } } } diff --git a/packages/api/src/ticketing/user/services/zendesk/mappers.ts b/packages/api/src/ticketing/user/services/zendesk/mappers.ts index e69de29bb..6c58979c3 100644 --- a/packages/api/src/ticketing/user/services/zendesk/mappers.ts +++ b/packages/api/src/ticketing/user/services/zendesk/mappers.ts @@ -0,0 +1,53 @@ +import { IUserMapper } from '@ticketing/user/types'; +import { ZendeskUserInput, ZendeskUserOutput } from './types'; +import { + UnifiedUserInput, + UnifiedUserOutput, +} from '@ticketing/user/types/model.unified'; + +export class ZendeskUserMapper implements IUserMapper { + desunify( + source: UnifiedUserInput, + customFieldMappings?: { + slug: string; + remote_id: string; + }[], + ): ZendeskUserInput { + return; + } + + unify( + source: ZendeskUserOutput | ZendeskUserOutput[], + customFieldMappings?: { + slug: string; + remote_id: string; + }[], + ): UnifiedUserOutput | UnifiedUserOutput[] { + if (!Array.isArray(source)) { + return this.mapSingleUserToUnified(source, customFieldMappings); + } + return source.map((user) => + this.mapSingleUserToUnified(user, customFieldMappings), + ); + } + + private mapSingleUserToUnified( + user: ZendeskUserOutput, + customFieldMappings?: { + slug: string; + remote_id: string; + }[], + ): UnifiedUserOutput { + const field_mappings = customFieldMappings?.map((mapping) => ({ + [mapping.slug]: user.user_fields?.[mapping.remote_id], + })); + + const unifiedUser: UnifiedUserOutput = { + name: user.name, + email_address: user.email, + field_mappings: field_mappings, + }; + + return unifiedUser; + } +} diff --git a/packages/api/src/ticketing/user/services/zendesk/types.ts b/packages/api/src/ticketing/user/services/zendesk/types.ts index fa5442b53..f16b0433f 100644 --- a/packages/api/src/ticketing/user/services/zendesk/types.ts +++ b/packages/api/src/ticketing/user/services/zendesk/types.ts @@ -1,5 +1,50 @@ export type ZendeskUserInput = { - id: string; + _: string; }; -export type ZendeskUserOutput = ZendeskUserInput; +export type ZendeskUserOutput = Partial<{ + active: boolean; + alias?: string; + chat_only: boolean; + created_at: string; + custom_role_id?: number; + default_group_id?: number; + details?: string; + email: string; + external_id?: string; + iana_time_zone: string; + id: number; + last_login_at: string; + locale?: string; + locale_id?: number; + moderator?: boolean; + name: string; + notes?: string; + only_private_comments?: boolean; + organization_id?: number; + phone?: string; + photo?: { [key: string]: any }; // Assuming an object type for the Attachment object + remote_photo_url?: string; + report_csv: boolean; + restricted_agent?: boolean; + role?: string; + role_type: number; + shared: boolean; + shared_agent: boolean; + shared_phone_number?: boolean; + signature?: string; + suspended?: boolean; + tags?: string[]; + ticket_restriction?: + | 'organization' + | 'groups' + | 'assigned' + | 'requested' + | null; + time_zone?: string; + two_factor_auth_enabled: boolean; + updated_at: string; + url: string; + user_fields?: { [key: string]: any }; // Assuming an object type for custom fields + verified?: boolean; +}>; diff --git a/packages/api/src/ticketing/user/sync/sync.service.ts b/packages/api/src/ticketing/user/sync/sync.service.ts index e61d3d900..cfdce00a6 100644 --- a/packages/api/src/ticketing/user/sync/sync.service.ts +++ b/packages/api/src/ticketing/user/sync/sync.service.ts @@ -6,12 +6,14 @@ import { Cron } from '@nestjs/schedule'; import { ApiResponse, TICKETING_PROVIDERS } from '@@core/utils/types'; import { v4 as uuidv4 } from 'uuid'; import { FieldMappingService } from '@@core/field-mapping/field-mapping.service'; +import { ServiceRegistry } from '../services/registry.service'; import { unify } from '@@core/utils/unification/unify'; import { TicketingObject } from '@ticketing/@utils/@types'; import { WebhookService } from '@@core/webhook/webhook.service'; -import { UnifiedUserOutput } from '../types/model.unified'; import { IUserService } from '../types'; -import { ServiceRegistry } from '../services/registry.service'; +import { OriginalUserOutput } from '@@core/utils/types/original/original.ticketing'; +import { tcg_users as TicketingUser } from '@prisma/client'; +import { UnifiedUserOutput } from '../types/model.unified'; @Injectable() export class SyncService implements OnModuleInit { @@ -26,8 +28,269 @@ export class SyncService implements OnModuleInit { } async onModuleInit() { - // Initialization logic + try { + //await this.syncUsers(); + } catch (error) { + handleServiceError(error, this.logger); + } + } + + //@Cron('*/20 * * * *') + //function used by sync worker which populate our tcg_users table + //its role is to fetch all users from providers 3rd parties and save the info inside our db + async syncUsers() { + try { + this.logger.log(`Syncing users....`); + const defaultOrg = await this.prisma.organizations.findFirst({ + where: { + name: 'Acme Inc', + }, + }); + + const defaultProject = await this.prisma.projects.findFirst({ + where: { + id_organization: defaultOrg.id_organization, + name: 'Project 1', + }, + }); + const id_project = defaultProject.id_project; + const linkedUsers = await this.prisma.linked_users.findMany({ + where: { + id_project: id_project, + }, + }); + linkedUsers.map(async (linkedUser) => { + try { + const providers = TICKETING_PROVIDERS; + for (const provider of providers) { + try { + await this.syncUsersForLinkedUser( + provider, + linkedUser.id_linked_user, + id_project, + ); + } catch (error) { + handleServiceError(error, this.logger); + } + } + } catch (error) { + handleServiceError(error, this.logger); + } + }); + } catch (error) { + handleServiceError(error, this.logger); + } + } + + //todo: HANDLE DATA REMOVED FROM PROVIDER + async syncUsersForLinkedUser( + integrationId: string, + linkedUserId: string, + id_project: string, + ) { + try { + this.logger.log( + `Syncing ${integrationId} users for linkedUser ${linkedUserId}`, + ); + // check if linkedUser has a connection if not just stop sync + const connection = await this.prisma.connections.findFirst({ + where: { + id_linked_user: linkedUserId, + provider_slug: integrationId, + }, + }); + if (!connection) throw new Error('connection not found'); + + // get potential fieldMappings and extract the original properties name + const customFieldMappings = + await this.fieldMappingService.getCustomFieldMappings( + integrationId, + linkedUserId, + 'user', + ); + const remoteProperties: string[] = customFieldMappings.map( + (mapping) => mapping.remote_id, + ); + + const service: IUserService = + this.serviceRegistry.getService(integrationId); + const resp: ApiResponse = await service.syncUsers( + linkedUserId, + remoteProperties, + ); + + const sourceObject: OriginalUserOutput[] = resp.data; + //this.logger.log('SOURCE OBJECT DATA = ' + JSON.stringify(sourceObject)); + //unify the data according to the target obj wanted + const unifiedObject = (await unify({ + sourceObject, + targetType: TicketingObject.user, + providerName: integrationId, + customFieldMappings, + })) as UnifiedUserOutput[]; + + //TODO + const userIds = sourceObject.map((user) => + 'id' in user ? String(user.id) : undefined, + ); + + //insert the data in the DB with the fieldMappings (value table) + const user_data = await this.saveUsersInDb( + linkedUserId, + unifiedObject, + userIds, + integrationId, + sourceObject, + ); + const event = await this.prisma.events.create({ + data: { + id_event: uuidv4(), + status: 'success', + type: 'ticketing.user.pulled', + method: 'PULL', + url: '/pull', + provider: integrationId, + direction: '0', + timestamp: new Date(), + id_linked_user: linkedUserId, + }, + }); + await this.webhook.handleWebhook( + user_data, + 'ticketing.user.pulled', + id_project, + event.id_event, + ); + } catch (error) { + handleServiceError(error, this.logger); + } } - // Additional methods and logic + async saveUsersInDb( + linkedUserId: string, + users: UnifiedUserOutput[], + originIds: string[], + originSource: string, + remote_data: Record[], + ): Promise { + try { + let users_results: TicketingUser[] = []; + for (let i = 0; i < users.length; i++) { + const user = users[i]; + const originId = originIds[i]; + + if (!originId || originId == '') { + throw new NotFoundError(`Origin id not there, found ${originId}`); + } + + const existingUser = await this.prisma.tcg_users.findFirst({ + where: { + remote_id: originId, + remote_platform: originSource, + id_linked_user: linkedUserId, + }, + }); + + let unique_ticketing_user_id: string; + + if (existingUser) { + // Update the existing ticket + const res = await this.prisma.tcg_users.update({ + where: { + id_tcg_user: existingUser.id_tcg_user, + }, + data: { + name: user.name, + email_address: user.email_address, + teams: user.teams || [], + modified_at: new Date(), + }, + }); + unique_ticketing_user_id = res.id_tcg_user; + users_results = [...users_results, res]; + } else { + // Create a new user + this.logger.log('not existing user ' + user.name); + const data = { + id_tcg_user: uuidv4(), + name: user.name, + email_address: user.email_address, + teams: user.teams || [], + created_at: new Date(), + modified_at: new Date(), + id_linked_user: linkedUserId, + remote_id: originId, + remote_platform: originSource, + }; + const res = await this.prisma.tcg_users.create({ + data: data, + }); + users_results = [...users_results, res]; + unique_ticketing_user_id = res.id_tcg_user; + } + + // check duplicate or existing values + if (user.field_mappings && user.field_mappings.length > 0) { + const entity = await this.prisma.entity.create({ + data: { + id_entity: uuidv4(), + ressource_owner_id: unique_ticketing_user_id, + }, + }); + + for (const mapping of user.field_mappings) { + const attribute = await this.prisma.attribute.findFirst({ + where: { + slug: Object.keys(mapping)[0], + source: originSource, + id_consumer: linkedUserId, + }, + }); + + if (attribute) { + await this.prisma.value.create({ + data: { + id_value: uuidv4(), + data: Object.values(mapping)[0] + ? Object.values(mapping)[0] + : 'null', + attribute: { + connect: { + id_attribute: attribute.id_attribute, + }, + }, + entity: { + connect: { + id_entity: entity.id_entity, + }, + }, + }, + }); + } + } + } + + //insert remote_data in db + await this.prisma.remote_data.upsert({ + where: { + ressource_owner_id: unique_ticketing_user_id, + }, + create: { + id_remote_data: uuidv4(), + ressource_owner_id: unique_ticketing_user_id, + format: 'json', + data: JSON.stringify(remote_data[i]), + created_at: new Date(), + }, + update: { + data: JSON.stringify(remote_data[i]), + created_at: new Date(), + }, + }); + } + return users_results; + } catch (error) { + handleServiceError(error, this.logger); + } + } } diff --git a/packages/api/src/ticketing/user/types/index.ts b/packages/api/src/ticketing/user/types/index.ts index 19cb5adc3..dff14f804 100644 --- a/packages/api/src/ticketing/user/types/index.ts +++ b/packages/api/src/ticketing/user/types/index.ts @@ -1,15 +1,10 @@ import { DesunifyReturnType } from '@@core/utils/types/desunify.input'; import { UnifiedUserInput, UnifiedUserOutput } from './model.unified'; +import { OriginalUserOutput } from '@@core/utils/types/original/original.ticketing'; import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { ApiResponse } from '@@core/utils/types'; -import { OriginalUserOutput } from '@@core/utils/types/original/original.ticketing'; export interface IUserService { - addUser( - userData: DesunifyReturnType, - linkedUserId: string, - ): Promise>; - syncUsers( linkedUserId: string, custom_properties?: string[], diff --git a/packages/api/src/ticketing/user/types/mappingsTypes.ts b/packages/api/src/ticketing/user/types/mappingsTypes.ts index d08c20aaa..c3d3a6c6b 100644 --- a/packages/api/src/ticketing/user/types/mappingsTypes.ts +++ b/packages/api/src/ticketing/user/types/mappingsTypes.ts @@ -1 +1,22 @@ -// Content for mappingsTypes.ts +import { FrontUserMapper } from '../services/front/mappers'; +import { GithubUserMapper } from '../services/github/mappers'; +import { ZendeskUserMapper } from '../services/zendesk/mappers'; + +const zendeskUserMapper = new ZendeskUserMapper(); +const frontUserMapper = new FrontUserMapper(); +const githubUserMapper = new GithubUserMapper(); + +export const userUnificationMapping = { + zendesk_tcg: { + unify: zendeskUserMapper.unify, + desunify: zendeskUserMapper.desunify, + }, + front: { + unify: frontUserMapper.unify, + desunify: frontUserMapper.desunify, + }, + github: { + unify: githubUserMapper.unify, + desunify: githubUserMapper.desunify, + }, +}; diff --git a/packages/api/src/ticketing/user/types/model.unified.ts b/packages/api/src/ticketing/user/types/model.unified.ts index fe03f0afd..97d61e2b5 100644 --- a/packages/api/src/ticketing/user/types/model.unified.ts +++ b/packages/api/src/ticketing/user/types/model.unified.ts @@ -1,3 +1,12 @@ -export class UnifiedUserInput {} +export class UnifiedUserInput { + name: string; + email_address: string; + teams?: string[]; + field_mappings?: Record[]; +} -export class UnifiedUserOutput extends UnifiedUserInput {} +export class UnifiedUserOutput extends UnifiedUserInput { + id?: string; + remote_id?: string; + remote_data?: Record; +} diff --git a/packages/api/src/ticketing/user/user.controller.ts b/packages/api/src/ticketing/user/user.controller.ts index 46f3ff6eb..fb47cd2ca 100644 --- a/packages/api/src/ticketing/user/user.controller.ts +++ b/packages/api/src/ticketing/user/user.controller.ts @@ -1,4 +1,69 @@ -import { Controller } from '@nestjs/common'; +import { Controller, Query, Get, Param, Headers } from '@nestjs/common'; +import { LoggerService } from '@@core/logger/logger.service'; +import { + ApiOperation, + ApiParam, + ApiQuery, + ApiTags, + ApiHeader, +} from '@nestjs/swagger'; +import { ApiCustomResponse } from '@@core/utils/types'; +import { UserService } from './services/user.service'; +@ApiTags('ticketing/user') @Controller('ticketing/user') -export class UserController {} +export class UserController { + constructor( + private readonly userService: UserService, + private logger: LoggerService, + ) { + this.logger.setContext(UserController.name); + } + + @ApiOperation({ + operationId: 'getUsers', + summary: 'List a batch of Users', + }) + @ApiHeader({ name: 'integrationId', required: true }) + @ApiHeader({ name: 'linkedUserId', required: true }) + @ApiQuery({ + name: 'remoteData', + required: false, + type: Boolean, + description: + 'Set to true to include data from the original Ticketing software.', + }) + //@ApiCustomResponse(UserResponse) + @Get() + getUsers( + @Headers('integrationId') integrationId: string, + @Headers('linkedUserId') linkedUserId: string, + @Query('remoteData') remote_data?: boolean, + ) { + return this.userService.getUsers(integrationId, linkedUserId, remote_data); + } + + @ApiOperation({ + operationId: 'getUser', + summary: 'Retrieve a User', + description: 'Retrieve a user from any connected Ticketing software', + }) + @ApiParam({ + name: 'id', + required: true, + type: String, + description: 'id of the user you want to retrieve.', + }) + @ApiQuery({ + name: 'remoteData', + required: false, + type: Boolean, + description: + 'Set to true to include data from the original Ticketing software.', + }) + //@ApiCustomResponse(UserResponse) + @Get(':id') + getUser(@Param('id') id: string, @Query('remoteData') remote_data?: boolean) { + return this.userService.getUser(id, remote_data); + } +} diff --git a/packages/api/src/ticketing/user/user.module.ts b/packages/api/src/ticketing/user/user.module.ts index 9b1fd29ed..833733636 100644 --- a/packages/api/src/ticketing/user/user.module.ts +++ b/packages/api/src/ticketing/user/user.module.ts @@ -1,15 +1,17 @@ import { Module } from '@nestjs/common'; import { UserController } from './user.controller'; -import { BullModule } from '@nestjs/bull'; +import { SyncService } from './sync/sync.service'; +import { LoggerService } from '@@core/logger/logger.service'; import { UserService } from './services/user.service'; import { ServiceRegistry } from './services/registry.service'; -import { FieldMappingService } from '@@core/field-mapping/field-mapping.service'; -import { WebhookService } from '@@core/webhook/webhook.service'; import { EncryptionService } from '@@core/encryption/encryption.service'; +import { FieldMappingService } from '@@core/field-mapping/field-mapping.service'; import { PrismaService } from '@@core/prisma/prisma.service'; +import { WebhookService } from '@@core/webhook/webhook.service'; +import { BullModule } from '@nestjs/bull'; import { ZendeskService } from './services/zendesk'; -import { LoggerService } from '@@core/logger/logger.service'; -import { SyncService } from './sync/sync.service'; +import { FrontService } from './services/front'; +import { GithubService } from './services/github'; @Module({ imports: [ @@ -29,6 +31,8 @@ import { SyncService } from './sync/sync.service'; ServiceRegistry, /* PROVIDERS SERVICES */ ZendeskService, + FrontService, + GithubService, ], exports: [SyncService], }) diff --git a/packages/api/src/ticketing/user/utils/index.ts b/packages/api/src/ticketing/user/utils/index.ts index e69de29bb..f849788c1 100644 --- a/packages/api/src/ticketing/user/utils/index.ts +++ b/packages/api/src/ticketing/user/utils/index.ts @@ -0,0 +1 @@ +/* PUT ALL UTILS FUNCTIONS USED IN YOUR OBJECT METHODS HERE */ diff --git a/packages/api/swagger/swagger-spec.json b/packages/api/swagger/swagger-spec.json index 4e46c94c1..aa4896817 100644 --- a/packages/api/swagger/swagger-spec.json +++ b/packages/api/swagger/swagger-spec.json @@ -848,10 +848,676 @@ ] } }, + "/crm/deal": { + "get": { + "operationId": "getDeals", + "summary": "List a batch of Deals", + "parameters": [ + { + "name": "integrationId", + "required": true, + "in": "header", + "schema": { + "type": "string" + } + }, + { + "name": "linkedUserId", + "required": true, + "in": "header", + "schema": { + "type": "string" + } + }, + { + "name": "remoteData", + "required": false, + "in": "query", + "description": "Set to true to include data from the original Crm software.", + "schema": { + "type": "boolean" + } + } + ], + "responses": { + "200": { + "description": "" + } + }, + "tags": [ + "crm/deal" + ] + }, + "post": { + "operationId": "addDeal", + "summary": "Create a Deal", + "description": "Create a deal in any supported Crm software", + "parameters": [ + { + "name": "integrationId", + "required": true, + "in": "header", + "description": "The integration ID", + "schema": { + "type": "string" + } + }, + { + "name": "linkedUserId", + "required": true, + "in": "header", + "description": "The linked user ID", + "schema": { + "type": "string" + } + }, + { + "name": "remoteData", + "required": false, + "in": "query", + "description": "Set to true to include data from the original Crm software.", + "schema": { + "type": "boolean" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnifiedDealInput" + } + } + } + }, + "responses": { + "201": { + "description": "" + } + }, + "tags": [ + "crm/deal" + ] + }, + "patch": { + "operationId": "updateDeal", + "summary": "Update a Deal", + "parameters": [ + { + "name": "id", + "required": true, + "in": "query", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "" + } + }, + "tags": [ + "crm/deal" + ] + } + }, + "/crm/deal/{id}": { + "get": { + "operationId": "getDeal", + "summary": "Retrieve a Deal", + "description": "Retrieve a deal from any connected Crm software", + "parameters": [ + { + "name": "id", + "required": true, + "in": "path", + "description": "id of the you want to retrive.", + "schema": { + "type": "string" + } + }, + { + "name": "remoteData", + "required": false, + "in": "query", + "description": "Set to true to include data from the original Crm software.", + "schema": { + "type": "boolean" + } + } + ], + "responses": { + "200": { + "description": "" + } + }, + "tags": [ + "crm/deal" + ] + } + }, + "/crm/deal/batch": { + "post": { + "operationId": "addDeals", + "summary": "Add a batch of Deals", + "parameters": [ + { + "name": "integrationId", + "required": true, + "in": "header", + "schema": { + "type": "string" + } + }, + { + "name": "linkedUserId", + "required": true, + "in": "header", + "schema": { + "type": "string" + } + }, + { + "name": "remoteData", + "required": false, + "in": "query", + "description": "Set to true to include data from the original Crm software.", + "schema": { + "type": "boolean" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/UnifiedDealInput" + } + } + } + } + }, + "responses": { + "201": { + "description": "" + } + }, + "tags": [ + "crm/deal" + ] + } + }, "/ticketing/ticket": { "get": { - "operationId": "getTickets", - "summary": "List a batch of Tickets", + "operationId": "getTickets", + "summary": "List a batch of Tickets", + "parameters": [ + { + "name": "integrationId", + "required": true, + "in": "header", + "schema": { + "type": "string" + } + }, + { + "name": "linkedUserId", + "required": true, + "in": "header", + "schema": { + "type": "string" + } + }, + { + "name": "remoteData", + "required": false, + "in": "query", + "description": "Set to true to include data from the original Ticketing software.", + "schema": { + "type": "boolean" + } + } + ], + "responses": { + "200": { + "description": "" + } + }, + "tags": [ + "ticketing/ticket" + ] + }, + "post": { + "operationId": "addTicket", + "summary": "Create a Ticket", + "description": "Create a ticket in any supported Ticketing software", + "parameters": [ + { + "name": "integrationId", + "required": true, + "in": "header", + "description": "The integration ID", + "schema": { + "type": "string" + } + }, + { + "name": "linkedUserId", + "required": true, + "in": "header", + "description": "The linked user ID", + "schema": { + "type": "string" + } + }, + { + "name": "remoteData", + "required": false, + "in": "query", + "description": "Set to true to include data from the original Ticketing software.", + "schema": { + "type": "boolean" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnifiedTicketInput" + } + } + } + }, + "responses": { + "201": { + "description": "" + } + }, + "tags": [ + "ticketing/ticket" + ] + }, + "patch": { + "operationId": "updateTicket", + "summary": "Update a Ticket", + "parameters": [ + { + "name": "id", + "required": true, + "in": "query", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "" + } + }, + "tags": [ + "ticketing/ticket" + ] + } + }, + "/ticketing/ticket/{id}": { + "get": { + "operationId": "getTicket", + "summary": "Retrieve a Ticket", + "description": "Retrieve a ticket from any connected Ticketing software", + "parameters": [ + { + "name": "id", + "required": true, + "in": "path", + "description": "id of the `ticket` you want to retrive.", + "schema": { + "type": "string" + } + }, + { + "name": "remoteData", + "required": false, + "in": "query", + "description": "Set to true to include data from the original Ticketing software.", + "schema": { + "type": "boolean" + } + } + ], + "responses": { + "200": { + "description": "" + } + }, + "tags": [ + "ticketing/ticket" + ] + } + }, + "/ticketing/ticket/batch": { + "post": { + "operationId": "addTickets", + "summary": "Add a batch of Tickets", + "parameters": [ + { + "name": "integrationId", + "required": true, + "in": "header", + "schema": { + "type": "string" + } + }, + { + "name": "linkedUserId", + "required": true, + "in": "header", + "schema": { + "type": "string" + } + }, + { + "name": "remoteData", + "required": false, + "in": "query", + "description": "Set to true to include data from the original Ticketing software.", + "schema": { + "type": "boolean" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/UnifiedTicketInput" + } + } + } + } + }, + "responses": { + "201": { + "description": "" + } + }, + "tags": [ + "ticketing/ticket" + ] + } + }, + "/ticketing/comment": { + "get": { + "operationId": "getComments", + "summary": "List a batch of Comments", + "parameters": [ + { + "name": "integrationId", + "required": true, + "in": "header", + "schema": { + "type": "string" + } + }, + { + "name": "linkedUserId", + "required": true, + "in": "header", + "schema": { + "type": "string" + } + }, + { + "name": "remoteData", + "required": false, + "in": "query", + "description": "Set to true to include data from the original Ticketing software.", + "schema": { + "type": "boolean" + } + } + ], + "responses": { + "200": { + "description": "" + } + }, + "tags": [ + "ticketing/comment" + ] + }, + "post": { + "operationId": "addComment", + "summary": "Create a Comment", + "description": "Create a comment in any supported Ticketing software", + "parameters": [ + { + "name": "integrationId", + "required": true, + "in": "header", + "description": "The integration ID", + "schema": { + "type": "string" + } + }, + { + "name": "linkedUserId", + "required": true, + "in": "header", + "description": "The linked user ID", + "schema": { + "type": "string" + } + }, + { + "name": "remoteData", + "required": false, + "in": "query", + "description": "Set to true to include data from the original Ticketing software.", + "schema": { + "type": "boolean" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnifiedCommentInput" + } + } + } + }, + "responses": { + "201": { + "description": "" + } + }, + "tags": [ + "ticketing/comment" + ] + } + }, + "/ticketing/comment/{id}": { + "get": { + "operationId": "getComment", + "summary": "Retrieve a Comment", + "description": "Retrieve a comment from any connected Ticketing software", + "parameters": [ + { + "name": "id", + "required": true, + "in": "path", + "description": "id of the `comment` you want to retrive.", + "schema": { + "type": "string" + } + }, + { + "name": "remoteData", + "required": false, + "in": "query", + "description": "Set to true to include data from the original Ticketing software.", + "schema": { + "type": "boolean" + } + } + ], + "responses": { + "200": { + "description": "" + } + }, + "tags": [ + "ticketing/comment" + ] + } + }, + "/ticketing/comment/batch": { + "post": { + "operationId": "addComments", + "summary": "Add a batch of Comments", + "parameters": [ + { + "name": "integrationId", + "required": true, + "in": "header", + "schema": { + "type": "string" + } + }, + { + "name": "linkedUserId", + "required": true, + "in": "header", + "schema": { + "type": "string" + } + }, + { + "name": "remoteData", + "required": false, + "in": "query", + "description": "Set to true to include data from the original Ticketing software.", + "schema": { + "type": "boolean" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/UnifiedCommentInput" + } + } + } + } + }, + "responses": { + "201": { + "description": "" + } + }, + "tags": [ + "ticketing/comment" + ] + } + }, + "/ticketing/user": { + "get": { + "operationId": "getUsers", + "summary": "List a batch of Users", + "parameters": [ + { + "name": "integrationId", + "required": true, + "in": "header", + "schema": { + "type": "string" + } + }, + { + "name": "linkedUserId", + "required": true, + "in": "header", + "schema": { + "type": "string" + } + }, + { + "name": "remoteData", + "required": false, + "in": "query", + "description": "Set to true to include data from the original Ticketing software.", + "schema": { + "type": "boolean" + } + } + ], + "responses": { + "200": { + "description": "" + } + }, + "tags": [ + "ticketing/user" + ] + } + }, + "/ticketing/user/{id}": { + "get": { + "operationId": "getUser", + "summary": "Retrieve a User", + "description": "Retrieve a user from any connected Ticketing software", + "parameters": [ + { + "name": "id", + "required": true, + "in": "path", + "description": "id of the user you want to retrieve.", + "schema": { + "type": "string" + } + }, + { + "name": "remoteData", + "required": false, + "in": "query", + "description": "Set to true to include data from the original Ticketing software.", + "schema": { + "type": "boolean" + } + } + ], + "responses": { + "200": { + "description": "" + } + }, + "tags": [ + "ticketing/user" + ] + } + }, + "/ticketing/attachment": { + "get": { + "operationId": "getAttachments", + "summary": "List a batch of Attachments", "parameters": [ { "name": "integrationId", @@ -885,13 +1551,13 @@ } }, "tags": [ - "ticketing/ticket" + "ticketing/attachment" ] }, "post": { - "operationId": "addTicket", - "summary": "Create a Ticket", - "description": "Create a ticket in any supported Ticketing software", + "operationId": "addAttachment", + "summary": "Create a Attachment", + "description": "Create a attachment in any supported Ticketing software", "parameters": [ { "name": "integrationId", @@ -926,7 +1592,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/UnifiedTicketInput" + "$ref": "#/components/schemas/UnifiedAttachmentInput" } } } @@ -937,20 +1603,33 @@ } }, "tags": [ - "ticketing/ticket" + "ticketing/attachment" ] - }, - "patch": { - "operationId": "updateTicket", - "summary": "Update a Ticket", + } + }, + "/ticketing/attachment/{id}": { + "get": { + "operationId": "getAttachment", + "summary": "Retrieve a Attachment", + "description": "Retrieve a attachment from any connected Ticketing software", "parameters": [ { "name": "id", "required": true, - "in": "query", + "in": "path", + "description": "id of the attachment you want to retrive.", "schema": { "type": "string" } + }, + { + "name": "remoteData", + "required": false, + "in": "query", + "description": "Set to true to include data from the original Ticketing software.", + "schema": { + "type": "boolean" + } } ], "responses": { @@ -959,21 +1638,21 @@ } }, "tags": [ - "ticketing/ticket" + "ticketing/attachment" ] } }, - "/ticketing/ticket/{id}": { + "/ticketing/attachment/{id}/download": { "get": { - "operationId": "getTicket", - "summary": "Retrieve a Ticket", - "description": "Retrieve a ticket from any connected Ticketing software", + "operationId": "downloadAttachment", + "summary": "Download a Attachment", + "description": "Download a attachment from any connected Ticketing software", "parameters": [ { "name": "id", "required": true, "in": "path", - "description": "id of the `ticket` you want to retrive.", + "description": "id of the attachment you want to retrive.", "schema": { "type": "string" } @@ -994,14 +1673,14 @@ } }, "tags": [ - "ticketing/ticket" + "ticketing/attachment" ] } }, - "/ticketing/ticket/batch": { + "/ticketing/attachment/batch": { "post": { - "operationId": "addTickets", - "summary": "Add a batch of Tickets", + "operationId": "addAttachments", + "summary": "Add a batch of Attachments", "parameters": [ { "name": "integrationId", @@ -1036,7 +1715,7 @@ "schema": { "type": "array", "items": { - "$ref": "#/components/schemas/UnifiedTicketInput" + "$ref": "#/components/schemas/UnifiedAttachmentInput" } } } @@ -1048,14 +1727,14 @@ } }, "tags": [ - "ticketing/ticket" + "ticketing/attachment" ] } }, - "/ticketing/comment": { + "/ticketing/contact": { "get": { - "operationId": "getComments", - "summary": "List a batch of Comments", + "operationId": "getContacts", + "summary": "List a batch of Contacts", "parameters": [ { "name": "integrationId", @@ -1089,19 +1768,54 @@ } }, "tags": [ - "ticketing/comment" + "ticketing/contact" ] - }, - "post": { - "operationId": "addComment", - "summary": "Create a Comment", - "description": "Create a ticket in any supported Ticketing software", + } + }, + "/ticketing/contact/{id}": { + "get": { + "operationId": "getContact", + "summary": "Retrieve a Contact", + "description": "Retrieve a contact from any connected Ticketing software", + "parameters": [ + { + "name": "id", + "required": true, + "in": "path", + "description": "id of the contact you want to retrieve.", + "schema": { + "type": "string" + } + }, + { + "name": "remoteData", + "required": false, + "in": "query", + "description": "Set to true to include data from the original Ticketing software.", + "schema": { + "type": "boolean" + } + } + ], + "responses": { + "200": { + "description": "" + } + }, + "tags": [ + "ticketing/contact" + ] + } + }, + "/ticketing/account": { + "get": { + "operationId": "getAccounts", + "summary": "List a batch of Accounts", "parameters": [ { "name": "integrationId", "required": true, "in": "header", - "description": "The integration ID", "schema": { "type": "string" } @@ -1110,7 +1824,6 @@ "name": "linkedUserId", "required": true, "in": "header", - "description": "The linked user ID", "schema": { "type": "string" } @@ -1125,36 +1838,80 @@ } } ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/UnifiedCommentInput" - } - } - } - }, "responses": { - "201": { + "200": { "description": "" } }, "tags": [ - "ticketing/comment" + "ticketing/account" ] - }, - "patch": { - "operationId": "updateComment", - "summary": "Update a Comment", + } + }, + "/ticketing/account/{id}": { + "get": { + "operationId": "getAccount", + "summary": "Retrieve an Account", + "description": "Retrieve an account from any connected Ticketing software", "parameters": [ { "name": "id", "required": true, + "in": "path", + "description": "id of the account you want to retrieve.", + "schema": { + "type": "string" + } + }, + { + "name": "remoteData", + "required": false, "in": "query", + "description": "Set to true to include data from the original Ticketing software.", + "schema": { + "type": "boolean" + } + } + ], + "responses": { + "200": { + "description": "" + } + }, + "tags": [ + "ticketing/account" + ] + } + }, + "/ticketing/tag": { + "get": { + "operationId": "getTags", + "summary": "List a batch of Tags", + "parameters": [ + { + "name": "integrationId", + "required": true, + "in": "header", + "schema": { + "type": "string" + } + }, + { + "name": "linkedUserId", + "required": true, + "in": "header", "schema": { "type": "string" } + }, + { + "name": "remoteData", + "required": false, + "in": "query", + "description": "Set to true to include data from the original Ticketing software.", + "schema": { + "type": "boolean" + } } ], "responses": { @@ -1163,21 +1920,21 @@ } }, "tags": [ - "ticketing/comment" + "ticketing/tag" ] } }, - "/ticketing/comment/{id}": { + "/ticketing/tag/{id}": { "get": { - "operationId": "getComment", - "summary": "Retrieve a Comment", - "description": "Retrieve a ticket from any connected Ticketing software", + "operationId": "getTag", + "summary": "Retrieve a Tag", + "description": "Retrieve a tag from any connected Ticketing software", "parameters": [ { "name": "id", "required": true, "in": "path", - "description": "id of the `ticket` you want to retrive.", + "description": "id of the tag you want to retrieve.", "schema": { "type": "string" } @@ -1198,14 +1955,14 @@ } }, "tags": [ - "ticketing/comment" + "ticketing/tag" ] } }, - "/ticketing/comment/batch": { - "post": { - "operationId": "addComments", - "summary": "Add a batch of Comments", + "/ticketing/team": { + "get": { + "operationId": "getTeams", + "summary": "List a batch of Teams", "parameters": [ { "name": "integrationId", @@ -1233,26 +1990,48 @@ } } ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/UnifiedCommentInput" - } - } - } + "responses": { + "200": { + "description": "" } }, + "tags": [ + "ticketing/team" + ] + } + }, + "/ticketing/team/{id}": { + "get": { + "operationId": "getTeam", + "summary": "Retrieve a Team", + "description": "Retrieve a team from any connected Ticketing software", + "parameters": [ + { + "name": "id", + "required": true, + "in": "path", + "description": "id of the team you want to retrieve.", + "schema": { + "type": "string" + } + }, + { + "name": "remoteData", + "required": false, + "in": "query", + "description": "Set to true to include data from the original Ticketing software.", + "schema": { + "type": "boolean" + } + } + ], "responses": { - "201": { + "200": { "description": "" } }, "tags": [ - "ticketing/comment" + "ticketing/team" ] } } @@ -1596,21 +2375,21 @@ "field_mappings" ] }, + "UnifiedDealInput": { + "type": "object", + "properties": {} + }, "UnifiedTicketInput": { "type": "object", - "properties": { - "field_mappings": { - "type": "object", - "properties": {} - } - }, - "required": [ - "field_mappings" - ] + "properties": {} }, "UnifiedCommentInput": { "type": "object", "properties": {} + }, + "UnifiedAttachmentInput": { + "type": "object", + "properties": {} } } }