diff --git a/api.planx.uk/lib/hasura/metadata/index.ts b/api.planx.uk/lib/hasura/metadata/index.ts index dcc8d11ffc..e9a7b7eac5 100644 --- a/api.planx.uk/lib/hasura/metadata/index.ts +++ b/api.planx.uk/lib/hasura/metadata/index.ts @@ -11,6 +11,7 @@ interface ScheduledEvent { export interface CombinedResponse { bops?: ScheduledEventResponse; + bops_v2?: ScheduledEventResponse; uniform?: ScheduledEventResponse; email?: ScheduledEventResponse; } diff --git a/api.planx.uk/modules/pay/service/inviteToPay/createPaymentSendEvents.ts b/api.planx.uk/modules/pay/service/inviteToPay/createPaymentSendEvents.ts index 55b550cee6..be9b803d32 100644 --- a/api.planx.uk/modules/pay/service/inviteToPay/createPaymentSendEvents.ts +++ b/api.planx.uk/modules/pay/service/inviteToPay/createPaymentSendEvents.ts @@ -66,6 +66,17 @@ const createPaymentSendEvents = async ( comment: `bops_submission_${payload.sessionId}`, }); combinedResponse[Destination.BOPS] = bopsEvent; + + const isProduction = process.env.APP_ENVIRONMENT === "production"; + if (!isProduction) { + const bopsV2Event = await createScheduledEvent({ + webhook: `{{HASURA_PLANX_API_URL}}/bops-v2/${teamSlug}`, + schedule_at: new Date(now.getTime() + 30 * 1000), + payload: eventPayload, + comment: `bops_v2_submission_${payload.sessionId}`, + }); + combinedResponse["bops_v2"] = bopsV2Event; + } } if (destinations.includes(Destination.Email)) { diff --git a/api.planx.uk/modules/send/bops/bops.test.ts b/api.planx.uk/modules/send/bops/bops.test.ts index 4f44b0bbe3..b3150a6e2a 100644 --- a/api.planx.uk/modules/send/bops/bops.test.ts +++ b/api.planx.uk/modules/send/bops/bops.test.ts @@ -3,6 +3,7 @@ import supertest from "supertest"; import { queryMock } from "../../../tests/graphqlQueryMock"; import app from "../../../server"; import { expectedPayload } from "../../../tests/mocks/bopsMocks"; +import { expectedPlanningPermissionPayload } from "../../../tests/mocks/digitalPlanningDataMocks"; jest.mock("../../saveAndReturn/service/utils", () => ({ markSessionAsSubmitted: jest.fn(), @@ -22,6 +23,10 @@ jest.mock("@opensystemslab/planx-core", () => { exportData: expectedPayload, redactedExportData: expectedPayload, }); + this.export.digitalPlanningDataPayload = () => + jest.fn().mockResolvedValue({ + exportData: expectedPlanningPermissionPayload, + }); } }, }; @@ -36,7 +41,10 @@ describe(`sending an application to BOPS`, () => { data: { bopsApplications: [], }, - variables: { session_id: "123" }, + variables: { + session_id: "123", + search_string: "%/api/v1/planning_applications", + }, }); queryMock.mockQuery({ @@ -102,7 +110,10 @@ describe(`sending an application to BOPS`, () => { { response: { message: "Application created", id: "bops_app_id" } }, ], }, - variables: { session_id: "previously_submitted_app" }, + variables: { + session_id: "previously_submitted_app", + search_string: "%/api/v1/planning_applications", + }, }); await supertest(app) @@ -119,3 +130,100 @@ describe(`sending an application to BOPS`, () => { }); }); }); + +describe(`sending an application to BOPS v2`, () => { + beforeEach(() => { + queryMock.mockQuery({ + name: "FindApplication", + data: { + bopsApplications: [], + }, + variables: { + session_id: "123", + search_string: "%/api/v2/planning_applications", + }, + }); + + queryMock.mockQuery({ + name: "CreateBopsApplication", + matchOnVariables: false, + data: { + insertBopsApplication: { id: 22 }, + }, + }); + }); + + it("successfully proxies request and returns hasura id", async () => { + nock(`${submissionURL}/api/v2/planning_applications`).post("").reply(200, { + application: "0000123", + }); + + await supertest(app) + .post("/bops-v2/southwark") + .set({ Authorization: process.env.HASURA_PLANX_API_KEY }) + .send({ payload: { sessionId: "123" } }) + .expect(200) + .then((res) => { + expect(res.body).toEqual({ + application: { id: 22, bopsResponse: { application: "0000123" } }, + }); + }); + }); + + it("requires auth", async () => { + await supertest(app) + .post("/bops-v2/southwark") + .send({ payload: { sessionId: "123" } }) + .expect(401); + }); + + it("throws an error if payload is missing", async () => { + await supertest(app) + .post("/bops-v2/southwark") + .set({ Authorization: process.env.HASURA_PLANX_API_KEY }) + .send({ payload: null }) + .expect(400) + .then((res) => { + expect(res.body.error).toMatch(/Missing application/); + }); + }); + + it("throws an error if team is unsupported", async () => { + await supertest(app) + .post("/bops-v2/unsupported-team") + .set({ Authorization: process.env.HASURA_PLANX_API_KEY }) + .send({ payload: { sessionId: "123" } }) + .expect(400) + .then((res) => { + expect(res.body.error).toMatch(/not enabled for this local authority/); + }); + }); + + it("does not re-send an application which has already been submitted", async () => { + queryMock.mockQuery({ + name: "FindApplication", + data: { + bopsApplications: [ + { response: { message: "Application created", id: "bops_app_id" } }, + ], + }, + variables: { + session_id: "previously_submitted_app", + search_string: "%/api/v2/planning_applications", + }, + }); + + await supertest(app) + .post("/bops-v2/southwark") + .set({ Authorization: process.env.HASURA_PLANX_API_KEY }) + .send({ payload: { sessionId: "previously_submitted_app" } }) + .expect(200) + .then((res) => { + expect(res.body).toEqual({ + sessionId: "previously_submitted_app", + bopsId: "bops_app_id", + message: "Skipping send, already successfully submitted", + }); + }); + }); +}); diff --git a/api.planx.uk/modules/send/bops/bops.ts b/api.planx.uk/modules/send/bops/bops.ts index 61014b50a3..fed2afd51e 100644 --- a/api.planx.uk/modules/send/bops/bops.ts +++ b/api.planx.uk/modules/send/bops/bops.ts @@ -17,6 +17,7 @@ interface CreateBopsApplication { bopsId: string; }; } + const sendToBOPS = async (req: Request, res: Response, next: NextFunction) => { // `/bops/:localAuthority` is only called via Hasura's scheduled event webhook now, so body is wrapped in a "payload" key const { payload }: SendToBOPSRequest = req.body; @@ -30,7 +31,7 @@ const sendToBOPS = async (req: Request, res: Response, next: NextFunction) => { } // confirm that this session has not already been successfully submitted before proceeding - const submittedApp = await checkBOPSAuditTable(payload?.sessionId); + const submittedApp = await checkBOPSAuditTable(payload?.sessionId, "v1"); if (submittedApp?.message === "Application created") { return res.status(200).send({ sessionId: payload?.sessionId, @@ -144,6 +145,139 @@ const sendToBOPS = async (req: Request, res: Response, next: NextFunction) => { } }; +const sendToBOPSV2 = async ( + req: Request, + res: Response, + next: NextFunction, +) => { + // `/bops/:localAuthority` is only called via Hasura's scheduled event webhook now, so body is wrapped in a "payload" key + const { payload }: SendToBOPSRequest = req.body; + if (!payload) { + return next( + new ServerError({ + status: 400, + message: `Missing application payload data to send to BOPS`, + }), + ); + } + + // confirm that this session has not already been successfully submitted before proceeding + const submittedApp = await checkBOPSAuditTable(payload?.sessionId, "v2"); + if (submittedApp?.message === "Application created") { + return res.status(200).send({ + sessionId: payload?.sessionId, + bopsId: submittedApp?.id, + message: `Skipping send, already successfully submitted`, + }); + } + + // confirm this local authority (aka team) is supported by BOPS before creating the proxy + // a local or staging API instance should send to the BOPS staging endpoint + // production should send to the BOPS production endpoint + const localAuthority = req.params.localAuthority; + const bopsSubmissionURLEnvName = `BOPS_SUBMISSION_URL_${localAuthority.toUpperCase()}`; + const bopsSubmissionURL = process.env[bopsSubmissionURLEnvName]; + const isSupported = Boolean(bopsSubmissionURL); + if (!isSupported) { + return next( + new ServerError({ + status: 400, + message: `Back-office Planning System (BOPS) is not enabled for this local authority (${localAuthority})`, + }), + ); + } + const target = `${bopsSubmissionURL}/api/v2/planning_applications`; + const exportData = await $api.export.digitalPlanningDataPayload( + payload?.sessionId, + ); + + try { + const bopsResponse = await axios({ + method: "POST", + url: target, + adapter: "http", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${process.env.BOPS_API_TOKEN}`, + }, + data: exportData, + }) + .then(async (res: AxiosResponse<{ id: string }>) => { + // Mark session as submitted so that reminder and expiry emails are not triggered + markSessionAsSubmitted(payload?.sessionId); + + const applicationId = await $api.client.request( + gql` + mutation CreateBopsApplication( + $bops_id: String = "" + $destination_url: String! + $request: jsonb! + $req_headers: jsonb = {} + $response: jsonb = {} + $response_headers: jsonb = {} + $session_id: String! + ) { + insertBopsApplication: insert_bops_applications_one( + object: { + bops_id: $bops_id + destination_url: $destination_url + request: $request + req_headers: $req_headers + response: $response + response_headers: $response_headers + session_id: $session_id + } + ) { + id + bopsId: bops_id + } + } + `, + { + bops_id: res.data.id, + destination_url: target, + request: exportData, + response: res.data, + response_headers: res.headers, + session_id: payload?.sessionId, + }, + ); + + return { + application: { + ...applicationId.insertBopsApplication, + bopsResponse: res.data, + }, + }; + }) + .catch((error) => { + if (error.response) { + throw new Error( + `Sending to BOPS v2 failed (${localAuthority}):\n${JSON.stringify( + error.response.data, + null, + 2, + )}`, + ); + } else { + // re-throw other errors + throw new Error( + `Sending to BOPS v2 failed (${localAuthority}):\n${error}`, + ); + } + }); + res.send(bopsResponse); + } catch (err) { + next( + new ServerError({ + status: 500, + message: `Sending to BOPS v2 failed (${localAuthority})`, + cause: err, + }), + ); + } +}; + interface FindApplication { bopsApplications: { response: Record; @@ -155,12 +289,17 @@ interface FindApplication { */ async function checkBOPSAuditTable( sessionId: string, + version: "v1" | "v2", ): Promise> { + const searchString = `%/api/${version}/planning_applications`; const application = await $api.client.request( gql` - query FindApplication($session_id: String = "") { + query FindApplication($session_id: String = "", $search_string: String) { bopsApplications: bops_applications( - where: { session_id: { _eq: $session_id } } + where: { + session_id: { _eq: $session_id } + destination_url: { _like: $search_string } + } order_by: { created_at: desc } ) { response @@ -169,10 +308,11 @@ async function checkBOPSAuditTable( `, { session_id: sessionId, + search_string: searchString, }, ); return application?.bopsApplications[0]?.response; } -export { sendToBOPS }; +export { sendToBOPS, sendToBOPSV2 }; diff --git a/api.planx.uk/modules/send/createSendEvents/controller.ts b/api.planx.uk/modules/send/createSendEvents/controller.ts index 54f7af0ed7..6fa8b60808 100644 --- a/api.planx.uk/modules/send/createSendEvents/controller.ts +++ b/api.planx.uk/modules/send/createSendEvents/controller.ts @@ -35,6 +35,17 @@ const createSendEvents: CreateSendEventsController = async ( comment: `bops_submission_${sessionId}`, }); combinedResponse["bops"] = bopsEvent; + + const isProduction = process.env.APP_ENVIRONMENT === "production"; + if (!isProduction) { + const bopsV2Event = await createScheduledEvent({ + webhook: `{{HASURA_PLANX_API_URL}}/bops-v2/${bops.localAuthority}`, + schedule_at: new Date(now.getTime() + 45 * 1000), + payload: bops.body, + comment: `bops_v2_submission_${sessionId}`, + }); + combinedResponse["bops_v2"] = bopsV2Event; + } } if (uniform) { diff --git a/api.planx.uk/modules/send/docs.yaml b/api.planx.uk/modules/send/docs.yaml index 5796bfd6a3..dbf459d219 100644 --- a/api.planx.uk/modules/send/docs.yaml +++ b/api.planx.uk/modules/send/docs.yaml @@ -37,6 +37,23 @@ paths: application/json: schema: $ref: "#/components/schemas/SessionPayload" + /bops-v2/{localAuthority}: + post: + summary: Submits an application to the Back Office Planning System (BOPS) v2 + description: Submits an application to the Back Office Planning System (BOPS) using the ODP Schema payload (v2) + tags: + - send + parameters: + - $ref: "#/components/parameters/localAuthority" + security: + - hasuraAuth: [] + requestBody: + description: This endpoint is only called via Hasura's scheduled event webhook, so body is wrapped in a `payload` key + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/SessionPayload" /email-submission/{localAuthority}: post: summary: Sends an application by email using GOV.UK Notify diff --git a/api.planx.uk/modules/send/routes.ts b/api.planx.uk/modules/send/routes.ts index 73f5974263..f17462070c 100644 --- a/api.planx.uk/modules/send/routes.ts +++ b/api.planx.uk/modules/send/routes.ts @@ -1,7 +1,7 @@ import { Router } from "express"; import { createSendEvents } from "./createSendEvents/controller"; import { useHasuraAuth } from "../auth/middleware"; -import { sendToBOPS } from "./bops/bops"; +import { sendToBOPS, sendToBOPSV2 } from "./bops/bops"; import { sendToUniform } from "./uniform/uniform"; import { sendToEmail } from "./email"; import { validate } from "../../shared/middleware/validate"; @@ -16,6 +16,7 @@ router.post( createSendEvents, ); router.post("/bops/:localAuthority", useHasuraAuth, sendToBOPS); +router.post("/bops-v2/:localAuthority", useHasuraAuth, sendToBOPSV2); router.post("/uniform/:localAuthority", useHasuraAuth, sendToUniform); router.post("/email-submission/:localAuthority", useHasuraAuth, sendToEmail); router.get("/download-application-files/:sessionId", downloadApplicationFiles); diff --git a/editor.planx.uk/src/@planx/components/Send/Public.tsx b/editor.planx.uk/src/@planx/components/Send/Public.tsx index 898b1672f4..780cd47d04 100644 --- a/editor.planx.uk/src/@planx/components/Send/Public.tsx +++ b/editor.planx.uk/src/@planx/components/Send/Public.tsx @@ -75,9 +75,17 @@ const SendEvents: React.FC = ({ isReady && props.handleSubmit ) { - props.handleSubmit( - makeData(props, request.value.bops?.event_id, "bopsSendEventId"), + const v1 = makeData( + props, + request.value.bops?.event_id, + "bopsSendEventId", + ); + const v2 = makeData( + props, + request.value.bops_v2?.event_id, + "bopsV2SendEventId", ); + props.handleSubmit({ ...v1, ...v2 }); } if (