Skip to content

Commit

Permalink
feat(condo): DOMA-10516 reassign employee ticket (#5563)
Browse files Browse the repository at this point in the history
* feat(condo): DOMA-10516 first commit

* feat(condo): DOMA-10516 add access for bulk operation in ticket

* feat(condo): DOMA-10516 main commit

* feat(condo): DOMA-10516 small commit

* feat(condo): DOMA-10516 delete unusage icons

* feat(condo): DOMA-10516 add new logic

* feat(condo): DOMA-10516 add types in components

* feat(condo): DOMA-10516 change name and rewrite access

* feat(condo): DOMA-10516 first commit

* feat(condo): DOMA-10516 add access for bulk operation in ticket

* feat(condo): DOMA-10516 main commit

* feat(condo): DOMA-10516 small commit

* feat(condo): DOMA-10516 delete unusage icons

* feat(condo): DOMA-10516 add new logic

* feat(condo): DOMA-10516 add types in components

* feat(condo): DOMA-10516 change name and rewrite access

* feat(condo): DOMA-10516 add more test cases and rewrite access logic

* feat(condo): DOMA-10516 remove redirent

* feat(condo): DOMA-10516 add callcenter logic for this feature

* feat(condo): DOMA-10516 add types

* feat(condo): DOMA-10516 fix review

* feat(condo): DOMA-10516 update main

* feat(condo): DOMA-10516 add callcenter commit

* feat(condo): DOMA-10516 wait persistor

* feat(condo): DOMA-10516 wait persistor

* feat(condo): DOMA-10516 add commit from callcenter
  • Loading branch information
tolmachev21 authored Dec 16, 2024
1 parent 225cfe8 commit a5979e6
Show file tree
Hide file tree
Showing 19 changed files with 1,217 additions and 28 deletions.
2 changes: 1 addition & 1 deletion apps/callcenter
2 changes: 2 additions & 0 deletions apps/condo/domains/common/constants/featureflags.js
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ const NEWS_SHARING_TEMPLATES = 'news-sharing-templates'
const SERVICE_PROBLEMS_ALERT = 'service-problems-alert'
const TICKET_AUTO_ASSIGNMENT_MANAGEMENT = 'ticket-auto-assignment-management'
const POLL_TICKET_COMMENTS = 'poll-ticket-comments'
const REASSIGN_EMPLOYEE_TICKETS = 'reassign-employee-tickets'

module.exports = {
SMS_AFTER_TICKET_CREATION,
Expand Down Expand Up @@ -59,4 +60,5 @@ module.exports = {
SERVICE_PROBLEMS_ALERT,
TICKET_AUTO_ASSIGNMENT_MANAGEMENT,
POLL_TICKET_COMMENTS,
REASSIGN_EMPLOYEE_TICKETS,
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,249 @@
import {
GetOrganizationEmployeeTicketsForReassignmentQuery,
useGetOrganizationEmployeeTicketsForReassignmentLazyQuery,
useUpdateOrganizationEmployeeTicketsForReassignmentMutation,
} from '@app/condo/gql'
import { OrganizationEmployee } from '@app/condo/schema'
import { notification, Row } from 'antd'
import isEmpty from 'lodash/isEmpty'
import React, { useMemo, useCallback, useState } from 'react'

import { IUseSoftDeleteActionType } from '@open-condo/codegen/generate.hooks'
import { ArrowDownUp } from '@open-condo/icons'
import { useIntl } from '@open-condo/next/intl'
import { Alert, Button, Modal, Space, Typography } from '@open-condo/ui'
import { colors } from '@open-condo/ui/dist/colors'

import { GraphQlSearchInput } from '@condo/domains/common/components/GraphQlSearchInput'
import { runMutation } from '@condo/domains/common/utils/mutations.utils'
import { sleep } from '@condo/domains/common/utils/sleep'
import { searchEmployeeUser } from '@condo/domains/ticket/utils/clientSchema/search'


type IDeleteEmployeeButtonWithReassignmentModel = {
buttonContent: string
softDeleteAction: IUseSoftDeleteActionType<OrganizationEmployee>
disabled?: boolean
employee: Pick<OrganizationEmployee, 'name'> & { organization?:
Pick<OrganizationEmployee['organization'], 'id'>, user?:
Pick<OrganizationEmployee['organization'], 'id'> }
activeTicketsOrganizationEmployeeCount: number
}

const ERROR_NOTIFICATION_TYPE = 'error'
const WARNING_NOTIFICATION_TYPE = 'warning'
const SUCCESS_NOTIFICATION_TYPE = 'success'

const waitBetweenRequsted = async () => await sleep(1000)

export const DeleteEmployeeButtonWithReassignmentModel: React.FC<IDeleteEmployeeButtonWithReassignmentModel> = ({
buttonContent,
softDeleteAction,
disabled = false,
employee,
activeTicketsOrganizationEmployeeCount,
}) => {
const intl = useIntl()
const ConfirmReassignEmployeeTitle = intl.formatMessage({ id: 'employee.reassignTickets.title' })
const ConfirmDeleteButtonLabel = intl.formatMessage({ id: 'employee.reassignTickets.buttonContent.deleteUser' })
const ConfirmReassignTicketsButtonLabel = intl.formatMessage({ id: 'employee.reassignTickets.buttonContent.reassignTickets' })
const SearchPlaceholderLabel = intl.formatMessage({ id: 'EmployeesName' })
const AlertTitleLabel = intl.formatMessage({ id: 'employee.reassignTickets.alert.title' }, { employeeName: employee?.name || null })
const CountShortLabel = intl.formatMessage({ id: 'global.count.pieces.short' })
const AlertMessageLabel = intl.formatMessage({ id: 'employee.reassignTickets.alert.message' })
const NotificationTitleWarningLabel = intl.formatMessage({ id: 'employee.reassignTickets.notification.title.warning' })
const NotificationTitleErrorLabel = intl.formatMessage({ id: 'employee.reassignTickets.notification.title.error' })
const NotificationTitleSuccessLabel = intl.formatMessage({ id: 'employee.reassignTickets.notification.title.success' })
const NotificationMessageWarningLabel = intl.formatMessage({ id: 'employee.reassignTickets.notification.message.warning' })
const NotificationMessageErrorLabel = intl.formatMessage({ id: 'employee.reassignTickets.notification.message.error' })
const NotificationMessageSuccessLabel = intl.formatMessage({ id: 'employee.reassignTickets.notification.message.success' })

const [notificationApi, contextHolder] = notification.useNotification()

const employeeUserId = employee?.user?.id || null
const employeeOrganizationId = employee?.organization?.id || null

const getTicketReassignData = (ticket: GetOrganizationEmployeeTicketsForReassignmentQuery['tickets'][number]) => {
const resultObj = {}
if (ticket?.executor?.id === employeeUserId) resultObj['executor'] = { connect: { id: newEmployeeUserId } }
if (ticket?.assignee?.id === employeeUserId) resultObj['assignee'] = { connect: { id: newEmployeeUserId } }
return resultObj
}

const getNotificationInfo = useCallback((notificationType: 'error' | 'warning' | 'success', updatedTicketsCount = null) => {
switch (notificationType) {
case ERROR_NOTIFICATION_TYPE:
return {
message: <Typography.Text strong>{NotificationTitleErrorLabel}</Typography.Text>,
description: <Typography.Text strong>{NotificationMessageErrorLabel}</Typography.Text>,
duration: 0,
key: 'reassignTicket',
}
case WARNING_NOTIFICATION_TYPE:
return {
message: <Typography.Text strong>{NotificationTitleWarningLabel}</Typography.Text>,
description: <Space direction='vertical' size={4}>
<Typography.Text strong>
{NotificationMessageWarningLabel}
</Typography.Text>
<Typography.Text>
{intl.formatMessage({ id: 'employee.reassignTickets.notification.progress' }, { activeTicketsOrganizationEmployeeCount, updatedTicketsCount })}
</Typography.Text>
</Space>,
duration: 0,
key: 'reassignTicket',
}
case SUCCESS_NOTIFICATION_TYPE:
return {
message: <Typography.Text strong>{NotificationTitleSuccessLabel}</Typography.Text>,
description: <Space direction='vertical' size={4}>
<Typography.Text strong>
{NotificationMessageSuccessLabel}
</Typography.Text>
{updatedTicketsCount !== null && <Typography.Text>
{intl.formatMessage({ id: 'employee.reassignTickets.notification.progress' }, { activeTicketsOrganizationEmployeeCount, updatedTicketsCount })}
</Typography.Text>}
</Space>,
duration: 0,
key: 'reassignTicket',
}
}
}, [intl, NotificationTitleSuccessLabel, NotificationTitleErrorLabel, NotificationTitleWarningLabel, NotificationMessageErrorLabel, NotificationMessageSuccessLabel, NotificationMessageWarningLabel, activeTicketsOrganizationEmployeeCount])

const [newEmployeeUserId, setNewEmployeeUserId] = useState(null)
const onChange = (newEmployeeUserId: string) => {
setNewEmployeeUserId(newEmployeeUserId)
}

const [isDeleting, setIsDeleting] = useState(false)
const [isConfirmVisible, setIsConfirmVisible] = useState(false)

const showConfirm = () => setIsConfirmVisible(true)
const handleCancel = () => setIsConfirmVisible(false)

const [loadTicketsToReassign, { error: errorLoadTickets }] = useGetOrganizationEmployeeTicketsForReassignmentLazyQuery({
variables: {
organizationId: employeeOrganizationId,
userId: employeeUserId,
first: 100,
},
fetchPolicy: 'no-cache',
})
const [updateTickets, { error: errorUpdateTickets }] = useUpdateOrganizationEmployeeTicketsForReassignmentMutation()

const handleDeleteButtonClick = () => {
setIsDeleting(true)
setIsConfirmVisible(false)

runMutation(
{
action: softDeleteAction,
onError: (e) => { throw e },
OnErrorMsg: () => (getNotificationInfo(ERROR_NOTIFICATION_TYPE)),
onFinally: () => setIsDeleting(false),
intl,
},
)
}

const handleDeleteAndReassignTicketsClick = async () => {
setIsDeleting(true)
setIsConfirmVisible(false)

let updatedTicketsCount = 0

/* NOTE: push notifications for bulk tickets updates should not be sent here */
while (updatedTicketsCount < activeTicketsOrganizationEmployeeCount) {
notificationApi.warning(getNotificationInfo(WARNING_NOTIFICATION_TYPE, updatedTicketsCount))

try {
const { data: ticketsToReassign } = await loadTicketsToReassign()

if (isEmpty(ticketsToReassign?.tickets)) break
const { data: reassignedTickets } = await updateTickets({
variables: {
data: ticketsToReassign?.tickets?.filter(Boolean).map(ticket => ({
id: ticket?.id,
data: getTicketReassignData(ticket),
})),
},
})

updatedTicketsCount += reassignedTickets?.tickets?.length
await waitBetweenRequsted()
} catch (err) {
if (errorLoadTickets || errorUpdateTickets || err) notificationApi.error(getNotificationInfo(ERROR_NOTIFICATION_TYPE))
setIsDeleting(false)
return
}
}

runMutation(
{
action: softDeleteAction,
onError: (e) => { throw e },
OnErrorMsg: () => (getNotificationInfo(ERROR_NOTIFICATION_TYPE)),
OnCompletedMsg: () => (getNotificationInfo(SUCCESS_NOTIFICATION_TYPE, updatedTicketsCount)),
intl,
},
)
setIsDeleting(false)
}

// TODO: DOMA-10834 add search for an employee along with specialization
const search = useMemo(() => {
return searchEmployeeUser(employeeOrganizationId, (organizationEmployee: OrganizationEmployee) => {
return organizationEmployee?.isBlocked ? false : organizationEmployee.user.id !== employeeUserId
})
}, [employeeOrganizationId, employeeUserId])

return (
<>
{contextHolder}
<Button
key='submit'
onClick={showConfirm}
type='secondary'
loading={isDeleting}
danger
disabled={disabled || !employeeOrganizationId || !employeeUserId}
>
{buttonContent}
</Button>
<Modal
title={ConfirmReassignEmployeeTitle}
open={isConfirmVisible}
onCancel={handleCancel}
footer={<Button
key='submit'
type='primary'
loading={isDeleting}
onClick={newEmployeeUserId ? handleDeleteAndReassignTicketsClick : handleDeleteButtonClick}
>
{newEmployeeUserId ? ConfirmReassignTicketsButtonLabel : ConfirmDeleteButtonLabel}
</Button>
}
>
<Row justify='center' gutter={[0, 12]}>
<Alert
type='error'
showIcon
message={<Typography.Text strong>{AlertTitleLabel} ({activeTicketsOrganizationEmployeeCount}&nbsp;{CountShortLabel})</Typography.Text>}
description={<Typography.Paragraph>{AlertMessageLabel}</Typography.Paragraph>}
/>
<ArrowDownUp color={colors.gray[5]} />
<GraphQlSearchInput
search={search}
value={newEmployeeUserId}
onChange={onChange}
style={{
width: '100%',
}}
placeholder={SearchPlaceholderLabel}
/>
</Row>
</Modal>
</>

)
}
4 changes: 2 additions & 2 deletions apps/condo/domains/organization/hooks/useTableColumns.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ export const useTableColumns = (
</Typography.Paragraph>
</>
)
}, [])
}, [BlockedMessage, render])

const renderPhone = useCallback((phone) => {
return getTableCellRenderer(
Expand All @@ -93,7 +93,7 @@ export const useTableColumns = (
sorter: true,
filterDropdown: getFilterDropdownByKey(filterMetas, 'name'),
filterIcon: getFilterIcon,
render: (name, employee) => employee.isBlocked ? renderBlockedEmployee(name) : render(name),
render: (name, employee) => employee?.isBlocked ? renderBlockedEmployee(name) : render(name),
width: '15%',
},
{
Expand Down
35 changes: 30 additions & 5 deletions apps/condo/domains/ticket/access/Ticket.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,10 @@
* Generated by `createschema ticket.Ticket organization:Text; statusReopenedCounter:Integer; statusReason?:Text; status:Relationship:TicketStatus:PROTECT; number?:Integer; client?:Relationship:User:SET_NULL; clientName:Text; clientEmail:Text; clientPhone:Text; operator:Relationship:User:SET_NULL; assignee?:Relationship:User:SET_NULL; details:Text; meta?:Json;`
*/

const { get, isEmpty, omit } = require('lodash')
const { get, isEmpty, omit, uniq } = require('lodash')

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

const { canReadObjectsAsB2BAppServiceUser, canManageObjectsAsB2BAppServiceUser } = require('@condo/domains/miniapp/utils/b2bAppServiceUserAccess')
const {
Expand All @@ -14,13 +14,13 @@ const {
} = require('@condo/domains/organization/utils/accessSchema')
const { getUserResidents } = require('@condo/domains/resident/utils/accessSchema')
const { Resident } = require('@condo/domains/resident/utils/serverSchema')
const { CANCELED_STATUS_TYPE } = require('@condo/domains/ticket/constants')
const { CANCELED_STATUS_TYPE, BULK_UPDATE_ALLOWED_FIELDS } = require('@condo/domains/ticket/constants')
const {
AVAILABLE_TICKET_FIELDS_FOR_UPDATE_BY_RESIDENT,
INACCESSIBLE_TICKET_FIELDS_FOR_MANAGE_BY_RESIDENT,
INACCESSIBLE_TICKET_FIELDS_FOR_MANAGE_BY_STAFF,
} = require('@condo/domains/ticket/constants/common')
const { RESIDENT, SERVICE } = require('@condo/domains/user/constants/common')
const { RESIDENT, SERVICE, STAFF } = require('@condo/domains/user/constants/common')
const { canDirectlyManageSchemaObjects, canDirectlyReadSchemaObjects } = require('@condo/domains/user/utils/directAccess')

async function canReadTickets (args) {
Expand Down Expand Up @@ -59,15 +59,19 @@ async function canReadTickets (args) {
}

async function canManageTickets (args) {
const { authentication: { item: user }, operation, itemId, originalInput, context, listKey } = args
const { authentication: { item: user }, operation, itemId, itemIds, originalInput, context, listKey } = args

if (!user) return throwAuthenticationError()
if (user.deletedAt) return false
if (user.isAdmin) return true

const isBulkRequest = Array.isArray(originalInput)

const hasDirectAccess = await canDirectlyManageSchemaObjects(user, listKey, originalInput, operation)
if (hasDirectAccess) return true

if (isBulkRequest && (user.type !== STAFF || operation !== 'update')) return false

if (user.type === SERVICE) {
return await canManageObjectsAsB2BAppServiceUser(args)
}
Expand Down Expand Up @@ -111,6 +115,27 @@ async function canManageTickets (args) {
return ticket.client === user.id
}
} else {
// TODO: DOMA-10832 add check employee organization in Ticket access
if (isBulkRequest && operation === 'update') {
if (itemIds.length !== uniq(itemIds).length) return false
if (itemIds.length !== originalInput.length) return false

const changedInaccessibleFields = !originalInput.every((updateItem) => {
return Object.keys(updateItem.data).every(key => BULK_UPDATE_ALLOWED_FIELDS.includes(key))
})
if (changedInaccessibleFields) return false

const tickets = await find('Ticket', {
id_in: itemIds,
deletedAt: null,
})

const ticketOrganizationIds = uniq(tickets.map(ticket => get(ticket, 'organization', null)))
if (isEmpty(ticketOrganizationIds) || ticketOrganizationIds.some(ticketOrganizationId => !ticketOrganizationId)) return false

return await checkPermissionsInEmployedOrRelatedOrganizations(context, user, ticketOrganizationIds, 'canManageTickets')
}

const changedInaccessibleFields = Object.keys(originalInput).some(field => INACCESSIBLE_TICKET_FIELDS_FOR_MANAGE_BY_STAFF.includes(field))
if (changedInaccessibleFields) return false

Expand Down
19 changes: 19 additions & 0 deletions apps/condo/domains/ticket/constants.js
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,23 @@ const MAX_COMMENT_LENGTH = 700

const DEFAULT_DEFERRED_DAYS = 30

/**
* @example
* updateTickets - Query name in Ticket.updateMany. Usage in Ticket.test.js
* updateTicketsForReassignmentEmployee - Query name in query/Ticket.graphql. Usage in DeleteEmployeeButtonWithReassignmentModal.jsx
*/
const DISABLE_PUSH_NOTIFICATION_FOR_OPERATIONS = [
'updateTickets',
'updateTicketsForReassignmentEmployee',
]

const BULK_UPDATE_ALLOWED_FIELDS = [
'executor',
'assignee',
'dv',
'sender',
]

module.exports = {
NEW_OR_REOPENED_STATUS_TYPE,
PROCESSING_STATUS_TYPE,
Expand All @@ -72,4 +89,6 @@ module.exports = {
MAX_COMMENT_LENGTH,
DEFAULT_DEFERRED_DAYS,
MAX_DETAILS_LENGTH,
DISABLE_PUSH_NOTIFICATION_FOR_OPERATIONS,
BULK_UPDATE_ALLOWED_FIELDS,
}
Loading

0 comments on commit a5979e6

Please sign in to comment.