diff --git a/apps/api-harmonization/src/app.module.ts b/apps/api-harmonization/src/app.module.ts index dc879b4d..f9ef4d66 100644 --- a/apps/api-harmonization/src/app.module.ts +++ b/apps/api-harmonization/src/app.module.ts @@ -78,7 +78,7 @@ export const AuthModuleBaseModule = AuthModule.Module.register(AppConfig); @Module({ imports: [ - HttpModule, + HttpModule.register({ global: true }), LoggerModule, ConfigModule.forRoot({ isGlobal: true, diff --git a/packages/configs/integrations/package.json b/packages/configs/integrations/package.json index 92dd3056..bef79ef7 100644 --- a/packages/configs/integrations/package.json +++ b/packages/configs/integrations/package.json @@ -18,7 +18,8 @@ "dependencies": { "@o2s/framework": "*", "@o2s/integrations.mocked": "*", - "@o2s/integrations.strapi-cms": "*" + "@o2s/integrations.strapi-cms": "*", + "@o2s/integrations.zendesk": "*" }, "devDependencies": { "@o2s/eslint-config": "*", diff --git a/packages/configs/integrations/src/models/tickets.ts b/packages/configs/integrations/src/models/tickets.ts index fde166e1..f7769e9f 100644 --- a/packages/configs/integrations/src/models/tickets.ts +++ b/packages/configs/integrations/src/models/tickets.ts @@ -1,9 +1,8 @@ -import { Config, Integration } from '@o2s/integrations.mocked/integration'; +import { Config as ZendeskConfig, Integration as ZendeskIntegration } from '@o2s/integrations.zendesk/integration'; import { ApiConfig } from '@o2s/framework/modules'; -export const TicketsIntegrationConfig: ApiConfig['integrations']['tickets'] = Config.tickets!; - -export import Service = Integration.Tickets.Service; -export import Request = Integration.Tickets.Request; -export import Model = Integration.Tickets.Model; +export const TicketsIntegrationConfig: ApiConfig['integrations']['tickets'] = ZendeskConfig.tickets!; +export import Service = ZendeskIntegration.Tickets.Service; +export import Request = ZendeskIntegration.Tickets.Request; +export import Model = ZendeskIntegration.Tickets.Model; diff --git a/packages/integrations/zendesk/.gitignore b/packages/integrations/zendesk/.gitignore new file mode 100644 index 00000000..11eb8de0 --- /dev/null +++ b/packages/integrations/zendesk/.gitignore @@ -0,0 +1,56 @@ + +# compiled output +/dist +/node_modules +/build + +# Logs +logs +*.log +npm-debug.log* +pnpm-debug.log* +yarn-debug.log* +yarn-error.log* +lerna-debug.log* + +# OS +.DS_Store + +# Tests +/coverage +/.nyc_output + +# IDEs and editors +/.idea +.project +.classpath +.c9/ +*.launch +.settings/ +*.sublime-workspace + +# IDE - VSCode +.vscode/* +!.vscode/settings.json +!.vscode/tasks.json +!.vscode/launch.json +!.vscode/extensions.json + +# dotenv environment variable files +.env +.env.development.local +.env.test.local +.env.production.local + +# temp directory +.temp +.tmp + +# Runtime data +pids +*.pid +*.seed +*.pid.lock + +# Diagnostic reports (https://nodejs.org/api/report.html) +report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json diff --git a/packages/integrations/zendesk/.prettierrc.mjs b/packages/integrations/zendesk/.prettierrc.mjs new file mode 100644 index 00000000..4280c046 --- /dev/null +++ b/packages/integrations/zendesk/.prettierrc.mjs @@ -0,0 +1,11 @@ +import apiConfig from '@o2s/prettier-config/api.mjs'; + +/** + * @see https://prettier.io/docs/en/configuration.html + * @type {import("prettier").Config} + */ +const config = { + ...apiConfig, +}; + +export default config; diff --git a/packages/integrations/zendesk/eslint.config.mjs b/packages/integrations/zendesk/eslint.config.mjs new file mode 100644 index 00000000..93b00e9d --- /dev/null +++ b/packages/integrations/zendesk/eslint.config.mjs @@ -0,0 +1,4 @@ +import { config } from '@o2s/eslint-config/api'; + +/** @type {import("eslint").Linter.Config} */ +export default config; diff --git a/packages/integrations/zendesk/lint-staged.config.mjs b/packages/integrations/zendesk/lint-staged.config.mjs new file mode 100644 index 00000000..ff4483cc --- /dev/null +++ b/packages/integrations/zendesk/lint-staged.config.mjs @@ -0,0 +1,4 @@ +export default { + '*.{js,jsx,ts,tsx,css,scss}': ['prettier --write'], + '*.{js,jsx,ts,tsx}': () => 'tsc --noEmit', +}; diff --git a/packages/integrations/zendesk/package.json b/packages/integrations/zendesk/package.json new file mode 100644 index 00000000..c6fe5824 --- /dev/null +++ b/packages/integrations/zendesk/package.json @@ -0,0 +1,36 @@ +{ + "name": "@o2s/integrations.zendesk", + "version": "1.0.0", + "private": false, + "license": "MIT", + "exports": { + "./integration": "./dist/integration.js" + }, + "files": [ + "dist" + ], + "scripts": { + "dev": "tsc && (concurrently \"tsc -w\" \"tsc-alias -w\")", + "build": "tsc && tsc-alias", + "lint": "tsc --noEmit && eslint . --max-warnings 0", + "format": "prettier --write \"src/**/*.{js,jsx,ts,tsx,css,scss,json}\"" + }, + "dependencies": { + "@nestjs/axios": "^3.0.0", + "@nestjs/common": "^10.0.0", + "@o2s/framework": "*", + "@o2s/utils.logger": "*", + "axios": "^1.6.0", + "rxjs": "^7.8.1" + }, + "devDependencies": { + "@o2s/eslint-config": "*", + "@o2s/prettier-config": "*", + "@o2s/typescript-config": "*", + "concurrently": "^9.2.1", + "eslint": "^9.37.0", + "prettier": "^3.6.2", + "tsc-alias": "^1.8.10", + "typescript": "^5.7.3" + } +} \ No newline at end of file diff --git a/packages/integrations/zendesk/src/integration.ts b/packages/integrations/zendesk/src/integration.ts new file mode 100644 index 00000000..9d7a4648 --- /dev/null +++ b/packages/integrations/zendesk/src/integration.ts @@ -0,0 +1,15 @@ +import { HttpModule } from '@nestjs/axios'; + +import { ApiConfig, Users } from '@o2s/framework/modules'; + +import { Service as TicketsService } from './modules/tickets'; + +export * as Integration from './modules/index'; + +export const Config: Partial = { + tickets: { + name: 'zendesk', + service: TicketsService, + imports: [HttpModule, Users.Module], + }, +}; diff --git a/packages/integrations/zendesk/src/modules/index.ts b/packages/integrations/zendesk/src/modules/index.ts new file mode 100644 index 00000000..3eaa8f07 --- /dev/null +++ b/packages/integrations/zendesk/src/modules/index.ts @@ -0,0 +1 @@ +export * as Tickets from './tickets'; diff --git a/packages/integrations/zendesk/src/modules/tickets/index.ts b/packages/integrations/zendesk/src/modules/tickets/index.ts new file mode 100644 index 00000000..75c5349c --- /dev/null +++ b/packages/integrations/zendesk/src/modules/tickets/index.ts @@ -0,0 +1,8 @@ +import { Tickets } from '@o2s/framework/modules'; + +export * from './zendesk-ticket.service'; +export { ZendeskTicketService as Service } from './zendesk-ticket.service'; +export { ZendeskTicketModule as Module } from './zendesk-ticket.module'; + +export import Request = Tickets.Request; +export import Model = Tickets.Model; diff --git a/packages/integrations/zendesk/src/modules/tickets/zendesk-ticket.module.ts b/packages/integrations/zendesk/src/modules/tickets/zendesk-ticket.module.ts new file mode 100644 index 00000000..810fe7c3 --- /dev/null +++ b/packages/integrations/zendesk/src/modules/tickets/zendesk-ticket.module.ts @@ -0,0 +1,13 @@ +import { HttpModule } from '@nestjs/axios'; +import { Module } from '@nestjs/common'; + +import { Users } from '@o2s/framework/modules'; + +import { ZendeskTicketService } from './zendesk-ticket.service'; + +@Module({ + imports: [HttpModule, Users.Module], + providers: [ZendeskTicketService], + exports: [ZendeskTicketService], +}) +export class ZendeskTicketModule {} diff --git a/packages/integrations/zendesk/src/modules/tickets/zendesk-ticket.service.ts b/packages/integrations/zendesk/src/modules/tickets/zendesk-ticket.service.ts new file mode 100644 index 00000000..83f5432d --- /dev/null +++ b/packages/integrations/zendesk/src/modules/tickets/zendesk-ticket.service.ts @@ -0,0 +1,288 @@ +import { HttpService } from '@nestjs/axios'; +import { Injectable, NotFoundException, NotImplementedException } from '@nestjs/common'; +import axios from 'axios'; +import { Observable, catchError, map, of, switchMap, throwError } from 'rxjs'; + +import { Tickets, Users } from '@o2s/framework/modules'; + +interface ZendeskTicket { + id: number; + created_at: string; + updated_at: string; + subject: string; + description: string; + status: string; + priority: string; + requester_id: number; + submitter_id: number; + assignee_id: number; + tags: string[]; + custom_fields: Array<{ id: number; value: string }>; + via: { channel: string }; + type: string; +} + +interface ZendeskSearchResponse { + count: number; + next_page?: string; + previous_page?: string; + results: ZendeskTicket[]; +} + +interface ZendeskTicketResponse { + ticket: ZendeskTicket; +} + +interface ZendeskComment { + id: number; + author_id: number; + body: string; + created_at: string; + public: boolean; +} + +interface ZendeskCommentsResponse { + comments: ZendeskComment[]; +} + +interface ZendeskUser { + id: number; + name: string; + email: string; + photo?: { content_url: string }; +} + +@Injectable() +export class ZendeskTicketService extends Tickets.Service { + private readonly apiUrl: string; + private readonly apiToken: string; + + constructor( + private readonly httpService: HttpService, + private readonly usersService: Users.Service, + ) { + super(); + this.apiUrl = process.env.ZENDESK_API_URL || ''; + this.apiToken = process.env.ZENDESK_API_TOKEN || ''; + } + + getTicket( + options: Tickets.Request.GetTicketParams, + authorization?: string, + ): Observable { + return this.usersService.getCurrentUser(authorization).pipe( + switchMap((user) => { + if (!user?.email) { + return throwError(() => new NotFoundException('User email not found')); + } + + return this.fetchTicket(options.id).pipe( + switchMap((ticket) => { + return this.fetchUser(ticket.requester_id).pipe( + switchMap((requester) => { + if (requester.email !== user.email) { + return of(undefined); + } + + return this.fetchTicketComments(options.id).pipe( + map((comments) => this.mapTicketToModel(ticket, comments)), + ); + }), + ); + }), + catchError((error) => { + if (axios.isAxiosError(error) && error.response?.status === 404) { + return of(undefined); + } + return throwError(() => error); + }), + ); + }), + ); + } + + getTicketList( + options: Tickets.Request.GetTicketListQuery, + authorization?: string, + ): Observable { + return this.usersService.getCurrentUser(authorization).pipe( + switchMap((user) => { + if (!user?.email) { + return throwError(() => new NotFoundException('User email not found')); + } + + let searchQuery = `type:ticket requester:${user.email}`; + + if (options.status) { + searchQuery += ` status:${options.status.toLowerCase()}`; + } + + if (options.type) { + searchQuery += ` priority:${options.type.toLowerCase()}`; + } + + if (options.topic) { + searchQuery += ` tag:${options.topic.toLowerCase()}`; + } + + if (options.dateFrom) { + searchQuery += ` created>=${new Date(options.dateFrom).toISOString()}`; + } + + if (options.dateTo) { + searchQuery += ` created<=${new Date(options.dateTo).toISOString()}`; + } + + const page = options.offset ? Math.floor(options.offset / (options.limit || 10)) + 1 : 1; + const perPage = options.limit || 10; + + return this.searchTickets(searchQuery, page, perPage).pipe( + map((response) => { + const tickets = response.results.map((ticket) => this.mapTicketToModel(ticket)); + + return { + total: response.count, + data: tickets, + }; + }), + catchError((error) => { + if (axios.isAxiosError(error)) { + return throwError(() => new Error(`Failed to fetch ticket: ${error.message}`)); + } + return throwError(() => error); + }), + ); + }), + ); + } + + createTicket(_data: Tickets.Request.PostTicketBody, _authorization?: string): Observable { + return throwError(() => new NotImplementedException('Creating tickets in Zendesk is not implemented')); + } + + private fetchTicket(id: string): Observable { + return this.httpService + .get(`${this.apiUrl}/api/v2/tickets/${id}`, { + headers: { + Authorization: `Basic ${this.apiToken}`, + 'Content-Type': 'application/json', + }, + }) + .pipe( + map((response) => response.data.ticket), + catchError((error) => { + if (axios.isAxiosError(error)) { + return throwError(() => new Error(`Failed to fetch ticket: ${error.message}`)); + } + return throwError(() => error); + }), + ); + } + + private searchTickets(query: string, page: number, perPage: number): Observable { + return this.httpService + .get(`${this.apiUrl}/api/v2/search.json`, { + headers: { + Authorization: `Basic ${this.apiToken}`, + 'Content-Type': 'application/json', + }, + params: { + query, + page, + per_page: perPage, + }, + }) + .pipe( + map((response) => response.data), + catchError((error) => { + if (axios.isAxiosError(error)) { + return throwError(() => new Error(`Failed to search tickets: ${error.message}`)); + } + return throwError(() => error); + }), + ); + } + + private fetchTicketComments(ticketId: string): Observable { + return this.httpService + .get(`${this.apiUrl}/api/v2/tickets/${ticketId}/comments`, { + headers: { + Authorization: `Basic ${this.apiToken}`, + 'Content-Type': 'application/json', + }, + }) + .pipe( + map((response) => response.data.comments), + catchError((error) => { + if (axios.isAxiosError(error)) { + return throwError(() => new Error(`Failed to fetch ticket comments: ${error.message}`)); + } + return throwError(() => error); + }), + ); + } + + private fetchUser(userId: number): Observable { + return this.httpService + .get<{ user: ZendeskUser }>(`${this.apiUrl}/api/v2/users/${userId}`, { + headers: { + Authorization: `Basic ${this.apiToken}`, + 'Content-Type': 'application/json', + }, + }) + .pipe( + map((response) => response.data.user), + catchError((error) => { + if (axios.isAxiosError(error)) { + return throwError(() => new Error(`Failed to fetch user: ${error.message}`)); + } + return throwError(() => error); + }), + ); + } + + private mapTicketToModel(ticket: ZendeskTicket, comments: ZendeskComment[] = []): Tickets.Model.Ticket { + let status: Tickets.Model.TicketStatus = 'OPEN'; + if (ticket.status === 'closed' || ticket.status === 'solved') { + status = 'CLOSED'; + } else if (ticket.status === 'pending' || ticket.status === 'hold') { + status = 'IN_PROGRESS'; + } + + const properties: Tickets.Model.TicketProperty[] = [ + { id: 'subject', value: ticket.subject }, + { id: 'description', value: ticket.description || '' }, + ]; + + if (ticket.custom_fields) { + ticket.custom_fields.forEach((field) => { + if (field.value) { + properties.push({ + id: `custom_field_${field.id}`, + value: field.value, + }); + } + }); + } + + const mappedComments = comments.map((comment) => ({ + author: { + name: `User ${comment.author_id}`, + email: '', + }, + date: comment.created_at, + content: comment.body, + })); + + return { + id: ticket.id.toString(), + createdAt: ticket.created_at, + updatedAt: ticket.updated_at, + topic: ticket.tags[0] || 'general', + type: ticket.priority || 'normal', + status, + properties, + comments: mappedComments.length > 0 ? mappedComments : undefined, + }; + } +} diff --git a/packages/integrations/zendesk/tsconfig.json b/packages/integrations/zendesk/tsconfig.json new file mode 100644 index 00000000..c65ac133 --- /dev/null +++ b/packages/integrations/zendesk/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "@o2s/typescript-config/api.json", + "compilerOptions": { + "paths": { + "@/*": ["./src/*"] + }, + "outDir": "./dist" + }, + "include": ["src"], + "exclude": ["node_modules", "dist"] +} diff --git a/packages/integrations/zendesk/turbo.json b/packages/integrations/zendesk/turbo.json new file mode 100644 index 00000000..28f4ed45 --- /dev/null +++ b/packages/integrations/zendesk/turbo.json @@ -0,0 +1,13 @@ +{ + "extends": ["//"], + "tasks": { + "dev": { + "dependsOn": ["@o2s/utils.logger#build", "@o2s/framework#build"], + "env": ["ZENDESK_API_URL", "ZENDESK_API_TOKEN"] + }, + "build": { + "dependsOn": ["@o2s/utils.logger#build", "@o2s/framework#build"], + "env": ["ZENDESK_API_URL", "ZENDESK_API_TOKEN"] + } + } +}