Skip to content

Commit

Permalink
feat: customer service action log
Browse files Browse the repository at this point in the history
  • Loading branch information
sdjdd committed Jan 25, 2024
1 parent ca93dac commit fde4151
Show file tree
Hide file tree
Showing 11 changed files with 558 additions and 32 deletions.
52 changes: 51 additions & 1 deletion next/api/src/controller/customer-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ import {
StatusCode,
UseMiddlewares,
} from '@/common/http';
import { ParseBoolPipe, ZodValidationPipe } from '@/common/pipe';
import { ParseBoolPipe, ParseDatePipe, ParseIntPipe, ZodValidationPipe } from '@/common/pipe';
import { adminOnly, auth, systemRoleMemberGuard } from '@/middleware';
import { Category } from '@/model/Category';
import { Role } from '@/model/Role';
Expand All @@ -27,6 +27,14 @@ import { CustomerServiceResponse } from '@/response/customer-service';
import { GroupResponse } from '@/response/group';
import { roleService } from '@/service/role';
import { userService } from '@/user/services/user';
import { ReplyResponse } from '@/response/reply';
import { OpsLogResponse } from '@/response/ops-log';
import {
customerServiceActionLogService,
CustomerServiceActionLogType,
} from '@/service/customer-service-action-log';
import { TicketListItemResponse } from '@/response/ticket';
import { ReplyRevisionResponse } from '@/response/reply-revision';

class FindCustomerServicePipe {
static async transform(id: string, ctx: Context): Promise<User> {
Expand Down Expand Up @@ -221,4 +229,46 @@ export class CustomerServiceController {

return {};
}

@Get(':id/action-logs')
@UseMiddlewares(adminOnly)
async getActionLogs(
@Param('id') id: string,
@Query('from', ParseDatePipe) from: Date | undefined,
@Query('to', ParseDatePipe) to: Date | undefined,
@Query('pageSize', new ParseIntPipe({ min: 1, max: 100 })) pageSize = 10,
@Query('desc', ParseBoolPipe) desc: boolean
) {
if (!from || !to) {
throw new BadRequestError('"from" and "to" param is required');
}

const logs = await customerServiceActionLogService.getLogs({
from,
to,
customerServiceId: id,
limit: pageSize,
desc,
});

return logs.map((log) => {
switch (log.type) {
case CustomerServiceActionLogType.Reply:
return {
type: 'reply',
ticket: log.ticket && new TicketListItemResponse(log.ticket),
reply: log.reply && new ReplyResponse(log.reply),
revision: new ReplyRevisionResponse(log.revision),
ts: log.ts.toISOString(),
};
case CustomerServiceActionLogType.OpsLog:
return {
type: 'opsLog',
ticket: log.ticket && new TicketListItemResponse(log.ticket),
opsLog: new OpsLogResponse(log.opsLog),
ts: log.ts.toISOString(),
};
}
});
}
}
3 changes: 3 additions & 0 deletions next/api/src/model/ReplyRevision.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@ export class ReplyRevision extends Model {
@pointerId(() => Reply)
replyId!: string;

@pointTo(() => Reply)
reply?: Reply;

@field()
content?: string;

Expand Down
4 changes: 2 additions & 2 deletions next/api/src/response/reply.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,9 @@ export class ReplyResponse {
id: this.reply.id,
content: this.reply.content,
contentSafeHTML: sanitize(this.reply.contentHTML),
author: this.reply.author ? new UserResponse(this.reply.author) : undefined,
author: this.reply.author && new UserResponse(this.reply.author).toJSON(),
isCustomerService: this.reply.isCustomerService,
files: this.reply.files?.map((file) => new FileResponse(file)),
files: this.reply.files?.map((file) => new FileResponse(file).toJSON()),
internal: this.reply.internal,
edited: this.reply.edited,
createdAt: this.reply.createdAt.toISOString(),
Expand Down
153 changes: 153 additions & 0 deletions next/api/src/service/customer-service-action-log.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
import _ from 'lodash';

import { OpsLog } from '@/model/OpsLog';
import { Reply } from '@/model/Reply';
import { ReplyRevision } from '@/model/ReplyRevision';
import { Ticket } from '@/model/Ticket';
import { User } from '@/model/User';

export interface GetCustomerServiceActionLogsOptions {
from: Date;
to: Date;
customerServiceId: string;
limit?: number;
desc?: boolean;
}

export enum CustomerServiceActionLogType {
Reply = 1,
OpsLog = 2,
}

export type CustomerServiceActionLog =
| {
type: CustomerServiceActionLogType.Reply;
ticketId?: string;
ticket?: Ticket;
reply?: Reply;
revision: ReplyRevision;
ts: Date;
}
| {
type: CustomerServiceActionLogType.OpsLog;
ticket?: Ticket;
ticketId?: string;
opsLog: OpsLog;
ts: Date;
};

function topN<T>(arrays: T[][], N: number, cmp: (v1: T, v2: T) => number): T[] {
const totalCount = _.sum(arrays.map((array) => array.length));
const result: T[] = new Array(Math.min(N, totalCount));
const indices: number[] = new Array(arrays.length).fill(0);

let resultIndex = 0;

while (resultIndex < result.length) {
let minValue: T | null = null;
let minIndex = -1;

for (let i = 0; i < arrays.length; i += 1) {
const array = arrays[i];
const currentIndex = indices[i];
if (currentIndex < array.length) {
const currentValue = array[currentIndex];
if (!minValue || cmp(currentValue, minValue) <= 0) {
minValue = currentValue;
minIndex = i;
}
}
}

if (minIndex !== -1 && minValue) {
result[resultIndex] = minValue;
indices[minIndex] += 1;
resultIndex += 1;
} else {
break; // No more elements to consider
}
}

return result;
}

export class CustomerServiceActionLogService {
private getReplyRevisions({
from,
to,
customerServiceId,
limit = 10,
desc,
}: GetCustomerServiceActionLogsOptions) {
const query = ReplyRevision.queryBuilder()
.where('actionTime', '>=', from)
.where('actionTime', '<=', to)
.where('operator', '==', User.ptr(customerServiceId))
.preload('reply')
.limit(limit)
.orderBy('actionTime', desc ? 'desc' : 'asc');
return query.find({ useMasterKey: true });
}

private getOpsLogs({
from,
to,
customerServiceId,
limit = 10,
desc,
}: GetCustomerServiceActionLogsOptions) {
const query = OpsLog.queryBuilder()
.where('createdAt', '>=', from)
.where('createdAt', '<=', to)
.where('data.operator.objectId', '==', customerServiceId)
.limit(limit)
.orderBy('createdAt', desc ? 'desc' : 'asc');
return query.find({ useMasterKey: true });
}

async getLogs(options: GetCustomerServiceActionLogsOptions) {
const { limit = 10, desc } = options;

const replyRevisions = await this.getReplyRevisions(options);
const opsLogs = await this.getOpsLogs(options);

const replyLogs = replyRevisions.map<CustomerServiceActionLog>((rv) => ({
type: CustomerServiceActionLogType.Reply,
ticketId: rv.reply?.ticketId,
reply: rv.reply,
revision: rv,
ts: rv.actionTime,
}));

const opsLogLogs = opsLogs.map<CustomerServiceActionLog>((opsLog) => ({
type: CustomerServiceActionLogType.OpsLog,
ticketId: opsLog.ticketId,
opsLog,
ts: opsLog.createdAt,
}));

const logs = topN([replyLogs, opsLogLogs], limit, (a, b) => {
if (desc) {
return b.ts.getTime() - a.ts.getTime();
} else {
return a.ts.getTime() - b.ts.getTime();
}
});

const ticketIds = _.compact(logs.map((log) => log.ticketId));
const tickets = await Ticket.queryBuilder()
.where('objectId', 'in', ticketIds)
.find({ useMasterKey: true });
const ticketById = _.keyBy(tickets, (t) => t.id);

logs.forEach((log) => {
if (log.ticketId) {
log.ticket = ticketById[log.ticketId];
}
});

return logs;
}
}

export const customerServiceActionLogService = new CustomerServiceActionLogService();
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { ReactNode, useState } from 'react';
import { Modal, ModalProps } from 'antd';

export interface SimpleModalProps extends ModalProps {
trigger: ReactNode;
}

export function SimpleModal({ trigger, ...props }: SimpleModalProps) {
const [open, setOpen] = useState(false);
return (
<>
<div onClick={() => setOpen(!open)}>{trigger}</div>
<Modal destroyOnClose open={open} onCancel={() => setOpen(false)} footer={null} {...props} />
</>
);
}
Loading

0 comments on commit fde4151

Please sign in to comment.