diff --git a/next/api/src/service/ticket.ts b/next/api/src/service/ticket.ts index 6a1016515..8db981dbf 100644 --- a/next/api/src/service/ticket.ts +++ b/next/api/src/service/ticket.ts @@ -6,6 +6,7 @@ import { ACLBuilder } from '@/orm'; import { User } from '@/model/User'; import { Ticket } from '@/model/Ticket'; import { Reply } from '@/model/Reply'; +import { Action, OpsLog } from '@/model/OpsLog'; import { userService } from '@/user/services/user'; import { createQueue, Job, Queue } from '@/queue'; import { DetectTicketLanguageJobData } from '@/interfaces/ticket'; @@ -15,8 +16,17 @@ import { translateService } from './translate'; interface GetRepliesOptions { author?: boolean; files?: boolean; + + /** + * Should include internal replies + */ internal?: boolean; + + /** + * Should include deleted replies + */ deleted?: boolean; + skip?: number; limit?: number; cursor?: Date; @@ -24,6 +34,14 @@ interface GetRepliesOptions { count?: boolean; } +interface GetOpsLogsOptions { + actions?: Action[]; + skip?: number; + limit?: number; + cursor?: Date; + desc?: boolean; +} + interface TransferTicketJobData { sourceUserId: string; targetUserId: string; @@ -92,6 +110,26 @@ export class TicketService { return query.find({ useMasterKey: true }); } + async getOpsLogs(ticketId: string, options: GetOpsLogsOptions = {}) { + const query = OpsLog.queryBuilder() + .where('ticket', '==', Ticket.ptr(ticketId)) + .limit(options.limit || 10) + .orderBy('createdAt', options.desc ? 'desc' : 'asc'); + if (options.actions?.length) { + query.where('action', 'in', options.actions); + } + if (options.cursor) { + if (options.desc) { + query.where('createdAt', '<', options.cursor); + } else { + query.where('createdAt', '>', options.cursor); + } + } else if (options.skip) { + query.skip(options.skip); + } + return query.find({ useMasterKey: true }); + } + async isTicketEvaluable(ticket: Ticket) { if (!ticket.closedAt) { return true; diff --git a/next/api/src/ticket/TicketUpdater.ts b/next/api/src/ticket/TicketUpdater.ts index 909238e00..a4a6480ea 100644 --- a/next/api/src/ticket/TicketUpdater.ts +++ b/next/api/src/ticket/TicketUpdater.ts @@ -13,6 +13,7 @@ import { TinyReplyInfo } from '@/model/Reply'; import { TicketLog } from '@/model/TicketLog'; import { searchTicketService } from '@/service/search-ticket'; import htmlify from '@/utils/htmlify'; +import { durationMetricsService } from './services/duration-metrics'; export interface UpdateOptions { useMasterKey?: boolean; @@ -354,6 +355,9 @@ export class TicketUpdater { } await searchTicketService.addSyncJob([ticket.id]); + if (this.data.status && ticket.isClosed()) { + durationMetricsService.createCreateMetricsJob({ ticketId: ticket.id }); + } return ticket; } diff --git a/next/api/src/ticket/services/duration-metric.ts b/next/api/src/ticket/services/duration-metric.ts deleted file mode 100644 index 5888c2e02..000000000 --- a/next/api/src/ticket/services/duration-metric.ts +++ /dev/null @@ -1,109 +0,0 @@ -import { UpdateData } from '@/orm'; -import { DurationMetrics } from '@/model/DurationMetrics'; -import { Reply } from '@/model/Reply'; -import { Ticket } from '@/model/Ticket'; -import { OperateAction } from '@/model/OpsLog'; - -export class DurationMetricService { - createMetric(ticket: Ticket) { - return DurationMetrics.create( - { - ACL: {}, - ticketId: ticket.id, - ticketCreatedAt: ticket.createdAt, - }, - { - useMasterKey: true, - } - ); - } - - getMetricForTicket(ticket: Ticket) { - return DurationMetrics.queryBuilder() - .where('ticket', '==', ticket.toPointer()) - .first({ useMasterKey: true }); - } - - async recordReplyTicket(ticket: Ticket, reply: Reply, isAgent: boolean) { - if (isAgent) { - if (ticket.status === Ticket.Status.WAITING_CUSTOMER) { - return; - } - } else { - if ( - ticket.status === Ticket.Status.NEW || - ticket.status === Ticket.Status.WAITING_CUSTOMER_SERVICE - ) { - return; - } - } - - const durationMetric = await this.getMetricForTicket(ticket); - if (!durationMetric) { - return; - } - - const data: UpdateData = {}; - - if (isAgent) { - if (durationMetric.firstReplyTime === undefined) { - data.firstReplyTime = reply.createdAt.getTime() - ticket.createdAt.getTime(); - } - const requesterWaitAt = durationMetric.requesterWaitAt ?? ticket.createdAt; - const duration = reply.createdAt.getTime() - requesterWaitAt.getTime(); - data.requesterWaitTime = (durationMetric.requesterWaitTime || 0) + duration; - data.agentWaitAt = reply.createdAt; - } else { - if (durationMetric.agentWaitAt) { - const duration = reply.createdAt.getTime() - durationMetric.agentWaitAt.getTime(); - data.agentWaitTime = (durationMetric.agentWaitTime || 0) + duration; - } - data.requesterWaitAt = reply.createdAt; - } - - await durationMetric.update(data, { useMasterKey: true }); - } - - async recordOperateTicket(ticket: Ticket, action: OperateAction) { - if (action === 'close' || action === 'resolve') { - await this.recordResolveTicket(ticket); - } else if (action === 'reopen') { - await this.recordReopenTicket(ticket); - } - } - - async recordResolveTicket(ticket: Ticket) { - const durationMetric = await this.getMetricForTicket(ticket); - if (!durationMetric) { - return; - } - - const now = Date.now(); - const data: UpdateData = {}; - - if (!durationMetric.firstResolutionTime) { - data.firstResolutionTime = now - ticket.createdAt.getTime(); - } - - data.fullResolutionTime = now - ticket.createdAt.getTime(); - - await durationMetric.update(data, { useMasterKey: true }); - } - - async recordReopenTicket(ticket: Ticket) { - const durationMetric = await this.getMetricForTicket(ticket); - if (!durationMetric) { - return; - } - await durationMetric.update( - { - agentWaitAt: new Date(), - }, - { - useMasterKey: true, - } - ); - } -} - -export const durationMetricService = new DurationMetricService(); diff --git a/next/api/src/ticket/services/duration-metrics.ts b/next/api/src/ticket/services/duration-metrics.ts new file mode 100644 index 000000000..2a3debd92 --- /dev/null +++ b/next/api/src/ticket/services/duration-metrics.ts @@ -0,0 +1,284 @@ +import { addMilliseconds, differenceInMilliseconds, isAfter } from 'date-fns'; +import _ from 'lodash'; + +import { Queue, createQueue } from '@/queue'; +import { DurationMetrics } from '@/model/DurationMetrics'; +import { Ticket } from '@/model/Ticket'; +import { Action } from '@/model/OpsLog'; +import { ticketService } from '@/service/ticket'; + +interface CreateDurationMetricsJobData { + ticketId: string; + timelineCursor?: string; + metrics?: RawDurationMetricsData; + isTicketOpen?: boolean; +} + +interface DispatchDurationMetricsJobJobData { + from: string; + to: string; +} + +type DurationMetricsJobData = + | (CreateDurationMetricsJobData & { type: 'create' }) + | (DispatchDurationMetricsJobJobData & { type: 'dispatch' }); + +interface Timeline { + type: Action | 'reply'; + createdAt: Date; + isAgent?: boolean; +} + +type DurationMetricsData = Pick< + DurationMetrics, + | 'ticketCreatedAt' + | 'firstReplyTime' + | 'firstResolutionTime' + | 'fullResolutionTime' + | 'requesterWaitAt' + | 'requesterWaitTime' + | 'agentWaitAt' + | 'agentWaitTime' +>; + +type RawDurationMetricsData = { + [K in keyof DurationMetricsData]: number extends DurationMetricsData[K] + ? DurationMetricsData[K] + : string; +}; + +export class DurationMetricsService { + private queue: Queue; + + constructor() { + this.queue = createQueue('ticket_duration_metrics', { + defaultJobOptions: { + removeOnComplete: true, + removeOnFail: true, + }, + }); + this.queue.process((job) => { + switch (job.data.type) { + case 'create': + return this.processCreateMetricsJob(job.data); + case 'dispatch': + return this.processDispatchMetricsJobJob(job.data); + } + }); + } + + async saveMetrics(ticketId: string, metricsData: DurationMetricsData) { + const metrics = await DurationMetrics.queryBuilder() + .where('ticket', '==', Ticket.ptr(ticketId)) + .first({ useMasterKey: true }); + if (metrics) { + await metrics.update(metricsData, { useMasterKey: true }); + return; + } + await DurationMetrics.create( + { + ACL: {}, + ticketId, + ...metricsData, + }, + { + useMasterKey: true, + } + ); + } + + decodeDurationMetricsData(rawData: RawDurationMetricsData) { + const data: DurationMetricsData = { + ticketCreatedAt: new Date(rawData.ticketCreatedAt), + firstReplyTime: rawData.firstReplyTime, + firstResolutionTime: rawData.firstResolutionTime, + fullResolutionTime: rawData.fullResolutionTime, + requesterWaitTime: rawData.requesterWaitTime, + agentWaitTime: rawData.agentWaitTime, + }; + if (rawData.requesterWaitAt) { + data.requesterWaitAt = new Date(rawData.requesterWaitAt); + } + if (rawData.agentWaitAt) { + data.agentWaitAt = new Date(rawData.agentWaitAt); + } + return data; + } + + encodeDurationMetricsData(data: DurationMetricsData): RawDurationMetricsData { + return { + ticketCreatedAt: data.ticketCreatedAt.toISOString(), + firstReplyTime: data.firstReplyTime, + firstResolutionTime: data.firstResolutionTime, + fullResolutionTime: data.fullResolutionTime, + requesterWaitTime: data.requesterWaitTime, + agentWaitTime: data.agentWaitTime, + requesterWaitAt: data.requesterWaitAt?.toISOString(), + agentWaitAt: data.agentWaitAt?.toISOString(), + }; + } + + async createCreateMetricsJob( + data: CreateDurationMetricsJobData | CreateDurationMetricsJobData[] + ) { + if (Array.isArray(data)) { + await this.queue.addBulk(data.map((data) => ({ data: { type: 'create', ...data } }))); + } else { + await this.queue.add({ type: 'create', ...data }); + } + } + + async processCreateMetricsJob(data: CreateDurationMetricsJobData) { + let metrics: DurationMetricsData; + if (data.metrics) { + metrics = this.decodeDurationMetricsData(data.metrics); + } else { + const ticket = await Ticket.queryBuilder() + .where('objectId', '==', data.ticketId) + .first({ useMasterKey: true }); + if (!ticket || !ticket.isClosed()) { + return; + } + metrics = { + ticketCreatedAt: ticket.createdAt, + requesterWaitAt: ticket.createdAt, + }; + } + + const timelineCursor = data.timelineCursor ? new Date(data.timelineCursor) : undefined; + + const limit = 500; + let replies = await ticketService.getReplies(data.ticketId, { + internal: false, + deleted: false, + cursor: timelineCursor, + limit, + }); + let opsLogs = await ticketService.getOpsLogs(data.ticketId, { + actions: ['replyWithNoContent', 'close', 'resolve', 'reopen'], + cursor: timelineCursor, + limit, + }); + + let nextCursor: Date | undefined; + if (replies.length === limit || opsLogs.length === limit) { + nextCursor = _([_.last(replies)?.createdAt, _.last(opsLogs)?.createdAt]) + .compact() + .minBy((v) => v.getTime()); + } + + if (nextCursor) { + const boundary = nextCursor; + replies = _.reject(replies, (reply) => isAfter(reply.createdAt, boundary)); + opsLogs = _.reject(opsLogs, (opsLog) => isAfter(opsLog.createdAt, boundary)); + } + + const timeline = [ + ...replies.map((reply) => ({ + type: 'reply', + createdAt: reply.createdAt, + isAgent: reply.isCustomerService, + })), + ...opsLogs.map((opsLog) => ({ + type: opsLog.action, + createdAt: opsLog.createdAt, + })), + ].sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime()); + + let isTicketOpen = data.isTicketOpen ?? true; + for (const t of timeline) { + switch (t.type) { + case 'reply': + if (isTicketOpen) { + if (t.isAgent) { + metrics.firstReplyTime ??= differenceInMilliseconds( + t.createdAt, + metrics.ticketCreatedAt + ); + if (metrics.requesterWaitAt) { + metrics.requesterWaitTime ??= 0; + metrics.requesterWaitTime += differenceInMilliseconds( + t.createdAt, + metrics.requesterWaitAt + ); + metrics.requesterWaitAt = undefined; + } + metrics.agentWaitAt ??= t.createdAt; + } else { + if (metrics.agentWaitAt) { + metrics.agentWaitTime ??= 0; + metrics.agentWaitTime += differenceInMilliseconds(t.createdAt, metrics.agentWaitAt); + metrics.agentWaitAt = undefined; + } + metrics.requesterWaitAt ??= t.createdAt; + } + } + break; + case 'replyWithNoContent': + if (isTicketOpen) { + metrics.requesterWaitAt = undefined; + } + break; + case 'close': + case 'resolve': + isTicketOpen = false; + const resolutionTime = differenceInMilliseconds(t.createdAt, metrics.ticketCreatedAt); + metrics.firstResolutionTime ??= resolutionTime; + metrics.fullResolutionTime = resolutionTime; + break; + case 'reopen': + isTicketOpen = true; + metrics.agentWaitAt = undefined; + metrics.requesterWaitAt = undefined; + break; + } + } + + if (nextCursor) { + await this.createCreateMetricsJob({ + ticketId: data.ticketId, + timelineCursor: nextCursor.toISOString(), + metrics: this.encodeDurationMetricsData(metrics), + isTicketOpen, + }); + } else { + await this.saveMetrics(data.ticketId, metrics); + } + } + + async processDispatchMetricsJobJob(data: DispatchDurationMetricsJobJobData) { + const limit = 100; + const tickets = await Ticket.queryBuilder() + .where('createdAt', '>=', new Date(data.from)) + .where('createdAt', '<=', new Date(data.to)) + .orderBy('createdAt', 'asc') + .limit(limit) + .find({ useMasterKey: true }); + + if (tickets.length === 0) { + console.log('[Duration Metrics] dispatch jobs done'); + return; + } + + await this.createCreateMetricsJob(tickets.map((ticket) => ({ ticketId: ticket.id }))); + + console.log( + `[Duration Metrics] [${tickets[0].createdAt}..${_.last(tickets)!.createdAt}] ${ + tickets.length + } job(s) dispatched` + ); + + await this.queue.add( + { + type: 'dispatch', + from: addMilliseconds(_.last(tickets)!.createdAt, 1).toISOString(), + to: data.to, + }, + { + delay: 10000, + } + ); + } +} + +export const durationMetricsService = new DurationMetricsService(); diff --git a/resources/schema/DurationMetrics.json b/resources/schema/DurationMetrics.json index 1c0bba980..0820416cd 100644 --- a/resources/schema/DurationMetrics.json +++ b/resources/schema/DurationMetrics.json @@ -12,7 +12,8 @@ "type": "Date" }, "ACL": { - "type": "ACL" + "type": "ACL", + "default": {} }, "objectId": { "type": "String" @@ -119,6 +120,16 @@ "name": "-user-ticketCreatedAt_1", "ns": "_.DurationMetrics", "background": true + }, + { + "v": 2, + "unique": true, + "key": { + "ticket.$id": 1.0 + }, + "name": "-user-ticket.$id_1", + "ns": "_.DurationMetrics", + "background": true } ] } \ No newline at end of file