Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(condo): DOMA-10516 reassign employee ticket #5563

Merged
merged 26 commits into from
Dec 16, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
1a631c6
feat(condo): DOMA-10319 first commit
tolmachev21 Nov 27, 2024
b38f048
feat(condo): add access for bulk operation in ticket
tolmachev21 Dec 2, 2024
4c7ad62
feat(condo): main commit
tolmachev21 Dec 2, 2024
1f4c8bc
feat(condo): small commit
tolmachev21 Dec 2, 2024
e7d952c
feat(condo): DOMA-10516 delete unusage icons
tolmachev21 Dec 3, 2024
b33ab74
feat(condo): DOMA-10516 add new logic
tolmachev21 Dec 3, 2024
49cf1b0
feat(condo): DOMA-10516 add types in components
tolmachev21 Dec 5, 2024
169276b
feat(condo): DOMA-10516 change name and rewrite access
tolmachev21 Dec 9, 2024
e152999
feat(condo): DOMA-10319 first commit
tolmachev21 Nov 27, 2024
461b4fc
feat(condo): add access for bulk operation in ticket
tolmachev21 Dec 2, 2024
4fa8901
feat(condo): main commit
tolmachev21 Dec 2, 2024
a561650
feat(condo): small commit
tolmachev21 Dec 2, 2024
1d5d49b
feat(condo): DOMA-10516 delete unusage icons
tolmachev21 Dec 3, 2024
774bc1e
feat(condo): DOMA-10516 add new logic
tolmachev21 Dec 3, 2024
25c3c4f
feat(condo): DOMA-10516 add types in components
tolmachev21 Dec 5, 2024
1b7713f
feat(condo): DOMA-10516 change name and rewrite access
tolmachev21 Dec 9, 2024
856ebca
feat(condo): DOMA-10516 add more test cases and rewrite access logic
tolmachev21 Dec 11, 2024
5cfef78
feat(condo): DOMA-10516 remove redirent
tolmachev21 Dec 12, 2024
d8a2549
feat(condo): DOMA-10516 add callcenter logic for this feature
tolmachev21 Dec 12, 2024
00d0910
feat(condo): DOMA-10516 add types
tolmachev21 Dec 12, 2024
c041916
feat(condo): DOMA-10516 fix review
tolmachev21 Dec 14, 2024
ec520ce
feat(condo): DOMA-10516 update main
tolmachev21 Dec 14, 2024
4f76855
feat(condo): DOMA-10516 add callcenter commit
tolmachev21 Dec 14, 2024
cc611a1
feat(condo): DOMA-10516 wait persistor
tolmachev21 Dec 16, 2024
a75f994
feat(condo): DOMA-10516 wait persistor
tolmachev21 Dec 16, 2024
4783488
feat(condo): DOMA-10516 add commit from callcenter
tolmachev21 Dec 16, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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'
pahaz marked this conversation as resolved.
Show resolved Hide resolved
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

just a question, why do we want to use feature flags for this feature?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If they start dudosing us with this feature, we can turn it off


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 },
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So, if error happens, whole page will fail?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No, then it will work OnErrorMsg

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 },
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

^^

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) => {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There are wrong types here, but it's ok for now. Will need to be fixed when these search utilities are updated.

Not in this pr

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Okey, thanks for comment

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)
tolmachev21 marked this conversation as resolved.
Show resolved Hide resolved

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

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We may have crooked bulk updates of employees. This is true. I want to roll out this feature and then take on the task of updating accesses. However, if this is really bad, then I can make a crutch. We will check that only one user is updated. And only he will be in the check for compliance with the employee's organization - the ticket organization

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let me know what is the best way to proceed

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

const ticketOrganizationIds = uniq(tickets.map(ticket => get(ticket, 'organization', null)))
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why not let only update stuff from one org?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There may be a case when the CC employees want to remove someone and at the same time reassign all of his requests to another CC employee. The employee being removed may have requests from different organizations, which is why this operation will be rejected

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',
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We don't have this query

Suggested change
'updateTickets',

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I need this query for tests. Frontend queries cannot be used in tests

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I need this query for tests. Frontend queries cannot be used in tests

You can write the query you need for tests. You should to make sure that "updateTicketsForReassignmentEmployee" works correctly

'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
Loading