Skip to content

Commit b0c2386

Browse files
authored
feat: evaluation time limit (#944)
1 parent 8da6071 commit b0c2386

13 files changed

Lines changed: 163 additions & 49 deletions

File tree

next/api/src/common/http/handler/index.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import { getArguments, getParams } from '.';
55

66
const METADATA_KEY = Symbol('handlers');
77

8-
export type HttpMethod = 'get' | 'post' | 'patch' | 'delete';
8+
export type HttpMethod = 'get' | 'put' | 'post' | 'patch' | 'delete';
99

1010
export interface Handler {
1111
controllerMethod: string | symbol;
@@ -27,6 +27,7 @@ export function Handler(httpMethod: HttpMethod, path?: string) {
2727
}
2828

2929
export const Get = (path?: string) => Handler('get', path);
30+
export const Put = (path?: string) => Handler('put', path);
3031
export const Post = (path?: string) => Handler('post', path);
3132
export const Patch = (path?: string) => Handler('patch', path);
3233
export const Delete = (path?: string) => Handler('delete', path);

next/api/src/controller/config.ts

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
import { z } from 'zod';
2+
3+
import { BadRequestError, Body, Controller, Get, Param, Put, UseMiddlewares } from '@/common/http';
4+
import { Config } from '@/config';
5+
import { auth, customerServiceOnly } from '@/middleware';
6+
7+
const CONFIG_SCHEMAS: Record<string, z.Schema<any>> = {
8+
weekday: z.array(z.number()),
9+
work_time: z.object({
10+
from: z.object({
11+
hours: z.number(),
12+
minutes: z.number(),
13+
seconds: z.number(),
14+
}),
15+
to: z.object({
16+
hours: z.number(),
17+
minutes: z.number(),
18+
seconds: z.number(),
19+
}),
20+
}),
21+
evaluation: z.object({
22+
timeLimit: z.number().int().min(0),
23+
}),
24+
};
25+
26+
@Controller('config')
27+
@UseMiddlewares(auth, customerServiceOnly)
28+
export class ConfigController {
29+
@Get(':key')
30+
getEvaluation(@Param('key') key: string) {
31+
if (!(key in CONFIG_SCHEMAS)) {
32+
throw new BadRequestError(`Invalid config key "${key}"`);
33+
}
34+
return Config.get(key);
35+
}
36+
37+
@Put(':key')
38+
async setEvaluation(@Param('key') key: string, @Body() body: any) {
39+
const schema = CONFIG_SCHEMAS[key];
40+
if (!schema) {
41+
throw new BadRequestError(`Invalid config key "${key}"`);
42+
}
43+
const data = schema.parse(body);
44+
await Config.set(key, data);
45+
}
46+
}

next/api/src/model/Ticket.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -223,6 +223,9 @@ export class Ticket extends Model {
223223
@field()
224224
channel?: string;
225225

226+
@field()
227+
closedAt?: Date;
228+
226229
getUrlForEndUser() {
227230
return `${config.host}/tickets/${this.nid}`;
228231
}

next/api/src/router/config.ts

Lines changed: 0 additions & 24 deletions
This file was deleted.

next/api/src/router/index.ts

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,6 @@ import trigger from './trigger';
1111
import timeTrigger from './time-trigger';
1212
import reply from './reply';
1313
import ticketStats from './ticket-stats';
14-
import config from './config';
1514

1615
const router = new Router({ prefix: '/api/2' }).use(catchYupError, catchLCError, catchZodError);
1716

@@ -23,7 +22,6 @@ router.use('/triggers', trigger.routes());
2322
router.use('/time-triggers', timeTrigger.routes());
2423
router.use('/replies', reply.routes());
2524
router.use('/ticket-stats', ticketStats.routes());
26-
router.use('/config', config.routes());
2725

2826
initControllers(router);
2927

next/api/src/router/ticket.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import AV from 'leancloud-storage';
33
import _ from 'lodash';
44
import UAParser from 'ua-parser-js';
55

6-
import { config } from '@/config';
6+
import { Config, config } from '@/config';
77
import * as yup from '@/utils/yup';
88
import { auth, customerServiceOnly, include, parseRange, sort } from '@/middleware';
99
import { Model, QueryBuilder } from '@/orm';
@@ -34,6 +34,7 @@ import { dynamicContentService } from '@/dynamic-content';
3434
import { FileResponse } from '@/response/file';
3535
import { File } from '@/model/File';
3636
import { lookupIp } from '@/utils/ip';
37+
import { ticketService } from '@/service/ticket';
3738

3839
const router = new Router().use(auth);
3940

@@ -851,6 +852,9 @@ router.patch('/:id', async (ctx) => {
851852
if (!config.allowModifyEvaluation && ticket.evaluation) {
852853
return ctx.throw(409, 'Ticket is already evaluated');
853854
}
855+
if (!(await ticketService.isTicketEvaluable(ticket))) {
856+
return ctx.throw(400, 'Sorry, you cannot create an evaluation in an expired ticket');
857+
}
854858
updater.setEvaluation({
855859
...data.evaluation,
856860
content: (

next/api/src/service/ticket.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import { differenceInMilliseconds } from 'date-fns';
2+
3+
import { Ticket } from '@/model/Ticket';
4+
import { Config } from '@/config';
5+
6+
export class TicketService {
7+
async isTicketEvaluable(ticket: Ticket) {
8+
if (!ticket.closedAt) {
9+
return true;
10+
}
11+
const evaluationConfig = (await Config.get('evaluation')) as
12+
| {
13+
timeLimit: number;
14+
}
15+
| undefined;
16+
if (!evaluationConfig || !evaluationConfig.timeLimit) {
17+
return true;
18+
}
19+
return differenceInMilliseconds(new Date(), ticket.closedAt) <= evaluationConfig.timeLimit;
20+
}
21+
}
22+
23+
export const ticketService = new TicketService();

next/api/src/ticket/TicketUpdater.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -214,9 +214,11 @@ export class TicketUpdater {
214214
break;
215215
case 'close':
216216
this.data.status = Status.CLOSED;
217+
this.data.closedAt = new Date();
217218
break;
218219
case 'reopen':
219220
this.data.status = Status.WAITING_CUSTOMER;
221+
this.data.closedAt = null;
220222
break;
221223
}
222224

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
import { useConfig, useUpdateConfig } from '@/api/config';
2+
import { LoadingCover } from '@/components/common';
3+
import { Button, Form, InputNumber } from 'antd';
4+
5+
export function EvaluationConfig() {
6+
const { data, isLoading } = useConfig<{ timeLimit: number }>('evaluation');
7+
8+
const { mutate, isLoading: isSaving } = useUpdateConfig('evaluation');
9+
10+
return (
11+
<div className="p-10">
12+
{isLoading && <LoadingCover />}
13+
{!isLoading && (
14+
<Form
15+
layout="vertical"
16+
initialValues={{
17+
...data,
18+
timeLimit: Math.floor((data?.timeLimit || 0) / 1000 / 60),
19+
}}
20+
onFinish={(data) =>
21+
mutate({
22+
...data,
23+
timeLimit: data.timeLimit * 1000 * 60,
24+
})
25+
}
26+
>
27+
<Form.Item
28+
name="timeLimit"
29+
label="评价时限"
30+
extra="工单关闭后多长时间内允许用户评价,设为 0 表示没有限制"
31+
>
32+
<InputNumber min={0} addonAfter="分钟" />
33+
</Form.Item>
34+
<Button type="primary" htmlType="submit" loading={isSaving}>
35+
保存
36+
</Button>
37+
</Form>
38+
)}
39+
</div>
40+
);
41+
}

next/web/src/App/Admin/Settings/Others/Weekday.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -111,9 +111,9 @@ const WeekdayTime = () => {
111111

112112
export function Weekday() {
113113
return (
114-
<>
114+
<div className="p-10">
115115
<Weekdays />
116116
<WeekdayTime />
117-
</>
117+
</div>
118118
);
119119
}

0 commit comments

Comments
 (0)