Skip to content

Commit

Permalink
[Feature] Implement applicant deletion (#310)
Browse files Browse the repository at this point in the history
* Create deleteApplicant resolver

* Improve error message in deleteApplicant resolver

* Implement delete applicant UI

* Remove TODO comment

* Change delete permit holder button text

* Add doc string for deleteApplicant resolver

* Cleanup newApplication, renewalApplication, and replacementApplication records when deleting applicant
  • Loading branch information
sherryhli authored and leogjhuang committed Oct 4, 2023
1 parent ace642c commit 4ff54d9
Show file tree
Hide file tree
Showing 9 changed files with 306 additions and 1 deletion.
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 */
}}
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 (
_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 @@ -160,6 +161,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 @@ -460,6 +460,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 @@ -590,6 +600,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 @@ -655,6 +666,11 @@ export type MutationUpdateApplicantNotesArgs = {
};


export type MutationDeleteApplicantArgs = {
input: DeleteApplicantInput;
};


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

0 comments on commit 4ff54d9

Please sign in to comment.