From 7155fd1886a3838abf1027c14068fc540494326b Mon Sep 17 00:00:00 2001 From: Jessica McInchak Date: Thu, 16 Nov 2023 08:19:19 +0000 Subject: [PATCH 1/2] feat: create a `submission_services_summary` database view for analytics (#2430) --- hasura.planx.uk/metadata/tables.yaml | 3 ++ .../down.sql | 1 + .../up.sql | 47 +++++++++++++++++++ 3 files changed, 51 insertions(+) create mode 100644 hasura.planx.uk/migrations/1700072112794_create_view_submission_services_summary/down.sql create mode 100644 hasura.planx.uk/migrations/1700072112794_create_view_submission_services_summary/up.sql diff --git a/hasura.planx.uk/metadata/tables.yaml b/hasura.planx.uk/metadata/tables.yaml index c81b9218b2..fade60a7e9 100644 --- a/hasura.planx.uk/metadata/tables.yaml +++ b/hasura.planx.uk/metadata/tables.yaml @@ -1217,6 +1217,9 @@ - locked_at: _is_null: true check: null +- table: + schema: public + name: submission_services_summary - table: schema: public name: team_members diff --git a/hasura.planx.uk/migrations/1700072112794_create_view_submission_services_summary/down.sql b/hasura.planx.uk/migrations/1700072112794_create_view_submission_services_summary/down.sql new file mode 100644 index 0000000000..ce4411d705 --- /dev/null +++ b/hasura.planx.uk/migrations/1700072112794_create_view_submission_services_summary/down.sql @@ -0,0 +1 @@ +DROP VIEW public.submission_services_summary CASCADE; diff --git a/hasura.planx.uk/migrations/1700072112794_create_view_submission_services_summary/up.sql b/hasura.planx.uk/migrations/1700072112794_create_view_submission_services_summary/up.sql new file mode 100644 index 0000000000..824fa05854 --- /dev/null +++ b/hasura.planx.uk/migrations/1700072112794_create_view_submission_services_summary/up.sql @@ -0,0 +1,47 @@ +CREATE OR REPLACE VIEW public.submission_services_summary AS +with resumes_per_session as ( + select + session_id, + count(id) as number_times_resumed + from reconciliation_requests + group by session_id +) +select + ls.id as session_id, + t.slug as team_slug, + f.slug as service_slug, + ls.created_at, + ls.submitted_at, + (ls.submitted_at::date - ls.created_at::date) as session_length_days, + ls.has_user_saved as user_clicked_save, + rps.number_times_resumed, + case + when pr.id is null + then false + else true + end as user_invited_to_pay, + case + when ba.bops_id is null + then false + else true + end as sent_to_bops, + case + when ua.idox_submission_id is null + then false + else true + end as sent_to_uniform, + case + when ea.id is null + then false + else true + end as sent_to_email +from lowcal_sessions ls + left join flows f on f.id = ls.flow_id + left join teams t on t.id = f.team_id + left join resumes_per_session rps on rps.session_id = ls.id::text + left join payment_requests pr on pr.session_id = ls.id + left join bops_applications ba on ba.session_id = ls.id::text + left join uniform_applications ua on ua.submission_reference = ls.id::text + left join email_applications ea on ea.session_id = ls.id +where f.slug IS NOT NULL + and t.slug IS NOT NULL; From 43e222f4b61e073d734493dd26f31ddafdd674d8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dafydd=20Ll=C5=B7r=20Pearson?= Date: Thu, 16 Nov 2023 09:14:43 +0000 Subject: [PATCH 2/2] feat: Save and Return docs (#2426) --- .../inviteToPay/sendConfirmationEmail.ts | 2 +- api.planx.uk/inviteToPay/sendPaymentEmail.ts | 2 +- .../modules/saveAndReturn/controller.ts | 109 +++++++++++++ api.planx.uk/modules/saveAndReturn/docs.yaml | 123 +++++++++++++++ api.planx.uk/modules/saveAndReturn/routes.ts | 26 ++++ .../service}/resumeApplication.test.ts | 17 +- .../service}/resumeApplication.ts | 47 ++---- .../saveAndReturn/service}/utils.test.ts | 2 +- .../saveAndReturn/service}/utils.ts | 6 +- .../service}/validateSession.test.ts | 18 +-- .../saveAndReturn/service}/validateSession.ts | 147 ++++++------------ .../service/lowcalSessionEvents/index.ts | 2 +- .../service/paymentRequestEvents/index.ts | 2 +- api.planx.uk/notify/notify.ts | 2 +- api.planx.uk/notify/routeSendEmailRequest.ts | 2 +- api.planx.uk/saveAndReturn/index.ts | 4 - api.planx.uk/send/bops.test.ts | 2 +- api.planx.uk/send/bops.ts | 2 +- api.planx.uk/send/email.ts | 4 +- api.planx.uk/send/uniform.ts | 2 +- api.planx.uk/server.ts | 5 +- 21 files changed, 357 insertions(+), 169 deletions(-) create mode 100644 api.planx.uk/modules/saveAndReturn/controller.ts create mode 100644 api.planx.uk/modules/saveAndReturn/docs.yaml create mode 100644 api.planx.uk/modules/saveAndReturn/routes.ts rename api.planx.uk/{saveAndReturn => modules/saveAndReturn/service}/resumeApplication.test.ts (95%) rename api.planx.uk/{saveAndReturn => modules/saveAndReturn/service}/resumeApplication.ts (74%) rename api.planx.uk/{saveAndReturn => modules/saveAndReturn/service}/utils.test.ts (96%) rename api.planx.uk/{saveAndReturn => modules/saveAndReturn/service}/utils.ts (97%) rename api.planx.uk/{saveAndReturn => modules/saveAndReturn/service}/validateSession.test.ts (97%) rename api.planx.uk/{saveAndReturn => modules/saveAndReturn/service}/validateSession.ts (64%) delete mode 100644 api.planx.uk/saveAndReturn/index.ts diff --git a/api.planx.uk/inviteToPay/sendConfirmationEmail.ts b/api.planx.uk/inviteToPay/sendConfirmationEmail.ts index 06a5cca1f3..1ec7dfc369 100644 --- a/api.planx.uk/inviteToPay/sendConfirmationEmail.ts +++ b/api.planx.uk/inviteToPay/sendConfirmationEmail.ts @@ -1,7 +1,7 @@ import { $public, $api } from "../client"; import { sendEmail } from "../notify"; import { gql } from "graphql-request"; -import { convertSlugToName } from "../saveAndReturn/utils"; +import { convertSlugToName } from "../modules/saveAndReturn/service/utils"; import type { AgentAndPayeeSubmissionNotifyConfig } from "../types"; export async function sendAgentAndPayeeConfirmationEmail(sessionId: string) { diff --git a/api.planx.uk/inviteToPay/sendPaymentEmail.ts b/api.planx.uk/inviteToPay/sendPaymentEmail.ts index 96c6e31e99..bf62668d1c 100644 --- a/api.planx.uk/inviteToPay/sendPaymentEmail.ts +++ b/api.planx.uk/inviteToPay/sendPaymentEmail.ts @@ -3,7 +3,7 @@ import { calculateExpiryDate, convertSlugToName, getServiceLink, -} from "../saveAndReturn/utils"; +} from "../modules/saveAndReturn/service/utils"; import { Template, getClientForTemplate, sendEmail } from "../notify"; import { InviteToPayNotifyConfig } from "../types"; import { Team } from "../types"; diff --git a/api.planx.uk/modules/saveAndReturn/controller.ts b/api.planx.uk/modules/saveAndReturn/controller.ts new file mode 100644 index 0000000000..60d882a2ef --- /dev/null +++ b/api.planx.uk/modules/saveAndReturn/controller.ts @@ -0,0 +1,109 @@ +import { z } from "zod"; +import { ValidatedRequestHandler } from "../../shared/middleware/validate"; +import { resumeApplication } from "./service/resumeApplication"; +import { LowCalSessionData } from "../../types"; +import { findSession, validateSession } from "./service/validateSession"; +import { PaymentRequest } from "@opensystemslab/planx-core/types"; + +interface ResumeApplicationResponse { + message: string; + expiryDate?: string | undefined; +} + +export const resumeApplicationSchema = z.object({ + body: z.object({ + payload: z.object({ + teamSlug: z.string(), + email: z.string().email(), + }), + }), +}); + +export type ResumeApplication = ValidatedRequestHandler< + typeof resumeApplicationSchema, + ResumeApplicationResponse +>; + +export const resumeApplicationController: ResumeApplication = async ( + _req, + res, + next, +) => { + try { + const { teamSlug, email } = res.locals.parsedReq.body.payload; + const response = await resumeApplication(teamSlug, email); + return res.json(response); + } catch (error) { + return next({ + error, + message: `Failed to send "Resume" email. ${(error as Error).message}`, + }); + } +}; + +export interface ValidationResponse { + message: string; + changesFound: boolean | null; + alteredSectionIds?: Array; + reconciledSessionData: Omit; +} + +interface LockedSessionResponse { + message: "Session locked"; + paymentRequest?: Partial< + Pick + >; +} + +export const validateSessionSchema = z.object({ + body: z.object({ + payload: z.object({ + sessionId: z.string(), + email: z.string().email(), + }), + }), +}); + +export type ValidateSessionController = ValidatedRequestHandler< + typeof validateSessionSchema, + ValidationResponse | LockedSessionResponse +>; + +export const validateSessionController: ValidateSessionController = async ( + _req, + res, + next, +) => { + try { + const { email, sessionId } = res.locals.parsedReq.body.payload; + + const fetchedSession = await findSession({ + sessionId, + email: email.toLowerCase(), + }); + + if (!fetchedSession) { + return next({ + status: 404, + message: "Unable to find your session", + }); + } + + if (fetchedSession.lockedAt) { + return res.status(403).send({ + message: "Session locked", + paymentRequest: { + ...fetchedSession.paymentRequests?.[0], + }, + }); + } + const responseData = await validateSession(sessionId, fetchedSession); + + return res.status(200).json(responseData); + } catch (error) { + return next({ + error, + message: "Failed to validate session", + }); + } +}; diff --git a/api.planx.uk/modules/saveAndReturn/docs.yaml b/api.planx.uk/modules/saveAndReturn/docs.yaml new file mode 100644 index 0000000000..4154db5dea --- /dev/null +++ b/api.planx.uk/modules/saveAndReturn/docs.yaml @@ -0,0 +1,123 @@ +openapi: 3.1.0 +info: + title: Planâś• API + version: 0.1.0 +tags: + - name: save and return + description: Endpoints used for "Save and Return" functionality +components: + schema: + ResumeApplication: + required: true + content: + application/json: + schema: + type: object + properties: + payload: + type: object + properties: + teamSlug: + type: string + email: + type: string + format: email + ValidateSession: + required: true + content: + application/json: + schema: + type: object + properties: + payload: + type: object + properties: + sessionId: + type: string + format: uuid + email: + type: string + format: email + responses: + ResumeApplication: + content: + application/json: + schema: + type: object + properties: + message: + required: true + type: string + expiryDate: + required: false + oneOf: + - type: string + - type: "null" + ValidationResponse: + type: object + properties: + message: + type: string + changesFound: + type: boolean + nullable: true + alteredSectionIds: + type: array + items: + type: string + reconciledSessionData: + type: object + properties: + breadcrumbs: + type: object + id: + type: string + # TODO: Add $ref here when documenting payment endpoints + govUkPayment: + required: false + type: object + LockedSessionResponse: + type: object + properties: + message: + type: string + enum: + - Session locked + paymentRequest: + type: object + properties: + id: + type: string + payeeEmail: + type: string + format: email + payeeName: + type: string +paths: + /resume-application: + post: + summary: Resume application + description: Request a "resume" email which lists all of your open applications. This email acts as a "dashboard" for the user. + tags: + - save and return + requestBody: + $ref: "#/components/schema/ResumeApplication" + responses: + "200": + $ref: "#/components/responses/ResumeApplication" + /validate-session: + post: + summary: Validate session + description: Validates the session and reconciles the session's breadcrumbs to account for any differences between the current published flow and the last flow version a user traversed. + tags: + - save and return + requestBody: + $ref: "#/components/schema/ValidateSession" + responses: + "200": + content: + application/json: + schema: + oneOf: + - $ref: "#/components/responses/ValidationResponse" + - $ref: "#/components/responses/LockedSessionResponse" diff --git a/api.planx.uk/modules/saveAndReturn/routes.ts b/api.planx.uk/modules/saveAndReturn/routes.ts new file mode 100644 index 0000000000..f7e4a3bda7 --- /dev/null +++ b/api.planx.uk/modules/saveAndReturn/routes.ts @@ -0,0 +1,26 @@ +import { Router } from "express"; + +import { + resumeApplicationController, + resumeApplicationSchema, + validateSessionController, + validateSessionSchema, +} from "./controller"; +import { sendEmailLimiter } from "../../rateLimit"; +import { validate } from "../../shared/middleware/validate"; + +const router = Router(); + +router.post( + "/resume-application", + sendEmailLimiter, + validate(resumeApplicationSchema), + resumeApplicationController, +); +router.post( + "/validate-session", + validate(validateSessionSchema), + validateSessionController, +); + +export default router; diff --git a/api.planx.uk/saveAndReturn/resumeApplication.test.ts b/api.planx.uk/modules/saveAndReturn/service/resumeApplication.test.ts similarity index 95% rename from api.planx.uk/saveAndReturn/resumeApplication.test.ts rename to api.planx.uk/modules/saveAndReturn/service/resumeApplication.test.ts index 9f7917d32e..5dd2536801 100644 --- a/api.planx.uk/saveAndReturn/resumeApplication.test.ts +++ b/api.planx.uk/modules/saveAndReturn/service/resumeApplication.test.ts @@ -1,8 +1,11 @@ -import { LowCalSession, Team } from "./../types"; +import { LowCalSession, Team } from "../../../types"; import supertest from "supertest"; -import app from "../server"; -import { queryMock } from "../tests/graphqlQueryMock"; -import { mockLowcalSession, mockTeam } from "../tests/mocks/saveAndReturnMocks"; +import app from "../../../server"; +import { queryMock } from "../../../tests/graphqlQueryMock"; +import { + mockLowcalSession, + mockTeam, +} from "../../../tests/mocks/saveAndReturnMocks"; import { buildContentFromSessions } from "./resumeApplication"; import { PartialDeep } from "type-fest"; @@ -216,10 +219,8 @@ describe("Resume Application endpoint", () => { .send(invalidBody) .expect(400) .then((response) => { - expect(response.body).toHaveProperty( - "error", - "Required value missing", - ); + expect(response.body).toHaveProperty("issues"); + expect(response.body).toHaveProperty("name", "ZodError"); }); } }); diff --git a/api.planx.uk/saveAndReturn/resumeApplication.ts b/api.planx.uk/modules/saveAndReturn/service/resumeApplication.ts similarity index 74% rename from api.planx.uk/saveAndReturn/resumeApplication.ts rename to api.planx.uk/modules/saveAndReturn/service/resumeApplication.ts index 41a8cc552e..5a334670ec 100644 --- a/api.planx.uk/saveAndReturn/resumeApplication.ts +++ b/api.planx.uk/modules/saveAndReturn/service/resumeApplication.ts @@ -1,44 +1,25 @@ -import { NextFunction, Request, Response } from "express"; import { gql } from "graphql-request"; -import { LowCalSession, Team } from "../types"; +import { LowCalSession, Team } from "../../../types"; import { convertSlugToName, getResumeLink, calculateExpiryDate } from "./utils"; -import { sendEmail } from "../notify"; +import { sendEmail } from "../../../notify"; import type { SiteAddress } from "@opensystemslab/planx-core/types"; -import { $api, $public } from "../client"; +import { $api, $public } from "../../../client"; /** * Send a "Resume" email to an applicant which list all open applications for a given council (team) */ -const resumeApplication = async ( - req: Request, - res: Response, - next: NextFunction, -) => { - try { - const { teamSlug, email } = req.body.payload; - if (!teamSlug || !email) - return next({ - status: 400, - message: "Required value missing", - }); +const resumeApplication = async (teamSlug: string, email: string) => { + const { team, sessions } = await validateRequest(teamSlug, email); + // Protect against phishing by returning a positive response even if no matching sessions found + if (!sessions.length) return { message: "Success" }; - const { team, sessions } = await validateRequest(teamSlug, email); - // Protect against phishing by returning a positive response even if no matching sessions found - if (!sessions.length) return res.json({ message: "Success" }); - - const config = { - personalisation: await getPersonalisation(sessions, team), - reference: null, - emailReplyToId: team.notifyPersonalisation.emailReplyToId, - }; - const response = await sendEmail("resume", email, config); - return res.json(response); - } catch (error) { - return next({ - error, - message: `Failed to send "Resume" email. ${(error as Error).message}`, - }); - } + const config = { + personalisation: await getPersonalisation(sessions, team), + reference: null, + emailReplyToId: team.notifyPersonalisation.emailReplyToId, + }; + const response = await sendEmail("resume", email, config); + return response; }; interface ValidateRequest { diff --git a/api.planx.uk/saveAndReturn/utils.test.ts b/api.planx.uk/modules/saveAndReturn/service/utils.test.ts similarity index 96% rename from api.planx.uk/saveAndReturn/utils.test.ts rename to api.planx.uk/modules/saveAndReturn/service/utils.test.ts index fdda91c6e3..2d12b1e69a 100644 --- a/api.planx.uk/saveAndReturn/utils.test.ts +++ b/api.planx.uk/modules/saveAndReturn/service/utils.test.ts @@ -1,4 +1,4 @@ -import { Team } from "../types"; +import { Team } from "../../../types"; import { convertSlugToName, getResumeLink } from "./utils"; describe("convertSlugToName util function", () => { diff --git a/api.planx.uk/saveAndReturn/utils.ts b/api.planx.uk/modules/saveAndReturn/service/utils.ts similarity index 97% rename from api.planx.uk/saveAndReturn/utils.ts rename to api.planx.uk/modules/saveAndReturn/service/utils.ts index 7080861d1d..fd13b5ce82 100644 --- a/api.planx.uk/saveAndReturn/utils.ts +++ b/api.planx.uk/modules/saveAndReturn/service/utils.ts @@ -1,9 +1,9 @@ import { SiteAddress } from "@opensystemslab/planx-core/types"; import { format, addDays } from "date-fns"; import { gql } from "graphql-request"; -import { LowCalSession, Team } from "../types"; -import { Template, getClientForTemplate, sendEmail } from "../notify"; -import { $api, $public } from "../client"; +import { LowCalSession, Team } from "../../../types"; +import { Template, getClientForTemplate, sendEmail } from "../../../notify"; +import { $api, $public } from "../../../client"; const DAYS_UNTIL_EXPIRY = 28; const REMINDER_DAYS_FROM_EXPIRY = [7, 1]; diff --git a/api.planx.uk/saveAndReturn/validateSession.test.ts b/api.planx.uk/modules/saveAndReturn/service/validateSession.test.ts similarity index 97% rename from api.planx.uk/saveAndReturn/validateSession.test.ts rename to api.planx.uk/modules/saveAndReturn/service/validateSession.test.ts index d7cd811819..578408be07 100644 --- a/api.planx.uk/saveAndReturn/validateSession.test.ts +++ b/api.planx.uk/modules/saveAndReturn/service/validateSession.test.ts @@ -1,7 +1,7 @@ import supertest from "supertest"; import omit from "lodash.omit"; -import app from "../server"; -import { queryMock } from "../tests/graphqlQueryMock"; +import app from "../../../server"; +import { queryMock } from "../../../tests/graphqlQueryMock"; import { mockFlow, mockLowcalSession, @@ -11,10 +11,10 @@ import { mockGetMostRecentPublishedFlow, stubInsertReconciliationRequests, stubUpdateLowcalSessionData, -} from "../tests/mocks/saveAndReturnMocks"; -import type { Node, Flow, Breadcrumb } from "../types"; -import { userContext } from "../modules/auth/middleware"; -import { getJWT } from "../tests/mockJWT"; +} from "../../../tests/mocks/saveAndReturnMocks"; +import type { Node, Flow, Breadcrumb } from "../../../types"; +import { userContext } from "../../auth/middleware"; +import { getJWT } from "../../../tests/mockJWT"; const validateSessionPath = "/validate-session"; const getStoreMock = jest.spyOn(userContext, "getStore"); @@ -49,10 +49,8 @@ describe("Validate Session endpoint", () => { .send(invalidBody) .expect(400) .then((response) => { - expect(response.body).toHaveProperty( - "error", - "Required value missing", - ); + expect(response.body).toHaveProperty("issues"); + expect(response.body).toHaveProperty("name", "ZodError"); }); } }); diff --git a/api.planx.uk/saveAndReturn/validateSession.ts b/api.planx.uk/modules/saveAndReturn/service/validateSession.ts similarity index 64% rename from api.planx.uk/saveAndReturn/validateSession.ts rename to api.planx.uk/modules/saveAndReturn/service/validateSession.ts index 0c95d08cfd..981b671cd9 100644 --- a/api.planx.uk/saveAndReturn/validateSession.ts +++ b/api.planx.uk/modules/saveAndReturn/service/validateSession.ts @@ -1,7 +1,6 @@ import { gql } from "graphql-request"; import omit from "lodash.omit"; -import { NextFunction, Request, Response } from "express"; -import { getMostRecentPublishedFlow } from "../helpers"; +import { getMostRecentPublishedFlow } from "../../../helpers"; import { sortBreadcrumbs } from "@opensystemslab/planx-core"; import { ComponentType } from "@opensystemslab/planx-core/types"; import type { @@ -14,15 +13,9 @@ import type { LowCalSessionData, PublishedFlow, Node, -} from "../types"; -import { $api } from "../client"; - -export interface ValidationResponse { - message: string; - changesFound: boolean | null; - alteredSectionIds?: Array; - reconciledSessionData: Omit; -} +} from "../../../types"; +import { $api } from "../../../client"; +import { ValidationResponse } from "../controller"; export type ReconciledSession = { alteredSectionIds: Array; @@ -33,98 +26,60 @@ export type ReconciledSession = { // * collected flags // * component dependencies like FindProperty, DrawBoundary, PlanningConstraints export async function validateSession( - req: Request, - res: Response, - next: NextFunction, + sessionId: string, + fetchedSession: Partial, ) { - try { - const { email, sessionId } = req.body.payload; - - if (!email || !sessionId) { - return next({ - status: 400, - message: "Required value missing", - }); - } - - const fetchedSession = await findSession({ - sessionId, - email: email.toLowerCase(), - }); - if (!fetchedSession) { - return next({ - status: 404, - message: "Unable to find your session", - }); - } - - if (fetchedSession.lockedAt) { - return res.status(403).send({ - status: 403, - message: "Session locked", - paymentRequest: { - ...fetchedSession.paymentRequests?.[0], - }, - }); - } - - const sessionData = omit(fetchedSession.data!, "passport"); - const sessionUpdatedAt = fetchedSession.updated_at!; - const flowId = fetchedSession.flow_id!; - - // if a user has paid, skip reconciliation - const userHasPaid = sessionData?.govUkPayment?.state?.status === "created"; - if (userHasPaid) { - const responseData: ValidationResponse = { - message: "Payment process initiated, skipping reconciliation", - changesFound: null, - reconciledSessionData: sessionData, - }; - await createAuditEntry(sessionId, responseData); - return res.status(200).json(responseData); - } - - // fetch the latest flow diffs for this session's flow - const flowDiff = await diffLatestPublishedFlow({ - flowId, - since: sessionUpdatedAt, - }); - - if (!flowDiff) { - const responseData: ValidationResponse = { - message: "No content changes since last save point", - changesFound: false, - reconciledSessionData: sessionData, - }; - await createAuditEntry(sessionId, responseData); - return res.status(200).json(responseData); - } + const sessionData = omit(fetchedSession.data!, "passport"); + const sessionUpdatedAt = fetchedSession.updated_at!; + const flowId = fetchedSession.flow_id!; - const alteredNodes = Object.entries(flowDiff).map(([nodeId, node]) => ({ - ...node, - id: nodeId, - })); + // if a user has paid, skip reconciliation + const userHasPaid = sessionData?.govUkPayment?.state?.status === "created"; + if (userHasPaid) { + const responseData: ValidationResponse = { + message: "Payment process initiated, skipping reconciliation", + changesFound: null, + reconciledSessionData: sessionData, + }; + await createAuditEntry(sessionId, responseData); + return responseData; + } - const { reconciledSessionData, alteredSectionIds } = - await reconcileSessionData({ sessionData, alteredNodes }); + // fetch the latest flow diffs for this session's flow + const flowDiff = await diffLatestPublishedFlow({ + flowId, + since: sessionUpdatedAt, + }); + if (!flowDiff) { const responseData: ValidationResponse = { - message: - "This service has been updated since you last saved your application." + - " We will ask you to answer any updated questions again when you continue.", - changesFound: true, - alteredSectionIds, - reconciledSessionData, + message: "No content changes since last save point", + changesFound: false, + reconciledSessionData: sessionData, }; - await createAuditEntry(sessionId, responseData); - return res.status(200).json(responseData); - } catch (error) { - return next({ - error, - message: "Failed to validate session", - }); + return responseData; } + + const alteredNodes = Object.entries(flowDiff).map(([nodeId, node]) => ({ + ...node, + id: nodeId, + })); + + const { reconciledSessionData, alteredSectionIds } = + await reconcileSessionData({ sessionData, alteredNodes }); + + const responseData: ValidationResponse = { + message: + "This service has been updated since you last saved your application." + + " We will ask you to answer any updated questions again when you continue.", + changesFound: true, + alteredSectionIds, + reconciledSessionData, + }; + + await createAuditEntry(sessionId, responseData); + return responseData; } async function reconcileSessionData({ @@ -231,7 +186,7 @@ interface FindSession { sessions: Partial[]; } -async function findSession({ +export async function findSession({ sessionId, email, }: { diff --git a/api.planx.uk/modules/webhooks/service/lowcalSessionEvents/index.ts b/api.planx.uk/modules/webhooks/service/lowcalSessionEvents/index.ts index 58064a5e6a..cd6d4b9855 100644 --- a/api.planx.uk/modules/webhooks/service/lowcalSessionEvents/index.ts +++ b/api.planx.uk/modules/webhooks/service/lowcalSessionEvents/index.ts @@ -4,7 +4,7 @@ import { createScheduledEvent } from "../../../../lib/hasura/metadata"; import { DAYS_UNTIL_EXPIRY, REMINDER_DAYS_FROM_EXPIRY, -} from "../../../../saveAndReturn/utils"; +} from "../../../saveAndReturn/service/utils"; import { CreateSessionEvent } from "./schema"; /** diff --git a/api.planx.uk/modules/webhooks/service/paymentRequestEvents/index.ts b/api.planx.uk/modules/webhooks/service/paymentRequestEvents/index.ts index b438ce5236..c806a63550 100644 --- a/api.planx.uk/modules/webhooks/service/paymentRequestEvents/index.ts +++ b/api.planx.uk/modules/webhooks/service/paymentRequestEvents/index.ts @@ -4,7 +4,7 @@ import { createScheduledEvent } from "../../../../lib/hasura/metadata"; import { DAYS_UNTIL_EXPIRY, REMINDER_DAYS_FROM_EXPIRY, -} from "../../../../saveAndReturn/utils"; +} from "../../../saveAndReturn/service/utils"; import { CreatePaymentEvent } from "./schema"; /** diff --git a/api.planx.uk/notify/notify.ts b/api.planx.uk/notify/notify.ts index 2824506575..420eab0bf9 100644 --- a/api.planx.uk/notify/notify.ts +++ b/api.planx.uk/notify/notify.ts @@ -1,5 +1,5 @@ import { NotifyClient } from "notifications-node-client"; -import { softDeleteSession } from "../saveAndReturn/utils"; +import { softDeleteSession } from "../modules/saveAndReturn/service/utils"; import { NotifyConfig } from "../types"; import { $api, $public } from "../client"; diff --git a/api.planx.uk/notify/routeSendEmailRequest.ts b/api.planx.uk/notify/routeSendEmailRequest.ts index e132414d05..5e43364f95 100644 --- a/api.planx.uk/notify/routeSendEmailRequest.ts +++ b/api.planx.uk/notify/routeSendEmailRequest.ts @@ -3,7 +3,7 @@ import { sendSinglePaymentEmail, sendAgentAndPayeeConfirmationEmail, } from "../inviteToPay"; -import { sendSingleApplicationEmail } from "../saveAndReturn/utils"; +import { sendSingleApplicationEmail } from "../modules/saveAndReturn/service/utils"; import { Template } from "./notify"; import { ServerError } from "../errors"; diff --git a/api.planx.uk/saveAndReturn/index.ts b/api.planx.uk/saveAndReturn/index.ts deleted file mode 100644 index 052e5921d9..0000000000 --- a/api.planx.uk/saveAndReturn/index.ts +++ /dev/null @@ -1,4 +0,0 @@ -import { resumeApplication } from "./resumeApplication"; -import { validateSession } from "./validateSession"; - -export { resumeApplication, validateSession }; diff --git a/api.planx.uk/send/bops.test.ts b/api.planx.uk/send/bops.test.ts index 65d2030b47..e620e17569 100644 --- a/api.planx.uk/send/bops.test.ts +++ b/api.planx.uk/send/bops.test.ts @@ -4,7 +4,7 @@ import { queryMock } from "../tests/graphqlQueryMock"; import app from "../server"; import { expectedPayload } from "../tests/mocks/bopsMocks"; -jest.mock("../saveAndReturn/utils", () => ({ +jest.mock("../modules/saveAndReturn/service/utils", () => ({ markSessionAsSubmitted: jest.fn(), })); diff --git a/api.planx.uk/send/bops.ts b/api.planx.uk/send/bops.ts index 6d6d4a4956..dc8d53fbfc 100644 --- a/api.planx.uk/send/bops.ts +++ b/api.planx.uk/send/bops.ts @@ -1,5 +1,5 @@ import axios, { AxiosResponse } from "axios"; -import { markSessionAsSubmitted } from "../saveAndReturn/utils"; +import { markSessionAsSubmitted } from "../modules/saveAndReturn/service/utils"; import { NextFunction, Request, Response } from "express"; import { gql } from "graphql-request"; import { $api } from "../client"; diff --git a/api.planx.uk/send/email.ts b/api.planx.uk/send/email.ts index dadc5801b4..c182f15866 100644 --- a/api.planx.uk/send/email.ts +++ b/api.planx.uk/send/email.ts @@ -1,11 +1,11 @@ import type { NextFunction, Request, Response } from "express"; import { gql } from "graphql-request"; import capitalize from "lodash/capitalize"; -import { markSessionAsSubmitted } from "../saveAndReturn/utils"; +import { markSessionAsSubmitted } from "../modules/saveAndReturn/service/utils"; import { sendEmail } from "../notify"; import { EmailSubmissionNotifyConfig } from "../types"; import { buildSubmissionExportZip } from "./exportZip"; -import { $api, $public } from "../client"; +import { $api } from "../client"; import { NotifyPersonalisation } from "@opensystemslab/planx-core/dist/types/team"; import { Session } from "@opensystemslab/planx-core/types"; diff --git a/api.planx.uk/send/uniform.ts b/api.planx.uk/send/uniform.ts index 2e67440fa9..fb19558e12 100644 --- a/api.planx.uk/send/uniform.ts +++ b/api.planx.uk/send/uniform.ts @@ -3,7 +3,7 @@ import { NextFunction, Request, Response } from "express"; import { Buffer } from "node:buffer"; import FormData from "form-data"; import fs from "fs"; -import { markSessionAsSubmitted } from "../saveAndReturn/utils"; +import { markSessionAsSubmitted } from "../modules/saveAndReturn/service/utils"; import { gql } from "graphql-request"; import { $api } from "../client"; import { buildSubmissionExportZip } from "./exportZip"; diff --git a/api.planx.uk/server.ts b/api.planx.uk/server.ts index 0886e7ec98..901b4c578a 100644 --- a/api.planx.uk/server.ts +++ b/api.planx.uk/server.ts @@ -17,7 +17,6 @@ import { locationSearch } from "./gis/index"; import { validateAndDiffFlow, publishFlow } from "./editor/publish"; import { findAndReplaceInFlow } from "./editor/findReplace"; import { copyPortalAsFlow } from "./editor/copyPortalAsFlow"; -import { resumeApplication, validateSession } from "./saveAndReturn"; import { routeSendEmailRequest } from "./notify"; import { makePaymentViaProxy, @@ -57,6 +56,7 @@ import analyticsRoutes from "./modules/analytics/routes"; import adminRoutes from "./modules/admin/routes"; import ordnanceSurveyRoutes from "./modules/ordnanceSurvey/routes"; import fileRoutes from "./modules/file/routes"; +import saveAndReturnRoutes from "./modules/saveAndReturn/routes"; import { useSwaggerDocs } from "./docs"; import { Role } from "@opensystemslab/planx-core/types"; import { $public } from "./client"; @@ -179,6 +179,7 @@ app.use("/analytics", analyticsRoutes); app.use("/admin", adminRoutes); app.use(ordnanceSurveyRoutes); app.use("/file", fileRoutes); +app.use(saveAndReturnRoutes); app.use("/gis", router); @@ -308,8 +309,6 @@ app.post( useSendEmailAuth, routeSendEmailRequest, ); -app.post("/resume-application", sendEmailLimiter, resumeApplication); -app.post("/validate-session", validateSession); app.post("/invite-to-pay/:sessionId", inviteToPay);