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

[Feature] Implement applicant deletion #310

Merged
merged 7 commits into from
Oct 2, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
29 changes: 29 additions & 0 deletions components/admin/permit-holders/Header.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { useRouter } from 'next/router';
import {
Alert,
AlertIcon,
Expand All @@ -18,6 +19,7 @@ import { ChevronDownIcon, ChevronLeftIcon } from '@chakra-ui/icons'; // Chakra U
import Link from 'next/link'; // Link
import { ApplicantStatus } from '@lib/graphql/types';
import PermitHolderStatusBadge from '@components/admin/PermitHolderStatusBadge';
import ConfirmDeleteApplicantModal from '@components/admin/permit-holders/table/ConfirmDeleteApplicantModal';
import SetPermitHolderToInactiveModal from '@components/admin/permit-holders/table/ConfirmSetInactiveModal';
import SetPermitHolderToActiveModal from '@components/admin/permit-holders/table/ConfirmSetActiveModal';
import AdditionalNotesModal from '@components/admin/permit-holders/additional-notes/Modal';
Expand All @@ -37,13 +39,22 @@ export default function PermitHolderHeader({
applicant: { id, name, status, inactiveReason, notes },
refetch,
}: PermitHolderHeaderProps) {
const router = useRouter();

// Set Permit Holder Inactive/Active modal state
const {
isOpen: isSetPermitHolderStatusModalOpen,
onOpen: onOpenSetPermitHolderStatusModal,
onClose: onCloseSetPermitHolderStatusModal,
} = useDisclosure();

// Delete applicant modal state
const {
isOpen: isDeleteApplicantModalOpen,
onOpen: onOpenDeleteApplicantModal,
onClose: onCloseDeleteApplicantModal,
} = useDisclosure();

// Additional notes modal state
const {
isOpen: isNotesModalOpen,
Expand Down Expand Up @@ -97,6 +108,13 @@ export default function PermitHolderHeader({
>
{`Set as ${status === 'ACTIVE' ? 'Inactive' : 'Active'}`}
</MenuItem>
<MenuItem
color="text.critical"
textStyle="button-regular"
onClick={onOpenDeleteApplicantModal}
>
{'Delete Permit Holder'}
</MenuItem>
</MenuList>
</Menu>
</Box>
Expand Down Expand Up @@ -143,6 +161,17 @@ export default function PermitHolderHeader({
onClose={onCloseSetPermitHolderStatusModal}
/>
)}
<ConfirmDeleteApplicantModal
isOpen={isDeleteApplicantModalOpen}
applicantId={id}
refetch={() => {
/* Do not refetch, redirect to permit holders page */
Copy link
Member

Choose a reason for hiding this comment

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

Why do we redirect it seems to repopulate the correct list without it?

Copy link
Member Author

Choose a reason for hiding this comment

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

Without redirecting we'd stay on the info page of the permit holder who'd just been deleted. The redirect returns us to the page with the table of all permit holders.

}}
onClose={() => {
onCloseDeleteApplicantModal();
router.push('/admin/permit-holders');
}}
/>

{/* Additional notes modal */}
<AdditionalNotesModal
Expand Down
115 changes: 115 additions & 0 deletions components/admin/permit-holders/table/ConfirmDeleteApplicantModal.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
import { SyntheticEvent } from 'react';
import {
Modal,
ModalOverlay,
ModalContent,
ModalHeader,
ModalFooter,
ModalBody,
Button,
Text,
useToast,
} from '@chakra-ui/react';
import { useMutation } from '@tools/hooks/graphql';
import {
DeleteApplicantRequest,
DeleteApplicantResponse,
DELETE_APPLICANT,
} from '@tools/admin/permit-holders/permit-holders-table';

type ConfirmDeleteApplicantModalProps = {
readonly isOpen: boolean;
readonly applicantId: number;
readonly refetch: () => void;
readonly onClose: () => void;
};

/**
* Delete applicant confirmation modal
*/
export default function ConfirmDeleteApplicantModal({
isOpen,
applicantId,
refetch,
onClose,
}: ConfirmDeleteApplicantModalProps) {
const toast = useToast();

// API call to deleteApplicant
const [deleteApplicant] = useMutation<DeleteApplicantResponse, DeleteApplicantRequest>(
DELETE_APPLICANT,
{
onCompleted: data => {
if (data.deleteApplicant.ok) {
toast({
status: 'success',
description: `Permit holder successfully deleted.`,
});
} else {
toast({
status: 'error',
description: `Failed to delete permit holder.`,
});
}
},
}
);

// Close modal handler
const handleClose = () => {
onClose();
};

// Sets permit holder status to inactive and closes modal
const handleSubmit = async (event: SyntheticEvent) => {
event.preventDefault();
await deleteApplicant({
variables: { input: { id: applicantId } },
});
refetch();
onClose();
};

return (
<Modal isCentered={true} isOpen={isOpen} onClose={handleClose} size="3xl">
<ModalOverlay />
<form onSubmit={handleSubmit}>
<ModalContent paddingX="20px">
<ModalHeader paddingTop="20px" paddingBottom="12px" paddingX="4px">
<Text as="h2" textStyle="display-medium-bold">
{`Delete Permit Holder`}
</Text>
</ModalHeader>
<ModalBody paddingBottom="0px" paddingX="4px">
<Text as="p" textStyle="body-regular" marginBottom="20px">
Are you sure you want to delete this permit holder? This action is irreversible.
</Text>
<Text as="p" textStyle="body-regular" marginBottom="20px">
<b>
All of this user&apos;s data, including associated applications and permits will be
permanently deleted.
</b>
</Text>
<Text as="p" textStyle="body-regular" marginBottom="20px">
If you would like to retain this data, please cancel and mark this user as inactive
instead.
</Text>
</ModalBody>
<ModalFooter paddingY="16px">
<Button onClick={onClose} colorScheme="gray" variant="solid">
{'Cancel'}
</Button>
<Button
bg="secondary.critical"
_hover={{ bg: 'secondary.criticalHover' }}
type="submit"
ml={'12px'}
>
{'Delete Permit Holder'}
</Button>
</ModalFooter>
</ModalContent>
</form>
</Modal>
);
}
80 changes: 80 additions & 0 deletions lib/applicants/resolvers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ import { Resolver } from '@lib/graphql/resolvers'; // Resolver type
import { getMostRecentPermit } from '@lib/applicants/utils'; // Applicant utils
import {
Applicant,
DeleteApplicantResult,
MutationDeleteApplicantArgs,
MutationSetApplicantAsActiveArgs,
MutationSetApplicantAsInactiveArgs,
MutationUpdateApplicantDoctorInformationArgs,
Expand Down Expand Up @@ -710,3 +712,81 @@ export const updateApplicantNotes: Resolver<

return { ok: true, error: null };
};

/**
* Deletes the applicant with the provided ID, also deletes all associated
* data (permits, medical information, applications, and guardian).
* @returns Status of the operation (ok)
*/
export const deleteApplicant: Resolver<MutationDeleteApplicantArgs, DeleteApplicantResult> = async (
sherryhli marked this conversation as resolved.
Show resolved Hide resolved
_parent,
args,
{ prisma, logger }
) => {
const id = args.input.id;

try {
const applicant = await prisma.applicant.findUnique({
where: {
id,
},
rejectOnNotFound: true,
});

const applications = await prisma.application.findMany({
where: {
applicantId: applicant.id,
},
});
const applicationIds = applications.map(application => application.id);
const applicationProcessingIds = applications.map(
application => application.applicationProcessingId
);

// Ideally, we'd cascade the delete to relations with referential actions
// https://www.prisma.io/docs/concepts/components/prisma-schema/relations/referential-actions
// However, that would require making a schema change which is infeasible at this time
const cleanupOperations: any[] = [
prisma.permit.deleteMany({ where: { applicantId: applicant.id } }),
prisma.applicant.delete({ where: { id } }),
prisma.medicalInformation.delete({ where: { id: applicant.medicalInformationId } }),
];

if (applicant.guardianId !== null) {
cleanupOperations.push(prisma.guardian.delete({ where: { id: applicant.guardianId } }));
}

applicationIds.forEach(applicationId => {
cleanupOperations.push(prisma.newApplication.deleteMany({ where: { applicationId } }));
cleanupOperations.push(prisma.renewalApplication.deleteMany({ where: { applicationId } }));
cleanupOperations.push(
prisma.replacementApplication.deleteMany({ where: { applicationId } })
);
cleanupOperations.push(prisma.application.deleteMany({ where: { id: applicationId } }));
});

applicationProcessingIds.forEach(applicationProcessingId => {
cleanupOperations.push(
prisma.applicationProcessing.deleteMany({
where: {
id: applicationProcessingId,
},
})
);
});

await prisma.$transaction(cleanupOperations);
} catch (err) {
if (err instanceof Prisma.PrismaClientKnownRequestError) {
return {
ok: false,
error: err.message,
};
}

logger.error({ error: err }, 'Unknown error occurred when attempting to delete applicant');
throw new ApolloError('Unable to delete applicant after encountering unknown error');
}

return { ok: true, error: null };
};
11 changes: 11 additions & 0 deletions lib/applicants/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -209,4 +209,15 @@ export default gql`
ok: Boolean!
error: String
}

# Delete applicant
input DeleteApplicantInput {
# Applicant ID
id: Int!
}

type DeleteApplicantResult {
ok: Boolean!
error: String
}
`;
2 changes: 2 additions & 0 deletions lib/graphql/resolvers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
import {
applicant,
applicants,
deleteApplicant,
updateApplicantGeneralInformation,
updateApplicantDoctorInformation,
updateApplicantGuardianInformation,
Expand Down Expand Up @@ -159,6 +160,7 @@ const resolvers = {
setApplicantAsActive: authorize(setApplicantAsActive, ['SECRETARY']),
setApplicantAsInactive: authorize(setApplicantAsInactive, ['SECRETARY']),
verifyIdentity,
deleteApplicant: authorize(deleteApplicant, ['SECRETARY']),

// Applications
createNewApplication: authorize(createNewApplication, ['SECRETARY']),
Expand Down
1 change: 1 addition & 0 deletions lib/graphql/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ export default gql`
setApplicantAsInactive(input: SetApplicantAsInactiveInput!): SetApplicantAsInactiveResult
verifyIdentity(input: VerifyIdentityInput!): VerifyIdentityResult!
updateApplicantNotes(input: UpdateApplicantNotesInput!): UpdateApplicantNotesResult!
deleteApplicant(input: DeleteApplicantInput!): DeleteApplicantResult!

# Applications
createNewApplication(input: CreateNewApplicationInput!): CreateNewApplicationResult
Expand Down
16 changes: 16 additions & 0 deletions lib/graphql/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -450,6 +450,16 @@ export type CreateReplacementApplicationResult = {
};


export type DeleteApplicantInput = {
id: Scalars['Int'];
};

export type DeleteApplicantResult = {
__typename?: 'DeleteApplicantResult';
ok: Scalars['Boolean'];
error: Maybe<Scalars['String']>;
};

export type DeleteEmployeeInput = {
id: Scalars['Int'];
};
Expand Down Expand Up @@ -580,6 +590,7 @@ export type Mutation = {
setApplicantAsInactive: Maybe<SetApplicantAsInactiveResult>;
verifyIdentity: VerifyIdentityResult;
updateApplicantNotes: UpdateApplicantNotesResult;
deleteApplicant: DeleteApplicantResult;
createNewApplication: Maybe<CreateNewApplicationResult>;
createRenewalApplication: Maybe<CreateRenewalApplicationResult>;
createExternalRenewalApplication: CreateExternalRenewalApplicationResult;
Expand Down Expand Up @@ -644,6 +655,11 @@ export type MutationUpdateApplicantNotesArgs = {
};


export type MutationDeleteApplicantArgs = {
input: DeleteApplicantInput;
};


export type MutationCreateNewApplicationArgs = {
input: CreateNewApplicationInput;
};
Expand Down
Loading
Loading