Skip to content

Commit

Permalink
feat(hitl-next): create endpoint to reject handoffs (#12002)
Browse files Browse the repository at this point in the history
* feat(hitl-next): allow resolving handoff with API

* revert unwanted change

* Update modules/hitlnext/src/backend/api.ts

Co-authored-by: Laurent Leclerc Poulin <laurentlp@users.noreply.github.com>

* create reject api endpoint

* validation and comments

Co-authored-by: Laurent Leclerc Poulin <laurentlp@users.noreply.github.com>
  • Loading branch information
davidvitora and laurentlp committed Jul 20, 2022
1 parent 5ba4d32 commit 1f01d7f
Show file tree
Hide file tree
Showing 7 changed files with 82 additions and 12 deletions.
60 changes: 53 additions & 7 deletions modules/hitlnext/src/backend/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import { agentName } from '../helper'

import { StateType } from './index'
import { HandoffStatus, IAgent, IComment, IHandoff } from './../types'
import { UnprocessableEntityError } from './errors'
import { UnprocessableEntityError, UnauthorizedError } from './errors'
import { extendAgentSession, formatValidationError, makeAgentId } from './helpers'
import Repository, { CollectionConditions } from './repository'
import Service, { toEventDestination } from './service'
Expand All @@ -27,19 +27,22 @@ import {
validateHandoffStatusRule
} from './validation'

type HITLBPRequest = BPRequest & { agentId: string | undefined }

export default async (bp: typeof sdk, state: StateType, repository: Repository) => {
const router = bp.http.createRouterForBot(MODULE_NAME)
const realtime = Socket(bp)
const service = new Service(bp, state, repository, realtime)

// Enforces for an agent to be 'online' before executing an action
const agentOnlineMiddleware = async (req: BPRequest, res: Response, next) => {
const agentOnlineMiddleware = async (req: HITLBPRequest, res: Response, next) => {
const { email, strategy } = req.tokenUser!
const agentId = makeAgentId(strategy, email)
const online = await repository.getAgentOnline(req.params.botId, agentId)

try {
Joi.attempt({ online }, AgentOnlineValidation)
req.agentId = agentId
} catch (err) {
if (err instanceof Joi.ValidationError) {
return next(new UnprocessableEntityError(formatValidationError(err)))
Expand All @@ -51,6 +54,24 @@ export default async (bp: typeof sdk, state: StateType, repository: Repository)
next()
}

const hasPermission = (type: string, actionType: 'read' | 'write') => async (
req: HITLBPRequest,
res: Response,
next
) => {
const hasPermission = await bp.http.hasPermission(req, actionType, `module.hitlnext.${type}`, true)

if (hasPermission) {
return next()
}

return next(
new UnauthorizedError(
`user does not have sufficient permissions to "${actionType}" on resource "module.hitlnext.${type}"`
)
)
}

// Catches exceptions and handles those that are expected
const errorMiddleware = fn => {
return (req: BPRequest, res: Response, next) => {
Expand Down Expand Up @@ -290,15 +311,39 @@ export default async (bp: typeof sdk, state: StateType, repository: Repository)
router.post(
'/handoffs/:id/resolve',
agentOnlineMiddleware,
errorMiddleware(async (req: RequestWithUser, res: Response) => {
const { email, strategy } = req.tokenUser!
errorMiddleware(async (req: HITLBPRequest, res: Response) => {
const handoff = await repository.findHandoff(req.params.botId, req.params.id)

const agentId = makeAgentId(strategy, email)
const payload: Pick<IHandoff, 'status' | 'resolvedAt'> = {
status: 'resolved',
resolvedAt: new Date()
}

Joi.attempt(payload, ResolveHandoffSchema)

try {
validateHandoffStatusRule(handoff.status, payload.status)
} catch (e) {
throw new UnprocessableEntityError(formatValidationError(e))
}

const updated = await service.resolveHandoff(handoff, req.params.botId, payload)
req.agentId && (await extendAgentSession(repository, realtime, req.params.botId, req.agentId))

res.send(updated)
})
)

// Resolving -> can only occur after being assigned
// Rejecting -> can only occur if pending or assigned
router.post(
'/handoffs/:id/reject',
hasPermission('reject', 'write'),
errorMiddleware(async (req: HITLBPRequest, res: Response) => {
const handoff = await repository.findHandoff(req.params.botId, req.params.id)

const payload: Pick<IHandoff, 'status' | 'resolvedAt'> = {
status: 'resolved',
status: 'rejected',
resolvedAt: new Date()
}

Expand All @@ -310,8 +355,9 @@ export default async (bp: typeof sdk, state: StateType, repository: Repository)
throw new UnprocessableEntityError(formatValidationError(e))
}

// Rejecting a handoff is the same as resolving it
// But you can also transition from pending
const updated = await service.resolveHandoff(handoff, req.params.botId, payload)
await extendAgentSession(repository, realtime, req.params.botId, agentId)

res.send(updated)
})
Expand Down
10 changes: 8 additions & 2 deletions modules/hitlnext/src/backend/validation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ export const AssignHandoffSchema = Joi.object({
export const ResolveHandoffSchema = Joi.object({
status: Joi.string()
.required()
.valid('resolved'),
.valid('resolved', 'rejected'),
resolvedAt: Joi.date().required()
})

Expand All @@ -47,7 +47,13 @@ export const AgentOnlineValidation = Joi.object({
export const validateHandoffStatusRule = (original: string, value: string) => {
let message: string

if (original === 'pending' && value !== 'assigned') {
if (['pending', 'assigned'].includes(original) && value === 'rejected') {
return
}

if (original === 'expired') {
message = `Status "${original}" can't transition to "${value}"`
} else if (original === 'pending' && value !== 'assigned') {
message = `Status "${original}" can only transition to "assigned"`
} else if (original === 'assigned' && value !== 'resolved') {
message = `Status "${original}" can only transition to "resolved"`
Expand Down
3 changes: 3 additions & 0 deletions modules/hitlnext/src/translations/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
"assignedMe": "Assigned to Me",
"assignedOther": "Assigned to Others",
"resolved": "Resolved",
"rejected": "Rejected",
"expired": "Expired"
},
"sortBy": "Sort By",
Expand All @@ -32,6 +33,7 @@
"pending": "pending",
"assigned": "assigned",
"resolved": "resolved",
"rejected": "rejected",
"expired": "expired"
},
"assignedToAgent": "You've been assigned to an agent",
Expand All @@ -40,6 +42,7 @@
"resolve": "Resolve",
"assigned": "Handoff {id} successfully assigned",
"resolved": "Handoff {id} successfully resolved",
"rejected": "Handoff {id} successfully rejected",
"you": "You",
"from": "From {channel}"
},
Expand Down
3 changes: 3 additions & 0 deletions modules/hitlnext/src/translations/fr.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
"assignedMe": "Assigné à moi",
"assignedOther": "Assigné à d'autres",
"resolved": "Résolue",
"rejected": "Rejeté",
"expired": "Expiré"
},
"sortBy": "Trier Par",
Expand All @@ -32,6 +33,7 @@
"pending": "en attente",
"assigned": "assignée",
"resolved": "résolue",
"rejected": "rejeté",
"expired": "expiré"
},
"assignedToAgent": "Vous avez été assigné à un agent",
Expand All @@ -40,6 +42,7 @@
"resolve": "Résoudre",
"assigned": "L'escalation {id} a été assignée avec succès",
"resolved": "L'escalation {id} a été résoulue avec succès",
"rejected": "L'escalation {id} a été rejeté avec succès",
"noPreviousAgent": "Aucun agent précédent",
"you": "Vous",
"from": "Via {channel}"
Expand Down
2 changes: 1 addition & 1 deletion modules/hitlnext/src/types.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ export type IAgent = sdk.WorkspaceUserWithAttributes & {

export type AgentWithPermissions = IAgent & UserProfile

export type HandoffStatus = 'pending' | 'assigned' | 'resolved' | 'expired'
export type HandoffStatus = 'pending' | 'assigned' | 'resolved' | 'expired' | 'rejected'
export interface IHandoff {
id: string
botId: string
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,17 +27,19 @@ const HandoffList: FC<Props> = ({ tags, handoffs, loading }) => {
assignedOther: false,
expired: false,
resolved: false,
rejected: false,
tags: []
})
const [sortOption, setSortOption] = useState<SortType>('mostRecent')

function filterBy(item: IHandoff): boolean {
const conditions = {
unassigned: item.agentId == null && item.status !== 'expired',
unassigned: item.agentId == null && !['expired', 'rejected', 'resolved'].includes(item.status),
assignedMe: item.status === 'assigned' && item.agentId === state.currentAgent?.agentId,
assignedOther: item.status === 'assigned' && item.agentId !== state.currentAgent?.agentId,
expired: item.status === 'expired',
resolved: item.status === 'resolved'
resolved: item.status === 'resolved',
rejected: item.status === 'rejected'
}

if (filterOptions.tags.length) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ export interface FilterType {
assignedMe: boolean
assignedOther: boolean
resolved: boolean
rejected: boolean
expired: boolean
tags: string[]
}
Expand Down Expand Up @@ -70,6 +71,15 @@ const HandoffListHeader: FC<Props> = ({
/>
)
},
{
content: (
<Checkbox
checked={filterOptions.rejected}
label={lang.tr('module.hitlnext.filter.rejected')}
onChange={() => setFilterOptions({ ...filterOptions, rejected: !filterOptions.rejected })}
/>
)
},
{
content: (
<Checkbox
Expand Down

0 comments on commit 1f01d7f

Please sign in to comment.