diff --git a/api.planx.uk/lib/hasura/metadata/index.ts b/api.planx.uk/lib/hasura/metadata/index.ts index dc4937d45d..dcc8d11ffc 100644 --- a/api.planx.uk/lib/hasura/metadata/index.ts +++ b/api.planx.uk/lib/hasura/metadata/index.ts @@ -9,6 +9,12 @@ interface ScheduledEvent { args: ScheduledEventArgs; } +export interface CombinedResponse { + bops?: ScheduledEventResponse; + uniform?: ScheduledEventResponse; + email?: ScheduledEventResponse; +} + interface ScheduledEventArgs { headers: Record[]; retry_conf: { diff --git a/api.planx.uk/modules/admin/session/zip.test.ts b/api.planx.uk/modules/admin/session/zip.test.ts index 8bccccfbe3..dccf00e126 100644 --- a/api.planx.uk/modules/admin/session/zip.test.ts +++ b/api.planx.uk/modules/admin/session/zip.test.ts @@ -2,7 +2,7 @@ import supertest from "supertest"; import app from "../../../server"; import { authHeader } from "../../../tests/mockJWT"; -jest.mock("../../../send/exportZip", () => ({ +jest.mock("../../send/utils/exportZip", () => ({ buildSubmissionExportZip: jest.fn().mockResolvedValue({ filename: "tests/mocks/test.zip", remove: jest.fn, diff --git a/api.planx.uk/modules/admin/session/zip.ts b/api.planx.uk/modules/admin/session/zip.ts index 233392793d..5a9c5d38e2 100644 --- a/api.planx.uk/modules/admin/session/zip.ts +++ b/api.planx.uk/modules/admin/session/zip.ts @@ -1,5 +1,5 @@ import { NextFunction, Request, Response } from "express"; -import { buildSubmissionExportZip } from "../../../send/exportZip"; +import { buildSubmissionExportZip } from "../../send/utils/exportZip"; /** * @swagger diff --git a/api.planx.uk/modules/pay/controller.ts b/api.planx.uk/modules/pay/controller.ts index 8d2fe4bfb4..45aa7798ec 100644 --- a/api.planx.uk/modules/pay/controller.ts +++ b/api.planx.uk/modules/pay/controller.ts @@ -1,7 +1,7 @@ import assert from "assert"; import { Request } from "express"; import { responseInterceptor } from "http-proxy-middleware"; -import { logPaymentStatus } from "../../send/helpers"; +import { logPaymentStatus } from "../send/utils/helpers"; import { usePayProxy } from "./proxy"; import { $api } from "../../client"; import { ServerError } from "../../errors"; diff --git a/api.planx.uk/modules/pay/service/inviteToPay/createPaymentSendEvents.ts b/api.planx.uk/modules/pay/service/inviteToPay/createPaymentSendEvents.ts index 618fa1de43..55b550cee6 100644 --- a/api.planx.uk/modules/pay/service/inviteToPay/createPaymentSendEvents.ts +++ b/api.planx.uk/modules/pay/service/inviteToPay/createPaymentSendEvents.ts @@ -1,11 +1,11 @@ import { ComponentType } from "@opensystemslab/planx-core/types"; import { NextFunction, Request, Response } from "express"; import { gql } from "graphql-request"; -import { $api, $public } from "../../../../client"; import { - ScheduledEventResponse, + CombinedResponse, createScheduledEvent, } from "../../../../lib/hasura/metadata"; +import { $api, $public } from "../../../../client"; import { getMostRecentPublishedFlow } from "../../../../helpers"; import { Flow, Node, Team } from "../../../../types"; @@ -15,12 +15,6 @@ enum Destination { Email = "email", } -interface CombinedResponse { - bops?: ScheduledEventResponse; - uniform?: ScheduledEventResponse; - email?: ScheduledEventResponse; -} - // Create "One-off Scheduled Events" in Hasura when a payment request is paid const createPaymentSendEvents = async ( req: Request, diff --git a/api.planx.uk/send/bops.test.ts b/api.planx.uk/modules/send/bops/bops.test.ts similarity index 93% rename from api.planx.uk/send/bops.test.ts rename to api.planx.uk/modules/send/bops/bops.test.ts index e620e17569..4f44b0bbe3 100644 --- a/api.planx.uk/send/bops.test.ts +++ b/api.planx.uk/modules/send/bops/bops.test.ts @@ -1,10 +1,10 @@ import nock from "nock"; import supertest from "supertest"; -import { queryMock } from "../tests/graphqlQueryMock"; -import app from "../server"; -import { expectedPayload } from "../tests/mocks/bopsMocks"; +import { queryMock } from "../../../tests/graphqlQueryMock"; +import app from "../../../server"; +import { expectedPayload } from "../../../tests/mocks/bopsMocks"; -jest.mock("../modules/saveAndReturn/service/utils", () => ({ +jest.mock("../../saveAndReturn/service/utils", () => ({ markSessionAsSubmitted: jest.fn(), })); diff --git a/api.planx.uk/send/bops.ts b/api.planx.uk/modules/send/bops/bops.ts similarity index 86% rename from api.planx.uk/send/bops.ts rename to api.planx.uk/modules/send/bops/bops.ts index dc8d53fbfc..61014b50a3 100644 --- a/api.planx.uk/send/bops.ts +++ b/api.planx.uk/modules/send/bops/bops.ts @@ -1,9 +1,9 @@ import axios, { AxiosResponse } from "axios"; -import { markSessionAsSubmitted } from "../modules/saveAndReturn/service/utils"; +import { markSessionAsSubmitted } from "../../saveAndReturn/service/utils"; import { NextFunction, Request, Response } from "express"; import { gql } from "graphql-request"; -import { $api } from "../client"; -import { ServerError } from "../errors"; +import { $api } from "../../../client"; +import { ServerError } from "../../../errors"; interface SendToBOPSRequest { payload: { @@ -17,27 +17,6 @@ interface CreateBopsApplication { bopsId: string; }; } - -/** - * @swagger - * /bops/{localAuthority}: - * post: - * summary: Submits an application to the Back Office Planning System (BOPS) - * description: Submits an application to the Back Office Planning System (BOPS) - * tags: - * - submissions - * 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' - */ 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; diff --git a/api.planx.uk/modules/send/createSendEvents/controller.ts b/api.planx.uk/modules/send/createSendEvents/controller.ts new file mode 100644 index 0000000000..54f7af0ed7 --- /dev/null +++ b/api.planx.uk/modules/send/createSendEvents/controller.ts @@ -0,0 +1,59 @@ +import { + CombinedResponse, + createScheduledEvent, +} from "../../../lib/hasura/metadata"; +import { CreateSendEventsController } from "./types"; + +// Create "One-off Scheduled Events" in Hasura from Send component for selected destinations +const createSendEvents: CreateSendEventsController = async ( + _req, + res, + next, +) => { + const { email, uniform, bops } = res.locals.parsedReq.body; + const { sessionId } = res.locals.parsedReq.params; + + try { + const now = new Date(); + const combinedResponse: CombinedResponse = {}; + + if (email) { + const emailSubmissionEvent = await createScheduledEvent({ + webhook: `{{HASURA_PLANX_API_URL}}/email-submission/${email.localAuthority}`, + schedule_at: now, + payload: email.body, + comment: `email_submission_${sessionId}`, + }); + combinedResponse["email"] = emailSubmissionEvent; + } + + if (bops) { + const bopsEvent = await createScheduledEvent({ + webhook: `{{HASURA_PLANX_API_URL}}/bops/${bops.localAuthority}`, + schedule_at: new Date(now.getTime() + 30 * 1000), + payload: bops.body, + comment: `bops_submission_${sessionId}`, + }); + combinedResponse["bops"] = bopsEvent; + } + + if (uniform) { + const uniformEvent = await createScheduledEvent({ + webhook: `{{HASURA_PLANX_API_URL}}/uniform/${uniform.localAuthority}`, + schedule_at: new Date(now.getTime() + 60 * 1000), + payload: uniform.body, + comment: `uniform_submission_${sessionId}`, + }); + combinedResponse["uniform"] = uniformEvent; + } + + return res.json(combinedResponse); + } catch (error) { + return next({ + error, + message: `Failed to create send event(s) for session ${sessionId}. Error: ${error}`, + }); + } +}; + +export { createSendEvents }; diff --git a/api.planx.uk/modules/send/createSendEvents/types.ts b/api.planx.uk/modules/send/createSendEvents/types.ts new file mode 100644 index 0000000000..5b884aa0f2 --- /dev/null +++ b/api.planx.uk/modules/send/createSendEvents/types.ts @@ -0,0 +1,26 @@ +import { z } from "zod"; +import { CombinedResponse } from "../../../lib/hasura/metadata"; +import { ValidatedRequestHandler } from "../../../shared/middleware/validate"; + +const eventSchema = z.object({ + localAuthority: z.string(), + body: z.object({ + sessionId: z.string().uuid(), + }), +}); + +export const combinedEventsPayloadSchema = z.object({ + body: z.object({ + email: eventSchema.optional(), + bops: eventSchema.optional(), + uniform: eventSchema.optional(), + }), + params: z.object({ + sessionId: z.string().uuid(), + }), +}); + +export type CreateSendEventsController = ValidatedRequestHandler< + typeof combinedEventsPayloadSchema, + CombinedResponse +>; diff --git a/api.planx.uk/modules/send/docs.yaml b/api.planx.uk/modules/send/docs.yaml new file mode 100644 index 0000000000..5273cb3357 --- /dev/null +++ b/api.planx.uk/modules/send/docs.yaml @@ -0,0 +1,138 @@ +info: + title: Planâś• API + version: 0.1.0 +tags: + - name: send +components: + schemas: + EventSchema: + type: object + properties: + localAuthority: + type: string + body: + type: object + properties: + sessionId: + type: string + format: uuid + required: + - localAuthority + - body +paths: + /bops/{localAuthority}: + post: + summary: Submits an application to the Back Office Planning System (BOPS) + description: Submits an application to the Back Office Planning System (BOPS) + 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 + description: Send an application by email using GOV.UK Notify. The email body includes a link to download the application files. + 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" + /uniform/{localAuthority}: + post: + summary: Submits an application to Uniform + description: Submits an application to Uniform + 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" + /create-send-events/{sessionId}: + post: + summary: Create send events + description: Create "One-off Scheduled Events" in Hasura from Send component for selected destinations + tags: + - send + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + email: + $ref: "#/components/schemas/EventSchema" + bops: + $ref: "#/components/schemas/EventSchema" + uniform: + $ref: "#/components/schemas/EventSchema" + required: + - email + - bops + - uniform + parameters: + - in: query + name: sessionId + required: true + schema: + type: string + format: uuid + /download-application-files/{sessionId}: + get: + summary: Download application files + description: Download application files via a link send to a team's "send to email" email address + tags: + - send + parameters: + - in: path + name: sessionId + required: true + schema: + type: string + format: uuid + - in: query + name: sessionId + required: true + schema: + type: string + format: uuid + - in: query + name: email + required: true + schema: + type: string + format: email + - in: query + name: localAuthority + required: true + schema: + type: string + responses: + "200": + $ref: "#/components/responses/DownloadFile" + "500": + $ref: "#/components/responses/ErrorMessage" diff --git a/api.planx.uk/modules/send/downloadApplicationFiles/index.ts b/api.planx.uk/modules/send/downloadApplicationFiles/index.ts new file mode 100644 index 0000000000..ce5781483a --- /dev/null +++ b/api.planx.uk/modules/send/downloadApplicationFiles/index.ts @@ -0,0 +1,60 @@ +import type { NextFunction, Request, Response } from "express"; +import { buildSubmissionExportZip } from "../utils/exportZip"; +import { getSessionData, getTeamEmailSettings } from "../email/service"; + +export async function downloadApplicationFiles( + req: Request, + res: Response, + next: NextFunction, +) { + const sessionId: string = req.params?.sessionId; + if (!sessionId || !req.query?.email || !req.query?.localAuthority) { + return next({ + status: 400, + message: "Missing values required to access application files", + }); + } + + try { + // Confirm that the provided email matches the stored team settings for the provided localAuthority + const { sendToEmail } = await getTeamEmailSettings( + req.query.localAuthority as string, + ); + if (sendToEmail !== req.query.email) { + return next({ + status: 403, + message: + "Provided email address is not enabled to access application files", + }); + } + + // Fetch this lowcal_session's data + const sessionData = await getSessionData(sessionId); + if (!sessionData) { + return next({ + status: 400, + message: "Failed to find session data for this sessionId", + }); + } + + // create the submission zip + const zip = await buildSubmissionExportZip({ sessionId }); + + // Send it to the client + const zipData = zip.toBuffer(); + res.set("Content-Type", "application/octet-stream"); + res.set("Content-Disposition", `attachment; filename=${zip.filename}`); + res.set("Content-Length", zipData.length.toString()); + res.status(200).send(zipData); + + // Clean up the local zip file + zip.remove(); + + // TODO Record files_downloaded_at timestamp in lowcal_sessions ?? + } catch (error) { + return next({ + error, + message: `Failed to download application files. ${error}`, + }); + } +} diff --git a/api.planx.uk/send/email.test.ts b/api.planx.uk/modules/send/email/index.test.ts similarity index 97% rename from api.planx.uk/send/email.test.ts rename to api.planx.uk/modules/send/email/index.test.ts index 985213588e..d4d59114ca 100644 --- a/api.planx.uk/send/email.test.ts +++ b/api.planx.uk/modules/send/email/index.test.ts @@ -1,6 +1,6 @@ import supertest from "supertest"; -import { queryMock } from "../tests/graphqlQueryMock"; -import app from "../server"; +import { queryMock } from "../../../tests/graphqlQueryMock"; +import app from "../../../server"; const mockGenerateCSVData = jest.fn().mockResolvedValue([ { @@ -34,7 +34,7 @@ const mockBuildSubmissionExportZip = jest.fn().mockImplementation(() => ({ toBuffer: () => Buffer.from("test"), })); -jest.mock("./exportZip", () => { +jest.mock("../utils/exportZip", () => { return { buildSubmissionExportZip: (input: string) => Promise.resolve(mockBuildSubmissionExportZip(input)), diff --git a/api.planx.uk/modules/send/email/index.ts b/api.planx.uk/modules/send/email/index.ts new file mode 100644 index 0000000000..cb997bf545 --- /dev/null +++ b/api.planx.uk/modules/send/email/index.ts @@ -0,0 +1,84 @@ +import type { NextFunction, Request, Response } from "express"; +import capitalize from "lodash/capitalize"; +import { markSessionAsSubmitted } from "../../saveAndReturn/service/utils"; +import { sendEmail } from "../../../lib/notify"; +import { EmailSubmissionNotifyConfig } from "../../../types"; +import { + getSessionEmailDetailsById, + getTeamEmailSettings, + insertAuditEntry, +} from "./service"; + +export async function sendToEmail( + req: Request, + res: Response, + next: NextFunction, +) { + req.setTimeout(120 * 1000); // Temporary bump to address submission timeouts + + // `/email-submission/:localAuthority` is only called via Hasura's scheduled event webhook, so body is wrapped in a "payload" key + const { payload } = req.body; + if (!payload?.sessionId) { + return next({ + status: 400, + message: `Missing application payload data to send to email`, + }); + } + + try { + const localAuthority = req.params.localAuthority; + // Confirm this local authority (aka team) has an email configured in teams.submission_email + const { sendToEmail, notifyPersonalisation } = + await getTeamEmailSettings(localAuthority); + if (!sendToEmail) { + return next({ + status: 400, + message: `Send to email is not enabled for this local authority (${localAuthority})`, + }); + } + + // Get the applicant email and flow slug associated with the session + const { email, flow } = await getSessionEmailDetailsById(payload.sessionId); + const flowName = capitalize(flow?.slug?.replaceAll("-", " ")); + + // Prepare email template + const config: EmailSubmissionNotifyConfig = { + personalisation: { + serviceName: flowName || "PlanX", + sessionId: payload.sessionId, + applicantEmail: email, + downloadLink: `${process.env.API_URL_EXT}/download-application-files/${payload.sessionId}?email=${sendToEmail}&localAuthority=${localAuthority}`, + ...notifyPersonalisation, + }, + }; + + // Send the email + const response = await sendEmail("submit", sendToEmail, config); + if (response?.message !== "Success") { + return next({ + status: 500, + message: `Failed to send "Submit" email (${localAuthority}): ${response?.message}`, + }); + } + // Mark session as submitted so that reminder and expiry emails are not triggered + markSessionAsSubmitted(payload.sessionId); + + // Create audit table entry, which triggers a Slack notification on `insert` if production + insertAuditEntry( + payload.sessionId, + localAuthority, + sendToEmail, + config, + response, + ); + + return res.status(200).send({ + message: `Successfully sent "Submit" email`, + }); + } catch (error) { + return next({ + error, + message: `Failed to send "Submit" email. ${(error as Error).message}`, + }); + } +} diff --git a/api.planx.uk/modules/send/email/service.ts b/api.planx.uk/modules/send/email/service.ts new file mode 100644 index 0000000000..70c4cf3131 --- /dev/null +++ b/api.planx.uk/modules/send/email/service.ts @@ -0,0 +1,135 @@ +import { gql } from "graphql-request"; +import { $api } from "../../../client"; +import { NotifyPersonalisation } from "@opensystemslab/planx-core/dist/types/team"; +import { Session } from "@opensystemslab/planx-core/types"; +import { EmailSubmissionNotifyConfig } from "../../../types"; + +interface GetTeamEmailSettings { + teams: { + sendToEmail: string; + notifyPersonalisation: NotifyPersonalisation; + }[]; +} + +export async function getTeamEmailSettings(localAuthority: string) { + const response = await $api.client.request( + gql` + query GetTeamEmailSettings($slug: String) { + teams(where: { slug: { _eq: $slug } }) { + sendToEmail: submission_email + notifyPersonalisation: notify_personalisation + } + } + `, + { + slug: localAuthority, + }, + ); + + return response?.teams[0]; +} + +interface GetSessionData { + session: Partial>; +} + +export async function getSessionData(sessionId: string) { + const response = await $api.client.request( + gql` + query GetSessionData($id: uuid!) { + session: lowcal_sessions_by_pk(id: $id) { + data + } + } + `, + { + id: sessionId, + }, + ); + + return response?.session?.data; +} + +interface GetSessionEmailDetailsById { + session: { + email: string; + flow: { + slug: string; + }; + } | null; +} + +export async function getSessionEmailDetailsById(sessionId: string) { + const response = await $api.client.request( + gql` + query GetSessionEmailDetails($id: uuid!) { + session: lowcal_sessions_by_pk(id: $id) { + email + flow { + slug + } + } + } + `, + { + id: sessionId, + }, + ); + + if (!response.session) + throw Error( + `Cannot find session ${sessionId} in GetSessionEmailDetails query`, + ); + + return response.session; +} + +interface CreateEmailApplication { + application: { + id?: string; + }; +} + +export async function insertAuditEntry( + sessionId: string, + teamSlug: string, + recipient: string, + notifyRequest: EmailSubmissionNotifyConfig, + sendEmailResponse: { + message: string; + expiryDate?: string; + }, +) { + const response = await $api.client.request( + gql` + mutation CreateEmailApplication( + $session_id: uuid! + $team_slug: String + $recipient: String + $request: jsonb + $response: jsonb + ) { + application: insert_email_applications_one( + object: { + session_id: $session_id + team_slug: $team_slug + recipient: $recipient + request: $request + response: $response + } + ) { + id + } + } + `, + { + session_id: sessionId, + team_slug: teamSlug, + recipient: recipient, + request: notifyRequest, + response: sendEmailResponse, + }, + ); + + return response?.application?.id; +} diff --git a/api.planx.uk/modules/send/routes.ts b/api.planx.uk/modules/send/routes.ts new file mode 100644 index 0000000000..73f5974263 --- /dev/null +++ b/api.planx.uk/modules/send/routes.ts @@ -0,0 +1,23 @@ +import { Router } from "express"; +import { createSendEvents } from "./createSendEvents/controller"; +import { useHasuraAuth } from "../auth/middleware"; +import { sendToBOPS } from "./bops/bops"; +import { sendToUniform } from "./uniform/uniform"; +import { sendToEmail } from "./email"; +import { validate } from "../../shared/middleware/validate"; +import { combinedEventsPayloadSchema } from "./createSendEvents/types"; +import { downloadApplicationFiles } from "./downloadApplicationFiles"; + +const router = Router(); + +router.post( + "/create-send-events/:sessionId", + validate(combinedEventsPayloadSchema), + createSendEvents, +); +router.post("/bops/:localAuthority", useHasuraAuth, sendToBOPS); +router.post("/uniform/:localAuthority", useHasuraAuth, sendToUniform); +router.post("/email-submission/:localAuthority", useHasuraAuth, sendToEmail); +router.get("/download-application-files/:sessionId", downloadApplicationFiles); + +export default router; diff --git a/api.planx.uk/send/uniform.test.ts b/api.planx.uk/modules/send/uniform/uniform.test.ts similarity index 95% rename from api.planx.uk/send/uniform.test.ts rename to api.planx.uk/modules/send/uniform/uniform.test.ts index 9e90596960..4e6aaea544 100644 --- a/api.planx.uk/send/uniform.test.ts +++ b/api.planx.uk/modules/send/uniform/uniform.test.ts @@ -1,5 +1,5 @@ import supertest from "supertest"; -import app from "../server"; +import app from "../../../server"; describe(`sending an application to uniform`, () => { it("fails without authorization header", async () => { diff --git a/api.planx.uk/send/uniform.ts b/api.planx.uk/modules/send/uniform/uniform.ts similarity index 93% rename from api.planx.uk/send/uniform.ts rename to api.planx.uk/modules/send/uniform/uniform.ts index fb19558e12..8bc552e053 100644 --- a/api.planx.uk/send/uniform.ts +++ b/api.planx.uk/modules/send/uniform/uniform.ts @@ -3,10 +3,10 @@ import { NextFunction, Request, Response } from "express"; import { Buffer } from "node:buffer"; import FormData from "form-data"; import fs from "fs"; -import { markSessionAsSubmitted } from "../modules/saveAndReturn/service/utils"; +import { markSessionAsSubmitted } from "../../saveAndReturn/service/utils"; import { gql } from "graphql-request"; -import { $api } from "../client"; -import { buildSubmissionExportZip } from "./exportZip"; +import { $api } from "../../../client"; +import { buildSubmissionExportZip } from "../utils/exportZip"; interface UniformClient { clientId: string; @@ -44,26 +44,6 @@ interface SendToUniformPayload { sessionId: string; } -/** - * @swagger - * /uniform/{localAuthority}: - * post: - * summary: Submits an application to Uniform - * description: Submits an application to Uniform - * tags: - * - submissions - * 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' - */ export async function sendToUniform( req: Request, res: Response, diff --git a/api.planx.uk/send/exportZip.test.ts b/api.planx.uk/modules/send/utils/exportZip.test.ts similarity index 97% rename from api.planx.uk/send/exportZip.test.ts rename to api.planx.uk/modules/send/utils/exportZip.test.ts index c402ebcc64..48fe1ce7dd 100644 --- a/api.planx.uk/send/exportZip.test.ts +++ b/api.planx.uk/modules/send/utils/exportZip.test.ts @@ -1,6 +1,6 @@ -import { mockLowcalSession } from "../tests/mocks/saveAndReturnMocks"; +import { mockLowcalSession } from "../../../tests/mocks/saveAndReturnMocks"; import { buildSubmissionExportZip } from "./exportZip"; -import type { LowCalSession } from "../types"; +import type { LowCalSession } from "../../../types"; jest.mock("fs", () => ({ mkdtempSync: () => "tmpdir", @@ -54,7 +54,7 @@ const mockGenerateOneAppXML = jest .fn() .mockResolvedValue({ trim: () => "" }); -jest.mock("../client", () => { +jest.mock("../../../client", () => { return { $api: { getDocumentTemplateNamesForSession: jest diff --git a/api.planx.uk/send/exportZip.ts b/api.planx.uk/modules/send/utils/exportZip.ts similarity index 97% rename from api.planx.uk/send/exportZip.ts rename to api.planx.uk/modules/send/utils/exportZip.ts index 6c3dc6aac1..3cfec11cca 100644 --- a/api.planx.uk/send/exportZip.ts +++ b/api.planx.uk/modules/send/utils/exportZip.ts @@ -1,11 +1,11 @@ import os from "os"; import path from "path"; -import { $api } from "../client"; +import { $api } from "../../../client"; import { stringify } from "csv-stringify"; import fs from "fs"; import str from "string-to-stream"; import AdmZip from "adm-zip"; -import { getFileFromS3 } from "../modules/file/service/getFile"; +import { getFileFromS3 } from "../../file/service/getFile"; import { hasRequiredDataForTemplate, generateMapHTML, @@ -13,7 +13,7 @@ import { generateDocxTemplateStream, } from "@opensystemslab/planx-core"; import { Passport } from "@opensystemslab/planx-core"; -import type { Passport as IPassport } from "../types"; +import type { Passport as IPassport } from "../../../types"; import type { Stream } from "node:stream"; import type { PlanXExportData } from "@opensystemslab/planx-core/types"; diff --git a/api.planx.uk/send/helpers.ts b/api.planx.uk/modules/send/utils/helpers.ts similarity index 96% rename from api.planx.uk/send/helpers.ts rename to api.planx.uk/modules/send/utils/helpers.ts index eca20463a5..c66f22ad60 100644 --- a/api.planx.uk/send/helpers.ts +++ b/api.planx.uk/modules/send/utils/helpers.ts @@ -1,6 +1,6 @@ import { gql } from "graphql-request"; -import airbrake from "../airbrake"; -import { $api } from "../client"; +import airbrake from "../../../airbrake"; +import { $api } from "../../../client"; export async function logPaymentStatus({ sessionId, diff --git a/api.planx.uk/send/createSendEvents.ts b/api.planx.uk/send/createSendEvents.ts deleted file mode 100644 index 67c86db3a8..0000000000 --- a/api.planx.uk/send/createSendEvents.ts +++ /dev/null @@ -1,62 +0,0 @@ -import { NextFunction, Request, Response } from "express"; -import { - ScheduledEventResponse, - createScheduledEvent, -} from "../lib/hasura/metadata"; - -interface CombinedResponse { - bops?: ScheduledEventResponse; - uniform?: ScheduledEventResponse; - email?: ScheduledEventResponse; -} - -// Create "One-off Scheduled Events" in Hasura from Send component for selected destinations -const createSendEvents = async ( - req: Request, - res: Response, - next: NextFunction, -): Promise => { - try { - const now = new Date(); - const combinedResponse: CombinedResponse = {}; - - if ("email" in req.body) { - const emailSubmissionEvent = await createScheduledEvent({ - webhook: `{{HASURA_PLANX_API_URL}}/email-submission/${req.body.email.localAuthority}`, - schedule_at: now, - payload: req.body.email.body, - comment: `email_submission_${req.params.sessionId}`, - }); - combinedResponse["email"] = emailSubmissionEvent; - } - - if ("bops" in req.body) { - const bopsEvent = await createScheduledEvent({ - webhook: `{{HASURA_PLANX_API_URL}}/bops/${req.body.bops.localAuthority}`, - schedule_at: new Date(now.getTime() + 30 * 1000), - payload: req.body.bops.body, - comment: `bops_submission_${req.params.sessionId}`, - }); - combinedResponse["bops"] = bopsEvent; - } - - if ("uniform" in req.body) { - const uniformEvent = await createScheduledEvent({ - webhook: `{{HASURA_PLANX_API_URL}}/uniform/${req.body.uniform.localAuthority}`, - schedule_at: new Date(now.getTime() + 60 * 1000), - payload: req.body.uniform.body, - comment: `uniform_submission_${req.params.sessionId}`, - }); - combinedResponse["uniform"] = uniformEvent; - } - - return res.json(combinedResponse); - } catch (error) { - return next({ - error, - message: `Failed to create send event(s) for session ${req.params.sessionId}. Error: ${error}`, - }); - } -}; - -export { createSendEvents }; diff --git a/api.planx.uk/send/email.ts b/api.planx.uk/send/email.ts deleted file mode 100644 index 34de3fd3e7..0000000000 --- a/api.planx.uk/send/email.ts +++ /dev/null @@ -1,291 +0,0 @@ -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 "../lib/notify"; -import { EmailSubmissionNotifyConfig } from "../types"; -import { buildSubmissionExportZip } from "./exportZip"; -import { $api } from "../client"; -import { NotifyPersonalisation } from "@opensystemslab/planx-core/dist/types/team"; -import { Session } from "@opensystemslab/planx-core/types"; - -/** - * @swagger - * /email-submission/{localAuthority}: - * post: - * summary: Sends an application by email using GOV.UK Notify - * description: Send an application by email using GOV.UK Notify. The email body includes a link to download the application files. - * tags: - * - submissions - * 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' - */ -export async function sendToEmail( - req: Request, - res: Response, - next: NextFunction, -) { - req.setTimeout(120 * 1000); // Temporary bump to address submission timeouts - - // `/email-submission/:localAuthority` is only called via Hasura's scheduled event webhook, so body is wrapped in a "payload" key - const { payload } = req.body; - if (!payload?.sessionId) { - return next({ - status: 400, - message: `Missing application payload data to send to email`, - }); - } - - try { - const localAuthority = req.params.localAuthority; - // Confirm this local authority (aka team) has an email configured in teams.submission_email - const { sendToEmail, notifyPersonalisation } = - await getTeamEmailSettings(localAuthority); - if (!sendToEmail) { - return next({ - status: 400, - message: `Send to email is not enabled for this local authority (${localAuthority})`, - }); - } - - // Get the applicant email and flow slug associated with the session - const { email, flow } = await getSessionEmailDetailsById(payload.sessionId); - const flowName = capitalize(flow?.slug?.replaceAll("-", " ")); - - // Prepare email template - const config: EmailSubmissionNotifyConfig = { - personalisation: { - serviceName: flowName || "PlanX", - sessionId: payload.sessionId, - applicantEmail: email, - downloadLink: `${process.env.API_URL_EXT}/download-application-files/${payload.sessionId}?email=${sendToEmail}&localAuthority=${localAuthority}`, - ...notifyPersonalisation, - }, - }; - - // Send the email - const response = await sendEmail("submit", sendToEmail, config); - if (response?.message !== "Success") { - return next({ - status: 500, - message: `Failed to send "Submit" email (${localAuthority}): ${response?.message}`, - }); - } - // Mark session as submitted so that reminder and expiry emails are not triggered - markSessionAsSubmitted(payload.sessionId); - - // Create audit table entry, which triggers a Slack notification on `insert` if production - insertAuditEntry( - payload.sessionId, - localAuthority, - sendToEmail, - config, - response, - ); - - return res.status(200).send({ - message: `Successfully sent "Submit" email`, - }); - } catch (error) { - return next({ - error, - message: `Failed to send "Submit" email. ${(error as Error).message}`, - }); - } -} - -export async function downloadApplicationFiles( - req: Request, - res: Response, - next: NextFunction, -) { - const sessionId: string = req.params?.sessionId; - if (!sessionId || !req.query?.email || !req.query?.localAuthority) { - return next({ - status: 400, - message: "Missing values required to access application files", - }); - } - - try { - // Confirm that the provided email matches the stored team settings for the provided localAuthority - const { sendToEmail } = await getTeamEmailSettings( - req.query.localAuthority as string, - ); - if (sendToEmail !== req.query.email) { - return next({ - status: 403, - message: - "Provided email address is not enabled to access application files", - }); - } - - // Fetch this lowcal_session's data - const sessionData = await getSessionData(sessionId); - if (!sessionData) { - return next({ - status: 400, - message: "Failed to find session data for this sessionId", - }); - } - - // create the submission zip - const zip = await buildSubmissionExportZip({ sessionId }); - - // Send it to the client - const zipData = zip.toBuffer(); - res.set("Content-Type", "application/octet-stream"); - res.set("Content-Disposition", `attachment; filename=${zip.filename}`); - res.set("Content-Length", zipData.length.toString()); - res.status(200).send(zipData); - - // Clean up the local zip file - zip.remove(); - - // TODO Record files_downloaded_at timestamp in lowcal_sessions ?? - } catch (error) { - return next({ - error, - message: `Failed to download application files. ${error}`, - }); - } -} - -interface GetTeamEmailSettings { - teams: { - sendToEmail: string; - notifyPersonalisation: NotifyPersonalisation; - }[]; -} - -async function getTeamEmailSettings(localAuthority: string) { - const response = await $api.client.request( - gql` - query GetTeamEmailSettings($slug: String) { - teams(where: { slug: { _eq: $slug } }) { - sendToEmail: submission_email - notifyPersonalisation: notify_personalisation - } - } - `, - { - slug: localAuthority, - }, - ); - - return response?.teams[0]; -} - -interface GetSessionEmailDetailsById { - session: { - email: string; - flow: { - slug: string; - }; - } | null; -} - -async function getSessionEmailDetailsById(sessionId: string) { - const response = await $api.client.request( - gql` - query GetSessionEmailDetails($id: uuid!) { - session: lowcal_sessions_by_pk(id: $id) { - email - flow { - slug - } - } - } - `, - { - id: sessionId, - }, - ); - - if (!response.session) - throw Error( - `Cannot find session ${sessionId} in GetSessionEmailDetails query`, - ); - - return response.session; -} - -interface GetSessionData { - session: Partial>; -} - -async function getSessionData(sessionId: string) { - const response = await $api.client.request( - gql` - query GetSessionData($id: uuid!) { - session: lowcal_sessions_by_pk(id: $id) { - data - } - } - `, - { - id: sessionId, - }, - ); - - return response?.session?.data; -} - -interface CreateEmailApplication { - application: { - id?: string; - }; -} - -async function insertAuditEntry( - sessionId: string, - teamSlug: string, - recipient: string, - notifyRequest: EmailSubmissionNotifyConfig, - sendEmailResponse: { - message: string; - expiryDate?: string; - }, -) { - const response = await $api.client.request( - gql` - mutation CreateEmailApplication( - $session_id: uuid! - $team_slug: String - $recipient: String - $request: jsonb - $response: jsonb - ) { - application: insert_email_applications_one( - object: { - session_id: $session_id - team_slug: $team_slug - recipient: $recipient - request: $request - response: $response - } - ) { - id - } - } - `, - { - session_id: sessionId, - team_slug: teamSlug, - recipient: recipient, - request: notifyRequest, - response: sendEmailResponse, - }, - ); - - return response?.application?.id; -} diff --git a/api.planx.uk/server.ts b/api.planx.uk/server.ts index 3f7b65f7a5..0bf623673e 100644 --- a/api.planx.uk/server.ts +++ b/api.planx.uk/server.ts @@ -10,16 +10,9 @@ import pinoLogger from "express-pino-logger"; import { Server } from "http"; import passport from "passport"; import helmet from "helmet"; - import { ServerError } from "./errors"; -import { useHasuraAuth } from "./modules/auth/middleware"; - import airbrake from "./airbrake"; import { apiLimiter } from "./rateLimit"; -import { sendToBOPS } from "./send/bops"; -import { createSendEvents } from "./send/createSendEvents"; -import { downloadApplicationFiles, sendToEmail } from "./send/email"; -import { sendToUniform } from "./send/uniform"; import { googleStrategy } from "./modules/auth/strategy/google"; import authRoutes from "./modules/auth/routes"; import teamRoutes from "./modules/team/routes"; @@ -35,6 +28,7 @@ import sendEmailRoutes from "./modules/sendEmail/routes"; import fileRoutes from "./modules/file/routes"; import gisRoutes from "./modules/gis/routes"; import payRoutes from "./modules/pay/routes"; +import sendRoutes from "./modules/send/routes"; import { useSwaggerDocs } from "./docs"; import { Role } from "@opensystemslab/planx-core/types"; @@ -79,27 +73,16 @@ app.use(apiLimiter); // Secure Express by setting various HTTP headers 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); assert(process.env.BOPS_SUBMISSION_URL_LAMBETH); assert(process.env.BOPS_SUBMISSION_URL_BUCKINGHAMSHIRE); assert(process.env.BOPS_SUBMISSION_URL_SOUTHWARK); assert(process.env.BOPS_SUBMISSION_URL_CAMDEN); assert(process.env.BOPS_SUBMISSION_URL_GLOUCESTER); -app.post("/bops/:localAuthority", useHasuraAuth, sendToBOPS); - assert(process.env.UNIFORM_TOKEN_URL); assert(process.env.UNIFORM_SUBMISSION_URL); -app.post("/uniform/:localAuthority", useHasuraAuth, sendToUniform); - -app.post("/email-submission/:localAuthority", useHasuraAuth, sendToEmail); - -app.get("/download-application-files/:sessionId", downloadApplicationFiles); ["BUCKINGHAMSHIRE", "LAMBETH", "SOUTHWARK"].forEach((authority) => { assert(process.env[`GOV_UK_PAY_TOKEN_${authority}`]); @@ -141,6 +124,7 @@ app.use(sendEmailRoutes); app.use("/flows", flowRoutes); app.use(gisRoutes); app.use(payRoutes); +app.use(sendRoutes); const errorHandler: ErrorRequestHandler = (errorObject, _req, res, _next) => { const { status = 500, message = "Something went wrong" } = (() => {