From a083beb635410361b61d271507d7fe4a93dd4038 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dafydd=20Ll=C5=B7r=20Pearson?= Date: Fri, 17 Nov 2023 08:51:11 +0000 Subject: [PATCH] feat: Send email docs (#2432) * chore: Move notify to lib * chore: Move files to sendEmail module * fix: Resolve circular dependency issue * feat: Break into routes and controller * test: Update test cases * docs: Add Swagger docs --- api.planx.uk/inviteToPay/paymentRequest.ts | 27 --- .../inviteToPay/sendConfirmationEmail.test.ts | 12 +- .../inviteToPay/sendConfirmationEmail.ts | 2 +- .../inviteToPay/sendPaymentEmail.test.ts | 6 +- api.planx.uk/inviteToPay/sendPaymentEmail.ts | 2 +- .../{notify/notify.ts => lib/notify/index.ts} | 6 +- api.planx.uk/modules/auth/middleware.ts | 2 +- .../service/resumeApplication.ts | 2 +- .../modules/saveAndReturn/service/utils.ts | 2 +- api.planx.uk/modules/sendEmail/controller.ts | 88 ++++++++ api.planx.uk/modules/sendEmail/docs.yaml | 95 ++++++++ .../sendEmail/index.test.ts} | 17 +- api.planx.uk/modules/sendEmail/routes.ts | 42 ++++ api.planx.uk/modules/sendEmail/types.ts | 65 ++++++ api.planx.uk/notify/index.ts | 2 - api.planx.uk/notify/routeSendEmailRequest.ts | 106 --------- api.planx.uk/pay/index.ts | 206 +++++++++++++++++- api.planx.uk/pay/pay.ts | 205 ----------------- api.planx.uk/pay/utils.ts | 30 +++ api.planx.uk/send/email.ts | 2 +- api.planx.uk/server.ts | 15 +- 21 files changed, 555 insertions(+), 379 deletions(-) rename api.planx.uk/{notify/notify.ts => lib/notify/index.ts} (93%) create mode 100644 api.planx.uk/modules/sendEmail/controller.ts create mode 100644 api.planx.uk/modules/sendEmail/docs.yaml rename api.planx.uk/{notify/routeSendEmailRequest.test.ts => modules/sendEmail/index.test.ts} (94%) create mode 100644 api.planx.uk/modules/sendEmail/routes.ts create mode 100644 api.planx.uk/modules/sendEmail/types.ts delete mode 100644 api.planx.uk/notify/index.ts delete mode 100644 api.planx.uk/notify/routeSendEmailRequest.ts delete mode 100644 api.planx.uk/pay/pay.ts create mode 100644 api.planx.uk/pay/utils.ts diff --git a/api.planx.uk/inviteToPay/paymentRequest.ts b/api.planx.uk/inviteToPay/paymentRequest.ts index e6a82a34b4..bc09f7ff34 100644 --- a/api.planx.uk/inviteToPay/paymentRequest.ts +++ b/api.planx.uk/inviteToPay/paymentRequest.ts @@ -128,33 +128,6 @@ export const fetchPaymentRequestViaProxy = fetchPaymentViaProxyWithCallback( }, ); -export const addGovPayPaymentIdToPaymentRequest = async ( - paymentRequestId: string, - govUKPayment: GovUKPayment, -): Promise => { - const query = gql` - mutation AddGovPayPaymentIdToPaymentRequest( - $paymentRequestId: uuid! - $govPayPaymentId: String - ) { - update_payment_requests_by_pk( - pk_columns: { id: $paymentRequestId } - _set: { govpay_payment_id: $govPayPaymentId } - ) { - id - } - } - `; - try { - await $api.client.request(query, { - paymentRequestId, - govPayPaymentId: govUKPayment.payment_id, - }); - } catch (error) { - throw Error(`payment request ${paymentRequestId} not updated`); - } -}; - interface MarkPaymentRequestAsPaid { updatePaymentRequestPaidAt: { affectedRows: number; diff --git a/api.planx.uk/inviteToPay/sendConfirmationEmail.test.ts b/api.planx.uk/inviteToPay/sendConfirmationEmail.test.ts index 6a99895e9e..9e4126062b 100644 --- a/api.planx.uk/inviteToPay/sendConfirmationEmail.test.ts +++ b/api.planx.uk/inviteToPay/sendConfirmationEmail.test.ts @@ -2,9 +2,9 @@ import supertest from "supertest"; import app from "../server"; import { queryMock } from "../tests/graphqlQueryMock"; import { sendAgentAndPayeeConfirmationEmail } from "./sendConfirmationEmail"; -import { sendEmail } from "../notify/notify"; +import { sendEmail } from "../lib/notify"; -jest.mock("../notify/notify", () => ({ +jest.mock("../lib/notify", () => ({ sendEmail: jest.fn(), })); @@ -108,7 +108,7 @@ describe("Invite to pay confirmation templates cannot be sent individually", () test(`the "${template}" template`, async () => { const data = { payload: { - sessionId: "TestSesionID", + sessionId: "TestSessionID", lockedAt: "2023-05-18T12:49:22.839068+00:00", }, }; @@ -116,8 +116,10 @@ describe("Invite to pay confirmation templates cannot be sent individually", () .post(`/send-email/${template}`) .set("Authorization", "testtesttest") .send(data) - .expect(400, { - error: `Failed to send "${template}" email. Invalid template`, + .expect(400) + .then((res) => { + expect(res.body).toHaveProperty("issues"); + expect(res.body).toHaveProperty("name", "ZodError"); }); }); } diff --git a/api.planx.uk/inviteToPay/sendConfirmationEmail.ts b/api.planx.uk/inviteToPay/sendConfirmationEmail.ts index 1ec7dfc369..368f766731 100644 --- a/api.planx.uk/inviteToPay/sendConfirmationEmail.ts +++ b/api.planx.uk/inviteToPay/sendConfirmationEmail.ts @@ -1,5 +1,5 @@ import { $public, $api } from "../client"; -import { sendEmail } from "../notify"; +import { sendEmail } from "../lib/notify"; import { gql } from "graphql-request"; import { convertSlugToName } from "../modules/saveAndReturn/service/utils"; import type { AgentAndPayeeSubmissionNotifyConfig } from "../types"; diff --git a/api.planx.uk/inviteToPay/sendPaymentEmail.test.ts b/api.planx.uk/inviteToPay/sendPaymentEmail.test.ts index 32ec62f4c1..4c4012e9f4 100644 --- a/api.planx.uk/inviteToPay/sendPaymentEmail.test.ts +++ b/api.planx.uk/inviteToPay/sendPaymentEmail.test.ts @@ -90,10 +90,8 @@ describe("Send email endpoint for invite to pay templates", () => { .send(missingPaymentRequestId) .expect(400) .then((response) => { - expect(response.body).toHaveProperty( - "error", - `Failed to send "${template}" email. Required \`paymentRequestId\` missing`, - ); + expect(response.body).toHaveProperty("issues"); + expect(response.body).toHaveProperty("name", "ZodError"); }); }); diff --git a/api.planx.uk/inviteToPay/sendPaymentEmail.ts b/api.planx.uk/inviteToPay/sendPaymentEmail.ts index bf62668d1c..cf2f33fd96 100644 --- a/api.planx.uk/inviteToPay/sendPaymentEmail.ts +++ b/api.planx.uk/inviteToPay/sendPaymentEmail.ts @@ -4,7 +4,7 @@ import { convertSlugToName, getServiceLink, } from "../modules/saveAndReturn/service/utils"; -import { Template, getClientForTemplate, sendEmail } from "../notify"; +import { Template, getClientForTemplate, sendEmail } from "../lib/notify"; import { InviteToPayNotifyConfig } from "../types"; import { Team } from "../types"; import type { PaymentRequest } from "@opensystemslab/planx-core/types"; diff --git a/api.planx.uk/notify/notify.ts b/api.planx.uk/lib/notify/index.ts similarity index 93% rename from api.planx.uk/notify/notify.ts rename to api.planx.uk/lib/notify/index.ts index 420eab0bf9..a3963d4d9d 100644 --- a/api.planx.uk/notify/notify.ts +++ b/api.planx.uk/lib/notify/index.ts @@ -1,7 +1,7 @@ import { NotifyClient } from "notifications-node-client"; -import { softDeleteSession } from "../modules/saveAndReturn/service/utils"; -import { NotifyConfig } from "../types"; -import { $api, $public } from "../client"; +import { softDeleteSession } from "../../modules/saveAndReturn/service/utils"; +import { NotifyConfig } from "../../types"; +import { $api, $public } from "../../client"; const notifyClient = new NotifyClient(process.env.GOVUK_NOTIFY_API_KEY); diff --git a/api.planx.uk/modules/auth/middleware.ts b/api.planx.uk/modules/auth/middleware.ts index df1b1217c6..14663abf80 100644 --- a/api.planx.uk/modules/auth/middleware.ts +++ b/api.planx.uk/modules/auth/middleware.ts @@ -1,7 +1,7 @@ import crypto from "crypto"; import assert from "assert"; import { ServerError } from "../../errors"; -import { Template } from "../../notify"; +import { Template } from "../../lib/notify"; import { expressjwt } from "express-jwt"; import passport from "passport"; diff --git a/api.planx.uk/modules/saveAndReturn/service/resumeApplication.ts b/api.planx.uk/modules/saveAndReturn/service/resumeApplication.ts index 5a334670ec..1241ba5bcb 100644 --- a/api.planx.uk/modules/saveAndReturn/service/resumeApplication.ts +++ b/api.planx.uk/modules/saveAndReturn/service/resumeApplication.ts @@ -1,7 +1,7 @@ import { gql } from "graphql-request"; import { LowCalSession, Team } from "../../../types"; import { convertSlugToName, getResumeLink, calculateExpiryDate } from "./utils"; -import { sendEmail } from "../../../notify"; +import { sendEmail } from "../../../lib/notify"; import type { SiteAddress } from "@opensystemslab/planx-core/types"; import { $api, $public } from "../../../client"; diff --git a/api.planx.uk/modules/saveAndReturn/service/utils.ts b/api.planx.uk/modules/saveAndReturn/service/utils.ts index fd13b5ce82..f6665b8d80 100644 --- a/api.planx.uk/modules/saveAndReturn/service/utils.ts +++ b/api.planx.uk/modules/saveAndReturn/service/utils.ts @@ -2,7 +2,7 @@ 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 { Template, getClientForTemplate, sendEmail } from "../../../lib/notify"; import { $api, $public } from "../../../client"; const DAYS_UNTIL_EXPIRY = 28; diff --git a/api.planx.uk/modules/sendEmail/controller.ts b/api.planx.uk/modules/sendEmail/controller.ts new file mode 100644 index 0000000000..a91ae0bfdf --- /dev/null +++ b/api.planx.uk/modules/sendEmail/controller.ts @@ -0,0 +1,88 @@ +import { + sendSinglePaymentEmail, + sendAgentAndPayeeConfirmationEmail, +} from "../../inviteToPay"; +import { sendSingleApplicationEmail } from "../saveAndReturn/service/utils"; +import { ServerError } from "../../errors"; +import { NextFunction } from "express"; +import { + ConfirmationEmail, + PaymentEmail, + SingleApplicationEmail, +} from "./types"; + +export const singleApplicationEmailController: SingleApplicationEmail = async ( + _req, + res, + next, +) => { + const { email, sessionId } = res.locals.parsedReq.body.payload; + const { template } = res.locals.parsedReq.params; + + try { + const response = await sendSingleApplicationEmail({ + template, + email, + sessionId, + }); + return res.json(response); + } catch (error) { + emailErrorHandler(next, error, template); + } +}; + +export const paymentEmailController: PaymentEmail = async (_req, res, next) => { + const { paymentRequestId } = res.locals.parsedReq.body.payload; + const { template } = res.locals.parsedReq.params; + + try { + const response = await sendSinglePaymentEmail({ + template, + paymentRequestId, + }); + return res.json(response); + } catch (error) { + emailErrorHandler(next, error, template); + } +}; + +export const confirmationEmailController: ConfirmationEmail = async ( + _req, + res, + next, +) => { + const { lockedAt, sessionId, email } = res.locals.parsedReq.body.payload; + const { template } = res.locals.parsedReq.params; + + try { + // if the session is locked we can infer that a payment request has been initiated + const paymentRequestInitiated = Boolean(lockedAt); + if (paymentRequestInitiated) { + const response = await sendAgentAndPayeeConfirmationEmail(sessionId); + return res.json(response); + } else { + const response = await sendSingleApplicationEmail({ + template, + email, + sessionId, + }); + return res.json(response); + } + } catch (error) { + emailErrorHandler(next, error, template); + } +}; + +const emailErrorHandler = ( + next: NextFunction, + error: unknown, + template: string, +) => + next( + new ServerError({ + status: error instanceof ServerError ? error.status : undefined, + message: `Failed to send "${template}" email. ${ + (error as Error).message + }`, + }), + ); diff --git a/api.planx.uk/modules/sendEmail/docs.yaml b/api.planx.uk/modules/sendEmail/docs.yaml new file mode 100644 index 0000000000..1aeb4c1ba5 --- /dev/null +++ b/api.planx.uk/modules/sendEmail/docs.yaml @@ -0,0 +1,95 @@ +openapi: 3.1.0 +info: + title: Planâś• API + version: 0.1.0 +tags: + - name: send email + description: Send templated emails via the GovNotify service +components: + schemas: + SendEmailRequest: + type: object + properties: + payload: + oneOf: + - $ref: "#/components/schemas/SingleApplicationPayload" + - $ref: "#/components/schemas/PaymentPayload" + - $ref: "#/components/schemas/ConfirmationPayload" + SingleApplicationPayload: + type: object + properties: + email: + type: string + format: email + sessionId: + type: string + PaymentPayload: + type: object + properties: + paymentRequestId: + type: string + ConfirmationPayload: + type: object + properties: + sessionId: + type: string + lockedAt: + type: string + format: date-time + nullable: true + email: + type: string + format: email + responses: + SendEmailResponse: + type: object + properties: + message: + type: string + expiryDate: + type: string + format: date-time + nullable: true +paths: + /send-email/{template}: + post: + tags: [send email] + summary: Send an email + parameters: + - name: template + in: path + required: true + schema: + type: string + description: GovNotify template to use + enum: + [ + "reminder", + "expiry", + "save", + "invite-to-pay", + "invite-to-pay-agent", + "payment-reminder", + "payment-reminder-agent", + "payment-expiry", + "payment-expiry-agent", + "confirmation", + ] + requestBody: + description: | + Request body for sending email. + The structure varies based on the template. + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/SendEmailRequest" + responses: + "200": + description: Email sent successfully + content: + application/json: + schema: + $ref: "#/components/responses/SendEmailResponse" + "500": + $ref: "#/components/responses/ErrorMessage" diff --git a/api.planx.uk/notify/routeSendEmailRequest.test.ts b/api.planx.uk/modules/sendEmail/index.test.ts similarity index 94% rename from api.planx.uk/notify/routeSendEmailRequest.test.ts rename to api.planx.uk/modules/sendEmail/index.test.ts index b96c671be4..996fa16d35 100644 --- a/api.planx.uk/notify/routeSendEmailRequest.test.ts +++ b/api.planx.uk/modules/sendEmail/index.test.ts @@ -1,13 +1,13 @@ import supertest from "supertest"; -import app from "../server"; -import { queryMock } from "../tests/graphqlQueryMock"; +import app from "../../server"; +import { queryMock } from "../../tests/graphqlQueryMock"; import { mockFlow, mockLowcalSession, mockSetupEmailNotifications, mockSoftDeleteLowcalSession, mockValidateSingleSessionRequest, -} from "../tests/mocks/saveAndReturnMocks"; +} from "../../tests/mocks/saveAndReturnMocks"; import { CoreDomainClient } from "@opensystemslab/planx-core"; // https://docs.notifications.service.gov.uk/node.html#email-addresses @@ -39,10 +39,8 @@ describe("Send Email endpoint", () => { .send(invalidBody) .expect(400) .then((response) => { - expect(response.body).toHaveProperty( - "error", - 'Failed to send "save" email. Required value missing', - ); + expect(response.body).toHaveProperty("issues"); + expect(response.body).toHaveProperty("name", "ZodError"); }); } }); @@ -75,9 +73,10 @@ describe("Send Email endpoint", () => { await supertest(app) .post(SAVE_ENDPOINT) .send(data) - .expect(500) + .expect(400) .then((response) => { - expect(response.body).toHaveProperty("error"); + expect(response.body).toHaveProperty("issues"); + expect(response.body).toHaveProperty("name", "ZodError"); }); }); diff --git a/api.planx.uk/modules/sendEmail/routes.ts b/api.planx.uk/modules/sendEmail/routes.ts new file mode 100644 index 0000000000..b36b515744 --- /dev/null +++ b/api.planx.uk/modules/sendEmail/routes.ts @@ -0,0 +1,42 @@ +import { Router } from "express"; +import { useSendEmailAuth } from "../auth/middleware"; +import { + confirmationEmailController, + paymentEmailController, + singleApplicationEmailController, +} from "./controller"; +import { sendEmailLimiter } from "../../rateLimit"; +import { validate } from "../../shared/middleware/validate"; +import { + confirmationEmailSchema, + paymentEmailSchema, + singleApplicationEmailSchema, +} from "./types"; + +const router = Router(); + +router.post( + `/send-email/:template(reminder|expiry|save)`, + sendEmailLimiter, + useSendEmailAuth, + validate(singleApplicationEmailSchema), + singleApplicationEmailController, +); + +router.post( + "/send-email/:template(confirmation)", + sendEmailLimiter, + useSendEmailAuth, + validate(confirmationEmailSchema), + confirmationEmailController, +); + +router.post( + "/send-email/:template", + sendEmailLimiter, + useSendEmailAuth, + validate(paymentEmailSchema), + paymentEmailController, +); + +export default router; diff --git a/api.planx.uk/modules/sendEmail/types.ts b/api.planx.uk/modules/sendEmail/types.ts new file mode 100644 index 0000000000..ae24d85519 --- /dev/null +++ b/api.planx.uk/modules/sendEmail/types.ts @@ -0,0 +1,65 @@ +import { z } from "zod"; +import { ValidatedRequestHandler } from "../../shared/middleware/validate"; + +interface SendEmailResponse { + message: string; + expiryDate?: string; +} + +export const singleApplicationEmailSchema = z.object({ + body: z.object({ + payload: z.object({ + email: z.string().email(), + sessionId: z.string(), + }), + }), + params: z.object({ + template: z.enum(["reminder", "expiry", "save"]), + }), +}); + +export type SingleApplicationEmail = ValidatedRequestHandler< + typeof singleApplicationEmailSchema, + SendEmailResponse +>; + +export const paymentEmailSchema = z.object({ + body: z.object({ + payload: z.object({ + paymentRequestId: z.string(), + }), + }), + params: z.object({ + template: z.enum([ + "invite-to-pay", + "invite-to-pay-agent", + "payment-reminder", + "payment-reminder-agent", + "payment-expiry", + "payment-expiry-agent", + ]), + }), +}); + +export type PaymentEmail = ValidatedRequestHandler< + typeof paymentEmailSchema, + SendEmailResponse +>; + +export const confirmationEmailSchema = z.object({ + body: z.object({ + payload: z.object({ + sessionId: z.string(), + lockedAt: z.string().optional(), + email: z.string().email(), + }), + }), + params: z.object({ + template: z.enum(["confirmation"]), + }), +}); + +export type ConfirmationEmail = ValidatedRequestHandler< + typeof confirmationEmailSchema, + SendEmailResponse +>; diff --git a/api.planx.uk/notify/index.ts b/api.planx.uk/notify/index.ts deleted file mode 100644 index bdf6d3255d..0000000000 --- a/api.planx.uk/notify/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export * from "./routeSendEmailRequest"; -export * from "./notify"; diff --git a/api.planx.uk/notify/routeSendEmailRequest.ts b/api.planx.uk/notify/routeSendEmailRequest.ts deleted file mode 100644 index 5e43364f95..0000000000 --- a/api.planx.uk/notify/routeSendEmailRequest.ts +++ /dev/null @@ -1,106 +0,0 @@ -import { NextFunction, Request, Response } from "express"; -import { - sendSinglePaymentEmail, - sendAgentAndPayeeConfirmationEmail, -} from "../inviteToPay"; -import { sendSingleApplicationEmail } from "../modules/saveAndReturn/service/utils"; -import { Template } from "./notify"; -import { ServerError } from "../errors"; - -export async function routeSendEmailRequest( - req: Request, - res: Response, - next: NextFunction, -) { - try { - const { email, sessionId, paymentRequestId, lockedAt } = req.body.payload; - const template = req.params.template as Template; - - const invalidTemplate = (_unknownTemplate?: never) => { - throw new ServerError({ - message: "Invalid template", - status: 400, - }); - }; - - const handleSingleApplicationEmail = async () => { - if (!email || !sessionId) { - throw new ServerError({ - status: 400, - message: "Required value missing", - }); - } - const response = await sendSingleApplicationEmail({ - template, - email, - sessionId, - }); - return res.json(response); - }; - - const handlePaymentEmails = async () => { - if (!paymentRequestId) { - throw new ServerError({ - status: 400, - message: "Required `paymentRequestId` missing", - }); - } - const response = await sendSinglePaymentEmail({ - template, - paymentRequestId, - }); - return res.json(response); - }; - - const handleInviteToPayConfirmationEmails = async () => { - if (!sessionId) { - throw new ServerError({ - status: 400, - message: "Required `sessionId` missing", - }); - } - const response = await sendAgentAndPayeeConfirmationEmail(sessionId); - return res.json(response); - }; - - switch (template) { - case "reminder": - case "expiry": - case "save": - return await handleSingleApplicationEmail(); - case "invite-to-pay": - case "invite-to-pay-agent": - case "payment-reminder": - case "payment-reminder-agent": - case "payment-expiry": - case "payment-expiry-agent": - return await handlePaymentEmails(); - case "confirmation": { - // if the session is locked we can infer that a payment request has been initiated - const paymentRequestInitiated = Boolean(lockedAt); - if (paymentRequestInitiated) { - return await handleInviteToPayConfirmationEmails(); - } else { - return await handleSingleApplicationEmail(); - } - } - case "resume": - case "submit": - case "confirmation-agent": - case "confirmation-payee": - // templates that are already handled by other routes - return invalidTemplate(); - default: - return invalidTemplate(template); - } - } catch (error) { - next( - new ServerError({ - status: error instanceof ServerError ? error.status : undefined, - message: `Failed to send "${req.params.template}" email. ${ - (error as Error).message - }`, - }), - ); - } -} diff --git a/api.planx.uk/pay/index.ts b/api.planx.uk/pay/index.ts index 22d6f79171..0e525c3e04 100644 --- a/api.planx.uk/pay/index.ts +++ b/api.planx.uk/pay/index.ts @@ -1 +1,205 @@ -export * from "./pay"; +import assert from "assert"; +import { NextFunction, Request, Response } from "express"; +import { responseInterceptor } from "http-proxy-middleware"; +import SlackNotify from "slack-notify"; +import { logPaymentStatus } from "../send/helpers"; +import { usePayProxy } from "./proxy"; +import { $api } from "../client"; +import { ServerError } from "../errors"; +import { GovUKPayment } from "@opensystemslab/planx-core/types"; +import { addGovPayPaymentIdToPaymentRequest } from "./utils"; + +assert(process.env.SLACK_WEBHOOK_URL); + +// exposed as /pay/:localAuthority and also used as middleware +// returns the url to make a gov uk payment +export async function makePaymentViaProxy( + req: Request, + res: Response, + next: NextFunction, +) { + // confirm that this local authority (aka team) has a pay token configured before creating the proxy + const isSupported = + process.env[`GOV_UK_PAY_TOKEN_${req.params.localAuthority.toUpperCase()}`]; + + if (!isSupported) { + return next( + new ServerError({ + message: `GOV.UK Pay is not enabled for this local authority (${req.params.localAuthority})`, + status: 400, + }), + ); + } + + const flowId = req.query?.flowId as string | undefined; + const sessionId = req.query?.sessionId as string | undefined; + const teamSlug = req.params.localAuthority; + + if (!flowId || !sessionId || !teamSlug) { + return next( + new ServerError({ + message: "Missing required query param", + status: 400, + }), + ); + } + + const session = await $api.session.findDetails(sessionId); + + if (session?.lockedAt) { + return next( + new ServerError({ + message: `Cannot initialise a new payment for locked session ${sessionId}`, + status: 400, + }), + ); + } + + // drop req.params.localAuthority from the path when redirecting + // so redirects to plain [GOV_UK_PAY_URL] with correct bearer token + usePayProxy( + { + pathRewrite: (path) => path.replace(/^\/pay.*$/, ""), + selfHandleResponse: true, + onProxyRes: responseInterceptor( + async (responseBuffer, _proxyRes, _req, _res) => { + const responseString = responseBuffer.toString("utf8"); + const govUkResponse = JSON.parse(responseString); + await logPaymentStatus({ + sessionId, + flowId, + teamSlug, + govUkResponse, + }); + return responseBuffer; + }, + ), + }, + req, + )(req, res, next); +} + +export async function makeInviteToPayPaymentViaProxy( + req: Request, + res: Response, + next: NextFunction, +) { + // confirm that this local authority (aka team) has a pay token configured before creating the proxy + const isSupported = + process.env[`GOV_UK_PAY_TOKEN_${req.params.localAuthority.toUpperCase()}`]; + + if (!isSupported) { + return next({ + status: 400, + message: `GOV.UK Pay is not enabled for this local authority (${req.params.localAuthority})`, + }); + } + + const flowId = req.query?.flowId as string | undefined; + const sessionId = req.query?.sessionId as string | undefined; + const paymentRequestId = req.params?.paymentRequest as string; + const teamSlug = req.params.localAuthority; + + // drop req.params.localAuthority from the path when redirecting + // so redirects to plain [GOV_UK_PAY_URL] with correct bearer token + usePayProxy( + { + pathRewrite: (path) => path.replace(/^\/pay.*$/, ""), + selfHandleResponse: true, + onProxyRes: responseInterceptor(async (responseBuffer) => { + const responseString = responseBuffer.toString("utf8"); + const govUkResponse = JSON.parse(responseString); + await logPaymentStatus({ + sessionId, + flowId, + teamSlug, + govUkResponse, + }); + + try { + await addGovPayPaymentIdToPaymentRequest( + paymentRequestId, + govUkResponse, + ); + } catch (error) { + throw Error(error as string); + } + + return responseBuffer; + }), + }, + req, + )(req, res, next); +} + +// exposed as /pay/:localAuthority/:paymentId and also used as middleware +// fetches the status of the payment +export const fetchPaymentViaProxy = fetchPaymentViaProxyWithCallback( + async (req: Request, govUkPayment: GovUKPayment) => + postPaymentNotificationToSlack(req, govUkPayment), +); + +export function fetchPaymentViaProxyWithCallback( + callback: (req: Request, govUkPayment: GovUKPayment) => Promise, +) { + return async (req: Request, res: Response, next: NextFunction) => { + const flowId = req.query?.flowId as string | undefined; + const sessionId = req.query?.sessionId as string | undefined; + const teamSlug = req.params.localAuthority; + + // will redirect to [GOV_UK_PAY_URL]/:paymentId with correct bearer token + usePayProxy( + { + pathRewrite: () => `/${req.params.paymentId}`, + selfHandleResponse: true, + onProxyRes: responseInterceptor(async (responseBuffer) => { + const govUkResponse = JSON.parse(responseBuffer.toString("utf8")); + + await logPaymentStatus({ + sessionId, + flowId, + teamSlug, + govUkResponse, + }); + + try { + await callback(req, govUkResponse); + } catch (e) { + throw Error(e as string); + } + + // only return payment status, filter out PII + return JSON.stringify({ + payment_id: govUkResponse.payment_id, + amount: govUkResponse.amount, + state: govUkResponse.state, + _links: { + next_url: govUkResponse._links?.next_url, + }, + }); + }), + }, + req, + )(req, res, next); + }; +} + +export async function postPaymentNotificationToSlack( + req: Request, + govUkResponse: GovUKPayment, + label = "", +) { + // if it's a prod payment, notify #planx-notifications so we can monitor for subsequent submissions + if (govUkResponse?.payment_provider !== "sandbox") { + const slack = SlackNotify(process.env.SLACK_WEBHOOK_URL!); + const getStatus = (state: GovUKPayment["state"]) => + state.status + (state.message ? ` (${state.message})` : ""); + const payMessage = `:coin: New GOV Pay payment ${label} *${ + govUkResponse.payment_id + }* with status *${getStatus(govUkResponse.state)}* [${ + req.params.localAuthority + }]`; + await slack.send(payMessage); + console.log("Payment notification posted to Slack"); + } +} diff --git a/api.planx.uk/pay/pay.ts b/api.planx.uk/pay/pay.ts deleted file mode 100644 index ee7f818969..0000000000 --- a/api.planx.uk/pay/pay.ts +++ /dev/null @@ -1,205 +0,0 @@ -import assert from "assert"; -import { NextFunction, Request, Response } from "express"; -import { responseInterceptor } from "http-proxy-middleware"; -import SlackNotify from "slack-notify"; -import { logPaymentStatus } from "../send/helpers"; -import { usePayProxy } from "./proxy"; -import { addGovPayPaymentIdToPaymentRequest } from "../inviteToPay"; -import { $api } from "../client"; -import { ServerError } from "../errors"; -import { GovUKPayment } from "@opensystemslab/planx-core/types"; - -assert(process.env.SLACK_WEBHOOK_URL); - -// exposed as /pay/:localAuthority and also used as middleware -// returns the url to make a gov uk payment -export async function makePaymentViaProxy( - req: Request, - res: Response, - next: NextFunction, -) { - // confirm that this local authority (aka team) has a pay token configured before creating the proxy - const isSupported = - process.env[`GOV_UK_PAY_TOKEN_${req.params.localAuthority.toUpperCase()}`]; - - if (!isSupported) { - return next( - new ServerError({ - message: `GOV.UK Pay is not enabled for this local authority (${req.params.localAuthority})`, - status: 400, - }), - ); - } - - const flowId = req.query?.flowId as string | undefined; - const sessionId = req.query?.sessionId as string | undefined; - const teamSlug = req.params.localAuthority; - - if (!flowId || !sessionId || !teamSlug) { - return next( - new ServerError({ - message: "Missing required query param", - status: 400, - }), - ); - } - - const session = await $api.session.findDetails(sessionId); - - if (session?.lockedAt) { - return next( - new ServerError({ - message: `Cannot initialise a new payment for locked session ${sessionId}`, - status: 400, - }), - ); - } - - // drop req.params.localAuthority from the path when redirecting - // so redirects to plain [GOV_UK_PAY_URL] with correct bearer token - usePayProxy( - { - pathRewrite: (path) => path.replace(/^\/pay.*$/, ""), - selfHandleResponse: true, - onProxyRes: responseInterceptor( - async (responseBuffer, _proxyRes, _req, _res) => { - const responseString = responseBuffer.toString("utf8"); - const govUkResponse = JSON.parse(responseString); - await logPaymentStatus({ - sessionId, - flowId, - teamSlug, - govUkResponse, - }); - return responseBuffer; - }, - ), - }, - req, - )(req, res, next); -} - -export async function makeInviteToPayPaymentViaProxy( - req: Request, - res: Response, - next: NextFunction, -) { - // confirm that this local authority (aka team) has a pay token configured before creating the proxy - const isSupported = - process.env[`GOV_UK_PAY_TOKEN_${req.params.localAuthority.toUpperCase()}`]; - - if (!isSupported) { - return next({ - status: 400, - message: `GOV.UK Pay is not enabled for this local authority (${req.params.localAuthority})`, - }); - } - - const flowId = req.query?.flowId as string | undefined; - const sessionId = req.query?.sessionId as string | undefined; - const paymentRequestId = req.params?.paymentRequest as string; - const teamSlug = req.params.localAuthority; - - // drop req.params.localAuthority from the path when redirecting - // so redirects to plain [GOV_UK_PAY_URL] with correct bearer token - usePayProxy( - { - pathRewrite: (path) => path.replace(/^\/pay.*$/, ""), - selfHandleResponse: true, - onProxyRes: responseInterceptor(async (responseBuffer) => { - const responseString = responseBuffer.toString("utf8"); - const govUkResponse = JSON.parse(responseString); - await logPaymentStatus({ - sessionId, - flowId, - teamSlug, - govUkResponse, - }); - - try { - await addGovPayPaymentIdToPaymentRequest( - paymentRequestId, - govUkResponse, - ); - } catch (error) { - throw Error(error as string); - } - - return responseBuffer; - }), - }, - req, - )(req, res, next); -} - -// exposed as /pay/:localAuthority/:paymentId and also used as middleware -// fetches the status of the payment -export const fetchPaymentViaProxy = fetchPaymentViaProxyWithCallback( - async (req: Request, govUkPayment: GovUKPayment) => - postPaymentNotificationToSlack(req, govUkPayment), -); - -export function fetchPaymentViaProxyWithCallback( - callback: (req: Request, govUkPayment: GovUKPayment) => Promise, -) { - return async (req: Request, res: Response, next: NextFunction) => { - const flowId = req.query?.flowId as string | undefined; - const sessionId = req.query?.sessionId as string | undefined; - const teamSlug = req.params.localAuthority; - - // will redirect to [GOV_UK_PAY_URL]/:paymentId with correct bearer token - usePayProxy( - { - pathRewrite: () => `/${req.params.paymentId}`, - selfHandleResponse: true, - onProxyRes: responseInterceptor(async (responseBuffer) => { - const govUkResponse = JSON.parse(responseBuffer.toString("utf8")); - - await logPaymentStatus({ - sessionId, - flowId, - teamSlug, - govUkResponse, - }); - - try { - await callback(req, govUkResponse); - } catch (e) { - throw Error(e as string); - } - - // only return payment status, filter out PII - return JSON.stringify({ - payment_id: govUkResponse.payment_id, - amount: govUkResponse.amount, - state: govUkResponse.state, - _links: { - next_url: govUkResponse._links?.next_url, - }, - }); - }), - }, - req, - )(req, res, next); - }; -} - -export async function postPaymentNotificationToSlack( - req: Request, - govUkResponse: GovUKPayment, - label = "", -) { - // if it's a prod payment, notify #planx-notifications so we can monitor for subsequent submissions - if (govUkResponse?.payment_provider !== "sandbox") { - const slack = SlackNotify(process.env.SLACK_WEBHOOK_URL!); - const getStatus = (state: GovUKPayment["state"]) => - state.status + (state.message ? ` (${state.message})` : ""); - const payMessage = `:coin: New GOV Pay payment ${label} *${ - govUkResponse.payment_id - }* with status *${getStatus(govUkResponse.state)}* [${ - req.params.localAuthority - }]`; - await slack.send(payMessage); - console.log("Payment notification posted to Slack"); - } -} diff --git a/api.planx.uk/pay/utils.ts b/api.planx.uk/pay/utils.ts new file mode 100644 index 0000000000..f731abf085 --- /dev/null +++ b/api.planx.uk/pay/utils.ts @@ -0,0 +1,30 @@ +import { GovUKPayment } from "@opensystemslab/planx-core/types"; +import { $api } from "../client"; +import { gql } from "graphql-request"; + +export const addGovPayPaymentIdToPaymentRequest = async ( + paymentRequestId: string, + govUKPayment: GovUKPayment, +): Promise => { + const query = gql` + mutation AddGovPayPaymentIdToPaymentRequest( + $paymentRequestId: uuid! + $govPayPaymentId: String + ) { + update_payment_requests_by_pk( + pk_columns: { id: $paymentRequestId } + _set: { govpay_payment_id: $govPayPaymentId } + ) { + id + } + } + `; + try { + await $api.client.request(query, { + paymentRequestId, + govPayPaymentId: govUKPayment.payment_id, + }); + } catch (error) { + throw Error(`payment request ${paymentRequestId} not updated`); + } +}; diff --git a/api.planx.uk/send/email.ts b/api.planx.uk/send/email.ts index c182f15866..34de3fd3e7 100644 --- a/api.planx.uk/send/email.ts +++ b/api.planx.uk/send/email.ts @@ -2,7 +2,7 @@ import type { NextFunction, Request, Response } from "express"; import { gql } from "graphql-request"; import capitalize from "lodash/capitalize"; import { markSessionAsSubmitted } from "../modules/saveAndReturn/service/utils"; -import { sendEmail } from "../notify"; +import { sendEmail } from "../lib/notify"; import { EmailSubmissionNotifyConfig } from "../types"; import { buildSubmissionExportZip } from "./exportZip"; import { $api } from "../client"; diff --git a/api.planx.uk/server.ts b/api.planx.uk/server.ts index 901b4c578a..6bb5d70382 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 { routeSendEmailRequest } from "./notify"; import { makePaymentViaProxy, fetchPaymentViaProxy, @@ -31,13 +30,12 @@ import { } from "./inviteToPay"; import { useHasuraAuth, - useSendEmailAuth, usePlatformAdminAuth, useTeamEditorAuth, } from "./modules/auth/middleware"; import airbrake from "./airbrake"; -import { sendEmailLimiter, apiLimiter } from "./rateLimit"; +import { apiLimiter } from "./rateLimit"; import { sendToBOPS } from "./send/bops"; import { createSendEvents } from "./send/createSendEvents"; import { downloadApplicationFiles, sendToEmail } from "./send/email"; @@ -56,6 +54,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 sendEmailRoutes from "./modules/sendEmail/routes"; import saveAndReturnRoutes from "./modules/saveAndReturn/routes"; import { useSwaggerDocs } from "./docs"; import { Role } from "@opensystemslab/planx-core/types"; @@ -107,6 +106,7 @@ app.use(helmet()); // Create "One-off Scheduled Events" in Hasura from Send component for selected destinations app.post("/create-send-events/:sessionId", createSendEvents); +assert(process.env.GOVUK_NOTIFY_API_KEY); assert(process.env.HASURA_PLANX_API_KEY); assert(process.env.BOPS_API_TOKEN); @@ -180,6 +180,7 @@ app.use("/admin", adminRoutes); app.use(ordnanceSurveyRoutes); app.use("/file", fileRoutes); app.use(saveAndReturnRoutes); +app.use(sendEmailRoutes); app.use("/gis", router); @@ -302,14 +303,6 @@ app.get("/flows/:flowId/download-schema", async (req, res, next) => { } }); -assert(process.env.GOVUK_NOTIFY_API_KEY); -app.post( - "/send-email/:template", - sendEmailLimiter, - useSendEmailAuth, - routeSendEmailRequest, -); - app.post("/invite-to-pay/:sessionId", inviteToPay); const errorHandler: ErrorRequestHandler = (errorObject, _req, res, _next) => {