Skip to content

Commit

Permalink
feat: filter action log by operators
Browse files Browse the repository at this point in the history
  • Loading branch information
sdjdd committed Jan 25, 2024
1 parent fde4151 commit 63d3b5b
Show file tree
Hide file tree
Showing 8 changed files with 355 additions and 257 deletions.
94 changes: 94 additions & 0 deletions next/api/src/controller/customer-service-action-log.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
import { BadRequestError, Controller, Get, Query, UseMiddlewares } from '@/common/http';
import { ParseBoolPipe, ParseCsvPipe, ParseDatePipe, ParseIntPipe } from '@/common/pipe';
import { adminOnly, auth } from '@/middleware';
import {
customerServiceActionLogService,
CustomerServiceActionLogType,
} from '@/service/customer-service-action-log';
import { Ticket } from '@/model/Ticket';
import { User } from '@/model/User';
import { OpsLogResponse } from '@/response/ops-log';
import { ReplyResponse } from '@/response/reply';
import { ReplyRevisionResponse } from '@/response/reply-revision';
import { TicketListItemResponse } from '@/response/ticket';
import { UserResponse } from '@/response/user';

@Controller('customer-service-action-logs')
@UseMiddlewares(auth, adminOnly)
export class CustomerServiceActionLogController {
@Get()
async getLogs(
@Query('from', ParseDatePipe) from: Date | undefined,
@Query('to', ParseDatePipe) to: Date | undefined,
@Query('operatorIds', ParseCsvPipe) operatorIds: string[] | undefined,
@Query('pageSize', new ParseIntPipe({ min: 1, max: 100 })) pageSize = 10,
@Query('desc', ParseBoolPipe) desc: boolean | undefined
) {
if (!from || !to) {
throw new BadRequestError('Date range params "from" and "to" are required');
}
if (operatorIds && operatorIds.length > 20) {
throw new BadRequestError('The size of operatorIds must less than 20');
}

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

const ticketIds = new Set<string>();
const userIds = new Set<string>();

const logResult = logs.map((log) => {
switch (log.type) {
case CustomerServiceActionLogType.Reply:
if (log.ticketId) {
ticketIds.add(log.ticketId);
}
userIds.add(log.operatorId);
return {
type: 'reply',
ticketId: log.ticketId,
operatorId: log.operatorId,
reply: log.reply && new ReplyResponse(log.reply),
revision: new ReplyRevisionResponse(log.revision),
ts: log.ts.toISOString(),
};
case CustomerServiceActionLogType.OpsLog:
ticketIds.add(log.ticketId);
userIds.add(log.operatorId);
if (log.opsLog.data.assignee) {
userIds.add(log.opsLog.data.assignee.objectId);
}
return {
type: 'opsLog',
ticketId: log.ticketId,
operatorId: log.operatorId,
opsLog: new OpsLogResponse(log.opsLog),
ts: log.ts.toISOString(),
};
}
});

const tickets = ticketIds.size
? await Ticket.queryBuilder()
.where('objectId', 'in', Array.from(ticketIds))
.find({ useMasterKey: true })
: [];

const users = userIds.size
? await User.queryBuilder()
.where('objectId', 'in', Array.from(userIds))
.find({ useMasterKey: true })
: [];

return {
logs: logResult,
tickets: tickets.map((ticket) => new TicketListItemResponse(ticket)),
users: users.map((user) => new UserResponse(user)),
};
}
}
52 changes: 1 addition & 51 deletions 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, ParseDatePipe, ParseIntPipe, ZodValidationPipe } from '@/common/pipe';
import { ParseBoolPipe, ZodValidationPipe } from '@/common/pipe';
import { adminOnly, auth, systemRoleMemberGuard } from '@/middleware';
import { Category } from '@/model/Category';
import { Role } from '@/model/Role';
Expand All @@ -27,14 +27,6 @@ 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 @@ -229,46 +221,4 @@ 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(),
};
}
});
}
}
42 changes: 18 additions & 24 deletions next/api/src/service/customer-service-action-log.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,12 @@ 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;
operatorIds?: string[];
limit?: number;
desc?: boolean;
}
Expand All @@ -23,15 +22,15 @@ export type CustomerServiceActionLog =
| {
type: CustomerServiceActionLogType.Reply;
ticketId?: string;
ticket?: Ticket;
operatorId: string;
reply?: Reply;
revision: ReplyRevision;
ts: Date;
}
| {
type: CustomerServiceActionLogType.OpsLog;
ticket?: Ticket;
ticketId?: string;
ticketId: string;
operatorId: string;
opsLog: OpsLog;
ts: Date;
};
Expand Down Expand Up @@ -75,33 +74,40 @@ export class CustomerServiceActionLogService {
private getReplyRevisions({
from,
to,
customerServiceId,
operatorIds,
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');
if (operatorIds) {
query.where('operator', 'in', operatorIds.map(User.ptr.bind(User)));
}
return query.find({ useMasterKey: true });
}

private getOpsLogs({
from,
to,
customerServiceId,
operatorIds,
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');
if (operatorIds) {
query.where('data.operator.objectId', 'in', operatorIds);
} else {
query.where('data.operator.objectId', 'exists');
query.where('data.operator.objectId', '!=', 'system');
}
return query.find({ useMasterKey: true });
}

Expand All @@ -116,37 +122,25 @@ export class CustomerServiceActionLogService {
ticketId: rv.reply?.ticketId,
reply: rv.reply,
revision: rv,
operatorId: rv.operatorId,
ts: rv.actionTime,
}));

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

const logs = topN([replyLogs, opsLogLogs], limit, (a, b) => {
return 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;
}
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import { forwardRef } from 'react';
import { Button, DatePicker, Form, FormInstance } from 'antd';
import { Moment } from 'moment';

import { CustomerServiceSelect } from '@/components/common';

export interface FilterFormData {
dateRange: [Moment, Moment];
operatorIds?: string[];
}

export interface FilterFormProps {
initData?: Partial<FilterFormData>;
onSubmit?: (data: FilterFormData) => void;
loading?: boolean;
}

export const FilterForm = forwardRef<FormInstance, FilterFormProps>(
({ initData, onSubmit, loading }, ref) => {
const handleSubmit = (data: FilterFormData) => {
if (onSubmit) {
if (data.operatorIds && data.operatorIds.length === 0) {
delete data.operatorIds;
}
onSubmit(data);
}
};

return (
<Form
ref={ref}
className="flex flex-wrap gap-2"
initialValues={initData}
onFinish={handleSubmit}
>
<Form.Item noStyle name="dateRange">
<DatePicker.RangePicker allowClear={false} />
</Form.Item>

<Form.Item noStyle name="operatorIds">
<CustomerServiceSelect
allowClear
showArrow
mode="multiple"
placeholder="客服"
style={{ minWidth: 200 }}
/>
</Form.Item>

<Button type="primary" htmlType="submit" loading={loading}>
查询
</Button>
</Form>
);
}
);
Loading

0 comments on commit 63d3b5b

Please sign in to comment.