Skip to content

Commit

Permalink
feat(condo): DOMA-8408 ticket auto assignment by classifiers (#4435)
Browse files Browse the repository at this point in the history
* feat(condo): DOMA-8408 added aut assingned

* test(condo): DOMA-8404 added tests

* feat(condo): DOMA-8404 updated gql. Added translations

* refactor(condo): DOMA-8404 refactored naming

* feat(condo): DOMA-8404 updated AutoAssigner

* chore(condo): DOMA-8404 updated schema

* feat(condo): DOMA-8404 added migrations

* feat(condo): DOMA-8404 added page for configure of ticket auto assignment

* fix(condo): DOMA-8404 fixed refetch of useAllObjects

* feat(condo): DOMA-8404 updated direct access config (add schema TicketAutoAssignment)

* feat(condo): DOMA-8404 updated migrations

* feat(condo): DOMA-8404 updated schema

* feat(condo): DOMA-8404 updated migrations after rebase

* feat(condo): DOMA-8404 added translations

* fix(condo): DOMA-8404 fixed code-style

* refactor(condo): DOMA-8404 refactored code

* chore(condo): DOMA-8404 uodated docs

* refactor(condo): DOMA-8404 refactored code

* fix(condo): DOMA-8408 fixed translations

* fix(condo): DOMA-8408 fixed AutoAssigner logic

* chore(condo): DOMA-8408 changed columns order

* feat(condo): DOMA-8408 updated page for managing of TicketAutoAssignment

* chore(condo): DOMA-8404 updated migrations

* fix(condo): DOMA-8408 fixed translations

* fix(condo): DOMA-8637 changed fetchPolicy when get TicketAutoAssignment

* fix(condo): DOMA-8637 changed fetchPolicy when get TicketAutoAssignment

* fix(condo): DOMA-8636 fixed table with TicketAutoAssignment if no data

* chore(condo): DOMA-8408 updated migrations after rebase
  • Loading branch information
Alllex202 authored Mar 14, 2024
1 parent f59889f commit 35ecf78
Show file tree
Hide file tree
Showing 20 changed files with 2,455 additions and 23 deletions.
4 changes: 2 additions & 2 deletions apps/condo/domains/organization/utils/clientSchema/search.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@ export function searchEmployeeWithSpecializations (intl, organizationId, filter)
}
}

export function searchEmployeeUserWithSpecializations (intl, organizationId, filter) {
export function searchEmployeeUser (intl, organizationId, filter) {
if (!organizationId) return

return async function (client, value, query = {}, first, skip) {
Expand All @@ -91,4 +91,4 @@ export function searchEmployeeUserWithSpecializations (intl, organizationId, fil
employee,
}))
}
}
}
68 changes: 68 additions & 0 deletions apps/condo/domains/ticket/access/TicketAutoAssignment.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
/**
* Generated by `createschema ticket.TicketAutoAssignment 'assignee:Relationship:OrganizationEmployee:SET_NULL;executor:Relationship:OrganizationEmployee:SET_NULL;classifier:Relationship:TicketClassifier:CASCADE;'`
*/

const { throwAuthenticationError } = require('@open-condo/keystone/apolloErrorFormatter')

const {
queryOrganizationEmployeeFor,
queryOrganizationEmployeeFromRelatedOrganizationFor,
} = require('@condo/domains/organization/utils/accessSchema')
const { STAFF } = require('@condo/domains/user/constants/common')
const { canDirectlyReadSchemaObjects, canDirectlyManageSchemaObjects } = require('@condo/domains/user/utils/directAccess')


/**
* TicketAutoAssignment can be read by
* 1. Admin
* 2. Support
* 3. Users with direct access
* 4. Employee who can manage tickets
*/
async function canReadTicketAutoAssignments ({ authentication: { item: user }, listKey }) {
if (!user) return throwAuthenticationError()
if (user.deletedAt) return false

if (user.isAdmin || user.isSupport) return {}

const hasDirectAccess = await canDirectlyReadSchemaObjects(user, listKey)
if (hasDirectAccess) return {}

if (user.type === STAFF) {
return {
organization: {
OR: [
queryOrganizationEmployeeFor(user.id, 'canManageTickets'),
queryOrganizationEmployeeFromRelatedOrganizationFor(user.id, 'canManageTickets'),
],
deletedAt: null,
},
}
}

return false
}

/**
* TicketAutoAssignment can be manage by
* 1. Admin
* 2. Support
* 3. Users with direct access
*/
async function canManageTicketAutoAssignments ({ authentication: { item: user }, originalInput, operation, itemId, listKey }) {
if (!user) return throwAuthenticationError()
if (user.deletedAt) return false

if (user.isAdmin || user.isSupport) return true

return await canDirectlyManageSchemaObjects(user, listKey, originalInput, operation)
}

/*
Rules are logical functions that used for list access, and may return a boolean (meaning
all or no items are available) or a set of filters that limit the available items.
*/
module.exports = {
canReadTicketAutoAssignments,
canManageTicketAutoAssignments,
}
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,34 @@ import {
getEmployeesSortedByTicketVisibilityType,
getPropertyScopeNameByEmployee, isEmployeeSpecializationAndPropertyMatchesToScope,
} from '@condo/domains/scope/utils/clientSchema/utils'
import { TicketAutoAssignment } from '@condo/domains/ticket/utils/clientSchema'


const selectUserByAutoAssignmentRule = (rule, employees, key: 'assignee' | 'executor') => {
if (!rule) return null

const desiredEmployee = get(rule, key)
if (desiredEmployee === null) return null

const desiredEmployeeId = get(desiredEmployee, 'id')
if (!desiredEmployeeId) return null

const employee = employees.find(employee => employee.id === desiredEmployeeId && !employee.isBlocked)
const employeeUserId = get(employee, 'user.id')
if (!employeeUserId) return null

return employeeUserId
}

/**
* Sets the employee user in the assignee and executor fields after selecting ticket category classifier.
If an employee has a SpecializationScope with a specialization that matches the categoryClassifier
1) If the organization has configured rules for auto-substitution of “assignee” and “executor”
(the “TicketAutoAssignment” scheme), then we try to find a rule that matches the properties of a ticket
and set a corresponding employee users in the assignee and executor fields (there may be empty values).
If we couldn’t find the rules (not configured), then go to step 2.
2) If an employee has a SpecializationScope with a specialization that matches the categoryClassifier
and the employee is in a PropertyScope that has a ticket property,
then the employee's user is set to the assignee and executor fields.
If there was no such employee, then the author of the ticket is set in the assignee field.
Expand All @@ -26,6 +50,7 @@ export const AutoAssigner = ({
propertyScopeEmployees,
propertyScopes,
organizationEmployeeSpecializations,
organizationId,
}) => {
const intl = useIntl()
const AutoAssignAlertTitle = intl.formatMessage({ id: 'pages.condo.ticket.autoAssignAlert.title' })
Expand All @@ -37,8 +62,39 @@ export const AutoAssigner = ({

const [autoAssigneePropertyScopeName, setAutoAssigneePropertyScopeName] = useState<string>()

const classifierId = form.getFieldValue('classifier')

const { loading, obj: rule } = TicketAutoAssignment.useObject({
where: {
organization: { id: organizationId },
classifier: { id: classifierId },
},
}, {
skip: !organizationId || !classifierId,
fetchPolicy: 'cache-and-network',
nextFetchPolicy: 'cache-and-network',
})

const loadedRule = !!rule || !loading
const allLoaded = allDataLoaded && loadedRule

useDeepCompareEffect(() => {
if (allDataLoaded) {
if (allLoaded) {

// 1 - try set assignee and executor by TicketAutoAssignment
if (rule) {
const autoSelectedAssigneeUserId = selectUserByAutoAssignmentRule(rule, employees, 'assignee')
const autoSelectedExecutorUserId = selectUserByAutoAssignmentRule(rule, employees, 'executor')

form.setFieldsValue({
assignee: autoSelectedAssigneeUserId,
executor: autoSelectedExecutorUserId,
})
setAutoAssigneePropertyScopeName(null)
return
}

// 2 - try set assignee and executor by specialization and ticket visibility
const employeesWithMatchesPropertyAndSpecializationScope = employees.filter(
isEmployeeSpecializationAndPropertyMatchesToScope(
{
Expand All @@ -56,28 +112,29 @@ export const AutoAssigner = ({
organizationEmployeeSpecializations,
categoryClassifierId,
)

const firstEmployee = sortedEmployees.find(employee => !employee.isBlocked)
const firstEmployeeUserId = get(firstEmployee, 'user.id')
const propertyScopeName = getPropertyScopeNameByEmployee(firstEmployee, propertyScopes, propertyScopeEmployees)

form.setFieldsValue({
assignee: firstEmployeeUserId,
executor: firstEmployeeUserId,
})

const propertyScopeName = getPropertyScopeNameByEmployee(firstEmployee, propertyScopes, propertyScopeEmployees)
setAutoAssigneePropertyScopeName(propertyScopeName)
} else {
form.setFieldsValue({
assignee: currentUserCanBeAssignee ? currentUserId : null,
executor: null,
})

setAutoAssigneePropertyScopeName(null)
return
}

// 3 - set current user as assignee or null and executor to null
form.setFieldsValue({
assignee: currentUserCanBeAssignee ? currentUserId : null,
executor: null,
})
setAutoAssigneePropertyScopeName(null)
}
}, [
allDataLoaded, categoryClassifierId, employees, form, organizationEmployeeSpecializations,
propertyScopeEmployees, propertyScopes, currentUserId, currentUserCanBeAssignee,
categoryClassifierId, employees, form, organizationEmployeeSpecializations,
propertyScopeEmployees, propertyScopes, currentUserId, currentUserCanBeAssignee, allLoaded, rule,
])

return autoAssigneePropertyScopeName ? (
Expand All @@ -90,4 +147,4 @@ export const AutoAssigner = ({
/>
</Col>
) : null
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import {
OrganizationEmployee,
OrganizationEmployeeSpecialization,
} from '@condo/domains/organization/utils/clientSchema'
import { searchEmployeeUserWithSpecializations } from '@condo/domains/organization/utils/clientSchema/search'
import { searchEmployeeUser } from '@condo/domains/organization/utils/clientSchema/search'
import {
PropertyScope,
PropertyScopeOrganizationEmployee,
Expand Down Expand Up @@ -202,7 +202,7 @@ const TicketAssignments = ({
filteredPropertyScopeEmployees, intl, DeletedEmployeeMessage, EmployeesOnPropertyMessage, OtherMessage,
])

const search = useMemo(() => searchEmployeeUserWithSpecializations(intl, organizationId, null),
const search = useMemo(() => searchEmployeeUser(intl, organizationId, null),
[intl, organizationId])

const loading = propertiesLoading || scopesLoading || employeesLoading || specializationsLoading
Expand All @@ -216,7 +216,7 @@ const TicketAssignments = ({
<Col span={!breakpoints.TABLET_LARGE ? 24 : 18}>
<Row justify='space-between' gutter={[0, 12]}>
{
autoAssign && !loading && propertyId && categoryClassifier && (
autoAssign && !loading && propertyId && categoryClassifier && organizationId && (
<AutoAssigner
form={form}
categoryClassifierId={categoryClassifier}
Expand All @@ -225,6 +225,7 @@ const TicketAssignments = ({
propertyScopeEmployees={filteredPropertyScopeEmployees}
propertyScopes={propertyScopes}
organizationEmployeeSpecializations={filteredEmployeeSpecializations}
organizationId={organizationId}
/>
)
}
Expand Down
6 changes: 5 additions & 1 deletion apps/condo/domains/ticket/gql.js
Original file line number Diff line number Diff line change
Expand Up @@ -260,6 +260,9 @@ const TICKET_MULTIPLE_UPDATE_MUTATION = gql`
}
`

const TICKET_AUTO_ASSIGNMENT_FIELDS = `{ organization { id name } assignee { id name } executor { id name } classifier { id place { id name } category { id name } problem { id name } } ${COMMON_FIELDS} }`
const TicketAutoAssignment = generateGqlQueries('TicketAutoAssignment', TICKET_AUTO_ASSIGNMENT_FIELDS)

/* AUTOGENERATE MARKER <CONST> */
module.exports = {
Ticket,
Expand Down Expand Up @@ -297,5 +300,6 @@ module.exports = {
CallRecord,
CallRecordFragment,
TICKET_MULTIPLE_UPDATE_MUTATION,
/* AUTOGENERATE MARKER <EXPORTS> */
TicketAutoAssignment,
/* AUTOGENERATE MARKER <EXPORTS> */
}
109 changes: 109 additions & 0 deletions apps/condo/domains/ticket/schema/TicketAutoAssignment.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
/**
* Generated by `createschema ticket.TicketAutoAssignment 'assignee:Relationship:OrganizationEmployee:SET_NULL;executor:Relationship:OrganizationEmployee:SET_NULL;classifier:Relationship:TicketClassifier:CASCADE;'`
*/

const { GQLError, GQLErrorCode: { BAD_USER_INPUT } } = require('@open-condo/keystone/errors')
const { historical, versioned, uuided, tracked, softDeleted, dvAndSender } = require('@open-condo/keystone/plugins')
const { GQLListSchema, getByCondition } = require('@open-condo/keystone/schema')

const { ORGANIZATION_OWNED_FIELD } = require('@condo/domains/organization/schema/fields')
const access = require('@condo/domains/ticket/access/TicketAutoAssignment')

const ERRORS = {
ASSIGNEE_NOT_FOUND: {
code: BAD_USER_INPUT,
type: 'ASSIGNEE_NOT_FOUND',
messageForUser: 'api.ticket.TicketAutoAssignment.error.ASSIGNEE_NOT_FOUND',
message: 'There is no such employee (assignee) in the organization',
},
EXECUTOR_NOT_FOUND: {
code: BAD_USER_INPUT,
type: 'EXECUTOR_NOT_FOUND',
messageForUser: 'api.ticket.TicketAutoAssignment.error.EXECUTOR_NOT_FOUND',
message: 'There is no such employee (executor) in the organization',
},
}

const TicketAutoAssignment = new GQLListSchema('TicketAutoAssignment', {
schemaDoc: 'This schema helps decides who should be assigned to ticket as executor and assignee',
fields: {

organization: ORGANIZATION_OWNED_FIELD,

assignee: {
schemaDoc: 'An employee who will be appointed as assignee of ticket. ' +
'If "null", then the field will remain empty and dispatcher will have to fill it in independently in UI',
type: 'Relationship',
ref: 'OrganizationEmployee',
isRequired: false,
knexOptions: { isNotNullable: false }, // Required relationship only!
kmigratorOptions: { null: true, on_delete: 'models.SET_NULL' },
},

executor: {
schemaDoc: 'An employee who will be appointed as executor of ticket' +
'If "null", then the field will remain empty and dispatcher will have to fill it in independently in UI',
type: 'Relationship',
ref: 'OrganizationEmployee',
isRequired: false,
knexOptions: { isNotNullable: false }, // Required relationship only!
kmigratorOptions: { null: true, on_delete: 'models.SET_NULL' },
},

classifier: {
schemaDoc: 'Ticket classifier',
type: 'Relationship',
ref: 'TicketClassifier',
isRequired: true,
knexOptions: { isNotNullable: true }, // Required relationship only!
kmigratorOptions: { null: false, on_delete: 'models.CASCADE' },
},

},
hooks: {
validateInput: async ({ resolvedData, context, existingItem }) => {
const newItem = { ...existingItem, ...resolvedData }

if (newItem.assignee) {
const assignee = await getByCondition('OrganizationEmployee', {
id: newItem.assignee,
deletedAt: null,
organization: { id: newItem.organization, deletedAt: null },
})
if (!assignee) throw new GQLError(ERRORS.ASSIGNEE_NOT_FOUND, context)
}

if (newItem.executor) {
const executor = await getByCondition('OrganizationEmployee', {
id: newItem.executor,
deletedAt: null,
organization: { id: newItem.organization, deletedAt: null },
})
if (!executor) throw new GQLError(ERRORS.EXECUTOR_NOT_FOUND, context)
}
},
},
plugins: [uuided(), versioned(), tracked(), softDeleted(), dvAndSender(), historical()],
kmigratorOptions: {
constraints: [
{
type: 'models.UniqueConstraint',
fields: ['organization', 'classifier'],
condition: 'Q(deletedAt__isnull=True)',
name: 'ticket_auto_assignment_unique_organization_classifier',
},
],
},
access: {
read: access.canReadTicketAutoAssignments,
create: access.canManageTicketAutoAssignments,
update: access.canManageTicketAutoAssignments,
delete: false,
auth: true,
},
})

module.exports = {
TicketAutoAssignment,
ERRORS,
}
Loading

0 comments on commit 35ecf78

Please sign in to comment.