diff --git a/api.planx.uk/docs/index.ts b/api.planx.uk/docs/index.ts index f12939a0b6..734fb58606 100644 --- a/api.planx.uk/docs/index.ts +++ b/api.planx.uk/docs/index.ts @@ -36,7 +36,7 @@ const parameters = { type: "string", required: true, description: - "Name of the Local Authority, usually the same as Planx `team`", + "Name of the Local Authority, usually the same as PlanX `team`", }, hasuraAuth: { name: "authorization", diff --git a/api.planx.uk/inviteToPay/inviteToPay.ts b/api.planx.uk/inviteToPay/inviteToPay.ts deleted file mode 100644 index 6dfa87267e..0000000000 --- a/api.planx.uk/inviteToPay/inviteToPay.ts +++ /dev/null @@ -1,89 +0,0 @@ -import { NextFunction, Request, Response } from "express"; -import type { PaymentRequest, KeyPath } from "@opensystemslab/planx-core/types"; - -import { ServerError } from "../errors"; -import { $api } from "../client"; - -export async function inviteToPay( - req: Request, - res: Response, - next: NextFunction, -) { - const sessionId = req.params.sessionId; - const { - payeeEmail, - payeeName, - applicantName, - sessionPreviewKeys, - }: { - payeeEmail: string; - payeeName: string; - applicantName: string; - sessionPreviewKeys: Array; - } = req.body; - - for (const [requiredFieldName, requiredFieldValue] of Object.entries({ - sessionId, - applicantName, - payeeName, - payeeEmail, - })) { - if (!requiredFieldValue) { - return next( - new ServerError({ - message: `JSON body must contain ${requiredFieldName}`, - status: 400, - }), - ); - } - } - - // lock session before creating a payment request - const locked = await $api.session.lock(sessionId); - if (locked === null) { - return next( - new ServerError({ - message: "session not found", - status: 404, - }), - ); - } - if (locked === false) { - const cause = new Error( - "this session could not be locked, perhaps because it is already locked", - ); - return next( - new ServerError({ - message: `could not initiate a payment request: ${cause.message}`, - status: 400, - cause, - }), - ); - } - - let paymentRequest: PaymentRequest | undefined; - try { - paymentRequest = await $api.paymentRequest.create({ - sessionId, - applicantName, - payeeName, - payeeEmail, - sessionPreviewKeys, - }); - } catch (e: unknown) { - // revert the session lock on failure - await $api.session.unlock(sessionId); - return next( - new ServerError({ - message: - e instanceof Error - ? `could not initiate a payment request: ${e.message}` - : "could not initiate a payment request due to an unknown error", - status: 500, - cause: e, - }), - ); - } - - res.json(paymentRequest); -} 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/gis/routes.ts b/api.planx.uk/modules/gis/routes.ts new file mode 100644 index 0000000000..057a812bb6 --- /dev/null +++ b/api.planx.uk/modules/gis/routes.ts @@ -0,0 +1,10 @@ +import { Router } from "express"; +import { locationSearch } from "./service"; +import { classifiedRoadsSearch } from "./service/classifiedRoads"; + +const router = Router(); + +router.get("/gis/:localAuthority", locationSearch); +router.get("/roads", classifiedRoadsSearch); + +export default router; diff --git a/api.planx.uk/gis/classifiedRoads.test.ts b/api.planx.uk/modules/gis/service/classifiedRoads.test.ts similarity index 93% rename from api.planx.uk/gis/classifiedRoads.test.ts rename to api.planx.uk/modules/gis/service/classifiedRoads.test.ts index 22ce2a4b2c..169062176e 100644 --- a/api.planx.uk/gis/classifiedRoads.test.ts +++ b/api.planx.uk/modules/gis/service/classifiedRoads.test.ts @@ -1,7 +1,7 @@ import supertest from "supertest"; -import loadOrRecordNockRequests from "../tests/loadOrRecordNockRequests"; -import app from "../server"; +import loadOrRecordNockRequests from "../../../tests/loadOrRecordNockRequests"; +import app from "../../../server"; import { PASSPORT_FN } from "./classifiedRoads"; it("returns an error if required query param is missing", async () => { diff --git a/api.planx.uk/gis/classifiedRoads.ts b/api.planx.uk/modules/gis/service/classifiedRoads.ts similarity index 100% rename from api.planx.uk/gis/classifiedRoads.ts rename to api.planx.uk/modules/gis/service/classifiedRoads.ts diff --git a/api.planx.uk/gis/digitalLand.ts b/api.planx.uk/modules/gis/service/digitalLand.ts similarity index 99% rename from api.planx.uk/gis/digitalLand.ts rename to api.planx.uk/modules/gis/service/digitalLand.ts index 908bef56a7..13c31566fb 100644 --- a/api.planx.uk/gis/digitalLand.ts +++ b/api.planx.uk/modules/gis/service/digitalLand.ts @@ -7,7 +7,7 @@ import { gql } from "graphql-request"; import fetch from "isomorphic-fetch"; import { addDesignatedVariable, omitGeometry } from "./helpers"; import { baseSchema } from "./local_authorities/metadata/base"; -import { $api } from "../client"; +import { $api } from "../../../client"; export interface LocalAuthorityMetadata { planningConstraints: { diff --git a/api.planx.uk/gis/helpers.js b/api.planx.uk/modules/gis/service/helpers.js similarity index 100% rename from api.planx.uk/gis/helpers.js rename to api.planx.uk/modules/gis/service/helpers.js diff --git a/api.planx.uk/gis/helpers.test.js b/api.planx.uk/modules/gis/service/helpers.test.js similarity index 100% rename from api.planx.uk/gis/helpers.test.js rename to api.planx.uk/modules/gis/service/helpers.test.js diff --git a/api.planx.uk/gis/index.js b/api.planx.uk/modules/gis/service/index.js similarity index 100% rename from api.planx.uk/gis/index.js rename to api.planx.uk/modules/gis/service/index.js diff --git a/api.planx.uk/gis/index.test.ts b/api.planx.uk/modules/gis/service/index.test.ts similarity index 94% rename from api.planx.uk/gis/index.test.ts rename to api.planx.uk/modules/gis/service/index.test.ts index 091f6eced5..533454979d 100644 --- a/api.planx.uk/gis/index.test.ts +++ b/api.planx.uk/modules/gis/service/index.test.ts @@ -1,8 +1,8 @@ import supertest from "supertest"; -import { locationSearchWithTimeout } from "../gis"; -import loadOrRecordNockRequests from "../tests/loadOrRecordNockRequests"; -import app from "../server"; +import loadOrRecordNockRequests from "../../../tests/loadOrRecordNockRequests"; +import app from "../../../server"; +import { locationSearchWithTimeout } from "."; // Tests commented out due to reliance on external API calls and fallibility of nocks // Please comment in and run locally if making changes to /gis functionality diff --git a/api.planx.uk/gis/local_authorities/braintree.js b/api.planx.uk/modules/gis/service/local_authorities/braintree.js similarity index 100% rename from api.planx.uk/gis/local_authorities/braintree.js rename to api.planx.uk/modules/gis/service/local_authorities/braintree.js diff --git a/api.planx.uk/gis/local_authorities/metadata/base.ts b/api.planx.uk/modules/gis/service/local_authorities/metadata/base.ts similarity index 100% rename from api.planx.uk/gis/local_authorities/metadata/base.ts rename to api.planx.uk/modules/gis/service/local_authorities/metadata/base.ts diff --git a/api.planx.uk/gis/local_authorities/metadata/braintree.js b/api.planx.uk/modules/gis/service/local_authorities/metadata/braintree.js similarity index 100% rename from api.planx.uk/gis/local_authorities/metadata/braintree.js rename to api.planx.uk/modules/gis/service/local_authorities/metadata/braintree.js diff --git a/api.planx.uk/gis/local_authorities/metadata/buckinghamshire.ts b/api.planx.uk/modules/gis/service/local_authorities/metadata/buckinghamshire.ts similarity index 100% rename from api.planx.uk/gis/local_authorities/metadata/buckinghamshire.ts rename to api.planx.uk/modules/gis/service/local_authorities/metadata/buckinghamshire.ts diff --git a/api.planx.uk/gis/local_authorities/metadata/camden.ts b/api.planx.uk/modules/gis/service/local_authorities/metadata/camden.ts similarity index 100% rename from api.planx.uk/gis/local_authorities/metadata/camden.ts rename to api.planx.uk/modules/gis/service/local_authorities/metadata/camden.ts diff --git a/api.planx.uk/gis/local_authorities/metadata/canterbury.ts b/api.planx.uk/modules/gis/service/local_authorities/metadata/canterbury.ts similarity index 100% rename from api.planx.uk/gis/local_authorities/metadata/canterbury.ts rename to api.planx.uk/modules/gis/service/local_authorities/metadata/canterbury.ts diff --git a/api.planx.uk/gis/local_authorities/metadata/doncaster.ts b/api.planx.uk/modules/gis/service/local_authorities/metadata/doncaster.ts similarity index 100% rename from api.planx.uk/gis/local_authorities/metadata/doncaster.ts rename to api.planx.uk/modules/gis/service/local_authorities/metadata/doncaster.ts diff --git a/api.planx.uk/gis/local_authorities/metadata/lambeth.ts b/api.planx.uk/modules/gis/service/local_authorities/metadata/lambeth.ts similarity index 100% rename from api.planx.uk/gis/local_authorities/metadata/lambeth.ts rename to api.planx.uk/modules/gis/service/local_authorities/metadata/lambeth.ts diff --git a/api.planx.uk/gis/local_authorities/metadata/medway.ts b/api.planx.uk/modules/gis/service/local_authorities/metadata/medway.ts similarity index 100% rename from api.planx.uk/gis/local_authorities/metadata/medway.ts rename to api.planx.uk/modules/gis/service/local_authorities/metadata/medway.ts diff --git a/api.planx.uk/gis/local_authorities/metadata/newcastle.ts b/api.planx.uk/modules/gis/service/local_authorities/metadata/newcastle.ts similarity index 100% rename from api.planx.uk/gis/local_authorities/metadata/newcastle.ts rename to api.planx.uk/modules/gis/service/local_authorities/metadata/newcastle.ts diff --git a/api.planx.uk/gis/local_authorities/metadata/scotland.js b/api.planx.uk/modules/gis/service/local_authorities/metadata/scotland.js similarity index 100% rename from api.planx.uk/gis/local_authorities/metadata/scotland.js rename to api.planx.uk/modules/gis/service/local_authorities/metadata/scotland.js diff --git a/api.planx.uk/gis/local_authorities/metadata/southwark.ts b/api.planx.uk/modules/gis/service/local_authorities/metadata/southwark.ts similarity index 100% rename from api.planx.uk/gis/local_authorities/metadata/southwark.ts rename to api.planx.uk/modules/gis/service/local_authorities/metadata/southwark.ts diff --git a/api.planx.uk/gis/local_authorities/scotland.js b/api.planx.uk/modules/gis/service/local_authorities/scotland.js similarity index 97% rename from api.planx.uk/gis/local_authorities/scotland.js rename to api.planx.uk/modules/gis/service/local_authorities/scotland.js index fcb899a931..eef564475e 100644 --- a/api.planx.uk/gis/local_authorities/scotland.js +++ b/api.planx.uk/modules/gis/service/local_authorities/scotland.js @@ -7,7 +7,7 @@ import { rollupResultLayers, addDesignatedVariable, } from "../helpers.js"; -import { planningConstraints } from "./metadata/scotland"; +import { planningConstraints } from "./metadata/scotland.js"; // Process local authority metadata const gisLayers = getQueryableConstraints(planningConstraints); diff --git a/api.planx.uk/pay/index.ts b/api.planx.uk/modules/pay/controller.ts similarity index 57% rename from api.planx.uk/pay/index.ts rename to api.planx.uk/modules/pay/controller.ts index 0e525c3e04..45aa7798ec 100644 --- a/api.planx.uk/pay/index.ts +++ b/api.planx.uk/modules/pay/controller.ts @@ -1,48 +1,32 @@ import assert from "assert"; -import { NextFunction, Request, Response } from "express"; +import { Request } from "express"; import { responseInterceptor } from "http-proxy-middleware"; -import SlackNotify from "slack-notify"; -import { logPaymentStatus } from "../send/helpers"; +import { logPaymentStatus } from "../send/utils/helpers"; import { usePayProxy } from "./proxy"; -import { $api } from "../client"; -import { ServerError } from "../errors"; -import { GovUKPayment } from "@opensystemslab/planx-core/types"; -import { addGovPayPaymentIdToPaymentRequest } from "./utils"; +import { $api } from "../../client"; +import { ServerError } from "../../errors"; +import { GovUKPayment, PaymentRequest } from "@opensystemslab/planx-core/types"; +import { + addGovPayPaymentIdToPaymentRequest, + postPaymentNotificationToSlack, +} from "./service/utils"; +import { + InviteToPayController, + PaymentProxyController, + PaymentRequestProxyController, +} from "./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, - }), - ); - } +export const makePaymentViaProxy: PaymentProxyController = async ( + req, + res, + next, +) => { + const { flowId, sessionId } = res.locals.parsedReq.query; + const teamSlug = res.locals.parsedReq.params.localAuthority; const session = await $api.session.findDetails(sessionId); @@ -77,28 +61,16 @@ export async function makePaymentViaProxy( }, 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; +export const makeInviteToPayPaymentViaProxy: PaymentRequestProxyController = ( + req, + res, + next, +) => { + const { flowId, sessionId } = res.locals.parsedReq.query; + const { localAuthority: teamSlug, paymentRequest: paymentRequestId } = + res.locals.parsedReq.params; // drop req.params.localAuthority from the path when redirecting // so redirects to plain [GOV_UK_PAY_URL] with correct bearer token @@ -130,7 +102,7 @@ export async function makeInviteToPayPaymentViaProxy( }, req, )(req, res, next); -} +}; // exposed as /pay/:localAuthority/:paymentId and also used as middleware // fetches the status of the payment @@ -141,11 +113,10 @@ export const fetchPaymentViaProxy = fetchPaymentViaProxyWithCallback( 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; +): PaymentProxyController { + return async (req, res, next) => { + const { flowId, sessionId } = res.locals.parsedReq.query; + const teamSlug = res.locals.parsedReq.params.localAuthority; // will redirect to [GOV_UK_PAY_URL]/:paymentId with correct bearer token usePayProxy( @@ -184,22 +155,56 @@ export function fetchPaymentViaProxyWithCallback( }; } -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"); +export const inviteToPay: InviteToPayController = async (_req, res, next) => { + const { sessionId } = res.locals.parsedReq.params; + const { payeeEmail, payeeName, applicantName, sessionPreviewKeys } = + res.locals.parsedReq.body; + // lock session before creating a payment request + const locked = await $api.session.lock(sessionId); + if (locked === null) { + return next( + new ServerError({ + message: "session not found", + status: 404, + }), + ); } -} + if (locked === false) { + const cause = new Error( + "this session could not be locked, perhaps because it is already locked", + ); + return next( + new ServerError({ + message: `could not initiate a payment request: ${cause.message}`, + status: 400, + cause, + }), + ); + } + + let paymentRequest: PaymentRequest | undefined; + try { + paymentRequest = await $api.paymentRequest.create({ + sessionId, + applicantName, + payeeName, + payeeEmail, + sessionPreviewKeys, + }); + } catch (e: unknown) { + // revert the session lock on failure + await $api.session.unlock(sessionId); + return next( + new ServerError({ + message: + e instanceof Error + ? `could not initiate a payment request: ${e.message}` + : "could not initiate a payment request due to an unknown error", + status: 500, + cause: e, + }), + ); + } + + res.json(paymentRequest); +}; diff --git a/api.planx.uk/modules/pay/docs.yaml b/api.planx.uk/modules/pay/docs.yaml new file mode 100644 index 0000000000..25f8a7f953 --- /dev/null +++ b/api.planx.uk/modules/pay/docs.yaml @@ -0,0 +1,366 @@ +openapi: 3.1.0 +info: + title: Plan✕ API + version: 0.1.0 +tags: + - name: pay + description: Endpoints for interacting with the GovPay API + externalDocs: + url: https://docs.payments.service.gov.uk/ +components: + parameters: + paymentId: + in: path + name: paymentId + type: string + format: uuid + required: true + paymentRequest: + in: path + name: paymentId + type: string + format: uuid + required: true + schemas: + InviteToPayRequest: + content: + application/json: + schema: + type: object + properties: + payeeEmail: + type: string + format: email + payeeName: + type: string + applicantName: + type: string + sessionPreviewKeys: + type: array + items: + type: array + items: string + CreatePaymentRequest: + description: | + Payment response for the GovPay API + + Docs: [https://docs.payments.service.gov.uk/api_reference/single_payment_reference/#example-response-for-get-information-about-a-single-payment](https://docs.payments.service.gov.uk/api_reference/single_payment_reference/#example-response-for-get-information-about-a-single-payment) + content: + application/json: + schema: + type: object + properties: + amount: + type: integer + format: int32 + example: 14500 + reference: + type: string + example: "12345" + description: + type: string + example: "Pay your council tax" + return_url: + type: string + format: uri + example: "https://your.service.gov.uk/completed" + delayed_capture: + type: boolean + example: false + metadata: + type: object + properties: + ledger_code: + type: string + example: "AB100" + internal_reference_number: + type: integer + example: 200 + email: + type: string + format: email + example: "sherlock.holmes@example.com" + prefilled_cardholder_details: + type: object + properties: + cardholder_name: + type: string + example: "Sherlock Holmes" + billing_address: + type: object + properties: + line1: + type: string + example: "221 Baker Street" + line2: + type: string + example: "Flat b" + postcode: + type: string + example: "NW1 6XE" + city: + type: string + example: "London" + country: + type: string + example: "GB" + language: + type: string + example: "en" + responses: + CreatePaymentResponse: + content: + application/json: + description: | + Successful payment response + + Docs: [https://docs.payments.service.gov.uk/api_reference/create_a_payment_reference/#example-response-for-39-create-a-payment-39](https://docs.payments.service.gov.uk/api_reference/create_a_payment_reference/#example-response-for-39-create-a-payment-39) + schema: + type: object + properties: + amount: + type: integer + format: int32 + example: 14500 + description: + type: string + example: "Pay your council tax." + reference: + type: string + example: "12345" + language: + type: string + example: "en" + state: + type: object + properties: + status: + type: string + example: "created" + finished: + type: boolean + example: false + payment_id: + type: string + example: "hu20sqlact5260q2nanm0q8u93" + payment_provider: + type: string + example: "stripe" + created_date: + type: string + format: date-time + example: "2022-03-25T13:11:29.019Z" + refund_summary: + type: object + properties: + status: + type: string + example: "pending" + amount_available: + type: integer + format: int32 + example: 14500 + amount_submitted: + type: integer + format: int32 + example: 0 + settlement_summary: + type: object + properties: {} + delayed_capture: + type: boolean + example: false + moto: + type: boolean + example: false + return_url: + type: string + format: uri + example: "https://your.service.gov.uk/completed" + _links: + type: object + properties: + self: + type: object + properties: + href: + type: string + format: uri + example: "https://publicapi.payments.service.gov.uk/v1/payments/hu20sqlact5260q2nanm0q8u93" + method: + type: string + example: "GET" + next_url: + type: object + properties: + href: + type: string + format: uri + example: "https://www.payments.service.gov.uk/secure/ef1b6ff1-db34-4c62-b854-3ed4ba3c4049" + method: + type: string + example: "GET" + next_url_post: + type: object + properties: + type: + type: string + example: "application/x-www-form-urlencoded" + params: + type: object + properties: + chargeTokenId: + type: string + example: "ef1b6ff1-db34-4c62-b854-3ed4ba3c4049" + href: + type: string + format: uri + example: "https://www.payments.service.gov.uk/secure" + method: + type: string + example: "POST" + events: + type: object + properties: + href: + type: string + format: uri + example: "https://publicapi.payments.service.gov.uk/v1/payments/hu20sqlact5260q2nanm0q8u93/events" + method: + type: string + example: "GET" + refunds: + type: object + properties: + href: + type: string + format: uri + example: "https://publicapi.payments.service.gov.uk/v1/payments/hu20sqlact5260q2nanm0q8u93/refunds" + method: + type: string + example: "GET" + cancel: + type: object + properties: + href: + type: string + format: uri + example: "https://publicapi.payments.service.gov.uk/v1/payments/hu20sqlact5260q2nanm0q8u93/cancel" + method: + type: string + example: "POST" + FetchPaymentResponse: + content: + application/json: + description: | + Payment response for the GovPay API + + Docs: [https://docs.payments.service.gov.uk/api_reference/single_payment_reference/#example-response-for-get-information-about-a-single-payment](https://docs.payments.service.gov.uk/api_reference/single_payment_reference/#example-response-for-get-information-about-a-single-payment) + schema: + type: object + properties: + amount: + type: integer + format: int32 + example: 3750 + state: + type: object + properties: + status: + type: string + example: "success" + finished: + type: boolean + example: true + payment_id: + type: string + example: "hu20sqlact5260q2nanm0q8u93" + _links: + type: object + properties: + next_url: + type: object + properties: + href: + type: string + format: uri + example: "https://www.payments.service.gov.uk/secure/ef1b6ff1-db34-4c62-b854-3ed4ba3c4049" + method: + type: string + example: "GET" +paths: + /pay/{localAuthority}: + post: + summary: Initiate a new payment + description: | + Initiate a GovPay payment via proxy. This will return a GovPay URL which we can forward the user to in order for them to make a payment. + + Docs: [https://docs.payments.service.gov.uk/api_reference/create_a_payment_reference/](https://docs.payments.service.gov.uk/api_reference/create_a_payment_reference/) + tags: + - pay + parameters: + - $ref: "#/components/parameters/localAuthority" + requestBody: + $ref: "#/components/schemas/CreatePaymentRequest" + responses: + "200": + $ref: "#/components/responses/CreatePaymentResponse" + /pay/{localAuthority}/{paymentId}: + get: + summary: Get payment status + description: | + Get the status of a payment via a proxied request to the GovPay API + + Docs: [https://docs.payments.service.gov.uk/api_reference/single_payment_reference/#get-information-about-a-single-payment-api-reference](https://docs.payments.service.gov.uk/api_reference/single_payment_reference/#get-information-about-a-single-payment-api-reference) + tags: + - pay + parameters: + - $ref: "#/components/parameters/localAuthority" + - $ref: "#/components/parameters/paymentId" + responses: + "200": + $ref: "#/components/responses/FetchPaymentResponse" + /payment-request/{paymentRequest}/pay: + post: + summary: Create a request for an "invite to pay" payment + description: | + Create a payment request via a proxied request to the GovPay API + + Docs: [https://docs.payments.service.gov.uk/api_reference/create_a_payment_reference/](https://docs.payments.service.gov.uk/api_reference/create_a_payment_reference/) + tags: + - pay + parameters: + - $ref: "#/components/parameters/localAuthority" + - $ref: "#/components/parameters/paymentRequest" + requestBody: + $ref: "#/components/schemas/CreatePaymentRequest" + responses: + "200": + $ref: "#/components/responses/CreatePaymentResponse" + /payment-request/{paymentRequest}/payment/{paymentId}: + get: + summary: Get status for an "invite to pay" payment + description: | + Get the status of a payment via a proxied request to the GovPay API + + Docs: [https://docs.payments.service.gov.uk/api_reference/single_payment_reference/#get-information-about-a-single-payment-api-reference](https://docs.payments.service.gov.uk/api_reference/single_payment_reference/#get-information-about-a-single-payment-api-reference) + tags: + - pay + parameters: + - $ref: "#/components/parameters/paymentRequest" + - $ref: "#/components/parameters/paymentId" + responses: + "200": + $ref: "#/components/responses/FetchPaymentResponse" + /invite-to-pay/{sessionId}: + post: + summary: Generate a payment request + tags: + - pay + parameters: + - $ref: "#/components/parameters/sessionId" + requestBody: + $ref: "#/components/schemas/InviteToPayRequest" + responses: + "200": + $ref: "#/components/responses/CreatePaymentResponse" diff --git a/api.planx.uk/pay/index.test.ts b/api.planx.uk/modules/pay/index.test.ts similarity index 97% rename from api.planx.uk/pay/index.test.ts rename to api.planx.uk/modules/pay/index.test.ts index 4c24e51eef..ab4a129d37 100644 --- a/api.planx.uk/pay/index.test.ts +++ b/api.planx.uk/modules/pay/index.test.ts @@ -1,8 +1,8 @@ import nock from "nock"; -import { queryMock } from "../tests/graphqlQueryMock"; +import { queryMock } from "../../tests/graphqlQueryMock"; import supertest from "supertest"; -import app from "../server"; +import app from "../../server"; jest.mock("@opensystemslab/planx-core", () => { return { diff --git a/api.planx.uk/inviteToPay/paymentRequest.ts b/api.planx.uk/modules/pay/middleware.ts similarity index 50% rename from api.planx.uk/inviteToPay/paymentRequest.ts rename to api.planx.uk/modules/pay/middleware.ts index bc09f7ff34..f0a205899a 100644 --- a/api.planx.uk/inviteToPay/paymentRequest.ts +++ b/api.planx.uk/modules/pay/middleware.ts @@ -1,20 +1,25 @@ +import { NextFunction, Request, RequestHandler, Response } from "express"; import { gql } from "graphql-request"; -import { NextFunction, Request, Response } from "express"; -import { ServerError } from "../errors"; -import { - postPaymentNotificationToSlack, - fetchPaymentViaProxyWithCallback, -} from "../pay"; -import { GovUKPayment } from "@opensystemslab/planx-core/types"; -import { $api } from "../client"; +import { $api } from "../../client"; +import { ServerError } from "../../errors"; + +/** + * Confirm that this local authority (aka team) has a pay token + * TODO: Check this against a DB value instead of env vars? + */ +export const isTeamUsingGovPay: RequestHandler = (req, _res, next) => { + 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})`, + }); + } -// https://docs.payments.service.gov.uk/api_reference/create_a_payment_reference/#json-body-parameters-for-39-create-a-payment-39 -interface GovPayCreatePayment { - amount: number; - reference: string; - description: string; - return_url: string; -} + next(); +}; interface GetPaymentRequestDetails { paymentRequest: { @@ -31,9 +36,6 @@ interface GetPaymentRequestDetails { } | null; } -// middleware used by routes: -// * /payment-request/:paymentRequest/pay -// * /payment-request/:paymentRequest/payment/:paymentId export async function fetchPaymentRequestDetails( req: Request, _res: Response, @@ -82,7 +84,14 @@ export async function fetchPaymentRequestDetails( next(); } -// middleware used by /payment-request/:paymentRequest/pay?returnURL=... +// https://docs.payments.service.gov.uk/api_reference/create_a_payment_reference/#json-body-parameters-for-39-create-a-payment-39 +interface GovPayCreatePayment { + amount: number; + reference: string; + description: string; + return_url: string; +} + export async function buildPaymentPayload( req: Request, _res: Response, @@ -117,68 +126,3 @@ export async function buildPaymentPayload( next(); } - -export const fetchPaymentRequestViaProxy = fetchPaymentViaProxyWithCallback( - async (req: Request, govUkResponse: GovUKPayment) => { - const paymentRequestId = req.params.paymentRequest; - if (paymentRequestId && govUkResponse?.state.status === "success") { - await markPaymentRequestAsPaid(paymentRequestId, govUkResponse); - } - await postPaymentNotificationToSlack(req, govUkResponse, "(invite to pay)"); - }, -); - -interface MarkPaymentRequestAsPaid { - updatePaymentRequestPaidAt: { - affectedRows: number; - }; - appendGovUKPaymentToSessionData: { - affectedRows: number; - }; -} - -export const markPaymentRequestAsPaid = async ( - paymentRequestId: string, - govUkPayment: GovUKPayment, -) => { - const query = gql` - mutation MarkPaymentRequestAsPaid( - $paymentRequestId: uuid! - $govUkPayment: jsonb - ) { - updatePaymentRequestPaidAt: update_payment_requests( - where: { - _and: { id: { _eq: $paymentRequestId }, paid_at: { _is_null: true } } - } - _set: { paid_at: "now()" } - ) { - affectedRows: affected_rows - } - - # This will also overwrite any abandoned payments attempted on the session - appendGovUKPaymentToSessionData: update_lowcal_sessions( - _append: { data: $govUkPayment } - where: { payment_requests: { id: { _eq: $paymentRequestId } } } - ) { - affectedRows: affected_rows - } - } - `; - try { - const { updatePaymentRequestPaidAt, appendGovUKPaymentToSessionData } = - await $api.client.request(query, { - paymentRequestId, - govUkPayment: { govUkPayment }, - }); - if (!updatePaymentRequestPaidAt?.affectedRows) { - throw Error(`payment request ${paymentRequestId} not updated`); - } - if (!appendGovUKPaymentToSessionData?.affectedRows) { - throw Error( - `session for payment request ${paymentRequestId} not updated`, - ); - } - } catch (error) { - throw Error("Error marking payment request as paid: " + error); - } -}; diff --git a/api.planx.uk/pay/proxy.ts b/api.planx.uk/modules/pay/proxy.ts similarity index 91% rename from api.planx.uk/pay/proxy.ts rename to api.planx.uk/modules/pay/proxy.ts index e946101872..66d9ee47a5 100644 --- a/api.planx.uk/pay/proxy.ts +++ b/api.planx.uk/modules/pay/proxy.ts @@ -1,6 +1,6 @@ import { Request } from "express"; import { fixRequestBody, Options } from "http-proxy-middleware"; -import { useProxy } from "../shared/middleware/proxy"; +import { useProxy } from "../../shared/middleware/proxy"; export const usePayProxy = (options: Partial, req: Request) => { return useProxy({ diff --git a/api.planx.uk/modules/pay/routes.ts b/api.planx.uk/modules/pay/routes.ts new file mode 100644 index 0000000000..928f3b764e --- /dev/null +++ b/api.planx.uk/modules/pay/routes.ts @@ -0,0 +1,60 @@ +import { Router } from "express"; +import { + fetchPaymentViaProxy, + inviteToPay, + makeInviteToPayPaymentViaProxy, + makePaymentViaProxy, +} from "./controller"; +import { + buildPaymentPayload, + fetchPaymentRequestDetails, + isTeamUsingGovPay, +} from "./middleware"; +import { + inviteToPaySchema, + paymentProxySchema, + paymentRequestProxySchema, +} from "./types"; +import { validate } from "../../shared/middleware/validate"; +import { fetchPaymentRequestViaProxy } from "./service/inviteToPay"; + +const router = Router(); + +router.post( + "/pay/:localAuthority", + isTeamUsingGovPay, + validate(paymentProxySchema), + makePaymentViaProxy, +); + +router.get( + "/pay/:localAuthority/:paymentId", + isTeamUsingGovPay, + validate(paymentProxySchema), + fetchPaymentViaProxy, +); + +router.post( + "/payment-request/:paymentRequest/pay", + fetchPaymentRequestDetails, + buildPaymentPayload, + isTeamUsingGovPay, + validate(paymentRequestProxySchema), + makeInviteToPayPaymentViaProxy, +); + +router.get( + "/payment-request/:paymentRequest/payment/:paymentId", + fetchPaymentRequestDetails, + isTeamUsingGovPay, + validate(paymentProxySchema), + fetchPaymentRequestViaProxy, +); + +router.post( + "/invite-to-pay/:sessionId", + validate(inviteToPaySchema), + inviteToPay, +); + +export default router; diff --git a/api.planx.uk/inviteToPay/createPaymentSendEvents.test.ts b/api.planx.uk/modules/pay/service/inviteToPay/createPaymentSendEvents.test.ts similarity index 92% rename from api.planx.uk/inviteToPay/createPaymentSendEvents.test.ts rename to api.planx.uk/modules/pay/service/inviteToPay/createPaymentSendEvents.test.ts index 4855cb0b36..09fc3cf2fc 100644 --- a/api.planx.uk/inviteToPay/createPaymentSendEvents.test.ts +++ b/api.planx.uk/modules/pay/service/inviteToPay/createPaymentSendEvents.test.ts @@ -1,10 +1,10 @@ import supertest from "supertest"; -import app from "../server"; -import { createScheduledEvent } from "../lib/hasura/metadata"; -import { queryMock } from "../tests/graphqlQueryMock"; -import { flowWithInviteToPay } from "../tests/mocks/inviteToPayData"; +import app from "../../../../server"; +import { createScheduledEvent } from "../../../../lib/hasura/metadata"; +import { queryMock } from "../../../../tests/graphqlQueryMock"; +import { flowWithInviteToPay } from "../../../../tests/mocks/inviteToPayData"; -jest.mock("../lib/hasura/metadata"); +jest.mock("../../../../lib/hasura/metadata"); const mockedCreateScheduledEvent = createScheduledEvent as jest.MockedFunction< typeof createScheduledEvent >; diff --git a/api.planx.uk/inviteToPay/createPaymentSendEvents.ts b/api.planx.uk/modules/pay/service/inviteToPay/createPaymentSendEvents.ts similarity index 92% rename from api.planx.uk/inviteToPay/createPaymentSendEvents.ts rename to api.planx.uk/modules/pay/service/inviteToPay/createPaymentSendEvents.ts index 35ac8e3787..55b550cee6 100644 --- a/api.planx.uk/inviteToPay/createPaymentSendEvents.ts +++ b/api.planx.uk/modules/pay/service/inviteToPay/createPaymentSendEvents.ts @@ -1,13 +1,13 @@ 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 { getMostRecentPublishedFlow } from "../helpers"; -import { Flow, Node, Team } from "../types"; +} from "../../../../lib/hasura/metadata"; +import { $api, $public } from "../../../../client"; +import { getMostRecentPublishedFlow } from "../../../../helpers"; +import { Flow, Node, Team } from "../../../../types"; enum Destination { BOPS = "bops", @@ -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/inviteToPay/inviteToPay.test.ts b/api.planx.uk/modules/pay/service/inviteToPay/index.test.ts similarity index 85% rename from api.planx.uk/inviteToPay/inviteToPay.test.ts rename to api.planx.uk/modules/pay/service/inviteToPay/index.test.ts index e7d245d867..1dac53c222 100644 --- a/api.planx.uk/inviteToPay/inviteToPay.test.ts +++ b/api.planx.uk/modules/pay/service/inviteToPay/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"; import { validSessionQueryMock, notFoundQueryMock, @@ -10,14 +10,14 @@ import { unlockSessionQueryMock, getPublishedFlowDataQueryMock, createPaymentRequestQueryMock, -} from "../tests/mocks/inviteToPayMocks"; +} from "../../../../tests/mocks/inviteToPayMocks"; import { payee, applicant, validSession, notFoundSession, paymentRequestResponse, -} from "../tests/mocks/inviteToPayData"; +} from "../../../../tests/mocks/inviteToPayData"; describe("Invite to pay API route", () => { const inviteToPayBaseRoute = "/invite-to-pay"; @@ -69,10 +69,8 @@ describe("Invite to pay API route", () => { .send(invalidPostBody) .expect(400) .then((response) => { - expect(response.body).toHaveProperty( - "error", - "JSON body must contain payeeName", - ); + expect(response.body).toHaveProperty("issues"); + expect(response.body).toHaveProperty("name", "ZodError"); }); }); @@ -83,10 +81,8 @@ describe("Invite to pay API route", () => { .send(invalidPostBody) .expect(400) .then((response) => { - expect(response.body).toHaveProperty( - "error", - "JSON body must contain payeeEmail", - ); + expect(response.body).toHaveProperty("issues"); + expect(response.body).toHaveProperty("name", "ZodError"); }); }); diff --git a/api.planx.uk/inviteToPay/index.ts b/api.planx.uk/modules/pay/service/inviteToPay/index.ts similarity index 78% rename from api.planx.uk/inviteToPay/index.ts rename to api.planx.uk/modules/pay/service/inviteToPay/index.ts index b0d1494bd3..f7b095ca4d 100644 --- a/api.planx.uk/inviteToPay/index.ts +++ b/api.planx.uk/modules/pay/service/inviteToPay/index.ts @@ -1,4 +1,3 @@ -export * from "./inviteToPay"; export * from "./paymentRequest"; export * from "./sendPaymentEmail"; export * from "./sendConfirmationEmail"; diff --git a/api.planx.uk/modules/pay/service/inviteToPay/paymentRequest.ts b/api.planx.uk/modules/pay/service/inviteToPay/paymentRequest.ts new file mode 100644 index 0000000000..e69278d87e --- /dev/null +++ b/api.planx.uk/modules/pay/service/inviteToPay/paymentRequest.ts @@ -0,0 +1,71 @@ +import { gql } from "graphql-request"; +import { Request } from "express"; +import { fetchPaymentViaProxyWithCallback } from "../../controller"; +import { GovUKPayment } from "@opensystemslab/planx-core/types"; +import { $api } from "../../../../client"; +import { postPaymentNotificationToSlack } from "../utils"; + +export const fetchPaymentRequestViaProxy = fetchPaymentViaProxyWithCallback( + async (req: Request, govUkResponse: GovUKPayment) => { + const paymentRequestId = req.params.paymentRequest; + if (paymentRequestId && govUkResponse?.state.status === "success") { + await markPaymentRequestAsPaid(paymentRequestId, govUkResponse); + } + await postPaymentNotificationToSlack(req, govUkResponse, "(invite to pay)"); + }, +); + +interface MarkPaymentRequestAsPaid { + updatePaymentRequestPaidAt: { + affectedRows: number; + }; + appendGovUKPaymentToSessionData: { + affectedRows: number; + }; +} + +export const markPaymentRequestAsPaid = async ( + paymentRequestId: string, + govUkPayment: GovUKPayment, +) => { + const query = gql` + mutation MarkPaymentRequestAsPaid( + $paymentRequestId: uuid! + $govUkPayment: jsonb + ) { + updatePaymentRequestPaidAt: update_payment_requests( + where: { + _and: { id: { _eq: $paymentRequestId }, paid_at: { _is_null: true } } + } + _set: { paid_at: "now()" } + ) { + affectedRows: affected_rows + } + + # This will also overwrite any abandoned payments attempted on the session + appendGovUKPaymentToSessionData: update_lowcal_sessions( + _append: { data: $govUkPayment } + where: { payment_requests: { id: { _eq: $paymentRequestId } } } + ) { + affectedRows: affected_rows + } + } + `; + try { + const { updatePaymentRequestPaidAt, appendGovUKPaymentToSessionData } = + await $api.client.request(query, { + paymentRequestId, + govUkPayment: { govUkPayment }, + }); + if (!updatePaymentRequestPaidAt?.affectedRows) { + throw Error(`payment request ${paymentRequestId} not updated`); + } + if (!appendGovUKPaymentToSessionData?.affectedRows) { + throw Error( + `session for payment request ${paymentRequestId} not updated`, + ); + } + } catch (error) { + throw Error("Error marking payment request as paid: " + error); + } +}; diff --git a/api.planx.uk/inviteToPay/sendConfirmationEmail.test.ts b/api.planx.uk/modules/pay/service/inviteToPay/sendConfirmationEmail.test.ts similarity index 94% rename from api.planx.uk/inviteToPay/sendConfirmationEmail.test.ts rename to api.planx.uk/modules/pay/service/inviteToPay/sendConfirmationEmail.test.ts index 9e4126062b..b564744cfb 100644 --- a/api.planx.uk/inviteToPay/sendConfirmationEmail.test.ts +++ b/api.planx.uk/modules/pay/service/inviteToPay/sendConfirmationEmail.test.ts @@ -1,10 +1,10 @@ import supertest from "supertest"; -import app from "../server"; -import { queryMock } from "../tests/graphqlQueryMock"; +import app from "../../../../server"; +import { queryMock } from "../../../../tests/graphqlQueryMock"; import { sendAgentAndPayeeConfirmationEmail } from "./sendConfirmationEmail"; -import { sendEmail } from "../lib/notify"; +import { sendEmail } from "../../../../lib/notify"; -jest.mock("../lib/notify", () => ({ +jest.mock("../../../../lib/notify", () => ({ sendEmail: jest.fn(), })); diff --git a/api.planx.uk/inviteToPay/sendConfirmationEmail.ts b/api.planx.uk/modules/pay/service/inviteToPay/sendConfirmationEmail.ts similarity index 92% rename from api.planx.uk/inviteToPay/sendConfirmationEmail.ts rename to api.planx.uk/modules/pay/service/inviteToPay/sendConfirmationEmail.ts index 368f766731..fb69bad444 100644 --- a/api.planx.uk/inviteToPay/sendConfirmationEmail.ts +++ b/api.planx.uk/modules/pay/service/inviteToPay/sendConfirmationEmail.ts @@ -1,8 +1,8 @@ -import { $public, $api } from "../client"; -import { sendEmail } from "../lib/notify"; +import { $public, $api } from "../../../../client"; +import { sendEmail } from "../../../../lib/notify"; import { gql } from "graphql-request"; -import { convertSlugToName } from "../modules/saveAndReturn/service/utils"; -import type { AgentAndPayeeSubmissionNotifyConfig } from "../types"; +import { convertSlugToName } from "../../../saveAndReturn/service/utils"; +import type { AgentAndPayeeSubmissionNotifyConfig } from "../../../../types"; export async function sendAgentAndPayeeConfirmationEmail(sessionId: string) { const { personalisation, applicantEmail, payeeEmail, projectTypes } = diff --git a/api.planx.uk/inviteToPay/sendPaymentEmail.test.ts b/api.planx.uk/modules/pay/service/inviteToPay/sendPaymentEmail.test.ts similarity index 96% rename from api.planx.uk/inviteToPay/sendPaymentEmail.test.ts rename to api.planx.uk/modules/pay/service/inviteToPay/sendPaymentEmail.test.ts index 4c4012e9f4..cc2c593d67 100644 --- a/api.planx.uk/inviteToPay/sendPaymentEmail.test.ts +++ b/api.planx.uk/modules/pay/service/inviteToPay/sendPaymentEmail.test.ts @@ -1,11 +1,11 @@ import supertest from "supertest"; -import app from "../server"; -import { queryMock } from "../tests/graphqlQueryMock"; +import app from "../../../../server"; +import { queryMock } from "../../../../tests/graphqlQueryMock"; import { validatePaymentRequestNotFoundQueryMock, validatePaymentRequestQueryMock, -} from "../tests/mocks/inviteToPayMocks"; +} from "../../../../tests/mocks/inviteToPayMocks"; import { CoreDomainClient } from "@opensystemslab/planx-core"; jest diff --git a/api.planx.uk/inviteToPay/sendPaymentEmail.ts b/api.planx.uk/modules/pay/service/inviteToPay/sendPaymentEmail.ts similarity index 93% rename from api.planx.uk/inviteToPay/sendPaymentEmail.ts rename to api.planx.uk/modules/pay/service/inviteToPay/sendPaymentEmail.ts index cf2f33fd96..c23a806896 100644 --- a/api.planx.uk/inviteToPay/sendPaymentEmail.ts +++ b/api.planx.uk/modules/pay/service/inviteToPay/sendPaymentEmail.ts @@ -3,12 +3,16 @@ import { calculateExpiryDate, convertSlugToName, getServiceLink, -} from "../modules/saveAndReturn/service/utils"; -import { Template, getClientForTemplate, sendEmail } from "../lib/notify"; -import { InviteToPayNotifyConfig } from "../types"; -import { Team } from "../types"; +} from "../../../saveAndReturn/service/utils"; +import { + Template, + getClientForTemplate, + sendEmail, +} from "../../../../lib/notify"; +import { InviteToPayNotifyConfig } from "../../../../types"; +import { Team } from "../../../../types"; import type { PaymentRequest } from "@opensystemslab/planx-core/types"; -import { $public } from "../client"; +import { $public } from "../../../../client"; interface SessionDetails { email: string; diff --git a/api.planx.uk/modules/pay/service/utils.ts b/api.planx.uk/modules/pay/service/utils.ts new file mode 100644 index 0000000000..d5057fd48c --- /dev/null +++ b/api.planx.uk/modules/pay/service/utils.ts @@ -0,0 +1,53 @@ +import { GovUKPayment } from "@opensystemslab/planx-core/types"; +import { $api } from "../../../client"; +import { gql } from "graphql-request"; + +import SlackNotify from "slack-notify"; +import { Request } from "express"; + +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`); + } +}; + +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/modules/pay/types.ts b/api.planx.uk/modules/pay/types.ts new file mode 100644 index 0000000000..4c16ea8c8b --- /dev/null +++ b/api.planx.uk/modules/pay/types.ts @@ -0,0 +1,51 @@ +import { z } from "zod"; +import { ValidatedRequestHandler } from "../../shared/middleware/validate"; +import { PaymentRequest } from "@opensystemslab/planx-core/types"; + +export const paymentProxySchema = z.object({ + query: z.object({ + flowId: z.string().uuid(), + sessionId: z.string().uuid(), + }), + params: z.object({ + localAuthority: z.string(), + }), +}); + +export type PaymentProxyController = ValidatedRequestHandler< + typeof paymentProxySchema, + Buffer +>; + +export const inviteToPaySchema = z.object({ + body: z.object({ + payeeEmail: z.string().email(), + payeeName: z.string(), + applicantName: z.string(), + sessionPreviewKeys: z.array(z.array(z.string())), + }), + params: z.object({ + sessionId: z.string().uuid(), + }), +}); + +export type InviteToPayController = ValidatedRequestHandler< + typeof inviteToPaySchema, + PaymentRequest +>; + +export const paymentRequestProxySchema = z.object({ + query: z.object({ + flowId: z.string().uuid(), + sessionId: z.string().uuid(), + }), + params: z.object({ + paymentRequest: z.string(), + localAuthority: z.string(), + }), +}); + +export type PaymentRequestProxyController = ValidatedRequestHandler< + typeof paymentRequestProxySchema, + Buffer +>; diff --git a/api.planx.uk/modules/saveAndReturn/service/utils.ts b/api.planx.uk/modules/saveAndReturn/service/utils.ts index aa61ee7658..a46968a7b0 100644 --- a/api.planx.uk/modules/saveAndReturn/service/utils.ts +++ b/api.planx.uk/modules/saveAndReturn/service/utils.ts @@ -72,7 +72,8 @@ const sendSingleApplicationEmail = async ({ emailReplyToId: team.notifyPersonalisation.emailReplyToId, }; const firstSave = !session.hasUserSaved; - if (firstSave) await setupEmailEventTriggers(sessionId); + if (firstSave && !session.submittedAt) + await setupEmailEventTriggers(sessionId); return await sendEmail(template, email, config); } catch (error) { throw Error((error as Error).message); @@ -98,6 +99,7 @@ const validateSingleSessionRequest = async ( id data created_at + submitted_at has_user_saved flow { slug @@ -139,6 +141,7 @@ interface SessionDetails { projectType: string; id: string; expiryDate: string; + submittedAt: string | null; } /** @@ -162,6 +165,7 @@ const getSessionDetails = async ( id: session.id, expiryDate: calculateExpiryDate(session.created_at), hasUserSaved: session.has_user_saved, + submittedAt: session.submitted_at, }; }; 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 2ecb05da89..48fe1ce7dd 100644 --- a/api.planx.uk/send/exportZip.test.ts +++ b/api.planx.uk/modules/send/utils/exportZip.test.ts @@ -1,13 +1,12 @@ -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", existsSync: () => true, unlinkSync: () => undefined, createWriteStream: () => undefined, - writeFileSync: () => undefined, rmSync: () => undefined, })); @@ -55,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 96% rename from api.planx.uk/send/exportZip.ts rename to api.planx.uk/modules/send/utils/exportZip.ts index 80800d84e8..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"; @@ -197,9 +197,7 @@ export class ExportZip { throw new Error("file not found"); } const filePath = path.join(this.tmpDir, name); - fs.writeFileSync(filePath, body as Buffer); - this.zip.addLocalFile(filePath); - fs.unlinkSync(filePath); + this.zip.addFile(filePath, body as Buffer); } toBuffer(): Buffer { 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/modules/sendEmail/controller.ts b/api.planx.uk/modules/sendEmail/controller.ts index a91ae0bfdf..0f3570c02f 100644 --- a/api.planx.uk/modules/sendEmail/controller.ts +++ b/api.planx.uk/modules/sendEmail/controller.ts @@ -1,7 +1,7 @@ import { sendSinglePaymentEmail, sendAgentAndPayeeConfirmationEmail, -} from "../../inviteToPay"; +} from "../pay/service/inviteToPay"; import { sendSingleApplicationEmail } from "../saveAndReturn/service/utils"; import { ServerError } from "../../errors"; import { NextFunction } from "express"; diff --git a/api.planx.uk/modules/sendEmail/index.test.ts b/api.planx.uk/modules/sendEmail/index.test.ts index 996fa16d35..96debfbfcc 100644 --- a/api.planx.uk/modules/sendEmail/index.test.ts +++ b/api.planx.uk/modules/sendEmail/index.test.ts @@ -186,6 +186,7 @@ describe("Send Email endpoint", () => { payload: { sessionId: "123", email: TEST_EMAIL, + lockedAt: null, }, }; await supertest(app) diff --git a/api.planx.uk/modules/sendEmail/types.ts b/api.planx.uk/modules/sendEmail/types.ts index ae24d85519..4f17cd3a72 100644 --- a/api.planx.uk/modules/sendEmail/types.ts +++ b/api.planx.uk/modules/sendEmail/types.ts @@ -50,7 +50,7 @@ export const confirmationEmailSchema = z.object({ body: z.object({ payload: z.object({ sessionId: z.string(), - lockedAt: z.string().optional(), + lockedAt: z.string().or(z.null()), email: z.string().email(), }), }), diff --git a/api.planx.uk/modules/webhooks/routes.ts b/api.planx.uk/modules/webhooks/routes.ts index dc7c1bbbcd..233817d1c4 100644 --- a/api.planx.uk/modules/webhooks/routes.ts +++ b/api.planx.uk/modules/webhooks/routes.ts @@ -1,6 +1,6 @@ import { Router } from "express"; import { useHasuraAuth } from "../auth/middleware"; -import { createPaymentSendEvents } from "../../inviteToPay/createPaymentSendEvents"; +import { createPaymentSendEvents } from "../pay/service/inviteToPay/createPaymentSendEvents"; import { validate } from "../../shared/middleware/validate"; import { createPaymentExpiryEventsController, diff --git a/api.planx.uk/package.json b/api.planx.uk/package.json index 915f902c41..3e5d8d7078 100644 --- a/api.planx.uk/package.json +++ b/api.planx.uk/package.json @@ -4,7 +4,7 @@ "private": true, "dependencies": { "@airbrake/node": "^2.1.8", - "@opensystemslab/planx-core": "git+https://github.com/theopensystemslab/planx-core#799ce44", + "@opensystemslab/planx-core": "git+https://github.com/theopensystemslab/planx-core#41f4683", "@types/isomorphic-fetch": "^0.0.36", "adm-zip": "^0.5.10", "aws-sdk": "^2.1467.0", diff --git a/api.planx.uk/pay/utils.ts b/api.planx.uk/pay/utils.ts deleted file mode 100644 index f731abf085..0000000000 --- a/api.planx.uk/pay/utils.ts +++ /dev/null @@ -1,30 +0,0 @@ -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/pnpm-lock.yaml b/api.planx.uk/pnpm-lock.yaml index e858f9a55d..2fe0c132fc 100644 --- a/api.planx.uk/pnpm-lock.yaml +++ b/api.planx.uk/pnpm-lock.yaml @@ -9,8 +9,8 @@ dependencies: specifier: ^2.1.8 version: 2.1.8 '@opensystemslab/planx-core': - specifier: git+https://github.com/theopensystemslab/planx-core#799ce44 - version: github.com/theopensystemslab/planx-core/799ce44 + specifier: git+https://github.com/theopensystemslab/planx-core#41f4683 + version: github.com/theopensystemslab/planx-core/41f4683 '@types/isomorphic-fetch': specifier: ^0.0.36 version: 0.0.36 @@ -2838,6 +2838,10 @@ packages: - supports-color dev: false + /boolbase@1.0.0: + resolution: {integrity: sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==} + dev: false + /brace-expansion@1.1.11: resolution: {integrity: sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==} dependencies: @@ -3015,6 +3019,30 @@ packages: engines: {node: '>=10'} dev: true + /cheerio-select@2.1.0: + resolution: {integrity: sha512-9v9kG0LvzrlcungtnJtpGNxY+fzECQKhK4EGJX2vByejiMX84MFNQw4UxPJl3bFbTMw+Dfs37XaIkCwTZfLh4g==} + dependencies: + boolbase: 1.0.0 + css-select: 5.1.0 + css-what: 6.1.0 + domelementtype: 2.3.0 + domhandler: 5.0.3 + domutils: 3.1.0 + dev: false + + /cheerio@1.0.0-rc.12: + resolution: {integrity: sha512-VqR8m68vM46BNnuZ5NtnGBKIE/DfN0cRIzg9n40EIq9NOv90ayxLBXA8fXC5gquFRGJSTRqBq25Jt2ECLR431Q==} + engines: {node: '>= 6'} + dependencies: + cheerio-select: 2.1.0 + dom-serializer: 2.0.0 + domhandler: 5.0.3 + domutils: 3.1.0 + htmlparser2: 8.0.2 + parse5: 7.1.2 + parse5-htmlparser2-tree-adapter: 7.0.0 + dev: false + /chokidar@3.5.3: resolution: {integrity: sha512-Dr3sfKRP6oTcjf2JmUmFJfeVMvXBdegxB0iVQ5eb2V10uFJUCAS8OByZdVAyVb8xXNz3GjjTgj9kLWsZTqE6kw==} engines: {node: '>= 8.10.0'} @@ -3333,6 +3361,21 @@ packages: shebang-command: 2.0.0 which: 2.0.2 + /css-select@5.1.0: + resolution: {integrity: sha512-nwoRF1rvRRnnCqqY7updORDsuqKzqYJ28+oSMaJMMgOauh3fvwHqMS7EZpIPqK8GL+g9mKxF1vP/ZjSeNjEVHg==} + dependencies: + boolbase: 1.0.0 + css-what: 6.1.0 + domhandler: 5.0.3 + domutils: 3.1.0 + nth-check: 2.1.1 + dev: false + + /css-what@6.1.0: + resolution: {integrity: sha512-HTUrgRJ7r4dsZKU6GjmpfRK1O76h97Z8MfS1G0FozR+oF2kG6Vfe8JE6zwrkbxigziPHinCJ+gCPjA9EaBDtRw==} + engines: {node: '>= 6'} + dev: false + /csstype@3.1.2: resolution: {integrity: sha512-I7K1Uu0MBPzaFKg4nI5Q7Vs2t+3gWWW648spaF+Rg7pI9ds18Ugn+lvg4SHczUdKlHI5LWBXyqfS8+DufyBsgQ==} dev: false @@ -3532,6 +3575,33 @@ packages: csstype: 3.1.2 dev: false + /dom-serializer@2.0.0: + resolution: {integrity: sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==} + dependencies: + domelementtype: 2.3.0 + domhandler: 5.0.3 + entities: 4.5.0 + dev: false + + /domelementtype@2.3.0: + resolution: {integrity: sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==} + dev: false + + /domhandler@5.0.3: + resolution: {integrity: sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==} + engines: {node: '>= 4'} + dependencies: + domelementtype: 2.3.0 + dev: false + + /domutils@3.1.0: + resolution: {integrity: sha512-H78uMmQtI2AhgDJjWeQmHwJJ2bLPD3GMmO7Zja/ZZh84wkm+4ut+IUnUdRa8uCGX88DiVx1j6FRe1XfxEgjEZA==} + dependencies: + dom-serializer: 2.0.0 + domelementtype: 2.3.0 + domhandler: 5.0.3 + dev: false + /dotenv@16.3.1: resolution: {integrity: sha512-IPzF4w4/Rd94bA9imS68tZBaYyBWSCE47V1RGuMrB94iyTOIEwRmVL2x/4An+6mETpLrKJ5hQkB8W4kFAadeIQ==} engines: {node: '>=12'} @@ -3592,6 +3662,11 @@ packages: dependencies: once: 1.4.0 + /entities@4.5.0: + resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==} + engines: {node: '>=0.12'} + dev: false + /error-ex@1.3.2: resolution: {integrity: sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g==} dependencies: @@ -4622,6 +4697,15 @@ packages: resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==} dev: true + /htmlparser2@8.0.2: + resolution: {integrity: sha512-GYdjWKDkbRLkZ5geuHs5NY1puJ+PXwP7+fHPRz06Eirsb9ugf6d8kkXav6ADhcODhFFPMIXyxkxSuMf3D6NCFA==} + dependencies: + domelementtype: 2.3.0 + domhandler: 5.0.3 + domutils: 3.1.0 + entities: 4.5.0 + dev: false + /http-errors@2.0.0: resolution: {integrity: sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==} engines: {node: '>= 0.8'} @@ -5936,6 +6020,12 @@ packages: object-visit: 1.0.1 dev: true + /marked@9.1.6: + resolution: {integrity: sha512-jcByLnIFkd5gSXZmjNvS1TlmRhCXZjIzHYlaGkPlLIekG55JDR2Z4va9tZwCiP+/RDERiNhMOFu01xd6O5ct1Q==} + engines: {node: '>= 16'} + hasBin: true + dev: false + /media-typer@0.3.0: resolution: {integrity: sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==} engines: {node: '>= 0.6'} @@ -6262,6 +6352,12 @@ packages: path-key: 4.0.0 dev: true + /nth-check@2.1.1: + resolution: {integrity: sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==} + dependencies: + boolbase: 1.0.0 + dev: false + /oauth@0.9.15: resolution: {integrity: sha512-a5ERWK1kh38ExDEfoO6qUHJb32rd7aYmPHuyCu3Fta/cnICvYmgd2uhuKXvPD+PXB+gCEYYEaQdIRAjCOwAKNA==} dev: false @@ -6424,6 +6520,19 @@ packages: json-parse-even-better-errors: 2.3.1 lines-and-columns: 1.2.4 + /parse5-htmlparser2-tree-adapter@7.0.0: + resolution: {integrity: sha512-B77tOZrqqfUfnVcOrUvfdLbz4pu4RopLD/4vmu3HUPswwTA8OH0EMW9BlWR2B0RCoiZRAHEUu7IxeP1Pd1UU+g==} + dependencies: + domhandler: 5.0.3 + parse5: 7.1.2 + dev: false + + /parse5@7.1.2: + resolution: {integrity: sha512-Czj1WaSVpaoj0wbhMzLmWD69anp2WH7FXMB9n1Sy8/ZFF9jolSQVMu1Ij5WIyGmcBmhk7EOndpO4mIpihVqAXw==} + dependencies: + entities: 4.5.0 + dev: false + /parseurl@1.3.3: resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==} engines: {node: '>= 0.8'} @@ -7428,10 +7537,6 @@ packages: resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} engines: {node: '>=8'} - /striptags@3.2.0: - resolution: {integrity: sha512-g45ZOGzHDMe2bdYMdIvdAfCQkCTDMGBazSw1ypMowwGIee7ZQ5dU0rBJ8Jqgl+jAKIv4dbeE1jscZq9wid1Tkw==} - dev: false - /strnum@1.0.5: resolution: {integrity: sha512-J8bbNyKKXl5qYcR36TIO8W3mVGVHrmmxsd5PAItGkmyzwJvybiw2IVq5nqd0i4LSNSkB/sx9VHllbfFdr9k1JA==} dev: false @@ -8146,8 +8251,8 @@ packages: resolution: {integrity: sha512-iC+8Io04lddc+mVqQ9AZ7OQ2MrUKGN+oIQyq1vemgt46jwCwLfhq7/pwnBnNXXXZb8VTVLKwp9EDkx+ryxIWmg==} dev: false - github.com/theopensystemslab/planx-core/799ce44: - resolution: {tarball: https://codeload.github.com/theopensystemslab/planx-core/tar.gz/799ce44} + github.com/theopensystemslab/planx-core/41f4683: + resolution: {tarball: https://codeload.github.com/theopensystemslab/planx-core/tar.gz/41f4683} name: '@opensystemslab/planx-core' version: 1.0.0 prepare: true @@ -8159,6 +8264,7 @@ packages: '@types/geojson': 7946.0.12 ajv: 8.12.0 ajv-formats: 2.1.1(ajv@8.12.0) + cheerio: 1.0.0-rc.12 copyfiles: 2.4.1 docx: 8.2.4 eslint: 8.51.0 @@ -8176,10 +8282,10 @@ packages: lodash.omit: 4.5.0 lodash.set: 4.3.2 lodash.startcase: 4.4.0 + marked: 9.1.6 prettier: 3.0.3 react: 18.2.0 react-dom: 18.2.0(react@18.2.0) - striptags: 3.2.0 type-fest: 4.6.0 uuid: 9.0.1 zod: 3.22.4 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 38cdf5ed54..0bf623673e 100644 --- a/api.planx.uk/server.ts +++ b/api.planx.uk/server.ts @@ -4,36 +4,15 @@ import assert from "assert"; import cookieParser from "cookie-parser"; import cookieSession from "cookie-session"; import cors from "cors"; -import { stringify } from "csv-stringify"; import express, { ErrorRequestHandler } from "express"; import noir from "pino-noir"; import pinoLogger from "express-pino-logger"; import { Server } from "http"; import passport from "passport"; import helmet from "helmet"; - import { ServerError } from "./errors"; -import { locationSearch } from "./gis/index"; -import { - makePaymentViaProxy, - fetchPaymentViaProxy, - makeInviteToPayPaymentViaProxy, -} from "./pay"; -import { - inviteToPay, - fetchPaymentRequestDetails, - buildPaymentPayload, - fetchPaymentRequestViaProxy, -} from "./inviteToPay"; -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 { classifiedRoadsSearch } from "./gis/classifiedRoads"; import { googleStrategy } from "./modules/auth/strategy/google"; import authRoutes from "./modules/auth/routes"; import teamRoutes from "./modules/team/routes"; @@ -47,11 +26,12 @@ import ordnanceSurveyRoutes from "./modules/ordnanceSurvey/routes"; import saveAndReturnRoutes from "./modules/saveAndReturn/routes"; 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"; -const router = express.Router(); - const app = express(); useSwaggerDocs(app); @@ -93,51 +73,21 @@ 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}`]); }); -// used by startNewPayment() in @planx/components/Pay/Public/Pay.tsx -app.post("/pay/:localAuthority", makePaymentViaProxy); - -// used by refetchPayment() in @planx/components/Pay/Public/Pay.tsx -app.get("/pay/:localAuthority/:paymentId", fetchPaymentViaProxy); - -app.post( - "/payment-request/:paymentRequest/pay", - fetchPaymentRequestDetails, - buildPaymentPayload, - makeInviteToPayPaymentViaProxy, -); - -app.get( - "/payment-request/:paymentRequest/payment/:paymentId", - fetchPaymentRequestDetails, - fetchPaymentRequestViaProxy, -); - // needed for storing original URL to redirect to in login flow app.use( cookieSession({ @@ -172,41 +122,9 @@ app.use("/file", fileRoutes); app.use(saveAndReturnRoutes); app.use(sendEmailRoutes); app.use("/flows", flowRoutes); - -app.use("/gis", router); - -app.get("/gis", (_req, _res, next) => { - next({ - status: 400, - message: "Please specify a local authority", - }); -}); - -app.get("/gis/:localAuthority", locationSearch); - -app.get("/roads", classifiedRoadsSearch); - -// allows an applicant to download their application data on the Confirmation page -app.post("/download-application", async (req, res, next) => { - if (!req.body) { - res.send({ - message: "Missing application `data` to download", - }); - } - - try { - // build a CSV and stream the response - stringify(req.body, { - columns: ["question", "responses", "metadata"], - header: true, - }).pipe(res); - res.header("Content-type", "text/csv"); - } catch (err) { - next(err); - } -}); - -app.post("/invite-to-pay/:sessionId", inviteToPay); +app.use(gisRoutes); +app.use(payRoutes); +app.use(sendRoutes); const errorHandler: ErrorRequestHandler = (errorObject, _req, res, _next) => { const { status = 500, message = "Something went wrong" } = (() => { diff --git a/api.planx.uk/tests/serverErrorHandler.test.js b/api.planx.uk/tests/serverErrorHandler.test.js index cb14574208..f9711ac3c9 100644 --- a/api.planx.uk/tests/serverErrorHandler.test.js +++ b/api.planx.uk/tests/serverErrorHandler.test.js @@ -31,7 +31,7 @@ describe("bad requests", () => { }); test(`app.get("/gis")`, async () => { - await get("/gis").expect(400); + await get("/gis").expect(404); }); test(`app.get("/gis/wrong")`, async () => { diff --git a/docker-compose.yml b/docker-compose.yml index aaaa7df04b..1f2e6aa6e4 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -19,7 +19,7 @@ services: metabase: # if changing, also check infrastructure/application/index.ts - image: metabase/metabase:v0.45.3 + image: metabase/metabase:v0.47.8 profiles: ["analytics"] ports: - "${METABASE_PORT}:${METABASE_PORT}" diff --git a/e2e/tests/api-driven/package.json b/e2e/tests/api-driven/package.json index b1cdd23859..889975a830 100644 --- a/e2e/tests/api-driven/package.json +++ b/e2e/tests/api-driven/package.json @@ -6,7 +6,7 @@ }, "dependencies": { "@cucumber/cucumber": "^9.3.0", - "@opensystemslab/planx-core": "git+https://github.com/theopensystemslab/planx-core#799ce44", + "@opensystemslab/planx-core": "git+https://github.com/theopensystemslab/planx-core#41f4683", "axios": "^1.6.0", "dotenv": "^16.3.1", "dotenv-expand": "^10.0.0", diff --git a/e2e/tests/api-driven/pnpm-lock.yaml b/e2e/tests/api-driven/pnpm-lock.yaml index d9bb887eb2..881218c1c8 100644 --- a/e2e/tests/api-driven/pnpm-lock.yaml +++ b/e2e/tests/api-driven/pnpm-lock.yaml @@ -9,8 +9,8 @@ dependencies: specifier: ^9.3.0 version: 9.3.0 '@opensystemslab/planx-core': - specifier: git+https://github.com/theopensystemslab/planx-core#799ce44 - version: github.com/theopensystemslab/planx-core/799ce44 + specifier: git+https://github.com/theopensystemslab/planx-core#41f4683 + version: github.com/theopensystemslab/planx-core/41f4683 axios: specifier: ^1.6.0 version: 1.6.0 @@ -737,10 +737,6 @@ packages: resolution: {integrity: sha512-+68kP9yzs4LMp7VNh8gdzMSPZFL44MLGqiHWvttYJe+6qnuVr4Ek9wSBQoveqY/r+LwjCcU29kNVkidwim+kYA==} dev: false - /@types/prop-types@15.7.8: - resolution: {integrity: sha512-kMpQpfZKSCBqltAJwskgePRaYRFukDkm1oItcAbC3gNELR20XIBcN9VRgg4+m8DKsTfkWeA4m4Imp4DDuWy7FQ==} - dev: false - /@types/prop-types@15.7.9: resolution: {integrity: sha512-n1yyPsugYNSmHgxDFjicaI2+gCNjsBck8UX9kuofAKlc0h1bL+20oSF72KeNaW2DUlesbEVCFgyV2dPGTiY42g==} dev: false @@ -754,7 +750,7 @@ packages: /@types/react@18.2.18: resolution: {integrity: sha512-da4NTSeBv/P34xoZPhtcLkmZuJ+oYaCxHmyHzwaDQo9RQPBeXV+06gEk2FpqEcsX9XrnNLvRpVh6bdavDSjtiQ==} dependencies: - '@types/prop-types': 15.7.8 + '@types/prop-types': 15.7.9 '@types/scheduler': 0.16.3 csstype: 3.1.2 dev: false @@ -894,6 +890,10 @@ packages: resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} dev: false + /boolbase@1.0.0: + resolution: {integrity: sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==} + dev: false + /brace-expansion@1.1.11: resolution: {integrity: sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==} dependencies: @@ -943,6 +943,30 @@ packages: supports-color: 7.2.0 dev: false + /cheerio-select@2.1.0: + resolution: {integrity: sha512-9v9kG0LvzrlcungtnJtpGNxY+fzECQKhK4EGJX2vByejiMX84MFNQw4UxPJl3bFbTMw+Dfs37XaIkCwTZfLh4g==} + dependencies: + boolbase: 1.0.0 + css-select: 5.1.0 + css-what: 6.1.0 + domelementtype: 2.3.0 + domhandler: 5.0.3 + domutils: 3.1.0 + dev: false + + /cheerio@1.0.0-rc.12: + resolution: {integrity: sha512-VqR8m68vM46BNnuZ5NtnGBKIE/DfN0cRIzg9n40EIq9NOv90ayxLBXA8fXC5gquFRGJSTRqBq25Jt2ECLR431Q==} + engines: {node: '>= 6'} + dependencies: + cheerio-select: 2.1.0 + dom-serializer: 2.0.0 + domhandler: 5.0.3 + domutils: 3.1.0 + htmlparser2: 8.0.2 + parse5: 7.1.2 + parse5-htmlparser2-tree-adapter: 7.0.0 + dev: false + /class-transformer@0.5.1: resolution: {integrity: sha512-SQa1Ws6hUbfC98vKGxZH3KFY0Y1lm5Zm0SY8XX9zbK7FJCyVEac3ATW0RIpwzW+oOfmHE5PMPufDG9hCfoEOMw==} dev: false @@ -1084,6 +1108,21 @@ packages: which: 2.0.2 dev: false + /css-select@5.1.0: + resolution: {integrity: sha512-nwoRF1rvRRnnCqqY7updORDsuqKzqYJ28+oSMaJMMgOauh3fvwHqMS7EZpIPqK8GL+g9mKxF1vP/ZjSeNjEVHg==} + dependencies: + boolbase: 1.0.0 + css-what: 6.1.0 + domhandler: 5.0.3 + domutils: 3.1.0 + nth-check: 2.1.1 + dev: false + + /css-what@6.1.0: + resolution: {integrity: sha512-HTUrgRJ7r4dsZKU6GjmpfRK1O76h97Z8MfS1G0FozR+oF2kG6Vfe8JE6zwrkbxigziPHinCJ+gCPjA9EaBDtRw==} + engines: {node: '>= 6'} + dev: false + /csstype@3.1.2: resolution: {integrity: sha512-I7K1Uu0MBPzaFKg4nI5Q7Vs2t+3gWWW648spaF+Rg7pI9ds18Ugn+lvg4SHczUdKlHI5LWBXyqfS8+DufyBsgQ==} dev: false @@ -1146,6 +1185,33 @@ packages: csstype: 3.1.2 dev: false + /dom-serializer@2.0.0: + resolution: {integrity: sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==} + dependencies: + domelementtype: 2.3.0 + domhandler: 5.0.3 + entities: 4.5.0 + dev: false + + /domelementtype@2.3.0: + resolution: {integrity: sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==} + dev: false + + /domhandler@5.0.3: + resolution: {integrity: sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==} + engines: {node: '>= 4'} + dependencies: + domelementtype: 2.3.0 + dev: false + + /domutils@3.1.0: + resolution: {integrity: sha512-H78uMmQtI2AhgDJjWeQmHwJJ2bLPD3GMmO7Zja/ZZh84wkm+4ut+IUnUdRa8uCGX88DiVx1j6FRe1XfxEgjEZA==} + dependencies: + dom-serializer: 2.0.0 + domelementtype: 2.3.0 + domhandler: 5.0.3 + dev: false + /dotenv-expand@10.0.0: resolution: {integrity: sha512-GopVGCpVS1UKH75VKHGuQFqS1Gusej0z4FyQkPdwjil2gNIv+LNsqBlboOzpJFZKVT95GkCyWJbBSdFEFUWI2A==} engines: {node: '>=12'} @@ -1166,6 +1232,11 @@ packages: resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} dev: false + /entities@4.5.0: + resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==} + engines: {node: '>=0.12'} + dev: false + /error-ex@1.3.2: resolution: {integrity: sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g==} dependencies: @@ -1540,6 +1611,15 @@ packages: react-is: 16.13.1 dev: false + /htmlparser2@8.0.2: + resolution: {integrity: sha512-GYdjWKDkbRLkZ5geuHs5NY1puJ+PXwP7+fHPRz06Eirsb9ugf6d8kkXav6ADhcODhFFPMIXyxkxSuMf3D6NCFA==} + dependencies: + domelementtype: 2.3.0 + domhandler: 5.0.3 + domutils: 3.1.0 + entities: 4.5.0 + dev: false + /ignore@5.2.4: resolution: {integrity: sha512-MAb38BcSbH0eHNBxn7ql2NH/kX33OkB3lZ1BNdh7ENeRChHTYsTvWrMubiIAMNS2llXEEgZ1MUOBtXChP3kaFQ==} engines: {node: '>= 4'} @@ -1886,6 +1966,12 @@ packages: resolution: {integrity: sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==} dev: true + /marked@9.1.6: + resolution: {integrity: sha512-jcByLnIFkd5gSXZmjNvS1TlmRhCXZjIzHYlaGkPlLIekG55JDR2Z4va9tZwCiP+/RDERiNhMOFu01xd6O5ct1Q==} + engines: {node: '>= 16'} + hasBin: true + dev: false + /memoizee@0.4.15: resolution: {integrity: sha512-UBWmJpLZd5STPm7PMUlOw/TSy972M+z8gcyQ5veOnSDRREz/0bmpyTfKt3/51DhEBqCZQn1udM/5flcSPYhkdQ==} dependencies: @@ -2001,6 +2087,12 @@ packages: readable-stream: 1.0.34 dev: false + /nth-check@2.1.1: + resolution: {integrity: sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==} + dependencies: + boolbase: 1.0.0 + dev: false + /object-assign@4.1.1: resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} engines: {node: '>=0.10.0'} @@ -2066,6 +2158,19 @@ packages: lines-and-columns: 1.2.4 dev: false + /parse5-htmlparser2-tree-adapter@7.0.0: + resolution: {integrity: sha512-B77tOZrqqfUfnVcOrUvfdLbz4pu4RopLD/4vmu3HUPswwTA8OH0EMW9BlWR2B0RCoiZRAHEUu7IxeP1Pd1UU+g==} + dependencies: + domhandler: 5.0.3 + parse5: 7.1.2 + dev: false + + /parse5@7.1.2: + resolution: {integrity: sha512-Czj1WaSVpaoj0wbhMzLmWD69anp2WH7FXMB9n1Sy8/ZFF9jolSQVMu1Ij5WIyGmcBmhk7EOndpO4mIpihVqAXw==} + dependencies: + entities: 4.5.0 + dev: false + /path-exists@4.0.0: resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} engines: {node: '>=8'} @@ -2395,10 +2500,6 @@ packages: engines: {node: '>=8'} dev: false - /striptags@3.2.0: - resolution: {integrity: sha512-g45ZOGzHDMe2bdYMdIvdAfCQkCTDMGBazSw1ypMowwGIee7ZQ5dU0rBJ8Jqgl+jAKIv4dbeE1jscZq9wid1Tkw==} - dev: false - /strnum@1.0.5: resolution: {integrity: sha512-J8bbNyKKXl5qYcR36TIO8W3mVGVHrmmxsd5PAItGkmyzwJvybiw2IVq5nqd0i4LSNSkB/sx9VHllbfFdr9k1JA==} dev: false @@ -2715,10 +2816,11 @@ packages: resolution: {integrity: sha512-iC+8Io04lddc+mVqQ9AZ7OQ2MrUKGN+oIQyq1vemgt46jwCwLfhq7/pwnBnNXXXZb8VTVLKwp9EDkx+ryxIWmg==} dev: false - github.com/theopensystemslab/planx-core/799ce44: - resolution: {tarball: https://codeload.github.com/theopensystemslab/planx-core/tar.gz/799ce44} + github.com/theopensystemslab/planx-core/41f4683: + resolution: {tarball: https://codeload.github.com/theopensystemslab/planx-core/tar.gz/41f4683} name: '@opensystemslab/planx-core' version: 1.0.0 + prepare: true requiresBuild: true dependencies: '@emotion/react': 11.11.1(react@18.2.0) @@ -2727,6 +2829,7 @@ packages: '@types/geojson': 7946.0.12 ajv: 8.12.0 ajv-formats: 2.1.1(ajv@8.12.0) + cheerio: 1.0.0-rc.12 copyfiles: 2.4.1 docx: 8.2.4 eslint: 8.51.0 @@ -2744,10 +2847,10 @@ packages: lodash.omit: 4.5.0 lodash.set: 4.3.2 lodash.startcase: 4.4.0 + marked: 9.1.6 prettier: 3.0.3 react: 18.2.0 react-dom: 18.2.0(react@18.2.0) - striptags: 3.2.0 type-fest: 4.4.0 uuid: 9.0.1 zod: 3.22.4 diff --git a/e2e/tests/ui-driven/package.json b/e2e/tests/ui-driven/package.json index bf99a21a4e..bddb9e3f64 100644 --- a/e2e/tests/ui-driven/package.json +++ b/e2e/tests/ui-driven/package.json @@ -8,7 +8,7 @@ "postinstall": "./install-dependencies.sh" }, "dependencies": { - "@opensystemslab/planx-core": "git+https://github.com/theopensystemslab/planx-core#799ce44", + "@opensystemslab/planx-core": "git+https://github.com/theopensystemslab/planx-core#41f4683", "axios": "^1.6.0", "dotenv": "^16.3.1", "eslint": "^8.44.0", diff --git a/e2e/tests/ui-driven/pnpm-lock.yaml b/e2e/tests/ui-driven/pnpm-lock.yaml index 87b3b95000..8bd96d06c1 100644 --- a/e2e/tests/ui-driven/pnpm-lock.yaml +++ b/e2e/tests/ui-driven/pnpm-lock.yaml @@ -6,8 +6,8 @@ settings: dependencies: '@opensystemslab/planx-core': - specifier: git+https://github.com/theopensystemslab/planx-core#799ce44 - version: github.com/theopensystemslab/planx-core/799ce44 + specifier: git+https://github.com/theopensystemslab/planx-core#41f4683 + version: github.com/theopensystemslab/planx-core/41f4683 axios: specifier: ^1.6.0 version: 1.6.0 @@ -565,10 +565,6 @@ packages: resolution: {integrity: sha512-+68kP9yzs4LMp7VNh8gdzMSPZFL44MLGqiHWvttYJe+6qnuVr4Ek9wSBQoveqY/r+LwjCcU29kNVkidwim+kYA==} dev: false - /@types/prop-types@15.7.8: - resolution: {integrity: sha512-kMpQpfZKSCBqltAJwskgePRaYRFukDkm1oItcAbC3gNELR20XIBcN9VRgg4+m8DKsTfkWeA4m4Imp4DDuWy7FQ==} - dev: false - /@types/prop-types@15.7.9: resolution: {integrity: sha512-n1yyPsugYNSmHgxDFjicaI2+gCNjsBck8UX9kuofAKlc0h1bL+20oSF72KeNaW2DUlesbEVCFgyV2dPGTiY42g==} dev: false @@ -582,7 +578,7 @@ packages: /@types/react@18.2.20: resolution: {integrity: sha512-WKNtmsLWJM/3D5mG4U84cysVY31ivmyw85dE84fOCk5Hx78wezB/XEjVPWl2JTZ5FkEeaTJf+VgUAUn3PE7Isw==} dependencies: - '@types/prop-types': 15.7.8 + '@types/prop-types': 15.7.9 '@types/scheduler': 0.16.3 csstype: 3.1.2 dev: false @@ -726,6 +722,10 @@ packages: /balanced-match@1.0.2: resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} + /boolbase@1.0.0: + resolution: {integrity: sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==} + dev: false + /boxen@7.0.0: resolution: {integrity: sha512-j//dBVuyacJbvW+tvZ9HuH03fZ46QcaKvvhZickZqtB271DxJ7SNRSNxrV/dZX0085m7hISRZWbzWlJvx/rHSg==} engines: {node: '>=14.16'} @@ -796,6 +796,30 @@ packages: engines: {node: ^12.17.0 || ^14.13 || >=16.0.0} dev: false + /cheerio-select@2.1.0: + resolution: {integrity: sha512-9v9kG0LvzrlcungtnJtpGNxY+fzECQKhK4EGJX2vByejiMX84MFNQw4UxPJl3bFbTMw+Dfs37XaIkCwTZfLh4g==} + dependencies: + boolbase: 1.0.0 + css-select: 5.1.0 + css-what: 6.1.0 + domelementtype: 2.3.0 + domhandler: 5.0.3 + domutils: 3.1.0 + dev: false + + /cheerio@1.0.0-rc.12: + resolution: {integrity: sha512-VqR8m68vM46BNnuZ5NtnGBKIE/DfN0cRIzg9n40EIq9NOv90ayxLBXA8fXC5gquFRGJSTRqBq25Jt2ECLR431Q==} + engines: {node: '>= 6'} + dependencies: + cheerio-select: 2.1.0 + dom-serializer: 2.0.0 + domhandler: 5.0.3 + domutils: 3.1.0 + htmlparser2: 8.0.2 + parse5: 7.1.2 + parse5-htmlparser2-tree-adapter: 7.0.0 + dev: false + /cli-boxes@3.0.0: resolution: {integrity: sha512-/lzGpEWL/8PfI0BmBOPRwp0c/wFNX1RdUML3jK/RcSBA9T8mZDdQpqYBKtCFTOfQbwPqWEOpjqW+Fnayc0969g==} engines: {node: '>=10'} @@ -938,6 +962,21 @@ packages: shebang-command: 2.0.0 which: 2.0.2 + /css-select@5.1.0: + resolution: {integrity: sha512-nwoRF1rvRRnnCqqY7updORDsuqKzqYJ28+oSMaJMMgOauh3fvwHqMS7EZpIPqK8GL+g9mKxF1vP/ZjSeNjEVHg==} + dependencies: + boolbase: 1.0.0 + css-what: 6.1.0 + domhandler: 5.0.3 + domutils: 3.1.0 + nth-check: 2.1.1 + dev: false + + /css-what@6.1.0: + resolution: {integrity: sha512-HTUrgRJ7r4dsZKU6GjmpfRK1O76h97Z8MfS1G0FozR+oF2kG6Vfe8JE6zwrkbxigziPHinCJ+gCPjA9EaBDtRw==} + engines: {node: '>= 6'} + dev: false + /csstype@3.1.2: resolution: {integrity: sha512-I7K1Uu0MBPzaFKg4nI5Q7Vs2t+3gWWW648spaF+Rg7pI9ds18Ugn+lvg4SHczUdKlHI5LWBXyqfS8+DufyBsgQ==} dev: false @@ -1008,6 +1047,33 @@ packages: csstype: 3.1.2 dev: false + /dom-serializer@2.0.0: + resolution: {integrity: sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==} + dependencies: + domelementtype: 2.3.0 + domhandler: 5.0.3 + entities: 4.5.0 + dev: false + + /domelementtype@2.3.0: + resolution: {integrity: sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==} + dev: false + + /domhandler@5.0.3: + resolution: {integrity: sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==} + engines: {node: '>= 4'} + dependencies: + domelementtype: 2.3.0 + dev: false + + /domutils@3.1.0: + resolution: {integrity: sha512-H78uMmQtI2AhgDJjWeQmHwJJ2bLPD3GMmO7Zja/ZZh84wkm+4ut+IUnUdRa8uCGX88DiVx1j6FRe1XfxEgjEZA==} + dependencies: + dom-serializer: 2.0.0 + domelementtype: 2.3.0 + domhandler: 5.0.3 + dev: false + /dotenv@16.3.1: resolution: {integrity: sha512-IPzF4w4/Rd94bA9imS68tZBaYyBWSCE47V1RGuMrB94iyTOIEwRmVL2x/4An+6mETpLrKJ5hQkB8W4kFAadeIQ==} engines: {node: '>=12'} @@ -1031,6 +1097,11 @@ packages: resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==} dev: false + /entities@4.5.0: + resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==} + engines: {node: '>=0.12'} + dev: false + /error-ex@1.3.2: resolution: {integrity: sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g==} dependencies: @@ -1432,6 +1503,15 @@ packages: react-is: 16.13.1 dev: false + /htmlparser2@8.0.2: + resolution: {integrity: sha512-GYdjWKDkbRLkZ5geuHs5NY1puJ+PXwP7+fHPRz06Eirsb9ugf6d8kkXav6ADhcODhFFPMIXyxkxSuMf3D6NCFA==} + dependencies: + domelementtype: 2.3.0 + domhandler: 5.0.3 + domutils: 3.1.0 + entities: 4.5.0 + dev: false + /human-signals@2.1.0: resolution: {integrity: sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==} engines: {node: '>=10.17.0'} @@ -1714,6 +1794,12 @@ packages: es5-ext: 0.10.62 dev: false + /marked@9.1.6: + resolution: {integrity: sha512-jcByLnIFkd5gSXZmjNvS1TlmRhCXZjIzHYlaGkPlLIekG55JDR2Z4va9tZwCiP+/RDERiNhMOFu01xd6O5ct1Q==} + engines: {node: '>= 16'} + hasBin: true + dev: false + /memoizee@0.4.15: resolution: {integrity: sha512-UBWmJpLZd5STPm7PMUlOw/TSy972M+z8gcyQ5veOnSDRREz/0bmpyTfKt3/51DhEBqCZQn1udM/5flcSPYhkdQ==} dependencies: @@ -1838,6 +1924,12 @@ packages: path-key: 3.1.1 dev: false + /nth-check@2.1.1: + resolution: {integrity: sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==} + dependencies: + boolbase: 1.0.0 + dev: false + /object-assign@4.1.1: resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} engines: {node: '>=0.10.0'} @@ -1903,6 +1995,19 @@ packages: lines-and-columns: 1.2.4 dev: false + /parse5-htmlparser2-tree-adapter@7.0.0: + resolution: {integrity: sha512-B77tOZrqqfUfnVcOrUvfdLbz4pu4RopLD/4vmu3HUPswwTA8OH0EMW9BlWR2B0RCoiZRAHEUu7IxeP1Pd1UU+g==} + dependencies: + domhandler: 5.0.3 + parse5: 7.1.2 + dev: false + + /parse5@7.1.2: + resolution: {integrity: sha512-Czj1WaSVpaoj0wbhMzLmWD69anp2WH7FXMB9n1Sy8/ZFF9jolSQVMu1Ij5WIyGmcBmhk7EOndpO4mIpihVqAXw==} + dependencies: + entities: 4.5.0 + dev: false + /path-exists@4.0.0: resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} engines: {node: '>=8'} @@ -2259,10 +2364,6 @@ packages: resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} engines: {node: '>=8'} - /striptags@3.2.0: - resolution: {integrity: sha512-g45ZOGzHDMe2bdYMdIvdAfCQkCTDMGBazSw1ypMowwGIee7ZQ5dU0rBJ8Jqgl+jAKIv4dbeE1jscZq9wid1Tkw==} - dev: false - /strnum@1.0.5: resolution: {integrity: sha512-J8bbNyKKXl5qYcR36TIO8W3mVGVHrmmxsd5PAItGkmyzwJvybiw2IVq5nqd0i4LSNSkB/sx9VHllbfFdr9k1JA==} dev: false @@ -2498,10 +2599,11 @@ packages: resolution: {integrity: sha512-iC+8Io04lddc+mVqQ9AZ7OQ2MrUKGN+oIQyq1vemgt46jwCwLfhq7/pwnBnNXXXZb8VTVLKwp9EDkx+ryxIWmg==} dev: false - github.com/theopensystemslab/planx-core/799ce44: - resolution: {tarball: https://codeload.github.com/theopensystemslab/planx-core/tar.gz/799ce44} + github.com/theopensystemslab/planx-core/41f4683: + resolution: {tarball: https://codeload.github.com/theopensystemslab/planx-core/tar.gz/41f4683} name: '@opensystemslab/planx-core' version: 1.0.0 + prepare: true requiresBuild: true dependencies: '@emotion/react': 11.11.1(react@18.2.0) @@ -2510,6 +2612,7 @@ packages: '@types/geojson': 7946.0.12 ajv: 8.12.0 ajv-formats: 2.1.1(ajv@8.12.0) + cheerio: 1.0.0-rc.12 copyfiles: 2.4.1 docx: 8.2.4 eslint: 8.51.0 @@ -2527,10 +2630,10 @@ packages: lodash.omit: 4.5.0 lodash.set: 4.3.2 lodash.startcase: 4.4.0 + marked: 9.1.6 prettier: 3.0.3 react: 18.2.0 react-dom: 18.2.0(react@18.2.0) - striptags: 3.2.0 type-fest: 4.4.0 uuid: 9.0.1 zod: 3.22.4 diff --git a/editor.planx.uk/package.json b/editor.planx.uk/package.json index 004ea6a1a3..982b2ea5d7 100644 --- a/editor.planx.uk/package.json +++ b/editor.planx.uk/package.json @@ -14,7 +14,7 @@ "@mui/styles": "^5.14.5", "@mui/utils": "^5.14.5", "@opensystemslab/map": "^0.7.5", - "@opensystemslab/planx-core": "git+https://github.com/theopensystemslab/planx-core#799ce44", + "@opensystemslab/planx-core": "git+https://github.com/theopensystemslab/planx-core#41f4683", "@tiptap/core": "^2.0.3", "@tiptap/extension-bold": "^2.0.3", "@tiptap/extension-bubble-menu": "^2.1.6", @@ -221,11 +221,8 @@ }, "pnpm": { "overrides": { - "semver@<7.5.2": ">=7.5.2", - "trim@<0.0.3": ">=0.0.3", - "glob-parent@<5.1.2": ">=5.1.2", - "trim-newlines@<3.0.1": ">=3.0.1", - "nth-check@<2.0.1": ">=2.0.1" + "postcss@<8.4.31": ">=8.4.31", + "@adobe/css-tools@<4.3.1": ">=4.3.1" } } } diff --git a/editor.planx.uk/pnpm-lock.yaml b/editor.planx.uk/pnpm-lock.yaml index 31396d6b88..000e69ef68 100644 --- a/editor.planx.uk/pnpm-lock.yaml +++ b/editor.planx.uk/pnpm-lock.yaml @@ -5,11 +5,8 @@ settings: excludeLinksFromLockfile: false overrides: - semver@<7.5.2: '>=7.5.2' - trim@<0.0.3: '>=0.0.3' - glob-parent@<5.1.2: '>=5.1.2' - trim-newlines@<3.0.1: '>=3.0.1' - nth-check@<2.0.1: '>=2.0.1' + postcss@<8.4.31: '>=8.4.31' + '@adobe/css-tools@<4.3.1': '>=4.3.1' dependencies: '@airbrake/browser': @@ -46,8 +43,8 @@ dependencies: specifier: ^0.7.5 version: 0.7.5 '@opensystemslab/planx-core': - specifier: git+https://github.com/theopensystemslab/planx-core#799ce44 - version: github.com/theopensystemslab/planx-core/799ce44(@types/react@18.2.20) + specifier: git+https://github.com/theopensystemslab/planx-core#41f4683 + version: github.com/theopensystemslab/planx-core/41f4683(@types/react@18.2.20) '@tiptap/core': specifier: ^2.0.3 version: 2.0.3(@tiptap/pm@2.0.3) @@ -635,7 +632,7 @@ packages: debug: 4.3.4 gensync: 1.0.0-beta.2 json5: 2.2.3 - semver: 7.5.3 + semver: 6.3.1 transitivePeerDependencies: - supports-color @@ -728,7 +725,7 @@ packages: '@babel/helper-validator-option': 7.22.5 browserslist: 4.21.9 lru-cache: 5.1.1 - semver: 7.5.3 + semver: 6.3.1 dev: true /@babel/helper-compilation-targets@7.22.9(@babel/core@7.22.9): @@ -742,7 +739,7 @@ packages: '@babel/helper-validator-option': 7.22.5 browserslist: 4.21.9 lru-cache: 5.1.1 - semver: 7.5.3 + semver: 6.3.1 /@babel/helper-create-class-features-plugin@7.22.15(@babel/core@7.22.8): resolution: {integrity: sha512-jKkwA59IXcvSaiK2UN45kKwSC9o+KuoXsBDvHvU/7BecYIp8GQ2UwrVvFgJASUT+hBnwJx6MhvMCuMzwZZ7jlg==} @@ -759,7 +756,7 @@ packages: '@babel/helper-replace-supers': 7.22.20(@babel/core@7.22.8) '@babel/helper-skip-transparent-expression-wrappers': 7.22.5 '@babel/helper-split-export-declaration': 7.22.6 - semver: 7.5.3 + semver: 6.3.1 dev: true /@babel/helper-create-class-features-plugin@7.22.15(@babel/core@7.22.9): @@ -777,7 +774,7 @@ packages: '@babel/helper-replace-supers': 7.22.20(@babel/core@7.22.9) '@babel/helper-skip-transparent-expression-wrappers': 7.22.5 '@babel/helper-split-export-declaration': 7.22.6 - semver: 7.5.3 + semver: 6.3.1 /@babel/helper-create-class-features-plugin@7.22.6(@babel/core@7.22.8): resolution: {integrity: sha512-iwdzgtSiBxF6ni6mzVnZCF3xt5qE6cEA0J7nFt8QOAWZ0zjCFceEgpn3vtb2V7WFR6QzP2jmIFOHMTRo7eNJjQ==} @@ -3222,7 +3219,7 @@ packages: babel-plugin-polyfill-corejs3: 0.8.2(@babel/core@7.22.9) babel-plugin-polyfill-regenerator: 0.5.1(@babel/core@7.22.9) core-js-compat: 3.31.1 - semver: 7.5.3 + semver: 6.3.1 transitivePeerDependencies: - supports-color dev: true @@ -5154,7 +5151,7 @@ packages: '@material-ui/types': 5.1.0(@types/react@18.2.20) '@material-ui/utils': 4.11.3(react-dom@18.2.0)(react@18.2.0) '@types/react': 18.2.20 - '@types/react-transition-group': 4.4.7 + '@types/react-transition-group': 4.4.8 clsx: 1.2.1 hoist-non-react-statics: 3.3.2 popper.js: 1.16.1-lts @@ -5408,7 +5405,7 @@ packages: optional: true dependencies: '@babel/runtime': 7.23.2 - '@mui/utils': 5.14.15(@types/react@18.2.20)(react@18.2.0) + '@mui/utils': 5.14.16(@types/react@18.2.20)(react@18.2.0) '@types/react': 18.2.20 prop-types: 15.8.1 react: 18.2.0 @@ -5627,7 +5624,7 @@ packages: optional: true dependencies: '@babel/runtime': 7.23.2 - '@types/prop-types': 15.7.8 + '@types/prop-types': 15.7.9 '@types/react': 18.2.20 prop-types: 15.8.1 react: 18.2.0 @@ -8314,10 +8311,6 @@ packages: /@types/prop-types@15.7.5: resolution: {integrity: sha512-JCB8C6SnDoQf0cNycqd/35A7MjcnK+ZTqE7judS6o7utxUCg6imJg3QK2qzHKszlTjcj2cn+NwMB2i96ubpj7w==} - /@types/prop-types@15.7.8: - resolution: {integrity: sha512-kMpQpfZKSCBqltAJwskgePRaYRFukDkm1oItcAbC3gNELR20XIBcN9VRgg4+m8DKsTfkWeA4m4Imp4DDuWy7FQ==} - dev: false - /@types/prop-types@15.7.9: resolution: {integrity: sha512-n1yyPsugYNSmHgxDFjicaI2+gCNjsBck8UX9kuofAKlc0h1bL+20oSF72KeNaW2DUlesbEVCFgyV2dPGTiY42g==} dev: false @@ -8389,17 +8382,10 @@ packages: '@types/react': 18.2.20 dev: false - /@types/react-transition-group@4.4.7: - resolution: {integrity: sha512-ICCyBl5mvyqYp8Qeq9B5G/fyBSRC0zx3XM3sCC6KkcMsNeAHqXBKkmat4GqdJET5jtYUpZXrxI5flve5qhi2Eg==} - dependencies: - '@types/react': 18.2.20 - dev: true - /@types/react-transition-group@4.4.8: resolution: {integrity: sha512-QmQ22q+Pb+HQSn04NL3HtrqHwYMf4h3QKArOy5F8U5nEVMaihBs3SR10WiOM1iwPz5jIo8x/u11al+iEGZZrvg==} dependencies: '@types/react': 18.2.20 - dev: false /@types/react@18.2.20: resolution: {integrity: sha512-WKNtmsLWJM/3D5mG4U84cysVY31ivmyw85dE84fOCk5Hx78wezB/XEjVPWl2JTZ5FkEeaTJf+VgUAUn3PE7Isw==} @@ -10031,6 +10017,30 @@ packages: /check-types@11.2.2: resolution: {integrity: sha512-HBiYvXvn9Z70Z88XKjz3AEKd4HJhBXsa3j7xFnITAzoS8+q6eIGi8qDB8FKPBAjtuxjI/zFpwuiCb8oDtKOYrA==} + /cheerio-select@2.1.0: + resolution: {integrity: sha512-9v9kG0LvzrlcungtnJtpGNxY+fzECQKhK4EGJX2vByejiMX84MFNQw4UxPJl3bFbTMw+Dfs37XaIkCwTZfLh4g==} + dependencies: + boolbase: 1.0.0 + css-select: 5.1.0 + css-what: 6.1.0 + domelementtype: 2.3.0 + domhandler: 5.0.3 + domutils: 3.1.0 + dev: false + + /cheerio@1.0.0-rc.12: + resolution: {integrity: sha512-VqR8m68vM46BNnuZ5NtnGBKIE/DfN0cRIzg9n40EIq9NOv90ayxLBXA8fXC5gquFRGJSTRqBq25Jt2ECLR431Q==} + engines: {node: '>= 6'} + dependencies: + cheerio-select: 2.1.0 + dom-serializer: 2.0.0 + domhandler: 5.0.3 + domutils: 3.1.0 + htmlparser2: 8.0.2 + parse5: 7.1.2 + parse5-htmlparser2-tree-adapter: 7.0.0 + dev: false + /chokidar@3.5.3: resolution: {integrity: sha512-Dr3sfKRP6oTcjf2JmUmFJfeVMvXBdegxB0iVQ5eb2V10uFJUCAS8OByZdVAyVb8xXNz3GjjTgj9kLWsZTqE6kw==} engines: {node: '>= 8.10.0'} @@ -10342,7 +10352,7 @@ packages: dev: false /concat-map@0.0.1: - resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} + resolution: {integrity: sha1-2Klr13/Wjfd5OnMDajug1UBdR3s=} /concat-stream@1.6.2: resolution: {integrity: sha512-27HBghJxjiZtIk3Ycvn/4kbJk/1uZuJFfuPEns6LaEvpvG1f0hTea8lilrouyo9mVc2GWdcEZ8OLoGmSADlrCw==} @@ -10515,7 +10525,7 @@ packages: dependencies: nice-try: 1.0.5 path-key: 2.0.1 - semver: 7.5.3 + semver: 5.7.2 shebang-command: 1.2.0 which: 1.3.1 dev: true @@ -10646,7 +10656,7 @@ packages: boolbase: 1.0.0 css-what: 3.4.2 domutils: 1.7.0 - nth-check: 2.1.1 + nth-check: 1.0.2 /css-select@4.3.0: resolution: {integrity: sha512-wPpOYtnsVontu2mODhA19JrqWxNsfdatRKd64kmpRbQgh1KtItko5sTnEpPdpSaJszTOhEMlF/RPz28qj4HqhQ==} @@ -10657,6 +10667,16 @@ packages: domutils: 2.8.0 nth-check: 2.1.1 + /css-select@5.1.0: + resolution: {integrity: sha512-nwoRF1rvRRnnCqqY7updORDsuqKzqYJ28+oSMaJMMgOauh3fvwHqMS7EZpIPqK8GL+g9mKxF1vP/ZjSeNjEVHg==} + dependencies: + boolbase: 1.0.0 + css-what: 6.1.0 + domhandler: 5.0.3 + domutils: 3.1.0 + nth-check: 2.1.1 + dev: false + /css-tree@1.0.0-alpha.37: resolution: {integrity: sha512-DMxWJg0rnz7UgxKT0Q1HU/L9BeJI0M6ksor0OgqOnF+aRCDWg/N2641HmVyU9KVIu0OVVWOb2IpC9A+BJRnejg==} engines: {node: '>=8.0.0'} @@ -11128,6 +11148,14 @@ packages: domhandler: 4.3.1 entities: 2.2.0 + /dom-serializer@2.0.0: + resolution: {integrity: sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==} + dependencies: + domelementtype: 2.3.0 + domhandler: 5.0.3 + entities: 4.5.0 + dev: false + /dom-walk@0.1.2: resolution: {integrity: sha512-6QvTW9mrGeIegrFXdtQi9pk7O/nSK6lSdXW2eqUspN5LWD7UTji2Fqw5V2YLjBpHEoU9Xl/eUWNpDeZvoyOv2w==} dev: true @@ -11156,6 +11184,13 @@ packages: dependencies: domelementtype: 2.3.0 + /domhandler@5.0.3: + resolution: {integrity: sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==} + engines: {node: '>= 4'} + dependencies: + domelementtype: 2.3.0 + dev: false + /dompurify@2.4.7: resolution: {integrity: sha512-kxxKlPEDa6Nc5WJi+qRgPbOAbgTpSULL+vI3NUXsZMlkJxTqYI9wg5ZTay2sFrdZRWHPWNi+EdAhcJf81WtoMQ==} requiresBuild: true @@ -11175,6 +11210,14 @@ packages: domelementtype: 2.3.0 domhandler: 4.3.1 + /domutils@3.1.0: + resolution: {integrity: sha512-H78uMmQtI2AhgDJjWeQmHwJJ2bLPD3GMmO7Zja/ZZh84wkm+4ut+IUnUdRa8uCGX88DiVx1j6FRe1XfxEgjEZA==} + dependencies: + dom-serializer: 2.0.0 + domelementtype: 2.3.0 + domhandler: 5.0.3 + dev: false + /dot-case@3.0.4: resolution: {integrity: sha512-Kv5nKlh6yRrdrGvxeJ2e5y2eRUpkUosIW4A2AS38zwSz27zu7ufDwQPi5Jhs3XAlGNetl3bmnGhQsMtkKJnj3w==} dependencies: @@ -11223,7 +11266,7 @@ packages: dependencies: commander: 2.20.3 lru-cache: 4.1.5 - semver: 7.5.3 + semver: 5.7.2 sigmund: 1.0.1 dev: true @@ -11295,6 +11338,11 @@ packages: engines: {node: '>=0.12'} dev: false + /entities@4.5.0: + resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==} + engines: {node: '>=0.12'} + dev: false + /envinfo@7.10.0: resolution: {integrity: sha512-ZtUjZO6l5mwTHvc1L9+1q5p/R3wTopcfqMW8r5t8SJSKqeVI/LtajORwRFEKpEFuekjD0VBjwu1HMxL4UalIRw==} engines: {node: '>=4'} @@ -11883,7 +11931,7 @@ packages: minimatch: 3.1.2 object.values: 1.1.6 resolve: 1.22.2 - semver: 7.5.3 + semver: 6.3.1 tsconfig-paths: 3.14.2 transitivePeerDependencies: - eslint-import-resolver-typescript @@ -11933,7 +11981,7 @@ packages: minimatch: 3.1.2 object.entries: 1.1.6 object.fromentries: 2.0.6 - semver: 7.5.3 + semver: 6.3.1 /eslint-plugin-react-hooks@4.6.0(eslint@8.44.0): resolution: {integrity: sha512-oFc7Itz9Qxh2x4gNHStv3BqJq54ExXmfC+a1NjAta66IAN87Wu0R/QArgIS9qKzX3dXKPI9H5crl9QchNMY9+g==} @@ -11963,7 +12011,7 @@ packages: object.values: 1.1.6 prop-types: 15.8.1 resolve: 2.0.0-next.4 - semver: 7.5.3 + semver: 6.3.1 string.prototype.matchall: 4.0.8 /eslint-plugin-simple-import-sort@10.0.0(eslint@8.44.0): @@ -13313,6 +13361,15 @@ packages: domutils: 2.8.0 entities: 2.2.0 + /htmlparser2@8.0.2: + resolution: {integrity: sha512-GYdjWKDkbRLkZ5geuHs5NY1puJ+PXwP7+fHPRz06Eirsb9ugf6d8kkXav6ADhcODhFFPMIXyxkxSuMf3D6NCFA==} + dependencies: + domelementtype: 2.3.0 + domhandler: 5.0.3 + domutils: 3.1.0 + entities: 4.5.0 + dev: false + /http-deceiver@1.2.7: resolution: {integrity: sha512-LmpOGxTfbpgtGVxJrj5k7asXHCgNZp5nLfp+hWc8QQRqtb7fUy6kRY3BO1h9ddF6yIPYUARgxGOwB42DnxIaNw==} @@ -13958,7 +14015,7 @@ packages: '@babel/parser': 7.23.0 '@istanbuljs/schema': 0.1.3 istanbul-lib-coverage: 3.2.0 - semver: 7.5.3 + semver: 6.3.1 transitivePeerDependencies: - supports-color @@ -15254,14 +15311,14 @@ packages: engines: {node: '>=6'} dependencies: pify: 4.0.1 - semver: 7.5.3 + semver: 5.7.2 dev: true /make-dir@3.1.0: resolution: {integrity: sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==} engines: {node: '>=8'} dependencies: - semver: 7.5.3 + semver: 6.3.1 /make-error@1.3.6: resolution: {integrity: sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==} @@ -15322,6 +15379,12 @@ packages: hasBin: true dev: false + /marked@9.1.6: + resolution: {integrity: sha512-jcByLnIFkd5gSXZmjNvS1TlmRhCXZjIzHYlaGkPlLIekG55JDR2Z4va9tZwCiP+/RDERiNhMOFu01xd6O5ct1Q==} + engines: {node: '>= 16'} + hasBin: true + dev: false + /material-colors@1.2.6: resolution: {integrity: sha512-6qE4B9deFBIa9YSpOc9O0Sgc43zTeVYbgDT5veRKSlB2+ZuHNoVVxA1L/ckMUayV9Ay9y7Z/SZCLcGteW9i7bg==} @@ -15968,7 +16031,7 @@ packages: dependencies: hosted-git-info: 2.8.9 resolve: 1.22.2 - semver: 7.5.3 + semver: 5.7.2 validate-npm-package-license: 3.0.4 dev: true @@ -16011,6 +16074,11 @@ packages: path-key: 4.0.0 dev: true + /nth-check@1.0.2: + resolution: {integrity: sha512-WeBOdju8SnzPN5vTUJYxYUxLeXpCaVP5i5e0LF8fg7WORF2Wd7wFX/pk0tYZk7s8T+J7VLy0Da6J1+wCT0AtHg==} + dependencies: + boolbase: 1.0.0 + /nth-check@2.1.1: resolution: {integrity: sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==} dependencies: @@ -16328,9 +16396,22 @@ packages: json-parse-even-better-errors: 2.3.1 lines-and-columns: 1.2.4 + /parse5-htmlparser2-tree-adapter@7.0.0: + resolution: {integrity: sha512-B77tOZrqqfUfnVcOrUvfdLbz4pu4RopLD/4vmu3HUPswwTA8OH0EMW9BlWR2B0RCoiZRAHEUu7IxeP1Pd1UU+g==} + dependencies: + domhandler: 5.0.3 + parse5: 7.1.2 + dev: false + /parse5@6.0.1: resolution: {integrity: sha512-Ofn/CTFzRGTTxwpNEs9PP93gXShHcTq255nzRYSKe8AkVpZY7e1fpmTfOyoIvjP5HG7Z2ZM7VS9PPhQGW2pOpw==} + /parse5@7.1.2: + resolution: {integrity: sha512-Czj1WaSVpaoj0wbhMzLmWD69anp2WH7FXMB9n1Sy8/ZFF9jolSQVMu1Ij5WIyGmcBmhk7EOndpO4mIpihVqAXw==} + dependencies: + entities: 4.5.0 + dev: false + /parseurl@1.3.3: resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==} engines: {node: '>= 0.8'} @@ -16421,9 +16502,6 @@ packages: /performance-now@2.1.0: resolution: {integrity: sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow==} - /picocolors@0.2.1: - resolution: {integrity: sha512-cMlDqaLEqfSaW8Z7N5Jw+lyIW869EzT73/F5lhtY9cLGoVxSXznfgfXMO0Z5K0o0Q2TkTXq+0KFsdnSe3jDViA==} - /picocolors@1.0.0: resolution: {integrity: sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ==} @@ -17202,13 +17280,6 @@ packages: /postcss-value-parser@4.2.0: resolution: {integrity: sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==} - /postcss@7.0.39: - resolution: {integrity: sha512-yioayjNbHn6z1/Bywyb2Y4s3yvDAeXGOyxqD+LnVOinq6Mdmd++SW2wUNVzavyyHxd6+DxzWGIuosg6P1Rj8uA==} - engines: {node: '>=6.0.0'} - dependencies: - picocolors: 0.2.1 - source-map: 0.6.1 - /postcss@8.4.31: resolution: {integrity: sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==} engines: {node: ^10 || ^12 || >=14} @@ -17652,7 +17723,7 @@ packages: react-scripts: '>=2.1.3' dependencies: react-scripts: 5.0.1(@babel/plugin-syntax-flow@7.22.5)(@babel/plugin-transform-react-jsx@7.22.5)(@swc/core@1.3.71)(esbuild@0.14.54)(eslint@8.44.0)(react@18.2.0)(sass@1.63.6)(typescript@4.9.5) - semver: 7.5.3 + semver: 5.7.2 dev: true /react-base16-styling@0.6.0: @@ -18484,7 +18555,7 @@ packages: adjust-sourcemap-loader: 4.0.0 convert-source-map: 1.9.0 loader-utils: 2.0.4 - postcss: 7.0.39 + postcss: 8.4.31 source-map: 0.6.1 /resolve-url@0.2.1: @@ -18822,6 +18893,20 @@ packages: dependencies: node-forge: 1.3.1 + /semver@5.7.2: + resolution: {integrity: sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==} + hasBin: true + dev: true + + /semver@6.3.1: + resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} + hasBin: true + + /semver@7.0.0: + resolution: {integrity: sha512-+GB6zVA9LWh6zovYQLALHwv5rb2PHGlJi3lfiqIHxR0uuwCgefcOJc59v9fv1w8GbStwxuuqqAjI9NMAOOgq1A==} + hasBin: true + dev: true + /semver@7.5.3: resolution: {integrity: sha512-QBlUtyVk/5EeHbi7X0fw6liDZc7BBmEaSYn01fMU1OUYbf6GPsbTtd8WmnqbI20SeycoHSeiybkE/q1Q+qlThQ==} engines: {node: '>=10'} @@ -18994,7 +19079,7 @@ packages: resolution: {integrity: sha512-VpsrsJSUcJEseSbMHkrsrAVSdvVS5I96Qo1QAQ4FxQ9wXFcB+pjj7FB7/us9+GcgfW4ziHtYMc1J0PLczb55mg==} engines: {node: '>=8.10.0'} dependencies: - semver: 7.5.3 + semver: 7.0.0 dev: true /sisteransi@1.0.5: @@ -21214,9 +21299,9 @@ packages: use-sync-external-store: 1.2.0(react@18.2.0) dev: false - github.com/theopensystemslab/planx-core/799ce44(@types/react@18.2.20): - resolution: {tarball: https://codeload.github.com/theopensystemslab/planx-core/tar.gz/799ce44} - id: github.com/theopensystemslab/planx-core/799ce44 + github.com/theopensystemslab/planx-core/41f4683(@types/react@18.2.20): + resolution: {tarball: https://codeload.github.com/theopensystemslab/planx-core/tar.gz/41f4683} + id: github.com/theopensystemslab/planx-core/41f4683 name: '@opensystemslab/planx-core' version: 1.0.0 prepare: true @@ -21228,6 +21313,7 @@ packages: '@types/geojson': 7946.0.12 ajv: 8.12.0 ajv-formats: 2.1.1(ajv@8.12.0) + cheerio: 1.0.0-rc.12 copyfiles: 2.4.1 docx: 8.2.4 eslint: 8.51.0 @@ -21245,10 +21331,10 @@ packages: lodash.omit: 4.5.0 lodash.set: 4.3.2 lodash.startcase: 4.4.0 + marked: 9.1.6 prettier: 3.0.3 react: 18.2.0 react-dom: 18.2.0(react@18.2.0) - striptags: 3.2.0 type-fest: 4.4.0 uuid: 9.0.1 zod: 3.22.4 diff --git a/editor.planx.uk/src/@planx/components/FileUploadAndLabel/Modal.tsx b/editor.planx.uk/src/@planx/components/FileUploadAndLabel/Modal.tsx index b37bd17869..4afb33cf5b 100644 --- a/editor.planx.uk/src/@planx/components/FileUploadAndLabel/Modal.tsx +++ b/editor.planx.uk/src/@planx/components/FileUploadAndLabel/Modal.tsx @@ -147,7 +147,7 @@ const SelectMultiple = (props: SelectMultipleProps) => { const initialTags = getTagsForSlot(uploadedFile.id, fileList); const [tags, setTags] = useState(initialTags); - const previousTags = usePrevious(tags); + const previousTags = usePrevious(tags) || initialTags; const [open, setOpen] = React.useState(false); const handleChange = (event: SelectChangeEvent) => { diff --git a/editor.planx.uk/src/@planx/components/PlanningConstraints/Public.tsx b/editor.planx.uk/src/@planx/components/PlanningConstraints/Public.tsx index 82b94233f1..bbb2c4e980 100644 --- a/editor.planx.uk/src/@planx/components/PlanningConstraints/Public.tsx +++ b/editor.planx.uk/src/@planx/components/PlanningConstraints/Public.tsx @@ -55,23 +55,11 @@ function Component(props: Props) { const wktPolygon: string | undefined = siteBoundary && stringify(siteBoundary); - // Configure which planx teams should query Digital Land (or continue to use custom GIS) and set URL params accordingly - // In future, Digital Land will theoretically support any UK address and this list won't be necessary, but data collection still limited to select councils! - const digitalLandOrganisations: string[] = [ - "opensystemslab", // for UK-wide testing, subject to data availability - "barnet", - "birmingham", - "buckinghamshire", - "canterbury", - "camden", - "doncaster", - "gloucester", - "lambeth", - "medway", - "newcastle", - "southwark", - "st-albans", - ]; + // Check if this team should query Planning Data (or continue to use custom GIS) and set URL params accordingly + // In future, Planning Data will theoretically support any UK address and this db setting won't be necessary, but data collection still limited to select councils! + const hasPlanningData = useStore( + (state) => state.teamSettings?.hasPlanningData, + ); const digitalLandParams: Record = { geom: wktPolygon || wktPoint, @@ -89,9 +77,7 @@ function Component(props: Props) { const teamGisEndpoint: string = root + new URLSearchParams( - digitalLandOrganisations.includes(teamSlug) - ? digitalLandParams - : customGisParams, + hasPlanningData ? digitalLandParams : customGisParams, ).toString(); const fetcher: Fetcher = ( @@ -119,10 +105,7 @@ function Component(props: Props) { error: roadsError, isValidating: isValidatingRoads, } = useSWR( - () => - usrn && digitalLandOrganisations.includes(teamSlug) - ? classifiedRoadsEndpoint - : null, + () => (usrn && hasPlanningData ? classifiedRoadsEndpoint : null), fetcher, { revalidateOnFocus: false }, ); @@ -154,7 +137,7 @@ function Component(props: Props) { const _constraints: Array< EnhancedGISResponse | GISResponse["constraints"] > = []; - if (digitalLandOrganisations.includes(teamSlug)) { + if (hasPlanningData) { if (data && !dataError) _constraints.push({ ...data, diff --git a/editor.planx.uk/src/@planx/components/shared/Preview/SummaryList.tsx b/editor.planx.uk/src/@planx/components/shared/Preview/SummaryList.tsx index 984f5524ae..9f4890fb77 100644 --- a/editor.planx.uk/src/@planx/components/shared/Preview/SummaryList.tsx +++ b/editor.planx.uk/src/@planx/components/shared/Preview/SummaryList.tsx @@ -113,21 +113,17 @@ function SummaryListsBySections(props: SummaryListsBySectionsProps) { const isValidComponent = ([nodeId, userData]: BreadcrumbEntry) => { const node = props.flow[nodeId]; - const Component = node.type && presentationalComponents[node.type]; + const doesNodeExist = Boolean(props.flow[nodeId]); + if (!doesNodeExist) return false; + const Component = node.type && presentationalComponents[node.type]; const isPresentationalComponent = Boolean(Component); - const doesNodeExist = Boolean(props.flow[nodeId]); const isAutoAnswered = userData.auto; const isInfoOnlyMode = node.type === TYPES.FileUploadAndLabel && props.flow[nodeId].data?.hideDropZone; - return ( - doesNodeExist && - !isAutoAnswered && - isPresentationalComponent && - !isInfoOnlyMode - ); + return !isAutoAnswered && isPresentationalComponent && !isInfoOnlyMode; }; const removeNonPresentationalNodes = ( diff --git a/editor.planx.uk/src/pages/FlowEditor/components/PreviewBrowser.tsx b/editor.planx.uk/src/pages/FlowEditor/components/PreviewBrowser.tsx index 65d772e9e9..f66c17e228 100644 --- a/editor.planx.uk/src/pages/FlowEditor/components/PreviewBrowser.tsx +++ b/editor.planx.uk/src/pages/FlowEditor/components/PreviewBrowser.tsx @@ -173,9 +173,11 @@ const PreviewBrowser: React.FC<{ ) : ( - - - + + + + + )} diff --git a/editor.planx.uk/src/types.ts b/editor.planx.uk/src/types.ts index 1f1fd03de2..a5521603c1 100644 --- a/editor.planx.uk/src/types.ts +++ b/editor.planx.uk/src/types.ts @@ -42,6 +42,7 @@ export interface TeamSettings { }; supportEmail?: string; boundary?: string; + hasPlanningData?: boolean; } export interface NotifyPersonalisation { diff --git a/infrastructure/application/index.ts b/infrastructure/application/index.ts index 46a982965f..2dec97ad36 100644 --- a/infrastructure/application/index.ts +++ b/infrastructure/application/index.ts @@ -160,7 +160,7 @@ export = async () => { }), container: { // if changing, also check docker-compose.yml - image: "metabase/metabase:v0.45.3", + image: "metabase/metabase:v0.47.8", portMappings: [metabaseListenerHttp], // When changing `memory`, also update `JAVA_OPTS` below memory: 4096 /*MB*/, diff --git a/scripts/seed-database/write/teams.sql b/scripts/seed-database/write/teams.sql index 0eaabb0bb6..1ffd5914a4 100644 --- a/scripts/seed-database/write/teams.sql +++ b/scripts/seed-database/write/teams.sql @@ -10,7 +10,8 @@ CREATE TEMPORARY TABLE sync_teams ( notify_personalisation jsonb, domain text, submission_email text, - boundary jsonb + boundary jsonb, + reference_code text ); \copy sync_teams FROM '/tmp/teams.csv' WITH (FORMAT csv, DELIMITER ';'); @@ -22,7 +23,8 @@ INSERT INTO teams ( theme, settings, notify_personalisation, - boundary + boundary, + reference_code ) SELECT id, @@ -31,7 +33,8 @@ SELECT theme, settings, notify_personalisation, - boundary + boundary, + reference_code FROM sync_teams ON CONFLICT (id) DO UPDATE SET @@ -40,6 +43,7 @@ SET theme = EXCLUDED.theme, settings = EXCLUDED.settings, notify_personalisation = EXCLUDED.notify_personalisation, - boundary = EXCLUDED.boundary; + boundary = EXCLUDED.boundary, + reference_code = EXCLUDED.reference_code; SELECT setval('teams_id_seq', max(id)) FROM teams;