From 8fb8e0699e0c046fc4ada1f0aa04127edfc2c394 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dafydd=20Ll=C5=B7r=20Pearson?= Date: Mon, 2 Oct 2023 16:13:08 +0100 Subject: [PATCH] feat: Add payment exemption status to Slack notifications (#2251) * refactor: Move webhook routes from server to module * refactor: Update sendSlackNotification to modular structure * test: Add email-submission tests * refactor: Rename and restructure folders, simplify service * feat: Add exemption status to notification * docs: Add Swagger docs * fix: Don't use fee to calculate exemption statuses --- .../_old}/lowcalSessionEvents.test.ts | 6 +- .../webhooks/_old}/lowcalSessionEvents.ts | 4 +- .../_old}/paymentRequestEvents.test.ts | 6 +- .../webhooks/_old}/paymentRequestEvents.ts | 4 +- .../sanitiseApplicationData/index.test.ts | 2 +- .../_old}/sanitiseApplicationData/index.ts | 2 +- .../sanitiseApplicationData/mocks/queries.ts | 0 .../operations.test.ts | 8 +- .../sanitiseApplicationData/operations.ts | 8 +- .../_old}/sanitiseApplicationData/types.d.ts | 0 api.planx.uk/modules/webhooks/controller.ts | 31 ++ api.planx.uk/modules/webhooks/docs.yaml | 135 +++++++ api.planx.uk/modules/webhooks/routes.ts | 40 ++ .../webhooks/sendNotification/index.test.ts | 345 ++++++++++++++++++ .../webhooks/sendNotification/schema.ts | 68 ++++ .../webhooks/sendNotification/service.ts | 64 ++++ .../webhooks/sendNotification/types.ts | 33 ++ api.planx.uk/server.ts | 36 +- api.planx.uk/shared/middleware/validate.ts | 4 +- .../webhooks/sendNotification.test.ts | 186 ---------- api.planx.uk/webhooks/sendNotifications.ts | 78 ---- 21 files changed, 740 insertions(+), 320 deletions(-) rename api.planx.uk/{webhooks => modules/webhooks/_old}/lowcalSessionEvents.test.ts (97%) rename api.planx.uk/{webhooks => modules/webhooks/_old}/lowcalSessionEvents.ts (94%) rename api.planx.uk/{webhooks => modules/webhooks/_old}/paymentRequestEvents.test.ts (98%) rename api.planx.uk/{webhooks => modules/webhooks/_old}/paymentRequestEvents.ts (97%) rename api.planx.uk/{webhooks => modules/webhooks/_old}/sanitiseApplicationData/index.test.ts (99%) rename api.planx.uk/{webhooks => modules/webhooks/_old}/sanitiseApplicationData/index.ts (96%) rename api.planx.uk/{webhooks => modules/webhooks/_old}/sanitiseApplicationData/mocks/queries.ts (100%) rename api.planx.uk/{webhooks => modules/webhooks/_old}/sanitiseApplicationData/operations.test.ts (96%) rename api.planx.uk/{webhooks => modules/webhooks/_old}/sanitiseApplicationData/operations.ts (96%) rename api.planx.uk/{webhooks => modules/webhooks/_old}/sanitiseApplicationData/types.d.ts (100%) create mode 100644 api.planx.uk/modules/webhooks/controller.ts create mode 100644 api.planx.uk/modules/webhooks/docs.yaml create mode 100644 api.planx.uk/modules/webhooks/routes.ts create mode 100644 api.planx.uk/modules/webhooks/sendNotification/index.test.ts create mode 100644 api.planx.uk/modules/webhooks/sendNotification/schema.ts create mode 100644 api.planx.uk/modules/webhooks/sendNotification/service.ts create mode 100644 api.planx.uk/modules/webhooks/sendNotification/types.ts delete mode 100644 api.planx.uk/webhooks/sendNotification.test.ts delete mode 100644 api.planx.uk/webhooks/sendNotifications.ts diff --git a/api.planx.uk/webhooks/lowcalSessionEvents.test.ts b/api.planx.uk/modules/webhooks/_old/lowcalSessionEvents.test.ts similarity index 97% rename from api.planx.uk/webhooks/lowcalSessionEvents.test.ts rename to api.planx.uk/modules/webhooks/_old/lowcalSessionEvents.test.ts index 0158b7f888..0cddfe272b 100644 --- a/api.planx.uk/webhooks/lowcalSessionEvents.test.ts +++ b/api.planx.uk/modules/webhooks/_old/lowcalSessionEvents.test.ts @@ -1,10 +1,10 @@ import supertest from "supertest"; -import app from "../server"; -import { createScheduledEvent } from "../hasura/metadata"; +import app from "../../../server"; +import { createScheduledEvent } from "../../../hasura/metadata"; const { post } = supertest(app); -jest.mock("../hasura/metadata"); +jest.mock("../../../hasura/metadata"); const mockedCreateScheduledEvent = createScheduledEvent as jest.MockedFunction< typeof createScheduledEvent >; diff --git a/api.planx.uk/webhooks/lowcalSessionEvents.ts b/api.planx.uk/modules/webhooks/_old/lowcalSessionEvents.ts similarity index 94% rename from api.planx.uk/webhooks/lowcalSessionEvents.ts rename to api.planx.uk/modules/webhooks/_old/lowcalSessionEvents.ts index 407f951d82..1d50affdc3 100644 --- a/api.planx.uk/webhooks/lowcalSessionEvents.ts +++ b/api.planx.uk/modules/webhooks/_old/lowcalSessionEvents.ts @@ -1,11 +1,11 @@ import { addDays } from "date-fns"; import { Request, Response, NextFunction } from "express"; -import { createScheduledEvent } from "../hasura/metadata"; +import { createScheduledEvent } from "../../../hasura/metadata"; import { DAYS_UNTIL_EXPIRY, REMINDER_DAYS_FROM_EXPIRY, -} from "../saveAndReturn/utils"; +} from "../../../saveAndReturn/utils"; /** * Create "reminder" events for a lowcal_session record diff --git a/api.planx.uk/webhooks/paymentRequestEvents.test.ts b/api.planx.uk/modules/webhooks/_old/paymentRequestEvents.test.ts similarity index 98% rename from api.planx.uk/webhooks/paymentRequestEvents.test.ts rename to api.planx.uk/modules/webhooks/_old/paymentRequestEvents.test.ts index eb0edb9e34..282ca011a5 100644 --- a/api.planx.uk/webhooks/paymentRequestEvents.test.ts +++ b/api.planx.uk/modules/webhooks/_old/paymentRequestEvents.test.ts @@ -1,10 +1,10 @@ import supertest from "supertest"; -import app from "../server"; -import { createScheduledEvent } from "../hasura/metadata"; +import app from "../../../server"; +import { createScheduledEvent } from "../../../hasura/metadata"; const { post } = supertest(app); -jest.mock("../hasura/metadata"); +jest.mock("../../../hasura/metadata"); const mockedCreateScheduledEvent = createScheduledEvent as jest.MockedFunction< typeof createScheduledEvent >; diff --git a/api.planx.uk/webhooks/paymentRequestEvents.ts b/api.planx.uk/modules/webhooks/_old/paymentRequestEvents.ts similarity index 97% rename from api.planx.uk/webhooks/paymentRequestEvents.ts rename to api.planx.uk/modules/webhooks/_old/paymentRequestEvents.ts index e9d2dda42e..30a387b228 100644 --- a/api.planx.uk/webhooks/paymentRequestEvents.ts +++ b/api.planx.uk/modules/webhooks/_old/paymentRequestEvents.ts @@ -1,11 +1,11 @@ import { addDays } from "date-fns"; import { Request, Response, NextFunction } from "express"; -import { createScheduledEvent } from "../hasura/metadata"; +import { createScheduledEvent } from "../../../hasura/metadata"; import { DAYS_UNTIL_EXPIRY, REMINDER_DAYS_FROM_EXPIRY, -} from "../saveAndReturn/utils"; +} from "../../../saveAndReturn/utils"; /** * Create two "invitation" events for a payments_request record: one for the nominee and one for the agent diff --git a/api.planx.uk/webhooks/sanitiseApplicationData/index.test.ts b/api.planx.uk/modules/webhooks/_old/sanitiseApplicationData/index.test.ts similarity index 99% rename from api.planx.uk/webhooks/sanitiseApplicationData/index.test.ts rename to api.planx.uk/modules/webhooks/_old/sanitiseApplicationData/index.test.ts index 1eaf5b572e..96da6ca4b3 100644 --- a/api.planx.uk/webhooks/sanitiseApplicationData/index.test.ts +++ b/api.planx.uk/modules/webhooks/_old/sanitiseApplicationData/index.test.ts @@ -1,5 +1,5 @@ import supertest from "supertest"; -import app from "../../server"; +import app from "../../../../server"; import * as operations from "./operations"; const mockSend = jest.fn(); diff --git a/api.planx.uk/webhooks/sanitiseApplicationData/index.ts b/api.planx.uk/modules/webhooks/_old/sanitiseApplicationData/index.ts similarity index 96% rename from api.planx.uk/webhooks/sanitiseApplicationData/index.ts rename to api.planx.uk/modules/webhooks/_old/sanitiseApplicationData/index.ts index 4716d520aa..9454104edf 100644 --- a/api.planx.uk/webhooks/sanitiseApplicationData/index.ts +++ b/api.planx.uk/modules/webhooks/_old/sanitiseApplicationData/index.ts @@ -3,7 +3,7 @@ import { Request, Response, NextFunction } from "express"; import { getOperations, operationHandler } from "./operations"; import { OperationResult } from "./types"; -import { getFormattedEnvironment } from "../../helpers"; +import { getFormattedEnvironment } from "../../../../helpers"; /** * Called by Hasura cron job `sanitise_application_data` on a nightly basis diff --git a/api.planx.uk/webhooks/sanitiseApplicationData/mocks/queries.ts b/api.planx.uk/modules/webhooks/_old/sanitiseApplicationData/mocks/queries.ts similarity index 100% rename from api.planx.uk/webhooks/sanitiseApplicationData/mocks/queries.ts rename to api.planx.uk/modules/webhooks/_old/sanitiseApplicationData/mocks/queries.ts diff --git a/api.planx.uk/webhooks/sanitiseApplicationData/operations.test.ts b/api.planx.uk/modules/webhooks/_old/sanitiseApplicationData/operations.test.ts similarity index 96% rename from api.planx.uk/webhooks/sanitiseApplicationData/operations.test.ts rename to api.planx.uk/modules/webhooks/_old/sanitiseApplicationData/operations.test.ts index 79e4e3a8a6..9e178f682e 100644 --- a/api.planx.uk/webhooks/sanitiseApplicationData/operations.test.ts +++ b/api.planx.uk/modules/webhooks/_old/sanitiseApplicationData/operations.test.ts @@ -1,5 +1,5 @@ -import { runSQL } from "../../hasura/schema"; -import { queryMock } from "../../tests/graphqlQueryMock"; +import { runSQL } from "../../../../hasura/schema"; +import { queryMock } from "../../../../tests/graphqlQueryMock"; import { mockIds, mockSanitiseBOPSApplicationsMutation, @@ -25,11 +25,11 @@ import { deleteHasuraScheduledEventsForSubmittedSessions, } from "./operations"; -jest.mock("../../hasura/schema"); +jest.mock("../../../../hasura/schema"); const mockRunSQL = runSQL as jest.MockedFunction; const mockFindSession = jest.fn(); -jest.mock("../../client", () => { +jest.mock("../../../../client", () => { return { $admin: { session: { diff --git a/api.planx.uk/webhooks/sanitiseApplicationData/operations.ts b/api.planx.uk/modules/webhooks/_old/sanitiseApplicationData/operations.ts similarity index 96% rename from api.planx.uk/webhooks/sanitiseApplicationData/operations.ts rename to api.planx.uk/modules/webhooks/_old/sanitiseApplicationData/operations.ts index 2ab3a08d46..7e27194681 100644 --- a/api.planx.uk/webhooks/sanitiseApplicationData/operations.ts +++ b/api.planx.uk/modules/webhooks/_old/sanitiseApplicationData/operations.ts @@ -2,10 +2,10 @@ import { gql } from "graphql-request"; import { subMonths } from "date-fns"; import { Operation, OperationResult } from "./types"; -import { adminGraphQLClient } from "../../hasura"; -import { runSQL } from "../../hasura/schema"; -import { getFilesForSession } from "../../session/files"; -import { deleteFilesByURL } from "../../s3/deleteFile"; +import { adminGraphQLClient } from "../../../../hasura"; +import { runSQL } from "../../../../hasura/schema"; +import { getFilesForSession } from "../../../../session/files"; +import { deleteFilesByURL } from "../../../../s3/deleteFile"; const RETENTION_PERIOD_MONTHS = 6; export const getRetentionPeriod = () => diff --git a/api.planx.uk/webhooks/sanitiseApplicationData/types.d.ts b/api.planx.uk/modules/webhooks/_old/sanitiseApplicationData/types.d.ts similarity index 100% rename from api.planx.uk/webhooks/sanitiseApplicationData/types.d.ts rename to api.planx.uk/modules/webhooks/_old/sanitiseApplicationData/types.d.ts diff --git a/api.planx.uk/modules/webhooks/controller.ts b/api.planx.uk/modules/webhooks/controller.ts new file mode 100644 index 0000000000..8b98358f89 --- /dev/null +++ b/api.planx.uk/modules/webhooks/controller.ts @@ -0,0 +1,31 @@ +import { ServerError } from "../../errors"; +import { sendSlackNotification } from "./sendNotification/service"; +import { SendSlackNotification } from "./sendNotification/types"; + +export const sendSlackNotificationController: SendSlackNotification = async ( + req, + res, + next, +) => { + const isProduction = process.env.APP_ENVIRONMENT === "production"; + if (!isProduction) { + return res.status(200).send({ + message: `Staging application submitted, skipping Slack notification`, + }); + } + + const eventData = req.body.event.data.new; + const eventType = req.query.type; + + try { + const data = await sendSlackNotification(eventData, eventType); + return res.status(200).send({ message: "Posted to Slack", data }); + } catch (error) { + return next( + new ServerError({ + message: `Failed to send ${eventType} Slack notification`, + cause: error, + }), + ); + } +}; diff --git a/api.planx.uk/modules/webhooks/docs.yaml b/api.planx.uk/modules/webhooks/docs.yaml new file mode 100644 index 0000000000..837e64a8ea --- /dev/null +++ b/api.planx.uk/modules/webhooks/docs.yaml @@ -0,0 +1,135 @@ +openapi: 3.1.0 +info: + title: Planâś• API + version: 0.1.0 +tags: + - name: webhooks + description: Webhooks for event management +components: + schemas: + Payload: + type: object + properties: + sessionId: + type: string + required: + - sessionId + BopsSubmissionSchema: + type: object + properties: + body: + type: object + properties: + event: + type: object + properties: + data: + type: object + properties: + new: + type: object + properties: + payload: + $ref: "#/components/schemas/Payload" + bops_id: + type: string + destination_url: + type: string + required: + - body + UniformSubmissionSchema: + type: object + properties: + body: + type: object + properties: + event: + type: object + properties: + data: + type: object + properties: + new: + type: object + properties: + payload: + $ref: "#/components/schemas/Payload" + submission_reference: + type: string + response: + type: object + properties: + organisation: + type: string + required: + - body + EmailSubmissionSchema: + type: object + properties: + body: + type: object + properties: + event: + type: object + properties: + data: + type: object + properties: + new: + type: object + properties: + session_id: + type: string + team_slug: + type: string + request: + type: object + properties: + personalisation: + type: object + properties: + serviceName: + type: string + required: + - body + SendSlackNotificationSchema: + oneOf: + - $ref: "#/components/schemas/BopsSubmissionSchema" + - $ref: "#/components/schemas/UniformSubmissionSchema" + - $ref: "#/components/schemas/EmailSubmissionSchema" + responses: + SlackNotificationSuccessMessage: + content: + application/json: + schema: + type: object + properties: + data: + type: string + required: false + description: The generated Slack message + message: + type: string + required: true +paths: + /webhooks/hasura/sendSlackNotification: + post: + tags: ["webhooks"] + summary: Send Slack notification + description: Endpoint to trigger a Slack notification following a submission event + parameters: + - in: query + name: type + type: string + enum: ["bops-submission", "uniform-submission", "email-submission"] + required: true + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/SendSlackNotificationSchema" + responses: + "200": + $ref: "#/components/responses/SlackNotificationSuccessMessage" + "500": + $ref: "#/components/responses/ErrorMessage" diff --git a/api.planx.uk/modules/webhooks/routes.ts b/api.planx.uk/modules/webhooks/routes.ts new file mode 100644 index 0000000000..c98421b7e3 --- /dev/null +++ b/api.planx.uk/modules/webhooks/routes.ts @@ -0,0 +1,40 @@ +import { Router } from "express"; +import { useHasuraAuth } from "../auth/middleware"; +import { createPaymentSendEvents } from "../../inviteToPay/createPaymentSendEvents"; +import { sanitiseApplicationData } from "./_old/sanitiseApplicationData"; +import { + createExpiryEvent, + createReminderEvent, +} from "./_old/lowcalSessionEvents"; +import { + createPaymentExpiryEvents, + createPaymentInvitationEvents, + createPaymentReminderEvents, +} from "./_old/paymentRequestEvents"; +import { validate } from "../../shared/middleware/validate"; +import { sendSlackNotificationController } from "./controller"; +import { sendSlackNotificationSchema } from "./sendNotification/schema"; + +const router = Router(); + +router.use("/hasura", useHasuraAuth); +router.post("/hasura/create-reminder-event", createReminderEvent); +router.post("/hasura/create-expiry-event", createExpiryEvent); +router.post( + "/hasura/create-payment-invitation-events", + createPaymentInvitationEvents, +); +router.post( + "/hasura/create-payment-reminder-events", + createPaymentReminderEvents, +); +router.post("/hasura/create-payment-expiry-events", createPaymentExpiryEvents); +router.post("/hasura/create-payment-send-events", createPaymentSendEvents); +router.post( + "/hasura/send-slack-notification", + validate(sendSlackNotificationSchema), + sendSlackNotificationController, +); +router.post("/hasura/sanitise-application-data", sanitiseApplicationData); + +export default router; diff --git a/api.planx.uk/modules/webhooks/sendNotification/index.test.ts b/api.planx.uk/modules/webhooks/sendNotification/index.test.ts new file mode 100644 index 0000000000..e73c115371 --- /dev/null +++ b/api.planx.uk/modules/webhooks/sendNotification/index.test.ts @@ -0,0 +1,345 @@ +import supertest from "supertest"; +import app from "../../../server"; +import SlackNotify from "slack-notify"; +import { BOPSBody, EmailBody, UniformBody } from "./types"; +import { $admin } from "../../../client"; +import { CoreDomainClient } from "@opensystemslab/planx-core"; + +const mockSessionWithFee = { + data: { + passport: { + data: { + "application.fee.payable": "123", + }, + }, + }, +}; + +const mockSessionWithDisabilityExemption = { + data: { + passport: { + data: { + "application.fee.exemption.disability": ["true"], + }, + }, + }, +}; + +const mockSessionWithResubmissionExemption = { + data: { + passport: { + data: { + "application.fee.exemption.resubmission": ["true"], + }, + }, + }, +}; + +jest.mock("../../../client"); +const mockAdmin = jest.mocked($admin); + +const mockSend = jest.fn(); +jest.mock("slack-notify", () => + jest.fn().mockImplementation(() => { + return { send: mockSend }; + }), +); + +const { post } = supertest(app); + +describe("Send Slack notifications endpoint", () => { + const ENDPOINT = "/webhooks/hasura/send-slack-notification"; + const ORIGINAL_ENV = process.env; + + beforeEach(() => { + process.env = { ...ORIGINAL_ENV }; + mockAdmin.session.find = jest.fn().mockResolvedValue(mockSessionWithFee); + mockSend.mockResolvedValue("Success!"); + }); + + afterEach(jest.clearAllMocks); + + afterAll(() => (process.env = ORIGINAL_ENV)); + + describe("authentication and validation", () => { + it("fails without correct authentication", async () => { + await post(ENDPOINT) + .expect(401) + .then((response) => { + expect(response.body).toEqual({ + error: "Unauthorised", + }); + }); + }); + + it("returns a 400 if 'type' is missing", async () => { + const body = { event: {} }; + await post(ENDPOINT) + .set({ Authorization: process.env.HASURA_PLANX_API_KEY }) + .send(body) + .expect(400) + .then((response) => { + expect(response.body).toHaveProperty("issues"); + expect(response.body).toHaveProperty("name", "ZodError"); + }); + }); + + it("returns a 400 if 'type' is incorrect", async () => { + const body = { event: {} }; + await post(ENDPOINT + "?type=test-submission") + .set({ Authorization: process.env.HASURA_PLANX_API_KEY }) + .send(body) + .expect(400) + .then((response) => { + expect(response.body).toHaveProperty("issues"); + expect(response.body).toHaveProperty("name", "ZodError"); + }); + }); + + it("returns a 400 if 'event' is missing", async () => { + await post(ENDPOINT + "?type=bops-submission") + .set({ Authorization: process.env.HASURA_PLANX_API_KEY }) + .expect(400) + .then((response) => { + expect(response.body).toHaveProperty("issues"); + expect(response.body).toHaveProperty("name", "ZodError"); + }); + }); + }); + + describe("BOPS notifications", () => { + const body: BOPSBody = { + event: { + data: { + new: { + payload: { sessionId: "xyz123" }, + bops_id: "abc123", + destination_url: "https://www.bops-production.com", + }, + }, + }, + }; + + it("skips the staging environment", async () => { + process.env.APP_ENVIRONMENT = "staging"; + await post(ENDPOINT) + .query({ type: "bops-submission" }) + .set({ Authorization: process.env.HASURA_PLANX_API_KEY }) + .send(body) + .expect(200) + .then((response) => { + expect(response.body.message).toMatch(/skipping Slack notification/); + }); + }); + + it("posts to Slack on success", async () => { + process.env.APP_ENVIRONMENT = "production"; + + await post(ENDPOINT) + .query({ type: "bops-submission" }) + .set({ Authorization: process.env.HASURA_PLANX_API_KEY }) + .send(body) + .expect(200) + .then((response) => { + expect(SlackNotify).toHaveBeenCalledWith( + process.env.SLACK_WEBHOOK_URL, + ); + expect(mockSend).toHaveBeenCalledTimes(1); + expect(response.body.message).toBe("Posted to Slack"); + expect(response.body.data).toMatch(/abc123/); + expect(response.body.data).toMatch(/www.bops-production.com/); + }); + }); + + it("returns error when Slack fails", async () => { + process.env.APP_ENVIRONMENT = "production"; + mockSend.mockRejectedValue("Fail!"); + + await post(ENDPOINT) + .query({ type: "bops-submission" }) + .set({ Authorization: process.env.HASURA_PLANX_API_KEY }) + .send(body) + .expect(500) + .then((response) => { + expect(mockSend).toHaveBeenCalledTimes(1); + expect(response.body.error).toMatch(/Failed to send/); + }); + }); + }); + + describe("Uniform notifications", () => { + const body: UniformBody = { + event: { + data: { + new: { + payload: { sessionId: "xyz123" }, + submission_reference: "abc123", + response: { + organisation: "test-council", + }, + }, + }, + }, + }; + + it("skips the staging environment", async () => { + process.env.APP_ENVIRONMENT = "staging"; + await post(ENDPOINT) + .query({ type: "uniform-submission" }) + .set({ Authorization: process.env.HASURA_PLANX_API_KEY }) + .send(body) + .expect(200) + .then((response) => { + expect(response.body.message).toMatch(/skipping Slack notification/); + }); + }); + + it("posts to Slack on success", async () => { + process.env.APP_ENVIRONMENT = "production"; + + await post(ENDPOINT) + .query({ type: "uniform-submission" }) + .set({ Authorization: process.env.HASURA_PLANX_API_KEY }) + .send(body) + .expect(200) + .then((response) => { + expect(SlackNotify).toHaveBeenCalledWith( + process.env.SLACK_WEBHOOK_URL, + ); + expect(mockSend).toHaveBeenCalledTimes(1); + expect(mockAdmin.session.find).toHaveBeenCalledTimes(1); + + expect(response.body.message).toBe("Posted to Slack"); + expect(response.body.data).toMatch(/abc123/); + expect(response.body.data).toMatch(/test-council/); + }); + }); + + it("adds a status to the Slack message for a disability exemption", async () => { + process.env.APP_ENVIRONMENT = "production"; + mockAdmin.session.find = jest + .fn() + .mockResolvedValue(mockSessionWithDisabilityExemption); + + await post(ENDPOINT) + .query({ type: "uniform-submission" }) + .set({ Authorization: process.env.HASURA_PLANX_API_KEY }) + .send(body) + .expect(200) + .then((response) => { + expect(response.body.data).toMatch(/[Exempt]/); + }); + }); + + it("adds a status to the Slack message for a resubmission exemption", async () => { + process.env.APP_ENVIRONMENT = "production"; + mockAdmin.session.find = jest + .fn() + .mockResolvedValue(mockSessionWithResubmissionExemption); + + await post(ENDPOINT) + .query({ type: "uniform-submission" }) + .set({ Authorization: process.env.HASURA_PLANX_API_KEY }) + .send(body) + .expect(200) + .then((response) => { + expect(response.body.data).toMatch(/[Resubmission]/); + }); + }); + + it("handles missing sessions", async () => { + process.env.APP_ENVIRONMENT = "production"; + mockAdmin.session.find = jest.fn().mockResolvedValueOnce(null); + + await post(ENDPOINT) + .query({ type: "uniform-submission" }) + .set({ Authorization: process.env.HASURA_PLANX_API_KEY }) + .send(body) + .expect(500) + .then((response) => { + expect(mockAdmin.session.find).toHaveBeenCalledTimes(1); + expect(response.body.error).toMatch(/Failed to send/); + }); + }); + + it("returns error when Slack fails", async () => { + process.env.APP_ENVIRONMENT = "production"; + mockSend.mockRejectedValue("Fail!"); + + await post(ENDPOINT) + .query({ type: "uniform-submission" }) + .set({ Authorization: process.env.HASURA_PLANX_API_KEY }) + .send(body) + .expect(500) + .then((response) => { + expect(mockSend).toHaveBeenCalledTimes(1); + expect(response.body.error).toMatch(/Failed to send/); + }); + }); + }); + + describe("Email notifications", () => { + const body: EmailBody = { + event: { + data: { + new: { + session_id: "abc123", + team_slug: "testTeam", + request: { + personalisation: { + serviceName: "testServiceName", + }, + }, + }, + }, + }, + }; + + it("skips the staging environment", async () => { + process.env.APP_ENVIRONMENT = "staging"; + await post(ENDPOINT) + .query({ type: "email-submission" }) + .set({ Authorization: process.env.HASURA_PLANX_API_KEY }) + .send(body) + .expect(200) + .then((response) => { + expect(response.body.message).toMatch(/skipping Slack notification/); + }); + }); + + it("posts to Slack on success", async () => { + process.env.APP_ENVIRONMENT = "production"; + + await post(ENDPOINT) + .query({ type: "email-submission" }) + .set({ Authorization: process.env.HASURA_PLANX_API_KEY }) + .send(body) + .expect(200) + .then((response) => { + expect(SlackNotify).toHaveBeenCalledWith( + process.env.SLACK_WEBHOOK_URL, + ); + expect(mockSend).toHaveBeenCalledTimes(1); + expect(response.body.message).toBe("Posted to Slack"); + expect(response.body.data).toMatch(/abc123/); + expect(response.body.data).toMatch(/testTeam/); + expect(response.body.data).toMatch(/testServiceName/); + }); + }); + + it("returns error when Slack fails", async () => { + process.env.APP_ENVIRONMENT = "production"; + mockSend.mockRejectedValue("Fail!"); + + await post(ENDPOINT) + .query({ type: "email-submission" }) + .set({ Authorization: process.env.HASURA_PLANX_API_KEY }) + .send(body) + .expect(500) + .then((response) => { + expect(mockSend).toHaveBeenCalledTimes(1); + expect(response.body.error).toMatch(/Failed to send/); + }); + }); + }); +}); diff --git a/api.planx.uk/modules/webhooks/sendNotification/schema.ts b/api.planx.uk/modules/webhooks/sendNotification/schema.ts new file mode 100644 index 0000000000..e8cb1c4f74 --- /dev/null +++ b/api.planx.uk/modules/webhooks/sendNotification/schema.ts @@ -0,0 +1,68 @@ +import { z } from "zod"; + +const payload = z.object({ + sessionId: z.string(), +}); + +export const bopsSubmissionSchema = z.object({ + body: z.object({ + event: z.object({ + data: z.object({ + new: z.object({ + payload, + bops_id: z.string(), + destination_url: z.string(), + }), + }), + }), + }), + query: z.object({ + type: z.literal("bops-submission"), + }), +}); + +export const uniformSubmissionSchema = z.object({ + body: z.object({ + event: z.object({ + data: z.object({ + new: z.object({ + payload, + submission_reference: z.string(), + response: z.object({ + organisation: z.string(), + }), + }), + }), + }), + }), + query: z.object({ + type: z.literal("uniform-submission"), + }), +}); + +export const emailSubmissionSchema = z.object({ + body: z.object({ + event: z.object({ + data: z.object({ + new: z.object({ + session_id: z.string(), + team_slug: z.string(), + request: z.object({ + personalisation: z.object({ + serviceName: z.string(), + }), + }), + }), + }), + }), + }), + query: z.object({ + type: z.literal("email-submission"), + }), +}); + +export const sendSlackNotificationSchema = z.union([ + bopsSubmissionSchema, + uniformSubmissionSchema, + emailSubmissionSchema, +]); diff --git a/api.planx.uk/modules/webhooks/sendNotification/service.ts b/api.planx.uk/modules/webhooks/sendNotification/service.ts new file mode 100644 index 0000000000..be8678a0e9 --- /dev/null +++ b/api.planx.uk/modules/webhooks/sendNotification/service.ts @@ -0,0 +1,64 @@ +import { Passport } from "@opensystemslab/planx-core"; +import SlackNotify from "slack-notify"; +import { + BOPSEventData, + EmailEventData, + EventData, + EventType, + UniformEventData, +} from "./types"; +import { $admin } from "../../../client"; + +export const sendSlackNotification = async ( + data: EventData, + type: EventType, +) => { + const slack = SlackNotify(process.env.SLACK_WEBHOOK_URL!); + let message = getMessageForEventType(data, type); + + const sessionId = getSessionIdFromEvent(data, type); + const { disability, resubmission } = + await getExemptionStatusesForSession(sessionId); + if (disability) message += " [Exempt]"; + if (resubmission) message += " [Resubmission]"; + + await slack.send(":incoming_envelope: " + message); + return message; +}; + +const getMessageForEventType = (data: EventData, type: EventType) => { + if (type === "bops-submission") { + const { bops_id, destination_url } = data as BOPSEventData; + return `New BOPS submission *${bops_id}* [${destination_url}]`; + } + + if (type === "uniform-submission") { + const { submission_reference, response } = data as UniformEventData; + return `New Uniform submission *${submission_reference}* [${response.organisation}]`; + } + + if (type === "email-submission") { + const { request, session_id, team_slug } = data as EmailEventData; + return `New email submission "${request.personalisation.serviceName}" *${session_id}* [${team_slug}]`; + } +}; + +const getSessionIdFromEvent = (data: EventData, type: EventType) => + ({ + "bops-submission": (data as BOPSEventData).payload?.sessionId, + "uniform-submission": (data as UniformEventData).payload?.sessionId, + "email-submission": (data as EmailEventData).session_id, + })[type]; + +const getExemptionStatusesForSession = async (sessionId: string) => { + const session = await $admin.session.find(sessionId); + if (!session) throw Error(`Unable to find session with ID ${sessionId}`); + + const passport = new Passport(session.data.passport); + const disability = passport.boolean(["application.fee.exemption.disability"]); + const resubmission = passport.boolean([ + "application.fee.exemption.resubmission", + ]); + + return { disability, resubmission }; +}; diff --git a/api.planx.uk/modules/webhooks/sendNotification/types.ts b/api.planx.uk/modules/webhooks/sendNotification/types.ts new file mode 100644 index 0000000000..cd1e293bf9 --- /dev/null +++ b/api.planx.uk/modules/webhooks/sendNotification/types.ts @@ -0,0 +1,33 @@ +import { z } from "zod"; +import { ValidatedRequestHandler } from "../../../shared/middleware/validate"; +import { + bopsSubmissionSchema, + emailSubmissionSchema, + sendSlackNotificationSchema, + uniformSubmissionSchema, +} from "./schema"; + +interface SendSlackNotificationResponse { + message: string; + data?: string; +} + +export type EventType = z.infer< + typeof sendSlackNotificationSchema +>["query"]["type"]; + +export type BOPSBody = z.infer["body"]; +export type BOPSEventData = BOPSBody["event"]["data"]["new"]; + +export type UniformBody = z.infer["body"]; +export type UniformEventData = UniformBody["event"]["data"]["new"]; + +export type EmailBody = z.infer["body"]; +export type EmailEventData = EmailBody["event"]["data"]["new"]; + +export type EventData = BOPSEventData | UniformEventData | EmailEventData; + +export type SendSlackNotification = ValidatedRequestHandler< + typeof sendSlackNotificationSchema, + SendSlackNotificationResponse +>; diff --git a/api.planx.uk/server.ts b/api.planx.uk/server.ts index 6db24eb716..bb8e293c08 100644 --- a/api.planx.uk/server.ts +++ b/api.planx.uk/server.ts @@ -40,10 +40,6 @@ import { } from "./modules/auth/middleware"; import airbrake from "./airbrake"; -import { - createReminderEvent, - createExpiryEvent, -} from "./webhooks/lowcalSessionEvents"; import { adminGraphQLClient as adminClient } from "./hasura"; import { sendEmailLimiter, apiLimiter } from "./rateLimit"; import { @@ -56,31 +52,24 @@ import { sendToBOPS } from "./send/bops"; import { createSendEvents } from "./send/createSendEvents"; import { downloadApplicationFiles, sendToEmail } from "./send/email"; import { sendToUniform } from "./send/uniform"; -import { sendSlackNotification } from "./webhooks/sendNotifications"; import { copyFlow } from "./editor/copyFlow"; import { moveFlow } from "./editor/moveFlow"; import { useOrdnanceSurveyProxy } from "./proxy/ordnanceSurvey"; import { downloadFeedbackCSV } from "./admin/feedback/downloadFeedbackCSV"; -import { sanitiseApplicationData } from "./webhooks/sanitiseApplicationData"; import { getOneAppXML } from "./admin/session/oneAppXML"; import { gql } from "graphql-request"; -import { - createPaymentExpiryEvents, - createPaymentInvitationEvents, - createPaymentReminderEvents, -} from "./webhooks/paymentRequestEvents"; import { classifiedRoadsSearch } from "./gis/classifiedRoads"; import { getBOPSPayload } from "./admin/session/bops"; import { getCSVData, getRedactedCSVData } from "./admin/session/csv"; import { getHTMLExport, getRedactedHTMLExport } from "./admin/session/html"; import { generateZip } from "./admin/session/zip"; -import { createPaymentSendEvents } from "./inviteToPay/createPaymentSendEvents"; import { getSessionSummary } from "./admin/session/summary"; import { googleStrategy } from "./modules/auth/strategy/google"; import authRoutes from "./modules/auth/routes"; import teamRoutes from "./modules/team/routes"; import miscRoutes from "./modules/misc/routes"; import userRoutes from "./modules/user/routes"; +import webhookRoutes from "./modules/webhooks/routes"; import { useSwaggerDocs } from "./docs"; import { Role } from "@opensystemslab/planx-core/types"; @@ -197,6 +186,7 @@ app.use(authRoutes); app.use(miscRoutes); app.use("/user", userRoutes); app.use("/team", teamRoutes); +app.use("/webhooks", webhookRoutes); app.use("/gis", router); @@ -456,28 +446,6 @@ app.post("/validate-session", validateSession); app.post("/invite-to-pay/:sessionId", inviteToPay); -app.use("/webhooks/hasura", useHasuraAuth); -app.post("/webhooks/hasura/create-reminder-event", createReminderEvent); -app.post("/webhooks/hasura/create-expiry-event", createExpiryEvent); -app.post( - "/webhooks/hasura/create-payment-invitation-events", - createPaymentInvitationEvents, -); -app.post( - "/webhooks/hasura/create-payment-reminder-events", - createPaymentReminderEvents, -); -app.post( - "/webhooks/hasura/create-payment-expiry-events", - createPaymentExpiryEvents, -); -app.post( - "/webhooks/hasura/create-payment-send-events", - createPaymentSendEvents, -); -app.post("/webhooks/hasura/send-slack-notification", sendSlackNotification); -app.post("/webhooks/hasura/sanitise-application-data", sanitiseApplicationData); - app.use("/proxy/ordnance-survey", useOrdnanceSurveyProxy); app.get("/error", async (res, req, next) => { diff --git a/api.planx.uk/shared/middleware/validate.ts b/api.planx.uk/shared/middleware/validate.ts index a5ed42650f..ce647f2f16 100644 --- a/api.planx.uk/shared/middleware/validate.ts +++ b/api.planx.uk/shared/middleware/validate.ts @@ -1,12 +1,12 @@ import { Request, RequestHandler, Response, NextFunction } from "express"; -import { AnyZodObject, ZodSchema, z } from "zod"; +import { AnyZodObject, ZodSchema, ZodTypeAny, ZodUnion, z } from "zod"; /** * Middleware to validate incoming requests to the API * Takes a ZodSchema and returns a validated, and typed, Request object */ export const validate = - (schema: AnyZodObject) => + (schema: AnyZodObject | ZodUnion) => async ( req: Request>, res: Response, diff --git a/api.planx.uk/webhooks/sendNotification.test.ts b/api.planx.uk/webhooks/sendNotification.test.ts deleted file mode 100644 index 5d8eda61f6..0000000000 --- a/api.planx.uk/webhooks/sendNotification.test.ts +++ /dev/null @@ -1,186 +0,0 @@ -import supertest from "supertest"; -import app from "../server"; -import SlackNotify from "slack-notify"; - -const ENDPOINT = "/webhooks/hasura/send-slack-notification"; - -const mockSend = jest.fn(); -jest.mock("slack-notify", () => - jest.fn().mockImplementation(() => { - return { send: mockSend }; - }), -); - -const { post } = supertest(app); - -describe("Send Slack notifications endpoint", () => { - const ORIGINAL_ENV = process.env; - - beforeEach(() => { - jest.resetModules(); - process.env = { ...ORIGINAL_ENV }; - }); - - afterAll(() => { - process.env = ORIGINAL_ENV; - }); - - describe("authentication and validation", () => { - it("fails without correct authentication", async () => { - await post(ENDPOINT) - .expect(401) - .then((response) => { - expect(response.body).toEqual({ - error: "Unauthorised", - }); - }); - }); - - it("returns a 404 if 'type' is missing", async () => { - const body = { event: {} }; - await post(ENDPOINT) - .set({ Authorization: process.env.HASURA_PLANX_API_KEY }) - .send(body) - .expect(404) - .then((response) => { - expect(response.body).toEqual({ - message: "Missing info required to send a Slack notification", - }); - }); - }); - - it("returns a 404 if 'type' is incorrect", async () => { - const body = { event: {} }; - await post(ENDPOINT + "?type=test-submission") - .set({ Authorization: process.env.HASURA_PLANX_API_KEY }) - .send(body) - .expect(404) - .then((response) => { - expect(response.body).toEqual({ - message: "Missing info required to send a Slack notification", - }); - }); - }); - - it("returns a 404 if 'event' is missing", async () => { - await post(ENDPOINT + "?type=bops-submission") - .set({ Authorization: process.env.HASURA_PLANX_API_KEY }) - .expect(404) - .then((response) => { - expect(response.body).toEqual({ - message: "Missing info required to send a Slack notification", - }); - }); - }); - }); - - const destinations = [ - { - name: "BOPS", - type: "bops-submission", - stagingBody: { - event: { - data: { - new: { - destination_url: "https://www.bops-staging.com", - }, - }, - }, - }, - prodBody: { - event: { - data: { - new: { - destination_url: "https://www.bops-production.com", - }, - }, - }, - }, - }, - { - name: "Uniform", - type: "uniform-submission", - stagingBody: { - event: { - data: { - new: { - response: { - _links: { - self: { - href: "https://www.uniform-staging.com", - }, - }, - }, - }, - }, - }, - }, - prodBody: { - event: { - data: { - new: { - response: { - _links: { - self: { - href: "https://www.uniform-production.com", - }, - }, - }, - }, - }, - }, - }, - }, - ]; - - for (const destination of destinations) { - describe(`${destination.name} notifications`, () => { - afterEach(() => jest.clearAllMocks()); - - it("skips the staging environment", async () => { - process.env.APP_ENVIRONMENT = "staging"; - await post(ENDPOINT + `?type=${destination.type}`) - .set({ Authorization: process.env.HASURA_PLANX_API_KEY }) - .send(destination.stagingBody) - .expect(200) - .then((response) => { - expect(response.body.message).toMatch( - /skipping Slack notification/, - ); - }); - }); - - it("posts to Slack on success", async () => { - process.env.APP_ENVIRONMENT = "production"; - mockSend.mockResolvedValue("Success!"); - - await post(ENDPOINT + `?type=${destination.type}`) - .set({ Authorization: process.env.HASURA_PLANX_API_KEY }) - .send(destination.prodBody) - .expect(200) - .then((response) => { - expect(SlackNotify).toHaveBeenCalledWith( - process.env.SLACK_WEBHOOK_URL, - ); - expect(mockSend).toHaveBeenCalledTimes(1); - expect(response.body.message).toBe("Posted to Slack"); - }); - }); - - it("returns error when Slack fails", async () => { - process.env.APP_ENVIRONMENT = "production"; - mockSend.mockRejectedValue("Fail!"); - - await post(ENDPOINT + `?type=${destination.type}`) - .set({ Authorization: process.env.HASURA_PLANX_API_KEY }) - .send(destination.prodBody) - .expect(500) - .then((response) => { - expect(mockSend).toHaveBeenCalledTimes(1); - expect(response.body.error).toMatch(/Failed to send/); - expect(response.body.error).toMatch(/Fail!/); - }); - }); - }); - } -}); diff --git a/api.planx.uk/webhooks/sendNotifications.ts b/api.planx.uk/webhooks/sendNotifications.ts deleted file mode 100644 index c05061f3b1..0000000000 --- a/api.planx.uk/webhooks/sendNotifications.ts +++ /dev/null @@ -1,78 +0,0 @@ -import SlackNotify from "slack-notify"; - -import { Request, Response, NextFunction } from "express"; - -const sendSlackNotification = async ( - req: Request, - res: Response, - next: NextFunction, -): Promise => { - const isProduction = process.env.APP_ENVIRONMENT === "production"; - const supportedTypes = [ - "bops-submission", - "uniform-submission", - "email-submission", - ]; - if ( - !req.body?.event || - !req.query?.type || - !supportedTypes.includes(req.query.type as string) - ) { - return res.status(404).send({ - message: "Missing info required to send a Slack notification", - }); - } - - try { - // hook into the #planx-notifications channel - const slack = SlackNotify(process.env.SLACK_WEBHOOK_URL!); - - const data = req.body?.event?.data?.new; - - if (req.query.type === "bops-submission") { - if (isProduction) { - const bopsMessage = `:incoming_envelope: New BOPS submission *${data?.bops_id}* [${data?.destination_url}]`; - await slack.send(bopsMessage); - return res - .status(200) - .send({ message: "Posted to Slack", data: bopsMessage }); - } - return res.status(200).send({ - message: `Staging application submitted, skipping Slack notification`, - }); - } - - if (req.query.type === "uniform-submission") { - if (isProduction) { - const uniformMessage = `:incoming_envelope: New Uniform submission *${data?.submission_reference}* [${data?.response?.organisation}]`; - await slack.send(uniformMessage); - return res - .status(200) - .send({ message: "Posted to Slack", data: uniformMessage }); - } - return res.status(200).send({ - message: `Staging application submitted, skipping Slack notification`, - }); - } - - if (req.query.type === "email-submission") { - if (isProduction) { - const emailMessage = `:incoming_envelope: New email submission "${data?.request?.personalisation?.serviceName}" *${data?.session_id}* [${data?.team_slug}]`; - await slack.send(emailMessage); - return res - .status(200) - .send({ message: "Posted to Slack", data: emailMessage }); - } - return res.status(200).send({ - message: `Staging application submitted, skipping Slack notification`, - }); - } - } catch (error) { - return next({ - error, - message: `Failed to send ${req.query.type} Slack notification. Error: ${error}`, - }); - } -}; - -export { sendSlackNotification };