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 3d0f5fd07..5578365dd 100644 --- a/packages/api/src/@core/utils/types/original/original.ticketing.ts +++ b/packages/api/src/@core/utils/types/original/original.ticketing.ts @@ -7,7 +7,23 @@ import { 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 { FrontCommentInput, FrontCommentOutput, @@ -20,6 +36,30 @@ 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, @@ -138,4 +178,8 @@ export type TicketingObjectOutput = | OriginalTicketOutput | OriginalCommentOutput | OriginalUserOutput - | OriginalAttachmentOutput; + | OriginalAttachmentOutput + | OriginalTeamOutput + | OriginalTagOutput + | OriginalContactOutput + | OriginalAccountOutput; diff --git a/packages/api/src/ticketing/@utils/@types/index.ts b/packages/api/src/ticketing/@utils/@types/index.ts index d9f8f46b5..079ceee92 100644 --- a/packages/api/src/ticketing/@utils/@types/index.ts +++ b/packages/api/src/ticketing/@utils/@types/index.ts @@ -1,6 +1,16 @@ +import { contactUnificationMapping } from '@crm/contact/types/mappingsTypes'; import { IAccountService } from '@ticketing/account/types'; -import { UnifiedAccountInput, UnifiedAccountOutput } from '@ticketing/account/types/model.unified'; +import { accountUnificationMapping } from '@ticketing/account/types/mappingsTypes'; +import { + UnifiedAccountInput, + UnifiedAccountOutput, +} from '@ticketing/account/types/model.unified'; import { IAttachmentService } from '@ticketing/attachment/types'; +import { attachmentUnificationMapping } from '@ticketing/attachment/types/mappingsTypes'; +import { + UnifiedAttachmentInput, + UnifiedAttachmentOutput, +} from '@ticketing/attachment/types/model.unified'; import { ICommentService } from '@ticketing/comment/types'; import { commentUnificationMapping } from '@ticketing/comment/types/mappingsTypes'; import { @@ -8,6 +18,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 { @@ -40,9 +66,9 @@ export type UnifiedTicketing = | UnifiedUserInput | UnifiedUserOutput | UnifiedAccountInput - | UnifiedAccountOutput; + | UnifiedAccountOutput | UnifiedContactInput - | UnifiedContactOutput; + | UnifiedContactOutput | UnifiedTeamInput | UnifiedTeamOutput | UnifiedTagInput @@ -67,10 +93,9 @@ export type ITicketingService = | IUserService | IAttachmentService | IContactService + | IAccountService | ITeamService | ITagService; -; - export * from '../../ticket/services/zendesk/types'; export * from '../../comment/services/zendesk/types'; export * from '../../user/services/zendesk/types'; diff --git a/packages/api/src/ticketing/attachment/services/attachment.service.ts b/packages/api/src/ticketing/attachment/services/attachment.service.ts index 2624418dd..e1bf59e09 100644 --- a/packages/api/src/ticketing/attachment/services/attachment.service.ts +++ b/packages/api/src/ticketing/attachment/services/attachment.service.ts @@ -9,12 +9,13 @@ import { UnifiedAttachmentInput, UnifiedAttachmentOutput, } from '../types/model.unified'; -import { AttachmentResponse } from '../types'; +import { AttachmentResponse, IAttachmentService } 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 { OriginalAttachmentOutput } from '@@core/utils/types/original/original.ticketing'; @Injectable() export class AttachmentService { @@ -28,5 +29,411 @@ export class AttachmentService { 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, + ), + ), + ); + + const allAttachments = responses.flatMap( + (response) => response.data.attachments, + ); + const allRemoteData = responses.flatMap( + (response) => response.data.remote_data || [], + ); + + return { + data: { + attachments: allAttachments, + remote_data: allRemoteData, + }, + message: 'All attachments inserted successfully', + statusCode: 201, + }; + } 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, + }, + }); + const job_resp_create = await this.prisma.events.create({ + data: { + id_event: uuidv4(), + status: 'initialized', + type: 'ticketing.attachment.created', //sync, push or pull + method: 'POST', + url: '/ticketing/attachment', + provider: integrationId, + direction: '0', + timestamp: new Date(), + id_linked_user: linkedUserId, + }, + }); + const job_id = job_resp_create.id_event; + + //TODO + // Retrieve custom field mappings + // get potential fieldMappings and extract the original properties name + const customFieldMappings = + await this.fieldMappingService.getCustomFieldMappings( + integrationId, + linkedUserId, + 'attachment', + ); + //desunify the data according to the target obj wanted + const desunifiedObject = await desunify({ + sourceObject: unifiedAttachmentData, + targetType: TicketingObject.attachment, + providerName: integrationId, + customFieldMappings: unifiedAttachmentData.field_mappings + ? customFieldMappings + : [], + }); + + const service: IAttachmentService = + this.serviceRegistry.getService(integrationId); + const resp: ApiResponse = + await service.addAttachment(desunifiedObject, linkedUserId); + + //unify the data according to the target obj wanted + const unifiedObject = (await unify({ + sourceObject: [resp.data], + targetType: TicketingObject.attachment, + providerName: integrationId, + customFieldMappings: customFieldMappings, + })) as UnifiedAttachmentOutput[]; + + // add the attachment inside our db + const source_attachment = resp.data; + const target_attachment = unifiedObject[0]; + const originId = + 'id' in source_attachment ? String(source_attachment.id) : undefined; //TODO + + const existingAttachment = await this.prisma.tcg_attachments.findFirst({ + where: { + remote_id: originId, + remote_platform: integrationId, + events: { + 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: { + //TODO + modified_at: new Date(), + }, + }); + unique_ticketing_attachment_id = res.id_tcg_attachment; + } else { + // Create a new attachment + this.logger.log('not existing attachment ' + target_attachment); + const data = { + id_tcg_attachment: uuidv4(), + //TODO + created_at: new Date(), + modified_at: new Date(), + id_event: job_id, + remote_id: originId, + remote_platform: integrationId, + }; + + const res = await this.prisma.tcg_attachments.create({ + data: data, + }); + unique_ticketing_attachment_id = res.id_tcg_attachment; + } + + // check duplicate or existing values + if ( + target_attachment.field_mappings && + target_attachment.field_mappings.length > 0 + ) { + const entity = await this.prisma.entity.create({ + data: { + id_entity: uuidv4(), + ressource_owner_id: unique_ticketing_attachment_id, + }, + }); + + for (const mapping of target_attachment.field_mappings) { + const attribute = await this.prisma.attribute.findFirst({ + where: { + slug: Object.keys(mapping)[0], + source: integrationId, + id_consumer: linkedUserId, + }, + }); + + if (attribute) { + await this.prisma.value.create({ + data: { + id_value: uuidv4(), + data: Object.values(mapping)[0] || 'null', + attribute: { + connect: { + id_attribute: attribute.id_attribute, + }, + }, + entity: { + connect: { + id_entity: entity.id_entity, + }, + }, + }, + }); + } + } + } + if (remote_data) { + //insert remote_data in db + await this.prisma.remote_data.upsert({ + where: { + ressource_owner_id: unique_ticketing_attachment_id, + }, + create: { + id_remote_data: uuidv4(), + ressource_owner_id: unique_ticketing_attachment_id, + format: 'json', + data: JSON.stringify(source_attachment), + created_at: new Date(), + }, + update: { + data: JSON.stringify(source_attachment), + created_at: new Date(), + }, + }); + } + + ///// + const result_attachment = await this.getAttachment( + unique_ticketing_attachment_id, + remote_data, + ); + + const status_resp = resp.statusCode === 201 ? 'success' : 'fail'; + await this.prisma.events.update({ + where: { + id_event: job_id, + }, + data: { + status: status_resp, + }, + }); + await this.webhook.handleWebhook( + result_attachment.data.attachments, + 'ticketing.attachment.created', + linkedUser.id_project, + job_id, + ); + return { ...resp, data: result_attachment.data }; + } 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, + //TODO + field_mappings: field_mappings, + }; + + let res: AttachmentResponse = { + attachments: [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 { + data: res, + statusCode: 200, + }; + } 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 job_resp_create = await this.prisma.events.create({ + data: { + id_event: uuidv4(), + status: 'initialized', + type: 'ticketing.attachment.pull', + method: 'GET', + url: '/ticketing/attachment', + provider: integrationId, + direction: '0', + timestamp: new Date(), + id_linked_user: linkedUserId, + }, + }); + const job_id = job_resp_create.id_event; + const attachments = await this.prisma.tcg_attachments.findMany({ + where: { + remote_id: integrationId.toLowerCase(), + events: { + 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, + //TODO + field_mappings: field_mappings, + }; + }), + ); + + let res: AttachmentResponse = { + attachments: unifiedAttachments, + }; + + if (remote_data) { + const remote_array_data: Record[] = await Promise.all( + attachments.map(async (attachment) => { + const resp = await this.prisma.remote_data.findFirst({ + where: { + ressource_owner_id: attachment.id_tcg_attachment, + }, + }); + const remote_data = JSON.parse(resp.data); + return remote_data; + }), + ); + + res = { + ...res, + remote_data: remote_array_data, + }; + } + await this.prisma.events.update({ + where: { + id_event: job_id, + }, + data: { + status: 'success', + }, + }); + + return { + data: res, + statusCode: 200, + }; + } catch (error) { + handleServiceError(error, this.logger); + } + } } diff --git a/packages/api/src/ticketing/attachment/sync/sync.service.ts b/packages/api/src/ticketing/attachment/sync/sync.service.ts index 5caadfabc..d6a9b1afc 100644 --- a/packages/api/src/ticketing/attachment/sync/sync.service.ts +++ b/packages/api/src/ticketing/attachment/sync/sync.service.ts @@ -12,6 +12,8 @@ import { WebhookService } from '@@core/webhook/webhook.service'; import { UnifiedAttachmentOutput } from '../types/model.unified'; import { IAttachmentService } from '../types'; import { ServiceRegistry } from '../services/registry.service'; +import { OriginalAttachmentOutput } from '@@core/utils/types/original/original.ticketing'; +import { tcg_attachments as TicketingAttachment } from '@prisma/client'; @Injectable() export class SyncService implements OnModuleInit { @@ -26,8 +28,254 @@ export class SyncService implements OnModuleInit { } async onModuleInit() { - // Initialization logic + try { + await this.syncAttachments(); + } catch (error) { + handleServiceError(error, this.logger); + } } - // Additional methods and logic + @Cron('*/20 * * * *') + //function used by sync worker which populate our tcg_attachments table + //its role is to fetch all attachments from providers 3rd parties and save the info inside our db + async syncAttachments() { + try { + this.logger.log(`Syncing attachments....`); + 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 attachments for every ticket of the linkedUser (a attachment is tied to a ticket) + const tickets = await this.prisma.tcg_tickets.findMany({ + where: { + remote_platform: provider, + events: { + id_linked_user: linkedUser.id_linked_user, + }, + }, + }); + for (const ticket of tickets) { + await this.syncAttachmentsForLinkedUser( + 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 syncAttachmentsForLinkedUser( + integrationId: string, + linkedUserId: string, + id_project: string, + id_ticket: string, + ) { + try { + this.logger.log( + `Syncing ${integrationId} attachments 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) return; + const job_resp_create = await this.prisma.events.create({ + data: { + id_event: uuidv4(), + status: 'initialized', + type: 'ticketing.attachment.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 = + await this.fieldMappingService.getCustomFieldMappings( + integrationId, + linkedUserId, + 'attachment', + ); + const remoteProperties: string[] = customFieldMappings.map( + (mapping) => mapping.remote_id, + ); + + const service: IAttachmentService = + this.serviceRegistry.getService(integrationId); + const resp: ApiResponse = + await service.syncAttachments(linkedUserId, remoteProperties); + + const sourceObject: OriginalAttachmentOutput[] = 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.attachment, + providerName: integrationId, + customFieldMappings, + })) as UnifiedAttachmentOutput[]; + + //TODO + const attachmentsIds = sourceObject.map((attachment) => + 'id' in attachment ? String(attachment.id) : undefined, + ); + //insert the data in the DB with the fieldMappings (value table) + const attachments_data = await this.saveAttachmentsInDb( + linkedUserId, + unifiedObject, + attachmentsIds, + integrationId, + id_ticket, + job_id, + sourceObject, + ); + await this.prisma.events.update({ + where: { + id_event: job_id, + }, + data: { + status: 'success', + }, + }); + await this.webhook.handleWebhook( + attachments_data, + 'ticketing.attachment.pulled', + id_project, + job_id, + ); + } catch (error) { + handleServiceError(error, this.logger); + } + } + + async saveAttachmentsInDb( + linkedUserId: string, + attachments: UnifiedAttachmentOutput[], + originIds: string[], + originSource: string, + id_ticket: string, + jobId: string, + remote_data: Record[], + ): Promise { + try { + let attachments_results: TicketingAttachment[] = []; + for (let i = 0; i < attachments.length; i++) { + const attachment = attachments[i]; + const originId = originIds[i]; + + if (!originId || originId == '') { + throw new NotFoundError(`Origin id not there, found ${originId}`); + } + + const existingAttachment = await this.prisma.tcg_attachments.findFirst({ + where: { + remote_id: originId, + remote_platform: originSource, + events: { + 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: { + //TODO + id_tcg_ticket: id_ticket, + id_event: jobId, + modified_at: new Date(), + }, + }); + unique_ticketing_attachment_id = res.id_tcg_attachment; + attachments_results = [...attachments_results, res]; + } else { + // Create a new attachment + this.logger.log('attachment not exists'); + const data = { + id_tcg_attachment: uuidv4(), + //TODO + created_at: new Date(), + modified_at: new Date(), + id_tcg_ticket: id_ticket, + id_event: jobId, + remote_id: originId, + remote_platform: originSource, + //TODO; id_tcg_contact String? @db.Uuid + //TODO; id_tcg_user String? @db.Uuid + }; + const res = await this.prisma.tcg_attachments.create({ + data: data, + }); + attachments_results = [...attachments_results, res]; + unique_ticketing_attachment_id = res.id_tcg_attachment; + } + + //insert remote_data in db + await this.prisma.remote_data.upsert({ + where: { + ressource_owner_id: unique_ticketing_attachment_id, + }, + create: { + id_remote_data: uuidv4(), + ressource_owner_id: unique_ticketing_attachment_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 attachments_results; + } catch (error) { + handleServiceError(error, this.logger); + } + } } diff --git a/packages/api/src/ticketing/attachment/types/mappingsTypes.ts b/packages/api/src/ticketing/attachment/types/mappingsTypes.ts index d08c20aaa..267326725 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 frontAttachmentMapper = new FrontAttachmentMapper(); +const githubAttachmentMapper = new GithubAttachmentMapper(); + +export const attachmentUnificationMapping = { + zendesk: { + unify: zendeskAttachmentMapper.unify, + desunify: zendeskAttachmentMapper.desunify, + }, + front: { + unify: frontAttachmentMapper.unify, + desunify: frontAttachmentMapper.desunify, + }, + github: { + unify: githubAttachmentMapper.unify, + 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..0151979d3 100644 --- a/packages/api/src/ticketing/attachment/types/model.unified.ts +++ b/packages/api/src/ticketing/attachment/types/model.unified.ts @@ -1,3 +1,7 @@ -export class UnifiedAttachmentInput {} +export class UnifiedAttachmentInput { + field_mappings?: Record[]; +} -export class UnifiedAttachmentOutput extends UnifiedAttachmentInput {} +export class UnifiedAttachmentOutput extends UnifiedAttachmentInput { + id: string; +} 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..b10dce39c 100644 --- a/packages/api/src/ticketing/contact/services/contact.service.ts +++ b/packages/api/src/ticketing/contact/services/contact.service.ts @@ -4,27 +4,192 @@ 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: ContactResponse = { + contacts: [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 { + data: res, + statusCode: 200, + }; + } catch (error) { + handleServiceError(error, this.logger); + } + } + + async getContacts( + integrationId: string, + linkedContactId: string, + remote_data?: boolean, + ): 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.contact.pull', + method: 'GET', + url: '/ticketing/contact', + provider: integrationId, + direction: '0', + timestamp: new Date(), + id_linked_user: linkedContactId, + }, + }); + const job_id = job_resp_create.id_event; + const contacts = await this.prisma.tcg_contacts.findMany({ + where: { + remote_id: integrationId.toLowerCase(), + events: { + id_linked_user: linkedContactId, + }, + }, + }); + + 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: ContactResponse = { + contacts: unifiedContacts, + }; + + if (remote_data) { + const remote_array_data: Record[] = await Promise.all( + contacts.map(async (contact) => { + const resp = await this.prisma.remote_data.findFirst({ + where: { + ressource_owner_id: contact.id_tcg_contact, + }, + }); + const remote_data = JSON.parse(resp.data); + return remote_data; + }), + ); + + res = { + ...res, + remote_data: remote_array_data, + }; + } + await this.prisma.events.update({ + where: { + id_event: job_id, + }, + data: { + status: 'success', + }, + }); + + return { + data: res, + statusCode: 200, + }; + } 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..65c60e3fb --- /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( + linkedContactId: string, + ): Promise> { + try { + const connection = await this.prisma.connections.findFirst({ + where: { + id_linked_user: linkedContactId, + 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..1e9bd9119 --- /dev/null +++ b/packages/api/src/ticketing/contact/services/front/mappers.ts @@ -0,0 +1,43 @@ +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((ticket) => + this.mapSingleTicketToUnified(ticket, customFieldMappings), + ); + } + + private mapSingleTicketToUnified( + ticket: FrontContactOutput, + customFieldMappings?: { + slug: string; + remote_id: string; + }[], + ): UnifiedContactOutput { + return; + } +} 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..f744e464a --- /dev/null +++ b/packages/api/src/ticketing/contact/services/front/types.ts @@ -0,0 +1,5 @@ +export type FrontContactInput = { + id: string; +}; + +export type FrontContactOutput = FrontContactInput; 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..baf87f328 --- /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( + linkedContactId: string, + custom_properties?: string[], + ): Promise> { + try { + const connection = await this.prisma.connections.findFirst({ + where: { + id_linked_user: linkedContactId, + 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..b2001713f 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 { @@ -24,16 +27,45 @@ export class ZendeskService implements IContactService { ); this.registry.registerService('zendesk_t', this); } - addContact( - contactData: DesunifyReturnType, - linkedUserId: string, - ): Promise> { - throw new Error('Method not implemented.'); - } - syncContacts( - linkedUserId: string, + + async syncContacts( + linkedContactId: string, custom_properties?: string[], - ): Promise> { - throw new Error('Method not implemented.'); + ): Promise> { + try { + const connection = await this.prisma.connections.findFirst({ + where: { + id_linked_user: linkedContactId, + provider_slug: 'zendesk_t', + }, + }); + + 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..0e2bdbb09 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,282 @@ 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, + linkedContactId: string, + id_project: string, + ) { + try { + this.logger.log( + `Syncing ${integrationId} contacts for linkedContact ${linkedContactId}`, + ); + // check if linkedContact has a connection if not just stop sync + const connection = await this.prisma.connections.findFirst({ + where: { + id_linked_user: linkedContactId, + provider_slug: integrationId, + }, + }); + if (!connection) return; + const job_resp_create = await this.prisma.events.create({ + data: { + id_event: uuidv4(), + status: 'initialized', + type: 'ticketing.contact.pulled', + method: 'PULL', + url: '/pull', + provider: integrationId, + direction: '0', + timestamp: new Date(), + id_linked_user: linkedContactId, + }, + }); + const job_id = job_resp_create.id_event; + + // get potential fieldMappings and extract the original properties name + const customFieldMappings = + await this.fieldMappingService.getCustomFieldMappings( + integrationId, + linkedContactId, + 'contact', + ); + const remoteProperties: string[] = customFieldMappings.map( + (mapping) => mapping.remote_id, + ); + + const service: IContactService = + this.serviceRegistry.getService(integrationId); + const resp: ApiResponse = + await service.syncContacts(linkedContactId, 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( + linkedContactId, + unifiedObject, + contactIds, + integrationId, + job_id, + sourceObject, + ); + await this.prisma.events.update({ + where: { + id_event: job_id, + }, + data: { + status: 'success', + }, + }); + await this.webhook.handleWebhook( + contact_data, + 'ticketing.contact.pulled', + id_project, + job_id, + ); + } catch (error) { + handleServiceError(error, this.logger); + } + } + + async saveContactsInDb( + linkedContactId: string, + contacts: UnifiedContactOutput[], + originIds: string[], + originSource: string, + jobId: 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, + events: { + id_linked_user: linkedContactId, + }, + }, + }); + + 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_event: jobId, + 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: linkedContactId, + }, + }); + + 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..332b645b5 100644 --- a/packages/api/src/ticketing/contact/types/index.ts +++ b/packages/api/src/ticketing/contact/types/index.ts @@ -5,11 +5,6 @@ import { ApiResponse } from '@@core/utils/types'; import { OriginalContactOutput } from '@@core/utils/types/original/original.crm'; 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..7bd112f89 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: { + 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..a59aa7cdb 100644 --- a/packages/api/src/ticketing/contact/types/model.unified.ts +++ b/packages/api/src/ticketing/contact/types/model.unified.ts @@ -1,3 +1,11 @@ -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; +} 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..730023a82 --- /dev/null +++ b/packages/api/src/ticketing/tag/services/front/index.ts @@ -0,0 +1,61 @@ +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(linkedTagId: string): Promise> { + try { + const connection = await this.prisma.connections.findFirst({ + where: { + id_linked_user: linkedTagId, + 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 tags !`); + + return { + data: resp.data._results, + 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..965723b22 --- /dev/null +++ b/packages/api/src/ticketing/tag/services/front/mappers.ts @@ -0,0 +1,43 @@ +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((ticket) => + this.mapSingleTicketToUnified(ticket, customFieldMappings), + ); + } + + private mapSingleTicketToUnified( + ticket: FrontTagOutput, + customFieldMappings?: { + slug: string; + remote_id: string; + }[], + ): UnifiedTagOutput { + return; + } +} 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..38e26e603 --- /dev/null +++ b/packages/api/src/ticketing/tag/services/front/types.ts @@ -0,0 +1,5 @@ +export type FrontTagInput = { + id: string; +}; + +export type FrontTagOutput = FrontTagInput; 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..bb02c0a6d --- /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( + linkedTagId: string, + custom_properties?: string[], + ): Promise> { + try { + const connection = await this.prisma.connections.findFirst({ + where: { + id_linked_user: linkedTagId, + 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..6c1c99444 --- /dev/null +++ b/packages/api/src/ticketing/tag/services/tag.service.ts @@ -0,0 +1,189 @@ +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: TagResponse = { + tags: [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 { + data: res, + statusCode: 200, + }; + } catch (error) { + handleServiceError(error, this.logger); + } + } + + async getTags( + integrationId: string, + linkedTagId: string, + remote_data?: boolean, + ): 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.tag.pull', + method: 'GET', + url: '/ticketing/tag', + provider: integrationId, + direction: '0', + timestamp: new Date(), + id_linked_user: linkedTagId, + }, + }); + const job_id = job_resp_create.id_event; + const tags = await this.prisma.tcg_tags.findMany({ + where: { + remote_id: integrationId.toLowerCase(), + events: { + id_linked_user: linkedTagId, + }, + }, + }); + + 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: TagResponse = { + tags: unifiedTags, + }; + + if (remote_data) { + const remote_array_data: Record[] = await Promise.all( + tags.map(async (tag) => { + const resp = await this.prisma.remote_data.findFirst({ + where: { + ressource_owner_id: tag.id_tcg_tag, + }, + }); + const remote_data = JSON.parse(resp.data); + return remote_data; + }), + ); + + res = { + ...res, + remote_data: remote_array_data, + }; + } + await this.prisma.events.update({ + where: { + id_event: job_id, + }, + data: { + status: 'success', + }, + }); + + return { + data: res, + statusCode: 200, + }; + } 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..cc43752df --- /dev/null +++ b/packages/api/src/ticketing/tag/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, 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_t', this); + } + + async syncTags( + linkedTagId: string, + custom_properties?: string[], + ): Promise> { + try { + const connection = await this.prisma.connections.findFirst({ + where: { + id_linked_user: linkedTagId, + provider_slug: 'zendesk_t', + }, + }); + + const resp = await axios.get( + `https://${this.env.getZendeskTicketingSubdomain()}.zendesk.com/api/v2/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..40d81ea4d --- /dev/null +++ b/packages/api/src/ticketing/tag/services/zendesk/mappers.ts @@ -0,0 +1,43 @@ +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 | ZendeskTagOutput[], + customFieldMappings?: { + slug: string; + remote_id: string; + }[], + ): UnifiedTagOutput | UnifiedTagOutput[] { + if (!Array.isArray(source)) { + return this.mapSingleTagToUnified(source, customFieldMappings); + } + return source.map((ticket) => + this.mapSingleTagToUnified(ticket, customFieldMappings), + ); + } + + private mapSingleTagToUnified( + ticket: ZendeskTagOutput, + customFieldMappings?: { + slug: string; + remote_id: string; + }[], + ): UnifiedTagOutput { + return; + } +} 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..e8891b6d8 --- /dev/null +++ b/packages/api/src/ticketing/tag/services/zendesk/types.ts @@ -0,0 +1,7 @@ +export type ZendeskTagInput = { + _: string; +}; + +export type ZendeskTagOutput = ZendeskTagInput & { + id: number; // Read-only. Automatically assigned when the ticket is created. +}; 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..9d6f775c2 --- /dev/null +++ b/packages/api/src/ticketing/tag/sync/sync.service.ts @@ -0,0 +1,305 @@ +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 linkedTags = await this.prisma.linked_users.findMany({ + where: { + id_project: id_project, + }, + }); + linkedTags.map(async (linkedTag) => { + try { + const providers = TICKETING_PROVIDERS; + for (const provider of providers) { + try { + await this.syncTagsForLinkedTag( + provider, + linkedTag.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 syncTagsForLinkedTag( + integrationId: string, + linkedTagId: string, + id_project: string, + ) { + try { + this.logger.log( + `Syncing ${integrationId} tags for linkedTag ${linkedTagId}`, + ); + // check if linkedTag has a connection if not just stop sync + const connection = await this.prisma.connections.findFirst({ + where: { + id_linked_user: linkedTagId, + provider_slug: integrationId, + }, + }); + if (!connection) return; + const job_resp_create = await this.prisma.events.create({ + data: { + id_event: uuidv4(), + status: 'initialized', + type: 'ticketing.tag.pulled', + method: 'PULL', + url: '/pull', + provider: integrationId, + direction: '0', + timestamp: new Date(), + id_linked_user: linkedTagId, + }, + }); + const job_id = job_resp_create.id_event; + + // get potential fieldMappings and extract the original properties name + const customFieldMappings = + await this.fieldMappingService.getCustomFieldMappings( + integrationId, + linkedTagId, + 'tag', + ); + const remoteProperties: string[] = customFieldMappings.map( + (mapping) => mapping.remote_id, + ); + + const service: ITagService = + this.serviceRegistry.getService(integrationId); + const resp: ApiResponse = await service.syncTags( + linkedTagId, + remoteProperties, + ); + + 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( + linkedTagId, + unifiedObject, + tagIds, + integrationId, + job_id, + sourceObject, + ); + await this.prisma.events.update({ + where: { + id_event: job_id, + }, + data: { + status: 'success', + }, + }); + await this.webhook.handleWebhook( + tag_data, + 'ticketing.tag.pulled', + id_project, + job_id, + ); + } catch (error) { + handleServiceError(error, this.logger); + } + } + + async saveTagsInDb( + linkedTagId: string, + tags: UnifiedTagOutput[], + originIds: string[], + originSource: string, + jobId: string, + remote_data: Record[], + ): Promise { + try { + let tags_results: TicketingTag[] = []; + for (let i = 0; i < tags.length; i++) { + const tag = tags[i]; + const originId = originIds[i]; + + if (!originId || originId == '') { + throw new NotFoundError(`Origin id not there, found ${originId}`); + } + + const existingTag = await this.prisma.tcg_tags.findFirst({ + where: { + remote_id: originId, + remote_platform: originSource, + events: { + id_linked_user: linkedTagId, + }, + }, + }); + + 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_event: jobId, + 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: linkedTagId, + }, + }); + + 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..52abd91f0 --- /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, + custom_properties?: 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..010647ab5 --- /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: { + 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..5af56978f --- /dev/null +++ b/packages/api/src/ticketing/tag/types/model.unified.ts @@ -0,0 +1,8 @@ +export class UnifiedTagInput { + name: string; + field_mappings?: Record[]; +} + +export class UnifiedTagOutput extends UnifiedTagInput { + id: string; +} 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..23aa639e4 --- /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( + linkedTeamId: string, + ): Promise> { + try { + const connection = await this.prisma.connections.findFirst({ + where: { + id_linked_user: linkedTeamId, + 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 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..e3879bf8f --- /dev/null +++ b/packages/api/src/ticketing/team/services/front/mappers.ts @@ -0,0 +1,43 @@ +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((ticket) => + this.mapSingleTicketToUnified(ticket, customFieldMappings), + ); + } + + private mapSingleTicketToUnified( + ticket: FrontTeamOutput, + customFieldMappings?: { + slug: string; + remote_id: string; + }[], + ): UnifiedTeamOutput { + return; + } +} 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..04b193f85 --- /dev/null +++ b/packages/api/src/ticketing/team/services/front/types.ts @@ -0,0 +1,5 @@ +export type FrontTeamInput = { + id: string; +}; + +export type FrontTeamOutput = FrontTeamInput; 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..a1cc32f77 --- /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( + linkedTeamId: string, + custom_properties?: string[], + ): Promise> { + try { + const connection = await this.prisma.connections.findFirst({ + where: { + id_linked_user: linkedTeamId, + 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..32f966fa2 --- /dev/null +++ b/packages/api/src/ticketing/team/services/team.service.ts @@ -0,0 +1,191 @@ +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: TeamResponse = { + teams: [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 { + data: res, + statusCode: 200, + }; + } catch (error) { + handleServiceError(error, this.logger); + } + } + + async getTeams( + integrationId: string, + linkedTeamId: string, + remote_data?: boolean, + ): 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.team.pull', + method: 'GET', + url: '/ticketing/team', + provider: integrationId, + direction: '0', + timestamp: new Date(), + id_linked_user: linkedTeamId, + }, + }); + const job_id = job_resp_create.id_event; + const teams = await this.prisma.tcg_teams.findMany({ + where: { + remote_id: integrationId.toLowerCase(), + events: { + id_linked_user: linkedTeamId, + }, + }, + }); + + 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: TeamResponse = { + teams: unifiedTeams, + }; + + if (remote_data) { + const remote_array_data: Record[] = await Promise.all( + teams.map(async (team) => { + const resp = await this.prisma.remote_data.findFirst({ + where: { + ressource_owner_id: team.id_tcg_team, + }, + }); + const remote_data = JSON.parse(resp.data); + return remote_data; + }), + ); + + res = { + ...res, + remote_data: remote_array_data, + }; + } + await this.prisma.events.update({ + where: { + id_event: job_id, + }, + data: { + status: 'success', + }, + }); + + return { + data: res, + statusCode: 200, + }; + } 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..f9199edd9 --- /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_t', this); + } + + async syncTeams( + linkedTeamId: string, + custom_properties?: string[], + ): Promise> { + try { + const connection = await this.prisma.connections.findFirst({ + where: { + id_linked_user: linkedTeamId, + provider_slug: 'zendesk_t', + }, + }); + + 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..1b74d7123 --- /dev/null +++ b/packages/api/src/ticketing/team/sync/sync.service.ts @@ -0,0 +1,307 @@ +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, + linkedTeamId: string, + id_project: string, + ) { + try { + this.logger.log( + `Syncing ${integrationId} teams for linkedTeam ${linkedTeamId}`, + ); + // check if linkedTeam has a connection if not just stop sync + const connection = await this.prisma.connections.findFirst({ + where: { + id_linked_user: linkedTeamId, + provider_slug: integrationId, + }, + }); + if (!connection) return; + const job_resp_create = await this.prisma.events.create({ + data: { + id_event: uuidv4(), + status: 'initialized', + type: 'ticketing.team.pulled', + method: 'PULL', + url: '/pull', + provider: integrationId, + direction: '0', + timestamp: new Date(), + id_linked_user: linkedTeamId, + }, + }); + const job_id = job_resp_create.id_event; + + // get potential fieldMappings and extract the original properties name + const customFieldMappings = + await this.fieldMappingService.getCustomFieldMappings( + integrationId, + linkedTeamId, + 'team', + ); + const remoteProperties: string[] = customFieldMappings.map( + (mapping) => mapping.remote_id, + ); + + const service: ITeamService = + this.serviceRegistry.getService(integrationId); + const resp: ApiResponse = await service.syncTeams( + linkedTeamId, + 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( + linkedTeamId, + unifiedObject, + teamIds, + integrationId, + job_id, + sourceObject, + ); + await this.prisma.events.update({ + where: { + id_event: job_id, + }, + data: { + status: 'success', + }, + }); + await this.webhook.handleWebhook( + team_data, + 'ticketing.team.pulled', + id_project, + job_id, + ); + } catch (error) { + handleServiceError(error, this.logger); + } + } + + async saveTeamsInDb( + linkedTeamId: string, + teams: UnifiedTeamOutput[], + originIds: string[], + originSource: string, + jobId: 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, + events: { + id_linked_user: linkedTeamId, + }, + }, + }); + + 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_event: jobId, + 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: linkedTeamId, + }, + }); + + 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..06240c4f8 --- /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: { + 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..9745c704c --- /dev/null +++ b/packages/api/src/ticketing/team/types/model.unified.ts @@ -0,0 +1,9 @@ +export class UnifiedTeamInput { + name: string; + description?: string; + field_mappings?: Record[]; +} + +export class UnifiedTeamOutput extends UnifiedTeamInput { + id: string; +} 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/ticketing.module.ts b/packages/api/src/ticketing/ticketing.module.ts index 4c6bedb63..c293ff4aa 100644 --- a/packages/api/src/ticketing/ticketing.module.ts +++ b/packages/api/src/ticketing/ticketing.module.ts @@ -5,6 +5,8 @@ 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: [ @@ -14,12 +16,12 @@ import { AccountModule } from './account/account.module'; AttachmentModule, ContactModule, AccountModule, + TagModule, + TeamModule, ], providers: [], controllers: [], - exports: [ TicketModule, - CommentModule, - UserModule, + exports: [ UserModule, AttachmentModule, ContactModule, ],