diff --git a/.env.example b/.env.example index 54aa00bb3..e238ab65a 100644 --- a/.env.example +++ b/.env.example @@ -96,6 +96,10 @@ GITLAB_TICKETING_CLOUD_CLIENT_SECRET= # Github GITHUB_TICKETING_CLOUD_CLIENT_ID= GITHUB_TICKETING_CLOUD_CLIENT_SECRET= +# Linear +LINEAR_TICKETING_CLOUD_CLIENT_ID= +LINEAR_TICKETING_CLOUD_CLIENT_SECRET= + # ================================================ # File Storage # ================================================ diff --git a/packages/api/scripts/init.sql b/packages/api/scripts/init.sql index b827837c5..d763e0830 100644 --- a/packages/api/scripts/init.sql +++ b/packages/api/scripts/init.sql @@ -548,6 +548,7 @@ CREATE TABLE connector_sets ecom_shopify boolean NULL, ecom_amazon boolean NULL, ecom_squarespace boolean NULL, + tcg_linear boolean NULL, ats_ashby boolean NULL, ecom_webflow boolean NULL, crm_microsoftdynamicssales boolean NULL, diff --git a/packages/api/scripts/seed.sql b/packages/api/scripts/seed.sql index 72f7eb37e..c27390425 100644 --- a/packages/api/scripts/seed.sql +++ b/packages/api/scripts/seed.sql @@ -1,10 +1,10 @@ INSERT INTO users (id_user, identification_strategy, email, password_hash, first_name, last_name) VALUES ('0ce39030-2901-4c56-8db0-5e326182ec6b', 'b2c','local@panora.dev', '$2b$10$Y7Q8TWGyGuc5ecdIASbBsuXMo3q/Rs3/cnY.mLZP4tUgfGUOCUBlG', 'local', 'Panora'); -INSERT INTO connector_sets (id_connector_set, crm_hubspot, crm_zoho, crm_pipedrive, crm_attio, crm_zendesk, crm_close, tcg_zendesk, tcg_gorgias, tcg_front, tcg_jira, tcg_gitlab, fs_box, tcg_github, hris_deel, hris_sage, ats_ashby, crm_microsoftdynamicssales, ecom_webflow) VALUES - ('1709da40-17f7-4d3a-93a0-96dc5da6ddd7', TRUE, TRUE, TRUE, TRUE, TRUE, TRUE, TRUE, TRUE, TRUE, TRUE, TRUE, TRUE, TRUE, TRUE, TRUE, TRUE, TRUE, TRUE), - ('852dfff8-ab63-4530-ae49-e4b2924407f8', TRUE, TRUE, TRUE, TRUE, TRUE, TRUE, TRUE, TRUE, TRUE, TRUE, TRUE, TRUE, TRUE, TRUE, TRUE, TRUE, TRUE, TRUE), - ('aed0f856-f802-4a79-8640-66d441581a99', TRUE, TRUE, TRUE, TRUE, TRUE, TRUE, TRUE, TRUE, TRUE, TRUE, TRUE, TRUE, TRUE, TRUE, TRUE, TRUE, TRUE, TRUE); +INSERT INTO connector_sets (id_connector_set, crm_hubspot, crm_zoho, crm_pipedrive, crm_attio, crm_zendesk, crm_close, tcg_zendesk, tcg_gorgias, tcg_front, tcg_jira, tcg_gitlab, fs_box, tcg_github, hris_deel, hris_sage, ats_ashby, crm_microsoftdynamicssales, ecom_webflow, tcg_linear) VALUES + ('1709da40-17f7-4d3a-93a0-96dc5da6ddd7', TRUE, TRUE, TRUE, TRUE, TRUE, TRUE, TRUE, TRUE, TRUE, TRUE, TRUE, TRUE, TRUE, TRUE, TRUE, TRUE, TRUE, TRUE, TRUE), + ('852dfff8-ab63-4530-ae49-e4b2924407f8', TRUE, TRUE, TRUE, TRUE, TRUE, TRUE, TRUE, TRUE, TRUE, TRUE, TRUE, TRUE, TRUE, TRUE, TRUE, TRUE, TRUE, TRUE, TRUE), + ('aed0f856-f802-4a79-8640-66d441581a99', TRUE, TRUE, TRUE, TRUE, TRUE, TRUE, TRUE, TRUE, TRUE, TRUE, TRUE, TRUE, TRUE, TRUE, TRUE, TRUE, TRUE, TRUE, TRUE); INSERT INTO projects (id_project, name, sync_mode, id_user, id_connector_set) VALUES ('1e468c15-aa57-4448-aa2b-7fed640d1e3d', 'Project 1', 'pull', '0ce39030-2901-4c56-8db0-5e326182ec6b', '1709da40-17f7-4d3a-93a0-96dc5da6ddd7'), 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 20285be76..3823aa975 100644 --- a/packages/api/src/@core/utils/types/original/original.ticketing.ts +++ b/packages/api/src/@core/utils/types/original/original.ticketing.ts @@ -1,3 +1,15 @@ +import { LinearTicketInput, LinearTicketOutput } from '@ticketing/ticket/services/linear/types'; + +import { LinearCommentInput, LinearCommentOutput } from '@ticketing/comment/services/linear/types'; + +import { LinearCollectionInput, LinearCollectionOutput } from '@ticketing/collection/services/linear/types'; + +import { LinearTagInput, LinearTagOutput } from '@ticketing/tag/services/linear/types'; + +import { LinearTeamInput, LinearTeamOutput } from '@ticketing/team/services/linear/types'; + +import { LinearUserInput, LinearUserOutput } from '@ticketing/user/services/linear/types'; + import { GithubCollectionInput, GithubCollectionOutput } from '@ticketing/collection/services/github/types'; import { GithubCommentInput, GithubCommentOutput } from '@ticketing/comment/services/github/types'; @@ -9,6 +21,7 @@ import { GithubTeamInput, GithubTeamOutput } from '@ticketing/team/services/gith import { GithubTicketInput, GithubTicketOutput } from '@ticketing/ticket/services/github/types'; import { GithubUserInput, GithubUserOutput } from '@ticketing/user/services/github/types'; + import { GitlabUserInput, GitlabUserOutput } from '@ticketing/user/services/gitlab/types'; import { @@ -144,7 +157,7 @@ export type OriginalTicketInput = | FrontTicketInput | GorgiasTicketInput | JiraTicketInput - | GitlabTicketInput | GithubTicketInput; + | GitlabTicketInput | GithubTicketInput | LinearTicketInput; //| JiraServiceMgmtTicketInput; /* comment */ @@ -153,14 +166,14 @@ export type OriginalCommentInput = | FrontCommentInput | GorgiasCommentInput | JiraCommentInput - | GitlabCommentInput | GithubCommentInput; + | GitlabCommentInput | GithubCommentInput | LinearCommentInput; //| JiraCommentServiceMgmtInput; /* user */ export type OriginalUserInput = | ZendeskUserInput | FrontUserInput | GorgiasUserInput - | JiraUserInput | GithubUserInput | GitlabUserInput; + | JiraUserInput | GithubUserInput | GitlabUserInput | LinearUserInput; //| JiraServiceMgmtUserInput; /* account */ export type OriginalAccountInput = ZendeskAccountInput | FrontAccountInput; @@ -176,20 +189,20 @@ export type OriginalTagInput = | FrontTagInput | GorgiasTagInput | JiraTagInput - | GitlabTagInput | GithubTagInput; + | GitlabTagInput | GithubTagInput | LinearTagInput; /* team */ export type OriginalTeamInput = | ZendeskTeamInput | FrontTeamInput | GorgiasTeamInput - | JiraTeamInput | GithubTeamInput; + | JiraTeamInput | GithubTeamInput | LinearTeamInput; /* attachment */ export type OriginalAttachmentInput = null; export type OriginalCollectionInput = | JiraCollectionInput - | GitlabCollectionInput | GithubCollectionInput; + | GitlabCollectionInput | GithubCollectionInput | LinearCollectionInput; export type TicketingObjectInput = | OriginalTicketInput @@ -210,7 +223,7 @@ export type OriginalTicketOutput = | FrontTicketOutput | GorgiasTicketOutput | JiraTicketOutput - | GitlabTicketOutput | GithubTicketOutput; + | GitlabTicketOutput | GithubTicketOutput | LinearTicketOutput; /* comment */ export type OriginalCommentOutput = @@ -218,13 +231,13 @@ export type OriginalCommentOutput = | FrontCommentOutput | GorgiasCommentOutput | JiraCommentOutput - | GitlabCommentOutput | GithubCommentOutput; + | GitlabCommentOutput | GithubCommentOutput | LinearCommentOutput; /* user */ export type OriginalUserOutput = | ZendeskUserOutput | FrontUserOutput | GorgiasUserOutput - | JiraUserOutput | GithubUserOutput | GitlabUserOutput; + | JiraUserOutput | GithubUserOutput | GitlabUserOutput | LinearUserOutput; /* account */ export type OriginalAccountOutput = ZendeskAccountOutput | FrontAccountOutput; /* contact */ @@ -239,14 +252,14 @@ export type OriginalTagOutput = | FrontTagOutput | GorgiasTagOutput | JiraTagOutput - | GitlabTagOutput | GithubTagOutput; + | GitlabTagOutput | GithubTagOutput | LinearTagOutput; /* team */ export type OriginalTeamOutput = | ZendeskTeamOutput | FrontTeamOutput | GorgiasTeamOutput - | JiraTeamOutput | GithubTeamOutput; + | JiraTeamOutput | GithubTeamOutput | LinearTeamOutput; /* attachment */ export type OriginalAttachmentOutput = @@ -259,7 +272,7 @@ export type OriginalAttachmentOutput = export type OriginalCollectionOutput = | JiraCollectionOutput - | GitlabCollectionOutput | GithubCollectionOutput; + | GitlabCollectionOutput | GithubCollectionOutput | LinearCollectionOutput; export type TicketingObjectOutput = | OriginalTicketOutput diff --git a/packages/api/src/ticketing/@lib/@utils/index.ts b/packages/api/src/ticketing/@lib/@utils/index.ts index 30f4dda2c..7dc10a419 100644 --- a/packages/api/src/ticketing/@lib/@utils/index.ts +++ b/packages/api/src/ticketing/@lib/@utils/index.ts @@ -5,7 +5,7 @@ import * as fs from 'fs'; @Injectable() export class Utils { - constructor(private readonly prisma: PrismaService) {} + constructor(private readonly prisma: PrismaService) { } async fetchFileStreamFromURL(file_url: string) { return fs.createReadStream(file_url); @@ -27,6 +27,20 @@ export class Utils { } } + async getTeamRemoteIdFromUuid(uuid: string) { + try { + const res = await this.prisma.tcg_teams.findFirst({ + where: { + id_tcg_team: uuid, + }, + }); + if (!res) return undefined; + return res.remote_id; + } catch (error) { + throw error; + } + } + async getRemoteIdFromTagName(name: string, connection_id: string) { try { const res = await this.prisma.tcg_tags.findFirst({ diff --git a/packages/api/src/ticketing/collection/collection.module.ts b/packages/api/src/ticketing/collection/collection.module.ts index e9d53225c..a88ed44bd 100644 --- a/packages/api/src/ticketing/collection/collection.module.ts +++ b/packages/api/src/ticketing/collection/collection.module.ts @@ -1,3 +1,5 @@ +import { LinearCollectionMapper } from './services/linear/mappers'; +import { LinearService } from './services/linear'; import { GithubCollectionMapper } from './services/github/mappers'; import { GithubService } from './services/github'; import { EncryptionService } from '@@core/@core-services/encryption/encryption.service'; @@ -39,7 +41,9 @@ import { IngestDataService } from '@@core/@core-services/unification/ingest-data GitlabCollectionMapper, GithubService, GithubCollectionMapper, + LinearService, + LinearCollectionMapper, ], exports: [SyncService, ServiceRegistry, WebhookService], }) -export class CollectionModule {} +export class CollectionModule { } diff --git a/packages/api/src/ticketing/collection/services/linear/index.ts b/packages/api/src/ticketing/collection/services/linear/index.ts new file mode 100644 index 000000000..357681884 --- /dev/null +++ b/packages/api/src/ticketing/collection/services/linear/index.ts @@ -0,0 +1,65 @@ +import { Injectable } from '@nestjs/common'; +import { LoggerService } from '@@core/@core-services/logger/logger.service'; +import { PrismaService } from '@@core/@core-services/prisma/prisma.service'; +import { EncryptionService } from '@@core/@core-services/encryption/encryption.service'; +import { TicketingObject } from '@ticketing/@lib/@types'; +import { ApiResponse } from '@@core/utils/types'; +import axios from 'axios'; +import { ActionType, handle3rdPartyServiceError } from '@@core/utils/errors'; +import { ServiceRegistry } from '../registry.service'; +import { ICollectionService } from '@ticketing/collection/types'; +import { LinearCollectionOutput, LinearCollectionInput } from './types'; +import { SyncParam } from '@@core/utils/types/interface'; + +@Injectable() +export class LinearService implements ICollectionService { + constructor( + private prisma: PrismaService, + private logger: LoggerService, + private cryptoService: EncryptionService, + private registry: ServiceRegistry, + ) { + this.logger.setContext( + TicketingObject.collection.toUpperCase() + ':' + LinearService.name, + ); + this.registry.registerService('linear', this); + } + + async sync(data: SyncParam): Promise> { + try { + const { linkedUserId } = data; + + const connection = await this.prisma.connections.findFirst({ + where: { + id_linked_user: linkedUserId, + provider_slug: 'linear', + vertical: 'ticketing', + }, + }); + + const projectQuery = { + "query": "query { projects { nodes { id, name, description } }}" + }; + + let resp = await axios.post( + `${connection.account_url}`, + projectQuery, { + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${this.cryptoService.decrypt( + connection.access_token, + )}`, + }, + }); + this.logger.log(`Synced linear collections !`); + + return { + data: resp.data.data.projects.nodes, + message: 'Linear collections retrieved', + statusCode: 200, + }; + } catch (error) { + throw error; + } + } +} diff --git a/packages/api/src/ticketing/collection/services/linear/mappers.ts b/packages/api/src/ticketing/collection/services/linear/mappers.ts new file mode 100644 index 000000000..62bb465a2 --- /dev/null +++ b/packages/api/src/ticketing/collection/services/linear/mappers.ts @@ -0,0 +1,69 @@ +import { ICollectionMapper } from '@ticketing/collection/types'; +import { LinearCollectionInput, LinearCollectionOutput } from './types'; +import { + UnifiedTicketingCollectionInput, + UnifiedTicketingCollectionOutput, +} from '@ticketing/collection/types/model.unified'; +import { MappersRegistry } from '@@core/@core-services/registries/mappers.registry'; +import { Injectable } from '@nestjs/common'; +import { Utils } from '@ticketing/@lib/@utils'; + +@Injectable() +export class LinearCollectionMapper implements ICollectionMapper { + constructor(private mappersRegistry: MappersRegistry, private utils: Utils) { + this.mappersRegistry.registerService( + 'ticketing', + 'collection', + 'linear', + this, + ); + } + desunify( + source: UnifiedTicketingCollectionInput, + customFieldMappings?: { + slug: string; + remote_id: string; + }[], + ): LinearCollectionInput { + return; + } + + unify( + source: LinearCollectionOutput | LinearCollectionOutput[], + connectionId: string, + customFieldMappings?: { + slug: string; + remote_id: string; + }[], + ): UnifiedTicketingCollectionOutput | UnifiedTicketingCollectionOutput[] { + // If the source is not an array, convert it to an array for mapping + const sourcesArray = Array.isArray(source) ? source : [source]; + + return sourcesArray.map((collection) => + this.mapSingleCollectionToUnified( + collection, + connectionId, + customFieldMappings, + ), + ); + } + + private mapSingleCollectionToUnified( + collection: LinearCollectionOutput, + connectionId: string, + customFieldMappings?: { + slug: string; + remote_id: string; + }[], + ): UnifiedTicketingCollectionOutput { + const unifiedCollection: UnifiedTicketingCollectionOutput = { + remote_id: collection.id, + remote_data: collection, + name: collection.name, + description: collection.description, + collection_type: 'PROJECT', + }; + + return unifiedCollection; + } +} diff --git a/packages/api/src/ticketing/collection/services/linear/types.ts b/packages/api/src/ticketing/collection/services/linear/types.ts new file mode 100644 index 000000000..e96b73023 --- /dev/null +++ b/packages/api/src/ticketing/collection/services/linear/types.ts @@ -0,0 +1,8 @@ +interface LinearCollection { + id: string + name: string + description: string +} + +export type LinearCollectionInput = Partial; +export type LinearCollectionOutput = LinearCollectionInput; diff --git a/packages/api/src/ticketing/comment/comment.module.ts b/packages/api/src/ticketing/comment/comment.module.ts index e6ac25fbe..819f9cb37 100644 --- a/packages/api/src/ticketing/comment/comment.module.ts +++ b/packages/api/src/ticketing/comment/comment.module.ts @@ -1,3 +1,5 @@ +import { LinearCommentMapper } from './services/linear/mappers'; +import { LinearService } from './services/linear'; import { GithubCommentMapper } from './services/github/mappers'; import { GithubService } from './services/github'; import { LoggerService } from '@@core/@core-services/logger/logger.service'; @@ -45,7 +47,9 @@ import { SyncService } from './sync/sync.service'; GitlabCommentMapper, GithubService, GithubCommentMapper, + LinearService, + LinearCommentMapper, ], exports: [SyncService, ServiceRegistry, WebhookService], }) -export class CommentModule {} +export class CommentModule { } diff --git a/packages/api/src/ticketing/comment/services/linear/index.ts b/packages/api/src/ticketing/comment/services/linear/index.ts new file mode 100644 index 000000000..bc7ff4dd6 --- /dev/null +++ b/packages/api/src/ticketing/comment/services/linear/index.ts @@ -0,0 +1,118 @@ +import { EncryptionService } from '@@core/@core-services/encryption/encryption.service'; +import { LoggerService } from '@@core/@core-services/logger/logger.service'; +import { PrismaService } from '@@core/@core-services/prisma/prisma.service'; +import { ApiResponse } from '@@core/utils/types'; +import { SyncParam } from '@@core/utils/types/interface'; +import { Injectable } from '@nestjs/common'; +import { TicketingObject } from '@ticketing/@lib/@types'; +import { Utils } from '@ticketing/@lib/@utils'; +import { ICommentService } from '@ticketing/comment/types'; +import axios from 'axios'; +import * as fs from 'fs'; +import { ServiceRegistry } from '../registry.service'; +import { LinearCommentInput, LinearCommentOutput } from './types'; +import { LinearTicketOutput } from '@ticketing/ticket/services/linear/types'; + +@Injectable() +export class LinearService implements ICommentService { + constructor( + private prisma: PrismaService, + private logger: LoggerService, + private cryptoService: EncryptionService, + private registry: ServiceRegistry, + private utils: Utils, + ) { + this.logger.setContext( + TicketingObject.comment.toUpperCase() + ':' + LinearService.name, + ); + this.registry.registerService('linear', this); + } + + async addComment( + commentData: LinearCommentInput, + linkedUserId: string, + remoteIdTicket: string, + ): Promise> { + try { + const connection = await this.prisma.connections.findFirst({ + where: { + id_linked_user: linkedUserId, + provider_slug: 'linear', + vertical: 'ticketing', + }, + }); + + // Skipping Storing the attachment in unified object as Linear stores attachment as link in Markdown Format + + const createCommentMutation = { + "query": `mutation { commentCreate( input: { body: \"${commentData.body}\" issueId: \"${remoteIdTicket}\" } ) { comment { body issue { id } user { id } } }}` + }; + + let resp = await axios.post( + `${connection.account_url}`, + createCommentMutation, { + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${this.cryptoService.decrypt( + connection.access_token, + )}`, + }, + }); + this.logger.log(`Created linear comment !`); + + return { + data: resp.data.data.commentCreate.comment, + message: 'Linear comment created', + statusCode: 201, + }; + } catch (error) { + throw error; + } + } + async sync(data: SyncParam): Promise> { + try { + const { linkedUserId, id_ticket } = data; + + const connection = await this.prisma.connections.findFirst({ + where: { + id_linked_user: linkedUserId, + provider_slug: 'linear', + vertical: 'ticketing', + }, + }); + + const ticket = await this.prisma.tcg_tickets.findUnique({ + where: { + id_tcg_ticket: id_ticket as string, + }, + select: { + remote_id: true, + }, + }); + + const commentQuery = { + "query": `query { issue(id: \"${ticket.remote_id}\") { comments { nodes { id body user { id } issue { id } } } }}` + }; + + let resp = await axios.post( + `${connection.account_url}`, + commentQuery, { + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${this.cryptoService.decrypt( + connection.access_token, + )}`, + }, + }); + this.logger.log(`Synced linear comments !`); + + return { + data: resp.data.data.issue.comments.nodes, + message: 'Linear comments retrieved', + statusCode: 200, + }; + } catch (error) { + throw error; + } + } +} \ No newline at end of file diff --git a/packages/api/src/ticketing/comment/services/linear/mappers.ts b/packages/api/src/ticketing/comment/services/linear/mappers.ts new file mode 100644 index 000000000..5d72d1b46 --- /dev/null +++ b/packages/api/src/ticketing/comment/services/linear/mappers.ts @@ -0,0 +1,99 @@ +import { MappersRegistry } from '@@core/@core-services/registries/mappers.registry'; +import { CoreUnification } from '@@core/@core-services/unification/core-unification.service'; +import { OriginalAttachmentOutput } from '@@core/utils/types/original/original.ticketing'; +import { Injectable } from '@nestjs/common'; +import { TicketingObject } from '@ticketing/@lib/@types'; +import { Utils } from '@ticketing/@lib/@utils'; +import { UnifiedTicketingAttachmentOutput } from '@ticketing/attachment/types/model.unified'; +import { ICommentMapper } from '@ticketing/comment/types'; +import { + UnifiedTicketingCommentInput, + UnifiedTicketingCommentOutput, +} from '@ticketing/comment/types/model.unified'; +import { LinearCommentInput, LinearCommentOutput } from './types'; + +@Injectable() +export class LinearCommentMapper implements ICommentMapper { + constructor( + private mappersRegistry: MappersRegistry, + private utils: Utils, + private coreUnificationService: CoreUnification, + ) { + this.mappersRegistry.registerService( + 'ticketing', + 'comment', + 'linear', + this, + ); + } + + async desunify( + source: UnifiedTicketingCommentInput, + customFieldMappings?: { + slug: string; + remote_id: string; + }[], + ): Promise { + // project_id and issue_id will be extracted and used so We do not need to set user (author) field here + + // TODO - Add attachments attribute + + const result: LinearCommentInput = { + body: source.body, + }; + return result; + } + + async unify( + source: LinearCommentOutput | LinearCommentOutput[], + connectionId: string, + customFieldMappings?: { + slug: string; + remote_id: string; + }[], + ): Promise { + if (!Array.isArray(source)) { + return await this.mapSingleCommentToUnified( + source, + connectionId, + customFieldMappings, + ); + } + return Promise.all( + source.map((comment) => + this.mapSingleCommentToUnified( + comment, + connectionId, + customFieldMappings, + ), + ), + ); + } + + private async mapSingleCommentToUnified( + comment: LinearCommentOutput, + connectionId: string, + customFieldMappings?: { + slug: string; + remote_id: string; + }[], + ): Promise { + let user_id: string; + + if (comment.user.id) { + user_id = await this.utils.getUserUuidFromRemoteId( + String(comment.user.id), + connectionId, + ); + } + + return { + remote_id: comment.id, + remote_data: comment, + body: comment.body || null, + ticket_id: comment.issue.id, + user_id: user_id, + creator_type: 'USER', + }; + } +} \ No newline at end of file diff --git a/packages/api/src/ticketing/comment/services/linear/types.ts b/packages/api/src/ticketing/comment/services/linear/types.ts new file mode 100644 index 000000000..d03ffe496 --- /dev/null +++ b/packages/api/src/ticketing/comment/services/linear/types.ts @@ -0,0 +1,13 @@ +interface LinearComment { + id: string + body: string + user: { + id: string + } + issue: { + id: string + } +} + +export type LinearCommentInput = Partial; +export type LinearCommentOutput = LinearCommentInput; diff --git a/packages/api/src/ticketing/tag/services/linear/index.ts b/packages/api/src/ticketing/tag/services/linear/index.ts new file mode 100644 index 000000000..7f5a6b072 --- /dev/null +++ b/packages/api/src/ticketing/tag/services/linear/index.ts @@ -0,0 +1,65 @@ +import { Injectable } from '@nestjs/common'; +import { LoggerService } from '@@core/@core-services/logger/logger.service'; +import { PrismaService } from '@@core/@core-services/prisma/prisma.service'; +import { EncryptionService } from '@@core/@core-services/encryption/encryption.service'; +import { TicketingObject } from '@ticketing/@lib/@types'; +import { ApiResponse } from '@@core/utils/types'; +import axios from 'axios'; +import { ActionType, handle3rdPartyServiceError } from '@@core/utils/errors'; +import { ServiceRegistry } from '../registry.service'; +import { ITagService } from '@ticketing/tag/types'; +import { LinearTagOutput } from './types'; +import { SyncParam } from '@@core/utils/types/interface'; + +@Injectable() +export class LinearService implements ITagService { + constructor( + private prisma: PrismaService, + private logger: LoggerService, + private cryptoService: EncryptionService, + private registry: ServiceRegistry, + ) { + this.logger.setContext( + TicketingObject.tag.toUpperCase() + ':' + LinearService.name, + ); + this.registry.registerService('linear', this); + } + + async sync(data: SyncParam): Promise> { + try { + const { linkedUserId, id_ticket } = data; + + const connection = await this.prisma.connections.findFirst({ + where: { + id_linked_user: linkedUserId, + provider_slug: 'linear', + vertical: 'ticketing', + }, + }); + + const labelQuery = { + "query": "query { issueLabels { nodes { id name } }}" + }; + + let resp = await axios.post( + `${connection.account_url}`, + labelQuery, { + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${this.cryptoService.decrypt( + connection.access_token, + )}`, + }, + }); + this.logger.log(`Synced linear tags !`); + + return { + data: resp.data.data.issueLabels.nodes, + message: 'Linear tags retrieved', + statusCode: 200, + }; + } catch (error) { + throw error; + } + } +} diff --git a/packages/api/src/ticketing/tag/services/linear/mappers.ts b/packages/api/src/ticketing/tag/services/linear/mappers.ts new file mode 100644 index 000000000..736ee385d --- /dev/null +++ b/packages/api/src/ticketing/tag/services/linear/mappers.ts @@ -0,0 +1,58 @@ +import { ITagMapper } from '@ticketing/tag/types'; +import { LinearTagInput, LinearTagOutput } from './types'; +import { + UnifiedTicketingTagInput, + UnifiedTicketingTagOutput, +} from '@ticketing/tag/types/model.unified'; +import { MappersRegistry } from '@@core/@core-services/registries/mappers.registry'; +import { Injectable } from '@nestjs/common'; +import { Utils } from '@ticketing/@lib/@utils'; + +@Injectable() +export class LinearTagMapper implements ITagMapper { + constructor(private mappersRegistry: MappersRegistry, private utils: Utils) { + this.mappersRegistry.registerService('ticketing', 'tag', 'linear', this); + } + desunify( + source: UnifiedTicketingTagInput, + customFieldMappings?: { + slug: string; + remote_id: string; + }[], + ): LinearTagInput { + return; + } + + unify( + source: LinearTagOutput | LinearTagOutput[], + connectionId: string, + customFieldMappings?: { + slug: string; + remote_id: string; + }[], + ): UnifiedTicketingTagOutput | UnifiedTicketingTagOutput[] { + // If the source is not an array, convert it to an array for mapping + const sourcesArray = Array.isArray(source) ? source : [source]; + + return sourcesArray.map((tag) => + this.mapSingleTagToUnified(tag, connectionId, customFieldMappings), + ); + } + + private mapSingleTagToUnified( + tag: LinearTagOutput, + connectionId: string, + customFieldMappings?: { + slug: string; + remote_id: string; + }[], + ): UnifiedTicketingTagOutput { + const unifiedTag: UnifiedTicketingTagOutput = { + remote_id: tag.id, + remote_data: tag, + name: tag.name, + }; + + return unifiedTag; + } +} diff --git a/packages/api/src/ticketing/tag/services/linear/types.ts b/packages/api/src/ticketing/tag/services/linear/types.ts new file mode 100644 index 000000000..e1a52557b --- /dev/null +++ b/packages/api/src/ticketing/tag/services/linear/types.ts @@ -0,0 +1,7 @@ +interface LinearTag { + id: string + name: string +} + +export type LinearTagInput = Partial; +export type LinearTagOutput = LinearTagInput; diff --git a/packages/api/src/ticketing/tag/tag.module.ts b/packages/api/src/ticketing/tag/tag.module.ts index 0b5d2ca94..3e4328106 100644 --- a/packages/api/src/ticketing/tag/tag.module.ts +++ b/packages/api/src/ticketing/tag/tag.module.ts @@ -1,3 +1,5 @@ +import { LinearTagMapper } from './services/linear/mappers'; +import { LinearService } from './services/linear'; import { GithubTagMapper } from './services/github/mappers'; import { GithubService } from './services/github'; import { BullQueueModule } from '@@core/@core-services/queues/queue.module'; @@ -44,7 +46,9 @@ import { GitlabTagMapper } from './services/gitlab/mappers'; GitlabTagMapper, GithubService, GithubTagMapper, + LinearService, + LinearTagMapper, ], exports: [SyncService, ServiceRegistry, WebhookService], }) -export class TagModule {} +export class TagModule { } diff --git a/packages/api/src/ticketing/team/services/linear/index.ts b/packages/api/src/ticketing/team/services/linear/index.ts new file mode 100644 index 000000000..952af6ad4 --- /dev/null +++ b/packages/api/src/ticketing/team/services/linear/index.ts @@ -0,0 +1,67 @@ +import { Injectable } from '@nestjs/common'; +import { LoggerService } from '@@core/@core-services/logger/logger.service'; +import { PrismaService } from '@@core/@core-services/prisma/prisma.service'; +import { EncryptionService } from '@@core/@core-services/encryption/encryption.service'; +import { TicketingObject } from '@ticketing/@lib/@types'; +import { ApiResponse } from '@@core/utils/types'; +import axios from 'axios'; +import { ActionType, handle3rdPartyServiceError } from '@@core/utils/errors'; +import { ServiceRegistry } from '../registry.service'; +import { ITeamService } from '@ticketing/team/types'; +import { LinearTeamOutput } from './types'; +import { SyncParam } from '@@core/utils/types/interface'; + +@Injectable() +export class LinearService implements ITeamService { + constructor( + private prisma: PrismaService, + private logger: LoggerService, + private cryptoService: EncryptionService, + private registry: ServiceRegistry, + ) { + this.logger.setContext( + TicketingObject.team.toUpperCase() + ':' + LinearService.name, + ); + this.registry.registerService('linear', this); + } + + async sync(data: SyncParam): Promise> { + try { + const { linkedUserId } = data; + + const connection = await this.prisma.connections.findFirst({ + where: { + id_linked_user: linkedUserId, + provider_slug: 'linear', + vertical: 'ticketing', + }, + }); + + const teamQuery = { + "query": "query { teams { nodes { id, name, description } }}" + }; + + let resp = await axios.post( + `${connection.account_url}`, + teamQuery, { + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${this.cryptoService.decrypt( + connection.access_token, + )}`, + }, + }); + this.logger.log(`Synced linear teams !`); + + return { + data: resp.data.data.teams.nodes, + message: 'Linear teams retrieved', + statusCode: 200, + }; + } catch (error) { + throw error; + } + } +} + + diff --git a/packages/api/src/ticketing/team/services/linear/mappers.ts b/packages/api/src/ticketing/team/services/linear/mappers.ts new file mode 100644 index 000000000..d298b56a7 --- /dev/null +++ b/packages/api/src/ticketing/team/services/linear/mappers.ts @@ -0,0 +1,59 @@ +import { ITeamMapper } from '@ticketing/team/types'; +import { LinearTeamInput, LinearTeamOutput } from './types'; +import { + UnifiedTicketingTeamInput, + UnifiedTicketingTeamOutput, +} from '@ticketing/team/types/model.unified'; +import { MappersRegistry } from '@@core/@core-services/registries/mappers.registry'; +import { Injectable } from '@nestjs/common'; +import { Utils } from '@ticketing/@lib/@utils'; + +@Injectable() +export class LinearTeamMapper implements ITeamMapper { + constructor(private mappersRegistry: MappersRegistry, private utils: Utils) { + this.mappersRegistry.registerService('ticketing', 'team', 'linear', this); + } + desunify( + source: UnifiedTicketingTeamInput, + customFieldMappings?: { + slug: string; + remote_id: string; + }[], + ): LinearTeamInput { + return; + } + + unify( + source: LinearTeamOutput | LinearTeamOutput[], + connectionId: string, + customFieldMappings?: { + slug: string; + remote_id: string; + }[], + ): UnifiedTicketingTeamOutput | UnifiedTicketingTeamOutput[] { + // If the source is not an array, convert it to an array for mapping + const sourcesArray = Array.isArray(source) ? source : [source]; + + return sourcesArray.map((team) => + this.mapSingleTeamToUnified(team, connectionId, customFieldMappings), + ); + } + + private mapSingleTeamToUnified( + team: LinearTeamOutput, + connectionId: string, + customFieldMappings?: { + slug: string; + remote_id: string; + }[], + ): UnifiedTicketingTeamOutput { + const unifiedTeam: UnifiedTicketingTeamOutput = { + remote_id: team.id, + remote_data: team, + name: team.name, + description: team.description, + }; + + return unifiedTeam; + } +} diff --git a/packages/api/src/ticketing/team/services/linear/types.ts b/packages/api/src/ticketing/team/services/linear/types.ts new file mode 100644 index 000000000..ec9064371 --- /dev/null +++ b/packages/api/src/ticketing/team/services/linear/types.ts @@ -0,0 +1,8 @@ +export type LinearTeam = { + id: string + name: string + description: string +}; + +export type LinearTeamInput = Partial; +export type LinearTeamOutput = LinearTeamInput; \ No newline at end of file diff --git a/packages/api/src/ticketing/team/team.module.ts b/packages/api/src/ticketing/team/team.module.ts index e105e0f81..83c5b4ed0 100644 --- a/packages/api/src/ticketing/team/team.module.ts +++ b/packages/api/src/ticketing/team/team.module.ts @@ -1,3 +1,5 @@ +import { LinearTeamMapper } from './services/linear/mappers'; +import { LinearService } from './services/linear'; import { GithubTeamMapper } from './services/github/mappers'; import { GithubService } from './services/github'; import { BullQueueModule } from '@@core/@core-services/queues/queue.module'; @@ -39,7 +41,9 @@ import { TeamController } from './team.controller'; GorgiasTeamMapper, GithubService, GithubTeamMapper, + LinearService, + LinearTeamMapper, ], exports: [SyncService, ServiceRegistry, WebhookService], }) -export class TeamModule {} +export class TeamModule { } diff --git a/packages/api/src/ticketing/ticket/services/linear/index.ts b/packages/api/src/ticketing/ticket/services/linear/index.ts new file mode 100644 index 000000000..c1f9bf0af --- /dev/null +++ b/packages/api/src/ticketing/ticket/services/linear/index.ts @@ -0,0 +1,122 @@ +import { Injectable } from '@nestjs/common'; +import { LoggerService } from '@@core/@core-services/logger/logger.service'; +import { PrismaService } from '@@core/@core-services/prisma/prisma.service'; +import { EncryptionService } from '@@core/@core-services/encryption/encryption.service'; +import { TicketingObject } from '@ticketing/@lib/@types'; +import { ITicketService } from '@ticketing/ticket/types'; +import { ApiResponse } from '@@core/utils/types'; +import axios from 'axios'; +import { ActionType, handle3rdPartyServiceError } from '@@core/utils/errors'; +import { ServiceRegistry } from '../registry.service'; +import { LinearTicketInput, LinearTicketOutput } from './types'; +import { SyncParam } from '@@core/utils/types/interface'; +import { LinearCollectionOutput } from '@ticketing/collection/services/linear/types'; + +@Injectable() +export class LinearService implements ITicketService { + constructor( + private prisma: PrismaService, + private logger: LoggerService, + private cryptoService: EncryptionService, + private registry: ServiceRegistry, + ) { + this.logger.setContext( + TicketingObject.ticket.toUpperCase() + ':' + LinearService.name, + ); + this.registry.registerService('linear', this); + } + async addTicket( + ticketData: LinearTicketInput, + linkedUserId: string, + ): Promise> { + try { + const connection = await this.prisma.connections.findFirst({ + where: { + id_linked_user: linkedUserId, + provider_slug: 'linear', + vertical: 'ticketing', + }, + }); + + if (!ticketData.team.id) { + throw new ReferenceError( + `team_id is required field and cant be empty while creating a ticket.`, + ); + } + + const createIssueMutation = { + "query": `mutation ($issueCreateInput: IssueCreateInput!) { issueCreate(input: $issueCreateInput) { issue { id title description dueDate parent{ id } state { name } project{ id } labels { nodes { id } } completedAt priorityLabel assignee { id } comments { nodes { id } } } }}`, + "variables": { + "issueCreateInput": { + "title": ticketData.title, + "description": ticketData.description, + "assigneeId": ticketData.assignee?.id, + "parentId": ticketData.parent?.id, + "labelIds": ticketData.labels?.nodes, + "projectId": ticketData.project?.id, + "sourceCommentId": ticketData.comments?.nodes, + "dueDate": ticketData.dueDate, + "teamId": ticketData.team.id + } + } + }; + + let resp = await axios.post( + `${connection.account_url}`, + createIssueMutation, { + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${this.cryptoService.decrypt( + connection.access_token, + )}`, + }, + }); + this.logger.log(`Created linear ticket !`); + + return { + data: resp.data.data.issueCreate.issue, + message: 'Linear ticket created', + statusCode: 201, + }; + } catch (error) { + throw error; + } + } + async sync(data: SyncParam): Promise> { + try { + const { linkedUserId } = data; + + const connection = await this.prisma.connections.findFirst({ + where: { + id_linked_user: linkedUserId, + provider_slug: 'linear', + vertical: 'ticketing', + }, + }); + + const issueQuery = { + "query": "query { issues { nodes { id title description dueDate parent{ id } state { name } project{ id } labels { nodes { id } } completedAt priorityLabel assignee { id } comments { nodes { id } } } }}" + }; + + let resp = await axios.post( + `${connection.account_url}`, + issueQuery, { + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${this.cryptoService.decrypt( + connection.access_token, + )}`, + }, + }); + this.logger.log(`Synced linear tickets !`); + + return { + data: resp.data.data.issues.nodes, + message: 'Linear tickets retrieved', + statusCode: 200, + }; + } catch (error) { + throw error; + } + } +} diff --git a/packages/api/src/ticketing/ticket/services/linear/mappers.ts b/packages/api/src/ticketing/ticket/services/linear/mappers.ts new file mode 100644 index 000000000..6d209c556 --- /dev/null +++ b/packages/api/src/ticketing/ticket/services/linear/mappers.ts @@ -0,0 +1,189 @@ +import { MappersRegistry } from '@@core/@core-services/registries/mappers.registry'; +import { CoreUnification } from '@@core/@core-services/unification/core-unification.service'; +import { OriginalTagOutput } from '@@core/utils/types/original/original.ticketing'; +import { UnifiedTicketingTagOutput } from '@ticketing/tag/types/model.unified'; +import { Injectable } from '@nestjs/common'; +import { TicketingObject } from '@ticketing/@lib/@types'; +import { Utils } from '@ticketing/@lib/@utils'; +import { ITicketMapper } from '@ticketing/ticket/types'; +import { + UnifiedTicketingTicketInput, + UnifiedTicketingTicketOutput, +} from '@ticketing/ticket/types/model.unified'; +import { LinearTicketInput, LinearTicketOutput } from './types'; +import { LinearTagInput, LinearTagOutput } from '@ticketing/tag/services/linear/types'; +import { IngestDataService } from '@@core/@core-services/unification/ingest-data.service'; +import { UnifiedTicketingCommentOutput } from '@ticketing/comment/types/model.unified'; +import { LinearCommentInput } from '@ticketing/comment/services/linear/types'; + +@Injectable() +export class LinearTicketMapper implements ITicketMapper { + constructor( + private mappersRegistry: MappersRegistry, + private utils: Utils, + private coreUnificationService: CoreUnification, + private ingestService: IngestDataService, + ) { + this.mappersRegistry.registerService('ticketing', 'ticket', 'linear', this); + } + + async desunify( + source: UnifiedTicketingTicketInput, + customFieldMappings?: { + slug: string; + remote_id: string; + }[], + connectionId?: string, + ): Promise { + // const remote_project_id = await this.utils.getCollectionRemoteIdFromUuid( + // source.collections[0] as string, + // ); + + const result: LinearTicketInput = { + title: source.name, + description: source.description ? source.description : null, + // Passing new Field to retreive repositroy info to add ticket to that repo + project: { id: source.collections ? await this.utils.getCollectionRemoteIdFromUuid(source.collections[0] as string) : null }, + team: { id: await this.utils.getTeamRemoteIdFromUuid(source.field_mappings["team_id"]) }, + dueDate: source.due_date.toISOString(), + }; + + if (source.assigned_to && source.assigned_to.length > 0) { + const data = await this.utils.getAsigneeRemoteIdFromUserUuid( + source.assigned_to[0], + ); + if (data) { + result.assignee = { id: data }; + } + } + const tags = source.tags as LinearTagInput[]; + if (tags) { + result.labels.nodes = tags; + } + + if (source.comment) { + const comment = + (await this.coreUnificationService.desunify({ + sourceObject: source.comment, + targetType: TicketingObject.comment, + providerName: 'linear', + vertical: 'ticketing', + connectionId: connectionId, + customFieldMappings: [], + })) as LinearCommentInput; + result.comments.nodes = [comment]; + } + + // TODO - Custom fields mapping + // if (customFieldMappings && source.field_mappings) { + // result.meta = {}; // Ensure meta exists + // for (const [k, v] of Object.entries(source.field_mappings)) { + // const mapping = customFieldMappings.find( + // (mapping) => mapping.slug === k, + // ); + // if (mapping) { + // result.meta[mapping.remote_id] = v; + // } + // } + // } + + return result; + } + + async unify( + source: LinearTicketOutput | LinearTicketOutput[], + connectionId: string, + customFieldMappings?: { + slug: string; + remote_id: string; + }[], + ): Promise { + const sourcesArray = Array.isArray(source) ? source : [source]; + return Promise.all( + sourcesArray.map(async (ticket) => + this.mapSingleTicketToUnified( + ticket, + connectionId, + customFieldMappings, + ), + ), + ); + } + + private async mapSingleTicketToUnified( + ticket: LinearTicketOutput, + connectionId: string, + customFieldMappings?: { + slug: string; + remote_id: string; + }[], + ): Promise { + const field_mappings: { [key: string]: any } = {}; + if (customFieldMappings) { + for (const mapping of customFieldMappings) { + field_mappings[mapping.slug] = ticket[mapping.remote_id]; + } + } + + let opts: any = {}; + if (ticket.state) { + opts = { ...opts, type: (ticket.state.name === 'Canceled' || ticket.state.name === 'Done') ? 'CLOSED' : 'OPEN' }; + } + + if (ticket.assignee) { + //fetch the right assignee uuid from remote id + const user_id = await this.utils.getUserUuidFromRemoteId( + String(ticket.assignee.id), + connectionId, + ); + if (user_id) { + opts = { ...opts, assigned_to: [user_id] }; + } + } + + if (ticket.labels.nodes.length > 0) { + const tags = await this.ingestService.ingestData< + UnifiedTicketingTagOutput, + LinearTagOutput + >( + ticket.labels.nodes.map( + (label) => + ({ + name: label.name, + } as LinearTagOutput), + ), + 'linear', + connectionId, + 'ticketing', + TicketingObject.tag, + [], + ); + opts = { + ...opts, + tags: tags.map((tag) => tag.id_tcg_tag), + }; + } + + if (ticket.project) { + const tcg_collection_id = await this.utils.getCollectionUuidFromRemoteId( + String(ticket.project.id), + connectionId, + ); + if (tcg_collection_id) { + opts = { ...opts, collections: [tcg_collection_id] }; + } + } + + const unifiedTicket: UnifiedTicketingTicketOutput = { + remote_id: ticket.id, + remote_data: ticket, + name: ticket.title, + description: ticket.description || null, + due_date: ticket.dueDate ? new Date(ticket.dueDate) : null, + field_mappings, + ...opts, + }; + + return unifiedTicket; + } +} diff --git a/packages/api/src/ticketing/ticket/services/linear/types.ts b/packages/api/src/ticketing/ticket/services/linear/types.ts new file mode 100644 index 000000000..b50e7c306 --- /dev/null +++ b/packages/api/src/ticketing/ticket/services/linear/types.ts @@ -0,0 +1,33 @@ +import { LinearCommentInput } from "@ticketing/comment/services/linear/types" +import { LinearTagInput } from "@ticketing/tag/services/linear/types" + +interface LinearTicket { + id: string + title: string + description?: string + dueDate?: string + parent?: { + id: string + } + state: { + name: string + } + project?: any + labels?: { + nodes: LinearTagInput[] + } + completedAt?: any + priorityLabel?: string + assignee?: { + id: string + } + comments?: { + nodes: LinearCommentInput[] + } + team?: { + id: string + } +} + +export type LinearTicketInput = Partial; +export type LinearTicketOutput = Partial; diff --git a/packages/api/src/ticketing/ticket/ticket.module.ts b/packages/api/src/ticketing/ticket/ticket.module.ts index 7ba04aabe..d58ae8ea0 100644 --- a/packages/api/src/ticketing/ticket/ticket.module.ts +++ b/packages/api/src/ticketing/ticket/ticket.module.ts @@ -1,3 +1,5 @@ +import { LinearTicketMapper } from './services/linear/mappers'; +import { LinearService } from './services/linear'; import { GithubTicketMapper } from './services/github/mappers'; import { GithubService } from './services/github'; import { BullQueueModule } from '@@core/@core-services/queues/queue.module'; @@ -42,6 +44,8 @@ import { TicketController } from './ticket.controller'; GitlabTicketMapper, GithubService, GithubTicketMapper, + LinearService, + LinearTicketMapper, ], exports: [SyncService, ServiceRegistry, WebhookService, IngestDataService], }) diff --git a/packages/api/src/ticketing/user/services/linear/index.ts b/packages/api/src/ticketing/user/services/linear/index.ts new file mode 100644 index 000000000..09ec92f0e --- /dev/null +++ b/packages/api/src/ticketing/user/services/linear/index.ts @@ -0,0 +1,65 @@ +import { Injectable } from '@nestjs/common'; +import { LoggerService } from '@@core/@core-services/logger/logger.service'; +import { PrismaService } from '@@core/@core-services/prisma/prisma.service'; +import { EncryptionService } from '@@core/@core-services/encryption/encryption.service'; +import { TicketingObject } from '@ticketing/@lib/@types'; +import { ApiResponse } from '@@core/utils/types'; +import axios from 'axios'; +import { ActionType, handle3rdPartyServiceError } from '@@core/utils/errors'; +import { ServiceRegistry } from '../registry.service'; +import { IUserService } from '@ticketing/user/types'; +import { LinearUserOutput } from './types'; +import { SyncParam } from '@@core/utils/types/interface'; + +@Injectable() +export class LinearService implements IUserService { + constructor( + private prisma: PrismaService, + private logger: LoggerService, + private cryptoService: EncryptionService, + private registry: ServiceRegistry, + ) { + this.logger.setContext( + TicketingObject.user.toUpperCase() + ':' + LinearService.name, + ); + this.registry.registerService('linear', this); + } + + async sync(data: SyncParam): Promise> { + try { + const { linkedUserId } = data; + + const connection = await this.prisma.connections.findFirst({ + where: { + id_linked_user: linkedUserId, + provider_slug: 'linear', + vertical: 'ticketing', + }, + }); + + const userQuery = { + "query": "query { users { nodes { id, name, email } }}" + }; + + let resp = await axios.post( + `${connection.account_url}`, + userQuery, { + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${this.cryptoService.decrypt( + connection.access_token, + )}`, + }, + }); + this.logger.log(`Synced linear users !`); + + return { + data: resp.data.data.users.nodes, + message: 'Linear users retrieved', + statusCode: 200, + }; + } catch (error) { + throw error; + } + } +} diff --git a/packages/api/src/ticketing/user/services/linear/mappers.ts b/packages/api/src/ticketing/user/services/linear/mappers.ts new file mode 100644 index 000000000..8c94e50d8 --- /dev/null +++ b/packages/api/src/ticketing/user/services/linear/mappers.ts @@ -0,0 +1,68 @@ +import { IUserMapper } from '@ticketing/user/types'; +import { + UnifiedTicketingUserInput, + UnifiedTicketingUserOutput, +} from '@ticketing/user/types/model.unified'; +import { LinearUserInput, LinearUserOutput } from './types'; +import { MappersRegistry } from '@@core/@core-services/registries/mappers.registry'; +import { Injectable } from '@nestjs/common'; +import { Utils } from '@ticketing/@lib/@utils'; + +@Injectable() +export class LinearUserMapper implements IUserMapper { + constructor(private mappersRegistry: MappersRegistry, private utils: Utils) { + this.mappersRegistry.registerService('ticketing', 'user', 'linear', this); + } + desunify( + source: UnifiedTicketingUserInput, + customFieldMappings?: { + slug: string; + remote_id: string; + }[], + ): LinearUserInput { + return; + } + + async unify( + source: LinearUserOutput | LinearUserOutput[], + connectionId: string, + customFieldMappings?: { + slug: string; + remote_id: string; + }[], + ): Promise { + const sourcesArray = Array.isArray(source) ? source : [source]; + return sourcesArray.map((user) => + this.mapSingleUserToUnified(user, connectionId, customFieldMappings), + ); + } + + private mapSingleUserToUnified( + user: LinearUserOutput, + connectionId: string, + customFieldMappings?: { + slug: string; + remote_id: string; + }[], + ): UnifiedTicketingUserOutput { + // Initialize field_mappings array from customFields, if provided + const field_mappings = customFieldMappings + ? customFieldMappings + .map((mapping) => ({ + key: mapping.slug, + value: user ? user[mapping.remote_id] : undefined, + })) + .filter((mapping) => mapping.value !== undefined) + : []; + + const unifiedUser: UnifiedTicketingUserOutput = { + remote_id: user.id, + remote_data: user, + name: user.name, + email_address: user.email || null, + field_mappings, + }; + + return unifiedUser; + } +} diff --git a/packages/api/src/ticketing/user/services/linear/types.ts b/packages/api/src/ticketing/user/services/linear/types.ts new file mode 100644 index 000000000..d0633d48a --- /dev/null +++ b/packages/api/src/ticketing/user/services/linear/types.ts @@ -0,0 +1,8 @@ +interface LinearUser { + id: string + name: string + email: string +} + +export type LinearUserInput = Partial; +export type LinearUserOutput = LinearUserInput; diff --git a/packages/api/src/ticketing/user/user.module.ts b/packages/api/src/ticketing/user/user.module.ts index 4eb7a2465..4e078e84c 100644 --- a/packages/api/src/ticketing/user/user.module.ts +++ b/packages/api/src/ticketing/user/user.module.ts @@ -1,3 +1,5 @@ +import { LinearUserMapper } from './services/linear/mappers'; +import { LinearService } from './services/linear'; import { GithubUserMapper } from './services/github/mappers'; import { GithubService } from './services/github'; import { WebhookService } from '@@core/@core-services/webhooks/panora-webhooks/webhook.service'; @@ -42,7 +44,9 @@ import { ZendeskUserMapper } from './services/zendesk/mappers'; GitlabUserMapper, GithubService, GithubUserMapper, + LinearService, + LinearUserMapper, ], exports: [SyncService, ServiceRegistry, WebhookService, IngestDataService], }) -export class UserModule {} +export class UserModule { } diff --git a/packages/shared/src/connectors/enum.ts b/packages/shared/src/connectors/enum.ts index 49aaa60d1..8067be7a8 100644 --- a/packages/shared/src/connectors/enum.ts +++ b/packages/shared/src/connectors/enum.ts @@ -21,7 +21,8 @@ export enum TicketingConnectors { FRONT = 'front', JIRA = 'jira', GITHUB = 'github', - GITLAB = 'gitlab' + GITLAB = 'gitlab', + LINEAR = 'linear' } export enum FilestorageConnectors { diff --git a/packages/shared/src/connectors/index.ts b/packages/shared/src/connectors/index.ts index 3a284173f..d06c9a292 100644 --- a/packages/shared/src/connectors/index.ts +++ b/packages/shared/src/connectors/index.ts @@ -2,7 +2,7 @@ export const CRM_PROVIDERS = ['zoho', 'zendesk', 'hubspot', 'pipedrive', 'attio' export const HRIS_PROVIDERS = []; export const ATS_PROVIDERS = ['ashby']; export const ACCOUNTING_PROVIDERS = []; -export const TICKETING_PROVIDERS = ['zendesk', 'front', 'jira', 'gitlab', 'github']; +export const TICKETING_PROVIDERS = ['zendesk', 'front', 'jira', 'gorgias', 'gitlab', 'github', 'linear']; export const MARKETINGAUTOMATION_PROVIDERS = []; export const FILESTORAGE_PROVIDERS = ['box']; export const ECOMMERCE_PROVIDERS = ['shopify', 'woocommerce', 'squarespace', 'amazon', 'webflow'];