diff --git a/app/(gcforms)/[locale]/(form administration)/form-builder/[id]/responses/[[...statusFilter]]/actions.ts b/app/(gcforms)/[locale]/(form administration)/form-builder/[id]/responses/[[...statusFilter]]/actions.ts index cc79c4a3d3..7da1ca85de 100644 --- a/app/(gcforms)/[locale]/(form administration)/form-builder/[id]/responses/[[...statusFilter]]/actions.ts +++ b/app/(gcforms)/[locale]/(form administration)/form-builder/[id]/responses/[[...statusFilter]]/actions.ts @@ -24,6 +24,7 @@ import { retrieveSubmissions, updateLastDownloadedBy, submissionTypeExists, + retrieveSubmissionRemovalDate, } from "@lib/vault"; import { transform as csvTransform } from "@lib/responseDownloadFormats/csv"; import { transform as htmlAggregatedTransform } from "@lib/responseDownloadFormats/html-aggregated"; @@ -228,119 +229,121 @@ export const getSubmissionsByFormat = async ({ const allowGroupsFlag = allowGrouping(); // Get responses into a ResponseSubmission array containing questions and answers that can be easily transformed - const responses = queryResult.map((item) => { - const submission = Object.entries(JSON.parse(String(item.formSubmission))).map( - ([questionId, answer]) => { - const question = fullFormTemplate.form.elements.find( - (element) => element.id === Number(questionId) - ); - - // Handle Dynamic Rows - if (question?.type === FormElementTypes.dynamicRow && answer instanceof Array) { - return { - questionId: question.id, - type: question?.type, - questionEn: question?.properties.titleEn, - questionFr: question?.properties.titleFr, - answer: answer.map((item) => { - return Object.values(item).map((value, index) => { - if (question?.properties.subElements) { - const subQuestion = question?.properties.subElements[index]; - return { - questionId: question?.id, - type: subQuestion.type, - questionEn: subQuestion.properties.titleEn, - questionFr: subQuestion.properties.titleFr, - answer: getAnswerAsString(subQuestion, value), - ...(subQuestion.type === "formattedDate" && { - dateFormat: subQuestion.properties.dateFormat, - }), - }; - } - }); - }), - } as Answer; - } - - // Handle "Split" AddressComplete in a similiar manner to dynamic fields. - if ( - question?.type === FormElementTypes.addressComplete && - question.properties.addressComponents?.splitAddress === true - ) { - const addressObject = JSON.parse(answer as string) as AddressElements; + const responses = queryResult + .sort((a, b) => a.createdAt - b.createdAt) + .map((item) => { + const submission = Object.entries(JSON.parse(String(item.formSubmission))).map( + ([questionId, answer]) => { + const question = fullFormTemplate.form.elements.find( + (element) => element.id === Number(questionId) + ); - const questionComponents = question.properties.addressComponents as AddressComponents; - if (questionComponents.canadianOnly) { - addressObject.country = "CAN"; + // Handle Dynamic Rows + if (question?.type === FormElementTypes.dynamicRow && answer instanceof Array) { + return { + questionId: question.id, + type: question?.type, + questionEn: question?.properties.titleEn, + questionFr: question?.properties.titleFr, + answer: answer.map((item) => { + return Object.values(item).map((value, index) => { + if (question?.properties.subElements) { + const subQuestion = question?.properties.subElements[index]; + return { + questionId: question?.id, + type: subQuestion.type, + questionEn: subQuestion.properties.titleEn, + questionFr: subQuestion.properties.titleFr, + answer: getAnswerAsString(subQuestion, value), + ...(subQuestion.type === "formattedDate" && { + dateFormat: subQuestion.properties.dateFormat, + }), + }; + } + }); + }), + } as Answer; } - const extraTranslations = { - streetAddress: { - en: tEn("addressComponents.streetAddress"), - fr: tFr("addressComponents.streetAddress"), - }, - city: { - en: tEn("addressComponents.city"), - fr: tFr("addressComponents.city"), - }, - province: { - en: tEn("addressComponents.province"), - fr: tFr("addressComponents.province"), - }, - postalCode: { - en: tEn("addressComponents.postalCode"), - fr: tFr("addressComponents.postalCode"), - }, - country: { - en: tEn("addressComponents.country"), - fr: tFr("addressComponents.country"), - }, - }; - - const reviewElements = getAddressAsAnswerElements( - question, - addressObject, - extraTranslations - ); - - const addressElements = [reviewElements]; + // Handle "Split" AddressComplete in a similiar manner to dynamic fields. + if ( + question?.type === FormElementTypes.addressComplete && + question.properties.addressComponents?.splitAddress === true + ) { + const addressObject = JSON.parse(answer as string) as AddressElements; + + const questionComponents = question.properties.addressComponents as AddressComponents; + if (questionComponents.canadianOnly) { + addressObject.country = "CAN"; + } + + const extraTranslations = { + streetAddress: { + en: tEn("addressComponents.streetAddress"), + fr: tFr("addressComponents.streetAddress"), + }, + city: { + en: tEn("addressComponents.city"), + fr: tFr("addressComponents.city"), + }, + province: { + en: tEn("addressComponents.province"), + fr: tFr("addressComponents.province"), + }, + postalCode: { + en: tEn("addressComponents.postalCode"), + fr: tFr("addressComponents.postalCode"), + }, + country: { + en: tEn("addressComponents.country"), + fr: tFr("addressComponents.country"), + }, + }; + + const reviewElements = getAddressAsAnswerElements( + question, + addressObject, + extraTranslations + ); + + const addressElements = [reviewElements]; + + return { + questionId: question.id, + type: FormElementTypes.address, + questionEn: question?.properties.titleEn, + questionFr: question?.properties.titleFr, + answer: addressElements, + } as Answer; + } + // return the final answer object return { - questionId: question.id, - type: FormElementTypes.address, + questionId: question?.id, + type: question?.type, questionEn: question?.properties.titleEn, questionFr: question?.properties.titleFr, - answer: addressElements, + answer: getAnswerAsString(question, answer), + ...(question?.type === "formattedDate" && { + dateFormat: question.properties.dateFormat, + }), } as Answer; } - - // return the final answer object - return { - questionId: question?.id, - type: question?.type, - questionEn: question?.properties.titleEn, - questionFr: question?.properties.titleFr, - answer: getAnswerAsString(question, answer), - ...(question?.type === "formattedDate" && { - dateFormat: question.properties.dateFormat, - }), - } as Answer; + ); + let sorted: Answer[]; + if (allowGroupsFlag && formHasGroups(fullFormTemplate.form)) { + sorted = sortByGroups({ form: fullFormTemplate.form, elements: submission }); + } else { + sorted = sortByLayout({ layout: fullFormTemplate.form.layout, elements: submission }); } - ); - let sorted: Answer[]; - if (allowGroupsFlag && formHasGroups(fullFormTemplate.form)) { - sorted = sortByGroups({ form: fullFormTemplate.form, elements: submission }); - } else { - sorted = sortByLayout({ layout: fullFormTemplate.form.layout, elements: submission }); - } - return { - id: item.name, - createdAt: parseInt(item.createdAt.toString()), - confirmationCode: item.confirmationCode, - answers: sorted, - }; - }) as FormResponseSubmissions["submissions"]; + return { + id: item.name, + createdAt: parseInt(item.createdAt.toString()), + confirmationCode: item.confirmationCode, + answers: sorted, + }; + }) as FormResponseSubmissions["submissions"]; if (!responses.length) { throw new FormBuilderError("No responses found.", FormServerErrorCodes.NO_RESPONSES_FOUND); @@ -446,3 +449,13 @@ export const unConfirmedResponsesExist = async (formId: string) => { return { error: "There was an error. Please try again later." } as ServerActionError; } }; + +export const getSubmissionRemovalDate = async (formId: string, submissionName: string) => { + try { + const { ability } = await authCheckAndRedirect(); + return retrieveSubmissionRemovalDate(ability, formId, submissionName); + } catch (error) { + // Throw sanitized error back to client + return { error: "There was an error. Please try again later." } as ServerActionError; + } +}; diff --git a/app/(gcforms)/[locale]/(form administration)/form-builder/[id]/responses/[[...statusFilter]]/components/CheckAll.tsx b/app/(gcforms)/[locale]/(form administration)/form-builder/[id]/responses/[[...statusFilter]]/components/CheckAll.tsx index 383351819b..1d5adebd4e 100644 --- a/app/(gcforms)/[locale]/(form administration)/form-builder/[id]/responses/[[...statusFilter]]/components/CheckAll.tsx +++ b/app/(gcforms)/[locale]/(form administration)/form-builder/[id]/responses/[[...statusFilter]]/components/CheckAll.tsx @@ -1,6 +1,6 @@ "use client"; import { CheckAllIcon, CheckBoxEmptyIcon, CheckIndeterminateIcon } from "@serverComponents/icons"; -import { VaultSubmissionList } from "@lib/types"; +import { VaultSubmissionOverview } from "@lib/types"; import React from "react"; import { useTranslation } from "@i18n/client"; import { ReducerTableItemsActions, TableActions } from "./DownloadTableReducer"; @@ -15,7 +15,7 @@ export const CheckAll = ({ tableItems: { checkedItems: Map; statusItems: Map; - allItems: VaultSubmissionList[]; + allItems: VaultSubmissionOverview[]; numberOfOverdueResponses: number; }; tableItemsDispatch: React.Dispatch; diff --git a/app/(gcforms)/[locale]/(form administration)/form-builder/[id]/responses/[[...statusFilter]]/components/DownloadTable.tsx b/app/(gcforms)/[locale]/(form administration)/form-builder/[id]/responses/[[...statusFilter]]/components/DownloadTable.tsx index f76c4dd737..543efc5a1a 100644 --- a/app/(gcforms)/[locale]/(form administration)/form-builder/[id]/responses/[[...statusFilter]]/components/DownloadTable.tsx +++ b/app/(gcforms)/[locale]/(form administration)/form-builder/[id]/responses/[[...statusFilter]]/components/DownloadTable.tsx @@ -1,11 +1,5 @@ import React, { useEffect, useReducer, useState } from "react"; -import { - NagwareResult, - TypeOmit, - VaultStatus, - VaultSubmission, - VaultSubmissionList, -} from "@lib/types"; +import { NagwareResult, VaultStatus, VaultSubmissionOverview } from "@lib/types"; import { useTranslation } from "@i18n/client"; import { SkipLinkReusable } from "@clientComponents/globals/SkipLinkReusable"; import Link from "next/link"; @@ -29,7 +23,7 @@ import { StatusFilter } from "../types"; import { useFormBuilderConfig } from "@lib/hooks/useFormBuilderConfig"; interface DownloadTableProps { - vaultSubmissions: VaultSubmissionList[]; + vaultSubmissions: VaultSubmissionOverview[]; formName: string; formId: string; nagwareResult: NagwareResult | null; @@ -83,9 +77,7 @@ export const DownloadTable = ({ } }; - const blockDownload = ( - submission: TypeOmit - ) => { + const blockDownload = (submission: VaultSubmissionOverview) => { const daysPast = getDaysPassed(submission.createdAt); if ( diff --git a/app/(gcforms)/[locale]/(form administration)/form-builder/[id]/responses/[[...statusFilter]]/components/DownloadTableReducer.ts b/app/(gcforms)/[locale]/(form administration)/form-builder/[id]/responses/[[...statusFilter]]/components/DownloadTableReducer.ts index d703d83e33..42aeb1e49d 100644 --- a/app/(gcforms)/[locale]/(form administration)/form-builder/[id]/responses/[[...statusFilter]]/components/DownloadTableReducer.ts +++ b/app/(gcforms)/[locale]/(form administration)/form-builder/[id]/responses/[[...statusFilter]]/components/DownloadTableReducer.ts @@ -1,5 +1,5 @@ import { getDaysPassed } from "@lib/client/clientHelpers"; -import { VaultSubmissionList, VaultStatus } from "@lib/types"; +import { VaultSubmissionOverview, VaultStatus } from "@lib/types"; export enum TableActions { UPDATE = "UPDATE", @@ -9,7 +9,7 @@ export enum TableActions { interface ReducerTableItemsState { statusItems: Map; checkedItems: Map; - allItems: VaultSubmissionList[]; + allItems: VaultSubmissionOverview[]; numberOfOverdueResponses: number; overdueAfter: number | undefined; } @@ -21,12 +21,12 @@ export interface ReducerTableItemsActions { name: string; checked: boolean; }; - vaultSubmissions?: VaultSubmissionList[]; + vaultSubmissions?: VaultSubmissionOverview[]; }; } export const initialTableItemsState = ( - vaultSubmissions: VaultSubmissionList[], + vaultSubmissions: VaultSubmissionOverview[], overdueAfter: number | undefined ) => { return { diff --git a/app/(gcforms)/[locale]/(form administration)/form-builder/[id]/responses/[[...statusFilter]]/components/NextStep.tsx b/app/(gcforms)/[locale]/(form administration)/form-builder/[id]/responses/[[...statusFilter]]/components/NextStep.tsx index 0bafc69e2d..7caa3bf433 100644 --- a/app/(gcforms)/[locale]/(form administration)/form-builder/[id]/responses/[[...statusFilter]]/components/NextStep.tsx +++ b/app/(gcforms)/[locale]/(form administration)/form-builder/[id]/responses/[[...statusFilter]]/components/NextStep.tsx @@ -1,9 +1,11 @@ import { useTranslation } from "@i18n/client"; import { getDaysPassed } from "@lib/client/clientHelpers"; -import { TypeOmit, VaultSubmission } from "@lib/types"; +import { VaultSubmissionOverview } from "@lib/types"; import { ExclamationIcon } from "@serverComponents/icons"; -import React from "react"; +import React, { useEffect, useState } from "react"; import { StatusFilter } from "../types"; +import Skeleton from "react-loading-skeleton"; +import { getSubmissionRemovalDate } from "../actions"; const ExclamationText = ({ text, @@ -22,6 +24,36 @@ const ExclamationText = ({ ); }; +const RemovalDateLabel = ({ submission }: { submission: VaultSubmissionOverview }) => { + const { t } = useTranslation("form-builder-responses"); + const [label, setLabel] = useState(null); + + const getRemovalByMessage = (removalAt?: Date | number) => { + const daysLeft = removalAt && getDaysPassed(removalAt); + if (daysLeft && daysLeft > 0) { + return t("downloadResponsesTable.status.removeWithinXDays", { daysLeft }); + } + return ""; + }; + + useEffect(() => { + getSubmissionRemovalDate(submission.formID, submission.name).then((value) => { + if (typeof value === "number") { + setLabel(getRemovalByMessage(value)); + } else { + setLabel("-"); + } + }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + if (label === null) { + return ; + } + + return

{label}

; +}; + export const NextStep = ({ statusFilter, submission, @@ -29,7 +61,7 @@ export const NextStep = ({ removedRows, }: { statusFilter: StatusFilter; - submission: TypeOmit; + submission: VaultSubmissionOverview; overdueAfter: number | undefined; removedRows: string[]; }) => { @@ -59,14 +91,6 @@ export const NextStep = ({ return t("downloadResponsesTable.status.confirmWithinXDays", { daysLeft }); }; - const getRemovalByMessage = (removalAt?: Date | number) => { - const daysLeft = removalAt && getDaysPassed(removalAt); - if (daysLeft && daysLeft > 0) { - return t("downloadResponsesTable.status.removeWithinXDays", { daysLeft }); - } - return ""; - }; - return ( <> {statusFilter === StatusFilter.NEW && ( @@ -85,9 +109,7 @@ export const NextStep = ({ {statusFilter === StatusFilter.DOWNLOADED && (

{getSignOffByMessage(daysPassed, overdueAfter)}

)} - {statusFilter === StatusFilter.CONFIRMED && ( -

{getRemovalByMessage(submission.removedAt)}

- )} + {statusFilter === StatusFilter.CONFIRMED && } {statusFilter === StatusFilter.PROBLEM && (

{t("supportWillContact")} diff --git a/app/(gcforms)/[locale]/(form administration)/form-builder/[id]/responses/[[...statusFilter]]/components/Responses.tsx b/app/(gcforms)/[locale]/(form administration)/form-builder/[id]/responses/[[...statusFilter]]/components/Responses.tsx index d52f8d8f77..4a7ac0bf80 100644 --- a/app/(gcforms)/[locale]/(form administration)/form-builder/[id]/responses/[[...statusFilter]]/components/Responses.tsx +++ b/app/(gcforms)/[locale]/(form administration)/form-builder/[id]/responses/[[...statusFilter]]/components/Responses.tsx @@ -9,7 +9,7 @@ import { DownloadTable } from "./DownloadTable"; import { NoResponses } from "./NoResponses"; import { useTemplateStore } from "@lib/store/useTemplateStore"; import { TitleAndDescription } from "./TitleAndDescription"; -import { NagLevel, VaultSubmissionList } from "@lib/types"; +import { NagLevel, VaultSubmissionOverview } from "@lib/types"; import { RetrievalError } from "./RetrievalError"; import { fetchSubmissions } from "../actions"; import { StatusFilter } from "../types"; @@ -47,7 +47,7 @@ export const Responses = ({ const [state, setState] = useState<{ loading: boolean; - submissions: VaultSubmissionList[]; + submissions: VaultSubmissionOverview[]; lastEvaluatedKey: Record | null | undefined; error: boolean; }>({ loading: true, submissions: [], lastEvaluatedKey: null, error: false }); diff --git a/app/(gcforms)/[locale]/(form administration)/form-builder/[id]/responses/[[...statusFilter]]/components/tests/DownloadTable.cy.tsx b/app/(gcforms)/[locale]/(form administration)/form-builder/[id]/responses/[[...statusFilter]]/components/tests/DownloadTable.cy.tsx index 912fa3dc15..ee7c153119 100644 --- a/app/(gcforms)/[locale]/(form administration)/form-builder/[id]/responses/[[...statusFilter]]/components/tests/DownloadTable.cy.tsx +++ b/app/(gcforms)/[locale]/(form administration)/form-builder/[id]/responses/[[...statusFilter]]/components/tests/DownloadTable.cy.tsx @@ -10,7 +10,7 @@ import React from "react"; import { DownloadTable } from "../DownloadTable"; import Router from "next/router"; -import { NagLevel, VaultStatus, VaultSubmissionList } from "@lib/types"; +import { NagLevel, VaultStatus, VaultSubmissionOverview } from "@lib/types"; const today = new Date("July 16, 2023").valueOf(); @@ -38,7 +38,7 @@ describe("", () => { }); it.skip("Blocks download of newer items when one is overdue by 35 days (account restricted)", () => { - const vaultSubmissions: VaultSubmissionList[] = [ + const vaultSubmissions: VaultSubmissionOverview[] = [ { formID: "clg17xha50008efkgfgxa8l4f", status: VaultStatus.NEW, @@ -136,7 +136,7 @@ describe("", () => { }); it.skip("Warns for overdue submissions (not restricted)", () => { - const vaultSubmissions: VaultSubmissionList[] = [ + const vaultSubmissions: VaultSubmissionOverview[] = [ { formID: "clg17xha50008efkgfgxa8l4f", status: VaultStatus.NEW, diff --git a/app/(gcforms)/[locale]/(form administration)/form-builder/[id]/responses/[[...statusFilter]]/components/tests/DownloadTable.test.tsx b/app/(gcforms)/[locale]/(form administration)/form-builder/[id]/responses/[[...statusFilter]]/components/tests/DownloadTable.test.tsx index 8cfd215c94..8f3490abcd 100644 --- a/app/(gcforms)/[locale]/(form administration)/form-builder/[id]/responses/[[...statusFilter]]/components/tests/DownloadTable.test.tsx +++ b/app/(gcforms)/[locale]/(form administration)/form-builder/[id]/responses/[[...statusFilter]]/components/tests/DownloadTable.test.tsx @@ -4,7 +4,7 @@ import React from "react"; import { act, render } from "@testing-library/react"; import { DownloadTable } from "../DownloadTable"; -import { VaultSubmissionList, VaultStatus } from "@lib/types"; +import { VaultSubmissionOverview, VaultStatus } from "@lib/types"; import axios from "axios"; import { StatusFilter } from "../../types"; @@ -62,258 +62,143 @@ describe("Download Table", () => { }); // Test Data taken from a local vault response on 2023-04-06 -const vaultSubmissions: VaultSubmissionList[] = [ +const vaultSubmissions: VaultSubmissionOverview[] = [ { formID: "clg17xha50008efkgfgxa8l4f", status: VaultStatus.NEW, - securityAttribute: "Unclassified", name: "03-04-0022", createdAt: 1680549853671, - lastDownloadedBy: "", - confirmedAt: undefined, - downloadedAt: undefined, - removedAt: undefined, }, { formID: "clg17xha50008efkgfgxa8l4f", status: VaultStatus.CONFIRMED, - securityAttribute: "Unclassified", name: "03-04-0c36", createdAt: 1680549843373, - lastDownloadedBy: "peter.thiessen@cds-snc.ca", - confirmedAt: 1680551168405, - downloadedAt: 1680550035137, - removedAt: 1683143168405, }, { formID: "clg17xha50008efkgfgxa8l4f", status: VaultStatus.DOWNLOADED, - securityAttribute: "Unclassified", name: "03-04-0caa", createdAt: 1680549836782, - lastDownloadedBy: "peter.thiessen@cds-snc.ca", - confirmedAt: undefined, - downloadedAt: 1680551178000, - removedAt: undefined, }, { formID: "clg17xha50008efkgfgxa8l4f", status: VaultStatus.DOWNLOADED, - securityAttribute: "Unclassified", name: "03-04-0d87", createdAt: 1680549838736, - lastDownloadedBy: "peter.thiessen@cds-snc.ca", - confirmedAt: undefined, - downloadedAt: 1680551178035, - removedAt: undefined, }, { formID: "clg17xha50008efkgfgxa8l4f", status: VaultStatus.CONFIRMED, - securityAttribute: "Unclassified", name: "03-04-18df", createdAt: 1680549858548, - lastDownloadedBy: "peter.thiessen@cds-snc.ca", - confirmedAt: 1680625853785, - downloadedAt: 1680625838662, - removedAt: 1683217853785, }, { formID: "clg17xha50008efkgfgxa8l4f", status: VaultStatus.CONFIRMED, - securityAttribute: "Unclassified", name: "03-04-2ccc", createdAt: 1680549825466, - lastDownloadedBy: "peter.thiessen@cds-snc.ca", - confirmedAt: 1680630735668, - downloadedAt: 1680630689700, - removedAt: 1683222735668, }, { formID: "clg17xha50008efkgfgxa8l4f", status: VaultStatus.CONFIRMED, - securityAttribute: "Unclassified", name: "03-04-2ce6", createdAt: 1680549852237, - lastDownloadedBy: "peter.thiessen@cds-snc.ca", - confirmedAt: 1680630735668, - downloadedAt: 1680630689705, - removedAt: 1683222735668, }, { formID: "clg17xha50008efkgfgxa8l4f", status: VaultStatus.CONFIRMED, - securityAttribute: "Unclassified", name: "03-04-3232", createdAt: 1680549829065, - lastDownloadedBy: "peter.thiessen@cds-snc.ca", - confirmedAt: 1680703319388, - downloadedAt: 1680703213201, - removedAt: 1683295319388, }, { formID: "clg17xha50008efkgfgxa8l4f", status: VaultStatus.CONFIRMED, - securityAttribute: "Unclassified", name: "03-04-394c", createdAt: 1680549827470, - lastDownloadedBy: "peter.thiessen@cds-snc.ca", - confirmedAt: 1680703319388, - downloadedAt: 1680703213174, - removedAt: 1683295319388, }, { formID: "clg17xha50008efkgfgxa8l4f", status: VaultStatus.CONFIRMED, - securityAttribute: "Unclassified", name: "03-04-40bb", createdAt: 1680549846213, - lastDownloadedBy: "peter.thiessen@cds-snc.ca", - confirmedAt: 1680705070151, - downloadedAt: 1680705027286, - removedAt: 1683297070151, }, { formID: "clg17xha50008efkgfgxa8l4f", status: VaultStatus.PROBLEM, - securityAttribute: "Unclassified", name: "03-04-54de", createdAt: 1680549841683, - lastDownloadedBy: "", - confirmedAt: undefined, - downloadedAt: undefined, - removedAt: undefined, }, { formID: "clg17xha50008efkgfgxa8l4f", status: VaultStatus.PROBLEM, - securityAttribute: "Unclassified", name: "03-04-6ca5", createdAt: 1680549856861, - lastDownloadedBy: "", - confirmedAt: undefined, - downloadedAt: undefined, - removedAt: undefined, }, { formID: "clg17xha50008efkgfgxa8l4f", status: VaultStatus.NEW, - securityAttribute: "Unclassified", name: "03-04-8a87", createdAt: 1680549855408, - lastDownloadedBy: "", - confirmedAt: undefined, - downloadedAt: undefined, - removedAt: undefined, }, { formID: "clg17xha50008efkgfgxa8l4f", status: VaultStatus.NEW, - securityAttribute: "Unclassified", name: "03-04-914b", createdAt: 1680549849354, - lastDownloadedBy: "", - confirmedAt: undefined, - downloadedAt: undefined, - removedAt: undefined, }, { formID: "clg17xha50008efkgfgxa8l4f", status: VaultStatus.NEW, - securityAttribute: "Unclassified", name: "03-04-a4c5", createdAt: 1680549844830, - lastDownloadedBy: "", - confirmedAt: undefined, - downloadedAt: undefined, - removedAt: undefined, }, { formID: "clg17xha50008efkgfgxa8l4f", status: VaultStatus.NEW, - securityAttribute: "Unclassified", name: "03-04-affc", createdAt: 1680549847443, - lastDownloadedBy: "", - confirmedAt: undefined, - downloadedAt: undefined, - removedAt: undefined, }, { formID: "clg17xha50008efkgfgxa8l4f", status: VaultStatus.NEW, - securityAttribute: "Unclassified", name: "03-04-bff8", createdAt: 1680549819049, - lastDownloadedBy: "", - confirmedAt: undefined, - downloadedAt: undefined, - removedAt: undefined, }, { formID: "clg17xha50008efkgfgxa8l4f", status: VaultStatus.NEW, - securityAttribute: "Unclassified", name: "03-04-d9b1", createdAt: 1680549823799, - lastDownloadedBy: "", - confirmedAt: undefined, - downloadedAt: undefined, - removedAt: undefined, }, { formID: "clg17xha50008efkgfgxa8l4f", status: VaultStatus.CONFIRMED, - securityAttribute: "Unclassified", name: "03-04-e721", createdAt: 1680549850908, - lastDownloadedBy: "peter.thiessen@cds-snc.ca", - confirmedAt: 1680728670633, - downloadedAt: 1680728208780, - removedAt: 1683320670633, }, { formID: "clg17xha50008efkgfgxa8l4f", status: VaultStatus.NEW, - securityAttribute: "Unclassified", name: "03-04-fe4e", createdAt: 1680549834891, - lastDownloadedBy: "", - confirmedAt: undefined, - downloadedAt: undefined, - removedAt: undefined, }, { formID: "clg17xha50008efkgfgxa8l4f", status: VaultStatus.NEW, - securityAttribute: "Unclassified", name: "06-04-4aab", createdAt: 1680794669621, - lastDownloadedBy: "", - confirmedAt: undefined, - downloadedAt: undefined, - removedAt: undefined, }, { formID: "clg17xha50008efkgfgxa8l4f", status: VaultStatus.NEW, - securityAttribute: "Unclassified", name: "06-04-7f15", createdAt: 1680794662371, - lastDownloadedBy: "", - confirmedAt: undefined, - downloadedAt: undefined, - removedAt: undefined, }, { formID: "clg17xha50008efkgfgxa8l4f", status: VaultStatus.NEW, - securityAttribute: "Unclassified", name: "06-04-c718", createdAt: 1680794671753, - lastDownloadedBy: "", - confirmedAt: undefined, - downloadedAt: undefined, - removedAt: undefined, }, ]; diff --git a/app/(gcforms)/[locale]/(form administration)/form-builder/[id]/responses/[statusFilter]/components/SystemStatus/ProblemsReported.tsx b/app/(gcforms)/[locale]/(form administration)/form-builder/[id]/responses/[statusFilter]/components/SystemStatus/ProblemsReported.tsx index f1799b312e..54986abe65 100644 --- a/app/(gcforms)/[locale]/(form administration)/form-builder/[id]/responses/[statusFilter]/components/SystemStatus/ProblemsReported.tsx +++ b/app/(gcforms)/[locale]/(form administration)/form-builder/[id]/responses/[statusFilter]/components/SystemStatus/ProblemsReported.tsx @@ -3,7 +3,7 @@ import Skeleton from "react-loading-skeleton"; import { fetchSubmissions } from "@formBuilder/[id]/responses/[[...statusFilter]]/actions"; -import { VaultStatus, VaultSubmissionList } from "@lib/types"; +import { VaultStatus, VaultSubmissionOverview } from "@lib/types"; import { HealthCheckBox, NumberCount, @@ -12,7 +12,7 @@ import { export const ProblemsReported = ({ formId }: { formId: string }) => { const [problemSubmissions, setProblemSubmissions] = useState<{ loading: boolean; - submissions: VaultSubmissionList[]; + submissions: VaultSubmissionOverview[]; lastEvaluatedKey: Record | null | undefined; error: boolean; }>({ loading: true, submissions: [], lastEvaluatedKey: null, error: false }); diff --git a/lib/types/index.ts b/lib/types/index.ts index 2028705d74..0e668aa0fc 100644 --- a/lib/types/index.ts +++ b/lib/types/index.ts @@ -45,7 +45,7 @@ export type { SearchParams, } from "./utility-types"; -export type { VaultSubmission, VaultSubmissionList } from "./retrieval-types"; +export type { VaultSubmission, VaultSubmissionOverview } from "./retrieval-types"; export { VaultStatus } from "./retrieval-types"; diff --git a/lib/types/retrieval-types.ts b/lib/types/retrieval-types.ts index 69a8fac93f..65e3394f7e 100644 --- a/lib/types/retrieval-types.ts +++ b/lib/types/retrieval-types.ts @@ -1,5 +1,11 @@ import { Responses } from "./form-response-types"; -import { TypeOmit } from "."; + +export enum VaultStatus { + NEW = "New", + DOWNLOADED = "Downloaded", + CONFIRMED = "Confirmed", + PROBLEM = "Problem", +} export type VaultSubmission = { formID: string; @@ -18,14 +24,9 @@ export type VaultSubmission = { removedAt?: number; }; -export type VaultSubmissionList = TypeOmit< - VaultSubmission, - "formSubmission" | "submissionID" | "confirmationCode" ->; - -export enum VaultStatus { - NEW = "New", - DOWNLOADED = "Downloaded", - CONFIRMED = "Confirmed", - PROBLEM = "Problem", -} +export type VaultSubmissionOverview = { + formID: string; + name: string; + createdAt: number; + status: VaultStatus; +}; diff --git a/lib/vault.ts b/lib/vault.ts index d02c4edb89..379f8f4df6 100644 --- a/lib/vault.ts +++ b/lib/vault.ts @@ -4,9 +4,10 @@ import { QueryCommandInput, TransactWriteCommand, BatchWriteCommand, + GetCommand, } from "@aws-sdk/lib-dynamodb"; import { prisma, prismaErrors } from "@lib/integration/prismaConnector"; -import { VaultSubmissionList, UserAbility, VaultStatus, VaultSubmission } from "@lib/types"; +import { VaultSubmissionOverview, UserAbility, VaultStatus, VaultSubmission } from "@lib/types"; import { logEvent } from "./auditLogs"; import { unprocessedSubmissionsCacheCheck, @@ -90,26 +91,38 @@ export const submissionTypeExists = async ( ); throw e; }); - const getItemsDbParams: QueryCommandInput = { + + const shouldNavigateThroughStatusCreatedAtIndexInAscendingOrder = ( + status: VaultStatus + ): boolean => { + switch (status) { + case VaultStatus.CONFIRMED: + case VaultStatus.DOWNLOADED: + return true; + default: + return false; + } + }; + + const queryCommand = new QueryCommand({ TableName: "Vault", - IndexName: "Status", - // Limit the amount of responses 1. + IndexName: "StatusCreatedAt", + // To optimize query since we only need to check whether one type of submission type exists + ScanIndexForward: shouldNavigateThroughStatusCreatedAtIndexInAscendingOrder(status), + // Limit the amount of responses to 1. // A single record existing is enough to trigger the boolean Limit: 1, - KeyConditionExpression: "FormID = :formID AND #status = :status", - // Sort by descending order of Status - ScanIndexForward: false, + KeyConditionExpression: "FormID = :formID AND begins_with(#statusCreatedAtKey, :status)", + ExpressionAttributeNames: { + "#statusCreatedAtKey": "Status#CreatedAt", + }, ExpressionAttributeValues: { ":formID": formID, ":status": status, }, - ExpressionAttributeNames: { - "#status": "Status", - }, ProjectionExpression: "FormID", - }; - const queryCommand = new QueryCommand(getItemsDbParams); - // eslint-disable-next-line no-await-in-loop + }); + const response = await dynamoDBDocumentClient.send(queryCommand); return Boolean(response.Items?.length); }; @@ -126,7 +139,7 @@ export async function listAllSubmissions( responseDownloadLimit?: number, lastEvaluatedKey: Record | null | undefined = null ): Promise<{ - submissions: VaultSubmissionList[]; + submissions: VaultSubmissionOverview[]; submissionsRemaining: boolean; lastEvaluatedKey: Record | null | undefined; }> { @@ -151,58 +164,41 @@ export async function listAllSubmissions( // We're going to request one more than the limit so we can consistently determine if there are more responses const responseRetrievalLimit = responseDownloadLimit + 1; - let accumulatedResponses: VaultSubmissionList[] = []; + let accumulatedResponses: VaultSubmissionOverview[] = []; let submissionsRemaining = false; let paginationLastEvaluatedKey = null; while (lastEvaluatedKey !== undefined) { - const getItemsDbParams: QueryCommandInput = { + const queryCommand: QueryCommand = new QueryCommand({ TableName: "Vault", - IndexName: "Status", + IndexName: "StatusCreatedAt", ExclusiveStartKey: lastEvaluatedKey ?? undefined, // Limit the amount of response to responseRetrievalLimit Limit: responseRetrievalLimit - accumulatedResponses.length, - KeyConditionExpression: "FormID = :formID" + (status ? " AND #status = :status" : ""), - // Sort by descending order of Status - ScanIndexForward: false, + KeyConditionExpression: + "FormID = :formID" + (status ? " AND begins_with(#statusCreatedAtKey, :status)" : ""), + ExpressionAttributeNames: { + ...(status && { "#statusCreatedAtKey": "Status#CreatedAt" }), + "#name": "Name", + }, ExpressionAttributeValues: { ":formID": formID, ...(status && { ":status": status }), }, - ExpressionAttributeNames: { - "#status": "Status", - "#name": "Name", - }, - ProjectionExpression: - "FormID,#status,SecurityAttribute,#name,CreatedAt,LastDownloadedBy,ConfirmTimestamp,DownloadedAt,RemovalDate", - }; - const queryCommand = new QueryCommand(getItemsDbParams); + ProjectionExpression: "FormID,#name,CreatedAt", + }); + // eslint-disable-next-line no-await-in-loop const response = await dynamoDBDocumentClient.send(queryCommand); if (response.Items?.length) { accumulatedResponses = accumulatedResponses.concat( response.Items.map( - ({ - FormID: formID, - SecurityAttribute: securityAttribute, - Status: status, - CreatedAt: createdAt, - LastDownloadedBy: lastDownloadedBy, - Name: name, - ConfirmTimestamp: confirmedAt, - DownloadedAt: downloadedAt, - RemovalDate: removedAt, - }) => ({ + ({ FormID: formID, Status: status, CreatedAt: createdAt, Name: name }) => ({ formID, status, - securityAttribute, name, createdAt, - lastDownloadedBy: lastDownloadedBy ?? null, - confirmedAt: confirmedAt ?? null, - downloadedAt: downloadedAt ?? null, - removedAt: removedAt ?? null, }) ) ); @@ -260,6 +256,57 @@ export async function listAllSubmissions( } } +/** + * This method returns the `RemovalDate` for a specific submission. + * @param formID - The form ID of the response you want to retrieve + * @param submissionName - The submission name of the response you want to retrieve + */ +export async function retrieveSubmissionRemovalDate( + ability: UserAbility, + formID: string, + submissionName: string +): Promise { + // Check access control first + try { + await checkAbilityToAccessSubmissions(ability, formID).catch((e) => { + if (e instanceof AccessControlError) + logEvent( + ability.userID, + { + type: "Form", + id: formID, + }, + "AccessDenied", + `Attempted to retrieve response for form ${formID}` + ); + throw e; + }); + + const getCommand = new GetCommand({ + TableName: "Vault", + Key: { + FormID: formID, + NAME_OR_CONF: `NAME#${submissionName}`, + }, + ProjectionExpression: "RemovalDate", + }); + + const response = await dynamoDBDocumentClient.send(getCommand); + + if (response.Item === undefined || response.Item["RemovalDate"] === undefined) { + return undefined; + } + + return response.Item["RemovalDate"]; + } catch (e) { + // Expected to error in APP_ENV test mode as dynamodb is not available + if (process.env.APP_ENV !== "test") { + logMessage.error(e); + } + return undefined; + } +} + /** * This method returns a list of selected form submission records. * The list contains the actual submission data