From cea07d179356ea349ed1265e548131d5922f0a9c Mon Sep 17 00:00:00 2001 From: Mike <36415632+Mike-Heneghan@users.noreply.github.com> Date: Thu, 16 Nov 2023 12:58:23 +0000 Subject: [PATCH 1/6] refactor: update the duplication step info (#2428) * refactor: update the duplication step info - Add a screenshot on how to duplicate an existing dashboard - Suggest only duplicating the dashboard not the cards - This allows us to manage the cards across all dashboards and update once --- .../how-to-setup-metabase-for-a-new-team.md | 9 ++++++++- .../duplicate_an_existing_dashboard.png | Bin 0 -> 90799 bytes .../setup-metabase/only_duplicate_dashboard.png | Bin 0 -> 26790 bytes 3 files changed, 8 insertions(+), 1 deletion(-) create mode 100644 doc/how-to/images/setup-metabase/duplicate_an_existing_dashboard.png create mode 100644 doc/how-to/images/setup-metabase/only_duplicate_dashboard.png diff --git a/doc/how-to/how-to-setup-metabase-for-a-new-team.md b/doc/how-to/how-to-setup-metabase-for-a-new-team.md index 534a15e894..109186594f 100644 --- a/doc/how-to/how-to-setup-metabase-for-a-new-team.md +++ b/doc/how-to/how-to-setup-metabase-for-a-new-team.md @@ -14,7 +14,14 @@ Metabase is set up and running on both Staging and Production environments, but ![Screenshot - Add a Collection](./images/setup-metabase/new_collection.png) 3. Duplicate existing Dashboards (FOIYNPP, LDC) from an existing team, renaming them and adding to new Collection. - * Not all teams host the same services on PlanX. Ensure you only duplicate Dashboards for which teams have an associated flows. This can be checked via the PlanX Editor + +![Screenshot - Duplicate and existing dashboard](./images/setup-metabase/duplicate_an_existing_dashboard.png) + + * Not all teams host the same services on PlanX. Ensure you only duplicate Dashboards for which teams have an associated flows. This can be checked via the PlanX Editor. + +![Screenshot - Only duplicate the dashboard](./images/setup-metabase/only_duplicate_dashboard.png) + + * Ensure "Only duplicate the dashboard" is selected. This avoids unecessarily duplicating the visualisations which we maintain. 4. Navigate to the new Collection, and edit each Dashboard to update the FlowID variable. diff --git a/doc/how-to/images/setup-metabase/duplicate_an_existing_dashboard.png b/doc/how-to/images/setup-metabase/duplicate_an_existing_dashboard.png new file mode 100644 index 0000000000000000000000000000000000000000..513cd358214b4d64bb5e675663a87fd137e8ff63 GIT binary patch literal 90799 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

e=2% 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 d>HAEcmyOWp2iKEH)R(~XJ}x&~aFF!b{{dxS{^ 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%0AKS{XomdO*p3><`KTXZN8uxatrN(xe^)O~ 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 Date: Thu, 16 Nov 2023 17:35:10 +0000 Subject: [PATCH 2/6] feat: enable st albans to query planning constraints (#2434) --- .../src/@planx/components/PlanningConstraints/Public.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/editor.planx.uk/src/@planx/components/PlanningConstraints/Public.tsx b/editor.planx.uk/src/@planx/components/PlanningConstraints/Public.tsx index 7fba332f40..82b94233f1 100644 --- a/editor.planx.uk/src/@planx/components/PlanningConstraints/Public.tsx +++ b/editor.planx.uk/src/@planx/components/PlanningConstraints/Public.tsx @@ -70,6 +70,7 @@ function Component(props: Props) { "medway", "newcastle", "southwark", + "st-albans", ]; const digitalLandParams: Record = { From 8ce1579eeb30e76044eaaf1b1943479032b20f82 Mon Sep 17 00:00:00 2001 From: Ian Jones <51156018+ianjon3s@users.noreply.github.com> Date: Thu, 16 Nov 2023 18:59:10 +0000 Subject: [PATCH 3/6] feat: enable transparent or custom background for content (#2429) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: enable transparent or custom background for content * refactor: simplify logic to look for default or white * Update editor.planx.uk/src/@planx/components/Content/Public.tsx Co-authored-by: Dafydd LlĹ·r Pearson --------- Co-authored-by: Dafydd LlĹ·r Pearson --- .../src/@planx/components/Content/Public.tsx | 11 +++++++++-- editor.planx.uk/src/ui/ColorPicker.tsx | 8 ++++++-- 2 files changed, 15 insertions(+), 4 deletions(-) diff --git a/editor.planx.uk/src/@planx/components/Content/Public.tsx b/editor.planx.uk/src/@planx/components/Content/Public.tsx index ef490d50ad..56959619f8 100644 --- a/editor.planx.uk/src/@planx/components/Content/Public.tsx +++ b/editor.planx.uk/src/@planx/components/Content/Public.tsx @@ -13,7 +13,6 @@ export type Props = PublicProps; const Content = styled(Box, { shouldForwardProp: (prop) => prop !== "color", })<{ color?: string }>(({ theme, color }) => ({ - padding: theme.spacing(2), backgroundColor: color, color: mostReadable(color || "#fff", [ @@ -25,10 +24,18 @@ const Content = styled(Box, { }, })); +Content.defaultProps = { + color: "#ffffff", +}; + const ContentComponent: React.FC = (props) => { return ( - + - {props.label || "Colour"}:{" "} + {props.label || "Background colour"}:{" "} @@ -108,7 +108,11 @@ export default function ColorPicker(props: Props): FCReturn { aria-label="Close Colour Picker" disableRipple /> - + ) : null} From a083beb635410361b61d271507d7fe4a93dd4038 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dafydd=20Ll=C5=B7r=20Pearson?= Date: Fri, 17 Nov 2023 08:51:11 +0000 Subject: [PATCH 4/6] feat: Send email docs (#2432) * chore: Move notify to lib * chore: Move files to sendEmail module * fix: Resolve circular dependency issue * feat: Break into routes and controller * test: Update test cases * docs: Add Swagger docs --- api.planx.uk/inviteToPay/paymentRequest.ts | 27 --- .../inviteToPay/sendConfirmationEmail.test.ts | 12 +- .../inviteToPay/sendConfirmationEmail.ts | 2 +- .../inviteToPay/sendPaymentEmail.test.ts | 6 +- api.planx.uk/inviteToPay/sendPaymentEmail.ts | 2 +- .../{notify/notify.ts => lib/notify/index.ts} | 6 +- api.planx.uk/modules/auth/middleware.ts | 2 +- .../service/resumeApplication.ts | 2 +- .../modules/saveAndReturn/service/utils.ts | 2 +- api.planx.uk/modules/sendEmail/controller.ts | 88 ++++++++ api.planx.uk/modules/sendEmail/docs.yaml | 95 ++++++++ .../sendEmail/index.test.ts} | 17 +- api.planx.uk/modules/sendEmail/routes.ts | 42 ++++ api.planx.uk/modules/sendEmail/types.ts | 65 ++++++ api.planx.uk/notify/index.ts | 2 - api.planx.uk/notify/routeSendEmailRequest.ts | 106 --------- api.planx.uk/pay/index.ts | 206 +++++++++++++++++- api.planx.uk/pay/pay.ts | 205 ----------------- api.planx.uk/pay/utils.ts | 30 +++ api.planx.uk/send/email.ts | 2 +- api.planx.uk/server.ts | 15 +- 21 files changed, 555 insertions(+), 379 deletions(-) rename api.planx.uk/{notify/notify.ts => lib/notify/index.ts} (93%) create mode 100644 api.planx.uk/modules/sendEmail/controller.ts create mode 100644 api.planx.uk/modules/sendEmail/docs.yaml rename api.planx.uk/{notify/routeSendEmailRequest.test.ts => modules/sendEmail/index.test.ts} (94%) create mode 100644 api.planx.uk/modules/sendEmail/routes.ts create mode 100644 api.planx.uk/modules/sendEmail/types.ts delete mode 100644 api.planx.uk/notify/index.ts delete mode 100644 api.planx.uk/notify/routeSendEmailRequest.ts delete mode 100644 api.planx.uk/pay/pay.ts create mode 100644 api.planx.uk/pay/utils.ts diff --git a/api.planx.uk/inviteToPay/paymentRequest.ts b/api.planx.uk/inviteToPay/paymentRequest.ts index e6a82a34b4..bc09f7ff34 100644 --- a/api.planx.uk/inviteToPay/paymentRequest.ts +++ b/api.planx.uk/inviteToPay/paymentRequest.ts @@ -128,33 +128,6 @@ export const fetchPaymentRequestViaProxy = fetchPaymentViaProxyWithCallback( }, ); -export const addGovPayPaymentIdToPaymentRequest = async ( - paymentRequestId: string, - govUKPayment: GovUKPayment, -): Promise => { - const query = gql` - mutation AddGovPayPaymentIdToPaymentRequest( - $paymentRequestId: uuid! - $govPayPaymentId: String - ) { - update_payment_requests_by_pk( - pk_columns: { id: $paymentRequestId } - _set: { govpay_payment_id: $govPayPaymentId } - ) { - id - } - } - `; - try { - await $api.client.request(query, { - paymentRequestId, - govPayPaymentId: govUKPayment.payment_id, - }); - } catch (error) { - throw Error(`payment request ${paymentRequestId} not updated`); - } -}; - interface MarkPaymentRequestAsPaid { updatePaymentRequestPaidAt: { affectedRows: number; diff --git a/api.planx.uk/inviteToPay/sendConfirmationEmail.test.ts b/api.planx.uk/inviteToPay/sendConfirmationEmail.test.ts index 6a99895e9e..9e4126062b 100644 --- a/api.planx.uk/inviteToPay/sendConfirmationEmail.test.ts +++ b/api.planx.uk/inviteToPay/sendConfirmationEmail.test.ts @@ -2,9 +2,9 @@ import supertest from "supertest"; import app from "../server"; import { queryMock } from "../tests/graphqlQueryMock"; import { sendAgentAndPayeeConfirmationEmail } from "./sendConfirmationEmail"; -import { sendEmail } from "../notify/notify"; +import { sendEmail } from "../lib/notify"; -jest.mock("../notify/notify", () => ({ +jest.mock("../lib/notify", () => ({ sendEmail: jest.fn(), })); @@ -108,7 +108,7 @@ describe("Invite to pay confirmation templates cannot be sent individually", () test(`the "${template}" template`, async () => { const data = { payload: { - sessionId: "TestSesionID", + sessionId: "TestSessionID", lockedAt: "2023-05-18T12:49:22.839068+00:00", }, }; @@ -116,8 +116,10 @@ describe("Invite to pay confirmation templates cannot be sent individually", () .post(`/send-email/${template}`) .set("Authorization", "testtesttest") .send(data) - .expect(400, { - error: `Failed to send "${template}" email. Invalid template`, + .expect(400) + .then((res) => { + expect(res.body).toHaveProperty("issues"); + expect(res.body).toHaveProperty("name", "ZodError"); }); }); } diff --git a/api.planx.uk/inviteToPay/sendConfirmationEmail.ts b/api.planx.uk/inviteToPay/sendConfirmationEmail.ts index 1ec7dfc369..368f766731 100644 --- a/api.planx.uk/inviteToPay/sendConfirmationEmail.ts +++ b/api.planx.uk/inviteToPay/sendConfirmationEmail.ts @@ -1,5 +1,5 @@ import { $public, $api } from "../client"; -import { sendEmail } from "../notify"; +import { sendEmail } from "../lib/notify"; import { gql } from "graphql-request"; import { convertSlugToName } from "../modules/saveAndReturn/service/utils"; import type { AgentAndPayeeSubmissionNotifyConfig } from "../types"; diff --git a/api.planx.uk/inviteToPay/sendPaymentEmail.test.ts b/api.planx.uk/inviteToPay/sendPaymentEmail.test.ts index 32ec62f4c1..4c4012e9f4 100644 --- a/api.planx.uk/inviteToPay/sendPaymentEmail.test.ts +++ b/api.planx.uk/inviteToPay/sendPaymentEmail.test.ts @@ -90,10 +90,8 @@ describe("Send email endpoint for invite to pay templates", () => { .send(missingPaymentRequestId) .expect(400) .then((response) => { - expect(response.body).toHaveProperty( - "error", - `Failed to send "${template}" email. Required \`paymentRequestId\` missing`, - ); + expect(response.body).toHaveProperty("issues"); + expect(response.body).toHaveProperty("name", "ZodError"); }); }); diff --git a/api.planx.uk/inviteToPay/sendPaymentEmail.ts b/api.planx.uk/inviteToPay/sendPaymentEmail.ts index bf62668d1c..cf2f33fd96 100644 --- a/api.planx.uk/inviteToPay/sendPaymentEmail.ts +++ b/api.planx.uk/inviteToPay/sendPaymentEmail.ts @@ -4,7 +4,7 @@ import { convertSlugToName, getServiceLink, } from "../modules/saveAndReturn/service/utils"; -import { Template, getClientForTemplate, sendEmail } from "../notify"; +import { Template, getClientForTemplate, sendEmail } from "../lib/notify"; import { InviteToPayNotifyConfig } from "../types"; import { Team } from "../types"; import type { PaymentRequest } from "@opensystemslab/planx-core/types"; diff --git a/api.planx.uk/notify/notify.ts b/api.planx.uk/lib/notify/index.ts similarity index 93% rename from api.planx.uk/notify/notify.ts rename to api.planx.uk/lib/notify/index.ts index 420eab0bf9..a3963d4d9d 100644 --- a/api.planx.uk/notify/notify.ts +++ b/api.planx.uk/lib/notify/index.ts @@ -1,7 +1,7 @@ import { NotifyClient } from "notifications-node-client"; -import { softDeleteSession } from "../modules/saveAndReturn/service/utils"; -import { NotifyConfig } from "../types"; -import { $api, $public } from "../client"; +import { softDeleteSession } from "../../modules/saveAndReturn/service/utils"; +import { NotifyConfig } from "../../types"; +import { $api, $public } from "../../client"; const notifyClient = new NotifyClient(process.env.GOVUK_NOTIFY_API_KEY); diff --git a/api.planx.uk/modules/auth/middleware.ts b/api.planx.uk/modules/auth/middleware.ts index df1b1217c6..14663abf80 100644 --- a/api.planx.uk/modules/auth/middleware.ts +++ b/api.planx.uk/modules/auth/middleware.ts @@ -1,7 +1,7 @@ import crypto from "crypto"; import assert from "assert"; import { ServerError } from "../../errors"; -import { Template } from "../../notify"; +import { Template } from "../../lib/notify"; import { expressjwt } from "express-jwt"; import passport from "passport"; diff --git a/api.planx.uk/modules/saveAndReturn/service/resumeApplication.ts b/api.planx.uk/modules/saveAndReturn/service/resumeApplication.ts index 5a334670ec..1241ba5bcb 100644 --- a/api.planx.uk/modules/saveAndReturn/service/resumeApplication.ts +++ b/api.planx.uk/modules/saveAndReturn/service/resumeApplication.ts @@ -1,7 +1,7 @@ import { gql } from "graphql-request"; import { LowCalSession, Team } from "../../../types"; import { convertSlugToName, getResumeLink, calculateExpiryDate } from "./utils"; -import { sendEmail } from "../../../notify"; +import { sendEmail } from "../../../lib/notify"; import type { SiteAddress } from "@opensystemslab/planx-core/types"; import { $api, $public } from "../../../client"; diff --git a/api.planx.uk/modules/saveAndReturn/service/utils.ts b/api.planx.uk/modules/saveAndReturn/service/utils.ts index fd13b5ce82..f6665b8d80 100644 --- a/api.planx.uk/modules/saveAndReturn/service/utils.ts +++ b/api.planx.uk/modules/saveAndReturn/service/utils.ts @@ -2,7 +2,7 @@ import { SiteAddress } from "@opensystemslab/planx-core/types"; import { format, addDays } from "date-fns"; import { gql } from "graphql-request"; import { LowCalSession, Team } from "../../../types"; -import { Template, getClientForTemplate, sendEmail } from "../../../notify"; +import { Template, getClientForTemplate, sendEmail } from "../../../lib/notify"; import { $api, $public } from "../../../client"; const DAYS_UNTIL_EXPIRY = 28; diff --git a/api.planx.uk/modules/sendEmail/controller.ts b/api.planx.uk/modules/sendEmail/controller.ts new file mode 100644 index 0000000000..a91ae0bfdf --- /dev/null +++ b/api.planx.uk/modules/sendEmail/controller.ts @@ -0,0 +1,88 @@ +import { + sendSinglePaymentEmail, + sendAgentAndPayeeConfirmationEmail, +} from "../../inviteToPay"; +import { sendSingleApplicationEmail } from "../saveAndReturn/service/utils"; +import { ServerError } from "../../errors"; +import { NextFunction } from "express"; +import { + ConfirmationEmail, + PaymentEmail, + SingleApplicationEmail, +} from "./types"; + +export const singleApplicationEmailController: SingleApplicationEmail = async ( + _req, + res, + next, +) => { + const { email, sessionId } = res.locals.parsedReq.body.payload; + const { template } = res.locals.parsedReq.params; + + try { + const response = await sendSingleApplicationEmail({ + template, + email, + sessionId, + }); + return res.json(response); + } catch (error) { + emailErrorHandler(next, error, template); + } +}; + +export const paymentEmailController: PaymentEmail = async (_req, res, next) => { + const { paymentRequestId } = res.locals.parsedReq.body.payload; + const { template } = res.locals.parsedReq.params; + + try { + const response = await sendSinglePaymentEmail({ + template, + paymentRequestId, + }); + return res.json(response); + } catch (error) { + emailErrorHandler(next, error, template); + } +}; + +export const confirmationEmailController: ConfirmationEmail = async ( + _req, + res, + next, +) => { + const { lockedAt, sessionId, email } = res.locals.parsedReq.body.payload; + const { template } = res.locals.parsedReq.params; + + try { + // if the session is locked we can infer that a payment request has been initiated + const paymentRequestInitiated = Boolean(lockedAt); + if (paymentRequestInitiated) { + const response = await sendAgentAndPayeeConfirmationEmail(sessionId); + return res.json(response); + } else { + const response = await sendSingleApplicationEmail({ + template, + email, + sessionId, + }); + return res.json(response); + } + } catch (error) { + emailErrorHandler(next, error, template); + } +}; + +const emailErrorHandler = ( + next: NextFunction, + error: unknown, + template: string, +) => + next( + new ServerError({ + status: error instanceof ServerError ? error.status : undefined, + message: `Failed to send "${template}" email. ${ + (error as Error).message + }`, + }), + ); diff --git a/api.planx.uk/modules/sendEmail/docs.yaml b/api.planx.uk/modules/sendEmail/docs.yaml new file mode 100644 index 0000000000..1aeb4c1ba5 --- /dev/null +++ b/api.planx.uk/modules/sendEmail/docs.yaml @@ -0,0 +1,95 @@ +openapi: 3.1.0 +info: + title: Planâś• API + version: 0.1.0 +tags: + - name: send email + description: Send templated emails via the GovNotify service +components: + schemas: + SendEmailRequest: + type: object + properties: + payload: + oneOf: + - $ref: "#/components/schemas/SingleApplicationPayload" + - $ref: "#/components/schemas/PaymentPayload" + - $ref: "#/components/schemas/ConfirmationPayload" + SingleApplicationPayload: + type: object + properties: + email: + type: string + format: email + sessionId: + type: string + PaymentPayload: + type: object + properties: + paymentRequestId: + type: string + ConfirmationPayload: + type: object + properties: + sessionId: + type: string + lockedAt: + type: string + format: date-time + nullable: true + email: + type: string + format: email + responses: + SendEmailResponse: + type: object + properties: + message: + type: string + expiryDate: + type: string + format: date-time + nullable: true +paths: + /send-email/{template}: + post: + tags: [send email] + summary: Send an email + parameters: + - name: template + in: path + required: true + schema: + type: string + description: GovNotify template to use + enum: + [ + "reminder", + "expiry", + "save", + "invite-to-pay", + "invite-to-pay-agent", + "payment-reminder", + "payment-reminder-agent", + "payment-expiry", + "payment-expiry-agent", + "confirmation", + ] + requestBody: + description: | + Request body for sending email. + The structure varies based on the template. + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/SendEmailRequest" + responses: + "200": + description: Email sent successfully + content: + application/json: + schema: + $ref: "#/components/responses/SendEmailResponse" + "500": + $ref: "#/components/responses/ErrorMessage" diff --git a/api.planx.uk/notify/routeSendEmailRequest.test.ts b/api.planx.uk/modules/sendEmail/index.test.ts similarity index 94% rename from api.planx.uk/notify/routeSendEmailRequest.test.ts rename to api.planx.uk/modules/sendEmail/index.test.ts index b96c671be4..996fa16d35 100644 --- a/api.planx.uk/notify/routeSendEmailRequest.test.ts +++ b/api.planx.uk/modules/sendEmail/index.test.ts @@ -1,13 +1,13 @@ import supertest from "supertest"; -import app from "../server"; -import { queryMock } from "../tests/graphqlQueryMock"; +import app from "../../server"; +import { queryMock } from "../../tests/graphqlQueryMock"; import { mockFlow, mockLowcalSession, mockSetupEmailNotifications, mockSoftDeleteLowcalSession, mockValidateSingleSessionRequest, -} from "../tests/mocks/saveAndReturnMocks"; +} from "../../tests/mocks/saveAndReturnMocks"; import { CoreDomainClient } from "@opensystemslab/planx-core"; // https://docs.notifications.service.gov.uk/node.html#email-addresses @@ -39,10 +39,8 @@ describe("Send Email endpoint", () => { .send(invalidBody) .expect(400) .then((response) => { - expect(response.body).toHaveProperty( - "error", - 'Failed to send "save" email. Required value missing', - ); + expect(response.body).toHaveProperty("issues"); + expect(response.body).toHaveProperty("name", "ZodError"); }); } }); @@ -75,9 +73,10 @@ describe("Send Email endpoint", () => { await supertest(app) .post(SAVE_ENDPOINT) .send(data) - .expect(500) + .expect(400) .then((response) => { - expect(response.body).toHaveProperty("error"); + expect(response.body).toHaveProperty("issues"); + expect(response.body).toHaveProperty("name", "ZodError"); }); }); diff --git a/api.planx.uk/modules/sendEmail/routes.ts b/api.planx.uk/modules/sendEmail/routes.ts new file mode 100644 index 0000000000..b36b515744 --- /dev/null +++ b/api.planx.uk/modules/sendEmail/routes.ts @@ -0,0 +1,42 @@ +import { Router } from "express"; +import { useSendEmailAuth } from "../auth/middleware"; +import { + confirmationEmailController, + paymentEmailController, + singleApplicationEmailController, +} from "./controller"; +import { sendEmailLimiter } from "../../rateLimit"; +import { validate } from "../../shared/middleware/validate"; +import { + confirmationEmailSchema, + paymentEmailSchema, + singleApplicationEmailSchema, +} from "./types"; + +const router = Router(); + +router.post( + `/send-email/:template(reminder|expiry|save)`, + sendEmailLimiter, + useSendEmailAuth, + validate(singleApplicationEmailSchema), + singleApplicationEmailController, +); + +router.post( + "/send-email/:template(confirmation)", + sendEmailLimiter, + useSendEmailAuth, + validate(confirmationEmailSchema), + confirmationEmailController, +); + +router.post( + "/send-email/:template", + sendEmailLimiter, + useSendEmailAuth, + validate(paymentEmailSchema), + paymentEmailController, +); + +export default router; diff --git a/api.planx.uk/modules/sendEmail/types.ts b/api.planx.uk/modules/sendEmail/types.ts new file mode 100644 index 0000000000..ae24d85519 --- /dev/null +++ b/api.planx.uk/modules/sendEmail/types.ts @@ -0,0 +1,65 @@ +import { z } from "zod"; +import { ValidatedRequestHandler } from "../../shared/middleware/validate"; + +interface SendEmailResponse { + message: string; + expiryDate?: string; +} + +export const singleApplicationEmailSchema = z.object({ + body: z.object({ + payload: z.object({ + email: z.string().email(), + sessionId: z.string(), + }), + }), + params: z.object({ + template: z.enum(["reminder", "expiry", "save"]), + }), +}); + +export type SingleApplicationEmail = ValidatedRequestHandler< + typeof singleApplicationEmailSchema, + SendEmailResponse +>; + +export const paymentEmailSchema = z.object({ + body: z.object({ + payload: z.object({ + paymentRequestId: z.string(), + }), + }), + params: z.object({ + template: z.enum([ + "invite-to-pay", + "invite-to-pay-agent", + "payment-reminder", + "payment-reminder-agent", + "payment-expiry", + "payment-expiry-agent", + ]), + }), +}); + +export type PaymentEmail = ValidatedRequestHandler< + typeof paymentEmailSchema, + SendEmailResponse +>; + +export const confirmationEmailSchema = z.object({ + body: z.object({ + payload: z.object({ + sessionId: z.string(), + lockedAt: z.string().optional(), + email: z.string().email(), + }), + }), + params: z.object({ + template: z.enum(["confirmation"]), + }), +}); + +export type ConfirmationEmail = ValidatedRequestHandler< + typeof confirmationEmailSchema, + SendEmailResponse +>; diff --git a/api.planx.uk/notify/index.ts b/api.planx.uk/notify/index.ts deleted file mode 100644 index bdf6d3255d..0000000000 --- a/api.planx.uk/notify/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export * from "./routeSendEmailRequest"; -export * from "./notify"; diff --git a/api.planx.uk/notify/routeSendEmailRequest.ts b/api.planx.uk/notify/routeSendEmailRequest.ts deleted file mode 100644 index 5e43364f95..0000000000 --- a/api.planx.uk/notify/routeSendEmailRequest.ts +++ /dev/null @@ -1,106 +0,0 @@ -import { NextFunction, Request, Response } from "express"; -import { - sendSinglePaymentEmail, - sendAgentAndPayeeConfirmationEmail, -} from "../inviteToPay"; -import { sendSingleApplicationEmail } from "../modules/saveAndReturn/service/utils"; -import { Template } from "./notify"; -import { ServerError } from "../errors"; - -export async function routeSendEmailRequest( - req: Request, - res: Response, - next: NextFunction, -) { - try { - const { email, sessionId, paymentRequestId, lockedAt } = req.body.payload; - const template = req.params.template as Template; - - const invalidTemplate = (_unknownTemplate?: never) => { - throw new ServerError({ - message: "Invalid template", - status: 400, - }); - }; - - const handleSingleApplicationEmail = async () => { - if (!email || !sessionId) { - throw new ServerError({ - status: 400, - message: "Required value missing", - }); - } - const response = await sendSingleApplicationEmail({ - template, - email, - sessionId, - }); - return res.json(response); - }; - - const handlePaymentEmails = async () => { - if (!paymentRequestId) { - throw new ServerError({ - status: 400, - message: "Required `paymentRequestId` missing", - }); - } - const response = await sendSinglePaymentEmail({ - template, - paymentRequestId, - }); - return res.json(response); - }; - - const handleInviteToPayConfirmationEmails = async () => { - if (!sessionId) { - throw new ServerError({ - status: 400, - message: "Required `sessionId` missing", - }); - } - const response = await sendAgentAndPayeeConfirmationEmail(sessionId); - return res.json(response); - }; - - switch (template) { - case "reminder": - case "expiry": - case "save": - return await handleSingleApplicationEmail(); - case "invite-to-pay": - case "invite-to-pay-agent": - case "payment-reminder": - case "payment-reminder-agent": - case "payment-expiry": - case "payment-expiry-agent": - return await handlePaymentEmails(); - case "confirmation": { - // if the session is locked we can infer that a payment request has been initiated - const paymentRequestInitiated = Boolean(lockedAt); - if (paymentRequestInitiated) { - return await handleInviteToPayConfirmationEmails(); - } else { - return await handleSingleApplicationEmail(); - } - } - case "resume": - case "submit": - case "confirmation-agent": - case "confirmation-payee": - // templates that are already handled by other routes - return invalidTemplate(); - default: - return invalidTemplate(template); - } - } catch (error) { - next( - new ServerError({ - status: error instanceof ServerError ? error.status : undefined, - message: `Failed to send "${req.params.template}" email. ${ - (error as Error).message - }`, - }), - ); - } -} diff --git a/api.planx.uk/pay/index.ts b/api.planx.uk/pay/index.ts index 22d6f79171..0e525c3e04 100644 --- a/api.planx.uk/pay/index.ts +++ b/api.planx.uk/pay/index.ts @@ -1 +1,205 @@ -export * from "./pay"; +import assert from "assert"; +import { NextFunction, Request, Response } from "express"; +import { responseInterceptor } from "http-proxy-middleware"; +import SlackNotify from "slack-notify"; +import { logPaymentStatus } from "../send/helpers"; +import { usePayProxy } from "./proxy"; +import { $api } from "../client"; +import { ServerError } from "../errors"; +import { GovUKPayment } from "@opensystemslab/planx-core/types"; +import { addGovPayPaymentIdToPaymentRequest } from "./utils"; + +assert(process.env.SLACK_WEBHOOK_URL); + +// exposed as /pay/:localAuthority and also used as middleware +// returns the url to make a gov uk payment +export async function makePaymentViaProxy( + req: Request, + res: Response, + next: NextFunction, +) { + // confirm that this local authority (aka team) has a pay token configured before creating the proxy + const isSupported = + process.env[`GOV_UK_PAY_TOKEN_${req.params.localAuthority.toUpperCase()}`]; + + if (!isSupported) { + return next( + new ServerError({ + message: `GOV.UK Pay is not enabled for this local authority (${req.params.localAuthority})`, + status: 400, + }), + ); + } + + const flowId = req.query?.flowId as string | undefined; + const sessionId = req.query?.sessionId as string | undefined; + const teamSlug = req.params.localAuthority; + + if (!flowId || !sessionId || !teamSlug) { + return next( + new ServerError({ + message: "Missing required query param", + status: 400, + }), + ); + } + + const session = await $api.session.findDetails(sessionId); + + if (session?.lockedAt) { + return next( + new ServerError({ + message: `Cannot initialise a new payment for locked session ${sessionId}`, + status: 400, + }), + ); + } + + // drop req.params.localAuthority from the path when redirecting + // so redirects to plain [GOV_UK_PAY_URL] with correct bearer token + usePayProxy( + { + pathRewrite: (path) => path.replace(/^\/pay.*$/, ""), + selfHandleResponse: true, + onProxyRes: responseInterceptor( + async (responseBuffer, _proxyRes, _req, _res) => { + const responseString = responseBuffer.toString("utf8"); + const govUkResponse = JSON.parse(responseString); + await logPaymentStatus({ + sessionId, + flowId, + teamSlug, + govUkResponse, + }); + return responseBuffer; + }, + ), + }, + req, + )(req, res, next); +} + +export async function makeInviteToPayPaymentViaProxy( + req: Request, + res: Response, + next: NextFunction, +) { + // confirm that this local authority (aka team) has a pay token configured before creating the proxy + const isSupported = + process.env[`GOV_UK_PAY_TOKEN_${req.params.localAuthority.toUpperCase()}`]; + + if (!isSupported) { + return next({ + status: 400, + message: `GOV.UK Pay is not enabled for this local authority (${req.params.localAuthority})`, + }); + } + + const flowId = req.query?.flowId as string | undefined; + const sessionId = req.query?.sessionId as string | undefined; + const paymentRequestId = req.params?.paymentRequest as string; + const teamSlug = req.params.localAuthority; + + // drop req.params.localAuthority from the path when redirecting + // so redirects to plain [GOV_UK_PAY_URL] with correct bearer token + usePayProxy( + { + pathRewrite: (path) => path.replace(/^\/pay.*$/, ""), + selfHandleResponse: true, + onProxyRes: responseInterceptor(async (responseBuffer) => { + const responseString = responseBuffer.toString("utf8"); + const govUkResponse = JSON.parse(responseString); + await logPaymentStatus({ + sessionId, + flowId, + teamSlug, + govUkResponse, + }); + + try { + await addGovPayPaymentIdToPaymentRequest( + paymentRequestId, + govUkResponse, + ); + } catch (error) { + throw Error(error as string); + } + + return responseBuffer; + }), + }, + req, + )(req, res, next); +} + +// exposed as /pay/:localAuthority/:paymentId and also used as middleware +// fetches the status of the payment +export const fetchPaymentViaProxy = fetchPaymentViaProxyWithCallback( + async (req: Request, govUkPayment: GovUKPayment) => + postPaymentNotificationToSlack(req, govUkPayment), +); + +export function fetchPaymentViaProxyWithCallback( + callback: (req: Request, govUkPayment: GovUKPayment) => Promise, +) { + return async (req: Request, res: Response, next: NextFunction) => { + const flowId = req.query?.flowId as string | undefined; + const sessionId = req.query?.sessionId as string | undefined; + const teamSlug = req.params.localAuthority; + + // will redirect to [GOV_UK_PAY_URL]/:paymentId with correct bearer token + usePayProxy( + { + pathRewrite: () => `/${req.params.paymentId}`, + selfHandleResponse: true, + onProxyRes: responseInterceptor(async (responseBuffer) => { + const govUkResponse = JSON.parse(responseBuffer.toString("utf8")); + + await logPaymentStatus({ + sessionId, + flowId, + teamSlug, + govUkResponse, + }); + + try { + await callback(req, govUkResponse); + } catch (e) { + throw Error(e as string); + } + + // only return payment status, filter out PII + return JSON.stringify({ + payment_id: govUkResponse.payment_id, + amount: govUkResponse.amount, + state: govUkResponse.state, + _links: { + next_url: govUkResponse._links?.next_url, + }, + }); + }), + }, + req, + )(req, res, next); + }; +} + +export async function postPaymentNotificationToSlack( + req: Request, + govUkResponse: GovUKPayment, + label = "", +) { + // if it's a prod payment, notify #planx-notifications so we can monitor for subsequent submissions + if (govUkResponse?.payment_provider !== "sandbox") { + const slack = SlackNotify(process.env.SLACK_WEBHOOK_URL!); + const getStatus = (state: GovUKPayment["state"]) => + state.status + (state.message ? ` (${state.message})` : ""); + const payMessage = `:coin: New GOV Pay payment ${label} *${ + govUkResponse.payment_id + }* with status *${getStatus(govUkResponse.state)}* [${ + req.params.localAuthority + }]`; + await slack.send(payMessage); + console.log("Payment notification posted to Slack"); + } +} diff --git a/api.planx.uk/pay/pay.ts b/api.planx.uk/pay/pay.ts deleted file mode 100644 index ee7f818969..0000000000 --- a/api.planx.uk/pay/pay.ts +++ /dev/null @@ -1,205 +0,0 @@ -import assert from "assert"; -import { NextFunction, Request, Response } from "express"; -import { responseInterceptor } from "http-proxy-middleware"; -import SlackNotify from "slack-notify"; -import { logPaymentStatus } from "../send/helpers"; -import { usePayProxy } from "./proxy"; -import { addGovPayPaymentIdToPaymentRequest } from "../inviteToPay"; -import { $api } from "../client"; -import { ServerError } from "../errors"; -import { GovUKPayment } from "@opensystemslab/planx-core/types"; - -assert(process.env.SLACK_WEBHOOK_URL); - -// exposed as /pay/:localAuthority and also used as middleware -// returns the url to make a gov uk payment -export async function makePaymentViaProxy( - req: Request, - res: Response, - next: NextFunction, -) { - // confirm that this local authority (aka team) has a pay token configured before creating the proxy - const isSupported = - process.env[`GOV_UK_PAY_TOKEN_${req.params.localAuthority.toUpperCase()}`]; - - if (!isSupported) { - return next( - new ServerError({ - message: `GOV.UK Pay is not enabled for this local authority (${req.params.localAuthority})`, - status: 400, - }), - ); - } - - const flowId = req.query?.flowId as string | undefined; - const sessionId = req.query?.sessionId as string | undefined; - const teamSlug = req.params.localAuthority; - - if (!flowId || !sessionId || !teamSlug) { - return next( - new ServerError({ - message: "Missing required query param", - status: 400, - }), - ); - } - - const session = await $api.session.findDetails(sessionId); - - if (session?.lockedAt) { - return next( - new ServerError({ - message: `Cannot initialise a new payment for locked session ${sessionId}`, - status: 400, - }), - ); - } - - // drop req.params.localAuthority from the path when redirecting - // so redirects to plain [GOV_UK_PAY_URL] with correct bearer token - usePayProxy( - { - pathRewrite: (path) => path.replace(/^\/pay.*$/, ""), - selfHandleResponse: true, - onProxyRes: responseInterceptor( - async (responseBuffer, _proxyRes, _req, _res) => { - const responseString = responseBuffer.toString("utf8"); - const govUkResponse = JSON.parse(responseString); - await logPaymentStatus({ - sessionId, - flowId, - teamSlug, - govUkResponse, - }); - return responseBuffer; - }, - ), - }, - req, - )(req, res, next); -} - -export async function makeInviteToPayPaymentViaProxy( - req: Request, - res: Response, - next: NextFunction, -) { - // confirm that this local authority (aka team) has a pay token configured before creating the proxy - const isSupported = - process.env[`GOV_UK_PAY_TOKEN_${req.params.localAuthority.toUpperCase()}`]; - - if (!isSupported) { - return next({ - status: 400, - message: `GOV.UK Pay is not enabled for this local authority (${req.params.localAuthority})`, - }); - } - - const flowId = req.query?.flowId as string | undefined; - const sessionId = req.query?.sessionId as string | undefined; - const paymentRequestId = req.params?.paymentRequest as string; - const teamSlug = req.params.localAuthority; - - // drop req.params.localAuthority from the path when redirecting - // so redirects to plain [GOV_UK_PAY_URL] with correct bearer token - usePayProxy( - { - pathRewrite: (path) => path.replace(/^\/pay.*$/, ""), - selfHandleResponse: true, - onProxyRes: responseInterceptor(async (responseBuffer) => { - const responseString = responseBuffer.toString("utf8"); - const govUkResponse = JSON.parse(responseString); - await logPaymentStatus({ - sessionId, - flowId, - teamSlug, - govUkResponse, - }); - - try { - await addGovPayPaymentIdToPaymentRequest( - paymentRequestId, - govUkResponse, - ); - } catch (error) { - throw Error(error as string); - } - - return responseBuffer; - }), - }, - req, - )(req, res, next); -} - -// exposed as /pay/:localAuthority/:paymentId and also used as middleware -// fetches the status of the payment -export const fetchPaymentViaProxy = fetchPaymentViaProxyWithCallback( - async (req: Request, govUkPayment: GovUKPayment) => - postPaymentNotificationToSlack(req, govUkPayment), -); - -export function fetchPaymentViaProxyWithCallback( - callback: (req: Request, govUkPayment: GovUKPayment) => Promise, -) { - return async (req: Request, res: Response, next: NextFunction) => { - const flowId = req.query?.flowId as string | undefined; - const sessionId = req.query?.sessionId as string | undefined; - const teamSlug = req.params.localAuthority; - - // will redirect to [GOV_UK_PAY_URL]/:paymentId with correct bearer token - usePayProxy( - { - pathRewrite: () => `/${req.params.paymentId}`, - selfHandleResponse: true, - onProxyRes: responseInterceptor(async (responseBuffer) => { - const govUkResponse = JSON.parse(responseBuffer.toString("utf8")); - - await logPaymentStatus({ - sessionId, - flowId, - teamSlug, - govUkResponse, - }); - - try { - await callback(req, govUkResponse); - } catch (e) { - throw Error(e as string); - } - - // only return payment status, filter out PII - return JSON.stringify({ - payment_id: govUkResponse.payment_id, - amount: govUkResponse.amount, - state: govUkResponse.state, - _links: { - next_url: govUkResponse._links?.next_url, - }, - }); - }), - }, - req, - )(req, res, next); - }; -} - -export async function postPaymentNotificationToSlack( - req: Request, - govUkResponse: GovUKPayment, - label = "", -) { - // if it's a prod payment, notify #planx-notifications so we can monitor for subsequent submissions - if (govUkResponse?.payment_provider !== "sandbox") { - const slack = SlackNotify(process.env.SLACK_WEBHOOK_URL!); - const getStatus = (state: GovUKPayment["state"]) => - state.status + (state.message ? ` (${state.message})` : ""); - const payMessage = `:coin: New GOV Pay payment ${label} *${ - govUkResponse.payment_id - }* with status *${getStatus(govUkResponse.state)}* [${ - req.params.localAuthority - }]`; - await slack.send(payMessage); - console.log("Payment notification posted to Slack"); - } -} diff --git a/api.planx.uk/pay/utils.ts b/api.planx.uk/pay/utils.ts new file mode 100644 index 0000000000..f731abf085 --- /dev/null +++ b/api.planx.uk/pay/utils.ts @@ -0,0 +1,30 @@ +import { GovUKPayment } from "@opensystemslab/planx-core/types"; +import { $api } from "../client"; +import { gql } from "graphql-request"; + +export const addGovPayPaymentIdToPaymentRequest = async ( + paymentRequestId: string, + govUKPayment: GovUKPayment, +): Promise => { + const query = gql` + mutation AddGovPayPaymentIdToPaymentRequest( + $paymentRequestId: uuid! + $govPayPaymentId: String + ) { + update_payment_requests_by_pk( + pk_columns: { id: $paymentRequestId } + _set: { govpay_payment_id: $govPayPaymentId } + ) { + id + } + } + `; + try { + await $api.client.request(query, { + paymentRequestId, + govPayPaymentId: govUKPayment.payment_id, + }); + } catch (error) { + throw Error(`payment request ${paymentRequestId} not updated`); + } +}; diff --git a/api.planx.uk/send/email.ts b/api.planx.uk/send/email.ts index c182f15866..34de3fd3e7 100644 --- a/api.planx.uk/send/email.ts +++ b/api.planx.uk/send/email.ts @@ -2,7 +2,7 @@ import type { NextFunction, Request, Response } from "express"; import { gql } from "graphql-request"; import capitalize from "lodash/capitalize"; import { markSessionAsSubmitted } from "../modules/saveAndReturn/service/utils"; -import { sendEmail } from "../notify"; +import { sendEmail } from "../lib/notify"; import { EmailSubmissionNotifyConfig } from "../types"; import { buildSubmissionExportZip } from "./exportZip"; import { $api } from "../client"; diff --git a/api.planx.uk/server.ts b/api.planx.uk/server.ts index 901b4c578a..6bb5d70382 100644 --- a/api.planx.uk/server.ts +++ b/api.planx.uk/server.ts @@ -17,7 +17,6 @@ import { locationSearch } from "./gis/index"; import { validateAndDiffFlow, publishFlow } from "./editor/publish"; import { findAndReplaceInFlow } from "./editor/findReplace"; import { copyPortalAsFlow } from "./editor/copyPortalAsFlow"; -import { routeSendEmailRequest } from "./notify"; import { makePaymentViaProxy, fetchPaymentViaProxy, @@ -31,13 +30,12 @@ import { } from "./inviteToPay"; import { useHasuraAuth, - useSendEmailAuth, usePlatformAdminAuth, useTeamEditorAuth, } from "./modules/auth/middleware"; import airbrake from "./airbrake"; -import { sendEmailLimiter, apiLimiter } from "./rateLimit"; +import { apiLimiter } from "./rateLimit"; import { sendToBOPS } from "./send/bops"; import { createSendEvents } from "./send/createSendEvents"; import { downloadApplicationFiles, sendToEmail } from "./send/email"; @@ -56,6 +54,7 @@ import analyticsRoutes from "./modules/analytics/routes"; import adminRoutes from "./modules/admin/routes"; import ordnanceSurveyRoutes from "./modules/ordnanceSurvey/routes"; import fileRoutes from "./modules/file/routes"; +import sendEmailRoutes from "./modules/sendEmail/routes"; import saveAndReturnRoutes from "./modules/saveAndReturn/routes"; import { useSwaggerDocs } from "./docs"; import { Role } from "@opensystemslab/planx-core/types"; @@ -107,6 +106,7 @@ app.use(helmet()); // Create "One-off Scheduled Events" in Hasura from Send component for selected destinations app.post("/create-send-events/:sessionId", createSendEvents); +assert(process.env.GOVUK_NOTIFY_API_KEY); assert(process.env.HASURA_PLANX_API_KEY); assert(process.env.BOPS_API_TOKEN); @@ -180,6 +180,7 @@ app.use("/admin", adminRoutes); app.use(ordnanceSurveyRoutes); app.use("/file", fileRoutes); app.use(saveAndReturnRoutes); +app.use(sendEmailRoutes); app.use("/gis", router); @@ -302,14 +303,6 @@ app.get("/flows/:flowId/download-schema", async (req, res, next) => { } }); -assert(process.env.GOVUK_NOTIFY_API_KEY); -app.post( - "/send-email/:template", - sendEmailLimiter, - useSendEmailAuth, - routeSendEmailRequest, -); - app.post("/invite-to-pay/:sessionId", inviteToPay); const errorHandler: ErrorRequestHandler = (errorObject, _req, res, _next) => { From 20d5f22abf815239085df5b4f67834b5b23327b0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dafydd=20Ll=C5=B7r=20Pearson?= Date: Fri, 17 Nov 2023 10:14:21 +0000 Subject: [PATCH 5/6] feat: Flows API module (#2419) --- api.planx.uk/editor/copyFlow.ts | 53 ---- api.planx.uk/editor/copyPortalAsFlow.ts | 58 ---- api.planx.uk/editor/findReplace.ts | 185 ------------- api.planx.uk/editor/moveFlow.ts | 89 ------ .../modules/flows/copyFlow/controller.ts | 54 ++++ .../flows/copyFlow}/copyFlow.test.ts | 37 ++- .../modules/flows/copyFlow/service.ts | 35 +++ .../flows/copyFlowAsPortal/controller.ts | 44 +++ .../copyPortalAsFlow.test.ts | 23 +- .../modules/flows/copyFlowAsPortal/service.ts | 42 +++ api.planx.uk/modules/flows/docs.yaml | 260 ++++++++++++++++++ .../flows/downloadSchema/controller.ts | 41 +++ .../modules/flows/downloadSchema/service.ts | 35 +++ .../modules/flows/findReplace/controller.ts | 51 ++++ .../flows/findReplace}/findReplace.test.ts | 16 +- .../modules/flows/findReplace/service.ts | 116 ++++++++ .../modules/flows/moveFlow/controller.ts | 37 +++ .../flows/moveFlow}/moveFlow.test.ts | 8 +- .../modules/flows/moveFlow/service.ts | 42 +++ .../modules/flows/publish/controller.ts | 45 +++ .../modules/flows/publish/publish.test.ts | 156 +++++++++++ api.planx.uk/modules/flows/publish/service.ts | 69 +++++ api.planx.uk/modules/flows/routes.ts | 74 +++++ .../modules/flows/validate/controller.ts | 37 +++ .../flows/validate/service.ts} | 169 +++--------- .../flows/validate/validate.test.ts} | 214 +------------- api.planx.uk/server.ts | 133 ++------- .../tests/mocks/validateAndPublishMocks.ts | 111 ++++++++ hasura.planx.uk/metadata/tables.yaml | 6 +- 29 files changed, 1357 insertions(+), 883 deletions(-) delete mode 100644 api.planx.uk/editor/copyFlow.ts delete mode 100644 api.planx.uk/editor/copyPortalAsFlow.ts delete mode 100644 api.planx.uk/editor/findReplace.ts delete mode 100644 api.planx.uk/editor/moveFlow.ts create mode 100644 api.planx.uk/modules/flows/copyFlow/controller.ts rename api.planx.uk/{editor => modules/flows/copyFlow}/copyFlow.test.ts (84%) create mode 100644 api.planx.uk/modules/flows/copyFlow/service.ts create mode 100644 api.planx.uk/modules/flows/copyFlowAsPortal/controller.ts rename api.planx.uk/{editor => modules/flows/copyFlowAsPortal}/copyPortalAsFlow.test.ts (88%) create mode 100644 api.planx.uk/modules/flows/copyFlowAsPortal/service.ts create mode 100644 api.planx.uk/modules/flows/docs.yaml create mode 100644 api.planx.uk/modules/flows/downloadSchema/controller.ts create mode 100644 api.planx.uk/modules/flows/downloadSchema/service.ts create mode 100644 api.planx.uk/modules/flows/findReplace/controller.ts rename api.planx.uk/{editor => modules/flows/findReplace}/findReplace.test.ts (93%) create mode 100644 api.planx.uk/modules/flows/findReplace/service.ts create mode 100644 api.planx.uk/modules/flows/moveFlow/controller.ts rename api.planx.uk/{editor => modules/flows/moveFlow}/moveFlow.test.ts (87%) create mode 100644 api.planx.uk/modules/flows/moveFlow/service.ts create mode 100644 api.planx.uk/modules/flows/publish/controller.ts create mode 100644 api.planx.uk/modules/flows/publish/publish.test.ts create mode 100644 api.planx.uk/modules/flows/publish/service.ts create mode 100644 api.planx.uk/modules/flows/routes.ts create mode 100644 api.planx.uk/modules/flows/validate/controller.ts rename api.planx.uk/{editor/publish.ts => modules/flows/validate/service.ts} (59%) rename api.planx.uk/{editor/publish.test.ts => modules/flows/validate/validate.test.ts} (59%) create mode 100644 api.planx.uk/tests/mocks/validateAndPublishMocks.ts diff --git a/api.planx.uk/editor/copyFlow.ts b/api.planx.uk/editor/copyFlow.ts deleted file mode 100644 index 8f8e703e0c..0000000000 --- a/api.planx.uk/editor/copyFlow.ts +++ /dev/null @@ -1,53 +0,0 @@ -import { Request, Response, NextFunction } from "express"; -import { makeUniqueFlow, getFlowData, insertFlow } from "../helpers"; -import { Flow } from "../types"; -import { userContext } from "../modules/auth/middleware"; - -const copyFlow = async ( - req: Request, - res: Response, - next: NextFunction, -): Promise => { - try { - if (!req.params?.flowId || !req.body?.replaceValue) { - return next({ - status: 400, - message: "Missing required values to proceed", - }); - } - - // Fetch the original flow - const flow: Flow = await getFlowData(req.params.flowId); - - // Generate new flow data which is an exact "content" copy of the original but with unique nodeIds - const uniqueFlowData = makeUniqueFlow(flow.data, req.body.replaceValue); - - // Check if copied flow data should be inserted into `flows` table, or just returned for reference - const shouldInsert = (req.body?.insert as boolean) || false; - if (shouldInsert) { - const newSlug = flow.slug + "-copy"; - const creatorId = userContext.getStore()?.user?.sub; - if (!creatorId) throw Error("User details missing from request"); - - // Insert the flow and an associated operation - await insertFlow( - flow.team_id, - newSlug, - uniqueFlowData, - parseInt(creatorId), - req.params.flowId, - ); - } - - res.status(200).send({ - message: `Successfully copied ${flow.slug}`, - inserted: shouldInsert, - replaceValue: req.body.replaceValue, - data: uniqueFlowData, - }); - } catch (error) { - return next(error); - } -}; - -export { copyFlow }; diff --git a/api.planx.uk/editor/copyPortalAsFlow.ts b/api.planx.uk/editor/copyPortalAsFlow.ts deleted file mode 100644 index c220942123..0000000000 --- a/api.planx.uk/editor/copyPortalAsFlow.ts +++ /dev/null @@ -1,58 +0,0 @@ -import { getFlowData, getChildren, makeUniqueFlow } from "../helpers"; -import { Request, Response, NextFunction } from "express"; -import { Flow } from "../types"; - -/** - * Copies an internal portal and transforms it to be an independent flow - */ -const copyPortalAsFlow = async ( - req: Request, - res: Response, - next: NextFunction, -) => { - try { - // fetch the parent flow data - const flow = await getFlowData(req.params.flowId); - if (!flow) { - return next({ status: 404, message: "Unknown flowId" }); - } - - // confirm that the node id provided is a valid portal - const portalId = req.params.portalNodeId; - if ( - !Object.keys(flow.data).includes(portalId) || - flow.data[portalId]?.type !== 300 - ) { - return next({ status: 404, message: "Unknown portalNodeId" }); - } - - // set the portal node as the new "_root", then extract all its' children from the parent flow and add them to the new flow data object - let portalData: Flow["data"] = { - _root: { edges: flow.data[portalId]?.edges }, - }; - Object.entries(portalData).forEach(([_nodeId, node]) => { - portalData = getChildren(node, flow.data, portalData); - }); - - // to avoid the new flow nodes acting as clones of the original internal portal, rename - // the non-root node ids using the first three alphanumeric characters of the portal name - const replacementCharacters = flow.data[portalId]?.data?.text - ?.replace(/\W/g, "") - ?.slice(0, 3); - portalData = makeUniqueFlow(portalData, replacementCharacters); - - // FUTURE: - // - change GET to POST and write portalData directly to a new flow? - // - assume same team as parent flow and use name of internal portal as slug, or pass in body? - // - update the parent flow to remove the original internal portal and reference this new flow as an external portal? - - res.status(200).send({ - message: `Successfully copied internal portal: ${flow.data[portalId]?.data?.text}`, - data: portalData, - }); - } catch (error) { - return next(error); - } -}; - -export { copyPortalAsFlow }; diff --git a/api.planx.uk/editor/findReplace.ts b/api.planx.uk/editor/findReplace.ts deleted file mode 100644 index a0beaae8eb..0000000000 --- a/api.planx.uk/editor/findReplace.ts +++ /dev/null @@ -1,185 +0,0 @@ -import { Flow } from "./../types"; -import { gql } from "graphql-request"; -import { getFlowData } from "../helpers"; -import { Request, Response, NextFunction } from "express"; -import { getClient } from "../client"; -import { FlowGraph } from "@opensystemslab/planx-core/types"; - -interface MatchResult { - matches: Flow["data"]; - flowData: Flow["data"]; -} - -/** - * Find and return the node ids and specific data properties that match a given search term, - * and return an updated copy of the flow data if a replaceValue is provided, else return the original flowData - */ -const getMatches = ( - flowData: Flow["data"], - searchTerm: string, - replaceValue: string | undefined = undefined, -): MatchResult => { - const matches: MatchResult["matches"] = {}; - - const nodes = Object.keys(flowData).filter((key) => key !== "_root"); - nodes.forEach((node) => { - const data = flowData[node]["data"]; - if (data) { - // search all "data" properties independent of component type (eg `fn`, `val`, `text`) - const keys = Object.keys(data); - keys.forEach((k) => { - // if any value strictly matches the searchTerm, add that node id & key to the matches object - if (data[k] === searchTerm) { - matches[node] = { - data: { - [k]: data[k], - }, - }; - // if a replaceValue is provided, additionally update the flowData - if (replaceValue) { - data[k] = replaceValue; - } - } - }); - } - }); - - return { - matches: matches, - flowData: flowData, - }; -}; - -interface UpdateFlow { - flow: { - id: string; - slug: string; - data: FlowGraph; - updatedAt: string; - }; -} - -/** - * @swagger - * /flows/{flowId}/search: - * post: - * summary: Find and replace - * description: Find and replace a data variable in a flow - * tags: - * - flows - * parameters: - * - in: path - * name: flowId - * type: string - * required: true - * - in: query - * name: find - * type: string - * required: true - * - in: query - * name: replace - * type: string - * required: false - * responses: - * '200': - * description: OK - * content: - * application/json: - * schema: - * type: object - * properties: - * message: - * type: string - * required: true - * matches: - * type: object - * required: true - * additionalProperties: true - * updatedFlow: - * type: object - * required: false - * additionalProperties: true - * properties: - * _root: - * type: object - * properties: - * edges: - * type: array - * items: - * type: string - */ -const findAndReplaceInFlow = async ( - req: Request, - res: Response, - next: NextFunction, -): Promise => { - try { - const flow = await getFlowData(req.params.flowId); - if (!flow) return next({ status: 401, message: "Unknown flowId" }); - - const { find, replace } = req.query as Record; - if (!find) - return next({ - status: 401, - message: `Expected at least one query parameter "find"`, - }); - - if (find && !replace) { - const matches = getMatches(flow.data, find)["matches"]; - - res.json({ - message: `Found ${ - Object.keys(matches).length - } matches of "${find}" in this flow`, - matches: matches, - }); - } - - if (find && replace) { - const { matches, flowData } = getMatches(flow.data, find, replace); - - // if no matches, send message & exit - if (Object.keys(matches).length === 0) { - res.json({ - message: `Didn't find "${find}" in this flow, nothing to replace`, - }); - } - - // if matches, proceed with mutation to update flow data - const { client: $client } = getClient(); - const response = await $client.request( - gql` - mutation UpdateFlow($data: jsonb = {}, $id: uuid!) { - flow: update_flows_by_pk( - pk_columns: { id: $id } - _set: { data: $data } - ) { - id - slug - data - updatedAt: updated_at - } - } - `, - { - data: flowData, - id: req.params.flowId, - }, - ); - - const updatedFlow = response.flow && response.flow.data; - - res.json({ - message: `Found ${ - Object.keys(matches).length - } matches of "${find}" and replaced with "${replace}"`, - matches: matches, - updatedFlow: updatedFlow, - }); - } - } catch (error) { - next(error); - } -}; - -export { findAndReplaceInFlow }; diff --git a/api.planx.uk/editor/moveFlow.ts b/api.planx.uk/editor/moveFlow.ts deleted file mode 100644 index 0135f81f37..0000000000 --- a/api.planx.uk/editor/moveFlow.ts +++ /dev/null @@ -1,89 +0,0 @@ -import { Request, Response, NextFunction } from "express"; -import { gql } from "graphql-request"; -import { Flow, Team } from "../types"; -import { $public, getClient } from "../client"; - -const moveFlow = async ( - req: Request, - res: Response, - next: NextFunction, -): Promise => { - try { - if (!req.params?.flowId || !req.params?.teamSlug) { - return next({ - status: 400, - message: "Missing required values to proceed", - }); - } - - // Translate teamSlug to teamId - const teamId = await getTeamIdBySlug(req.params.teamSlug); - - // If we have a valid teamId, update the flow record - if (teamId) { - await updateFlow(req.params.flowId, teamId); - res.status(200).send({ - message: `Successfully moved flow to ${req.params.teamSlug}`, - }); - } else { - return next({ - status: 400, - message: `Unable to find a team matching slug ${req.params.teamSlug}, exiting move`, - }); - } - } catch (error) { - return next(error); - } -}; - -interface GetTeam { - teams: Pick[]; -} - -const getTeamIdBySlug = async (slug: Team["slug"]): Promise => { - const data = await $public.client.request( - gql` - query GetTeam($slug: String!) { - teams(where: { slug: { _eq: $slug } }) { - id - } - } - `, - { - slug: slug, - }, - ); - - return data?.teams[0].id; -}; - -interface UpdateFlow { - flow: Pick; -} - -const updateFlow = async ( - flowId: Flow["id"], - teamId: Team["id"], -): Promise => { - const { client: $client } = getClient(); - const { flow } = await $client.request( - gql` - mutation UpdateFlow($id: uuid!, $team_id: Int!) { - flow: update_flows_by_pk( - pk_columns: { id: $id } - _set: { team_id: $team_id } - ) { - id - } - } - `, - { - id: flowId, - team_id: teamId, - }, - ); - - return flow.id; -}; - -export { moveFlow }; diff --git a/api.planx.uk/modules/flows/copyFlow/controller.ts b/api.planx.uk/modules/flows/copyFlow/controller.ts new file mode 100644 index 0000000000..c3bfcfc82a --- /dev/null +++ b/api.planx.uk/modules/flows/copyFlow/controller.ts @@ -0,0 +1,54 @@ +import { z } from "zod"; +import { ValidatedRequestHandler } from "../../../shared/middleware/validate"; +import { Flow } from "../../../types"; +import { ServerError } from "../../../errors"; +import { copyFlow } from "./service"; + +interface CopyFlowResponse { + message: string; + inserted: boolean; + replaceValue: string; + data: Flow["data"]; +} + +export const copyFlowSchema = z.object({ + params: z.object({ + flowId: z.string(), + }), + body: z.object({ + replaceValue: z.string().length(5), + insert: z.boolean().optional().default(false), + }), +}); + +export type CopyFlowController = ValidatedRequestHandler< + typeof copyFlowSchema, + CopyFlowResponse +>; + +export const copyFlowController: CopyFlowController = async ( + req, + res, + next, +) => { + try { + const { flowId } = res.locals.parsedReq.params; + const { replaceValue, insert } = res.locals.parsedReq.body; + const { flow, uniqueFlowData } = await copyFlow( + flowId, + replaceValue, + insert, + ); + + res.status(200).send({ + message: `Successfully copied ${flow.slug}`, + inserted: insert, + replaceValue: replaceValue, + data: uniqueFlowData, + }); + } catch (error) { + return next( + new ServerError({ message: "Failed to copy flow", cause: error }), + ); + } +}; diff --git a/api.planx.uk/editor/copyFlow.test.ts b/api.planx.uk/modules/flows/copyFlow/copyFlow.test.ts similarity index 84% rename from api.planx.uk/editor/copyFlow.test.ts rename to api.planx.uk/modules/flows/copyFlow/copyFlow.test.ts index 15c683719f..30bef2e79e 100644 --- a/api.planx.uk/editor/copyFlow.test.ts +++ b/api.planx.uk/modules/flows/copyFlow/copyFlow.test.ts @@ -1,9 +1,9 @@ import supertest from "supertest"; -import { queryMock } from "../tests/graphqlQueryMock"; -import { authHeader } from "../tests/mockJWT"; -import app from "../server"; -import { Flow } from "../types"; +import { queryMock } from "../../../tests/graphqlQueryMock"; +import { authHeader } from "../../../tests/mockJWT"; +import app from "../../../server"; +import { Flow } from "../../../types"; beforeEach(() => { queryMock.mockQuery({ @@ -42,7 +42,7 @@ const auth = authHeader({ role: "teamEditor" }); it("returns an error if authorization headers are not set", async () => { const validBody = { insert: false, - replaceValue: "T3ST", + replaceValue: "T3ST1", }; await supertest(app) @@ -59,7 +59,7 @@ it("returns an error if authorization headers are not set", async () => { it("returns an error if the user does not have the correct role", async () => { const validBody = { insert: false, - replaceValue: "T3ST", + replaceValue: "T3ST1", }; await supertest(app) @@ -80,16 +80,15 @@ it("returns an error if required replacement characters are not provided in the .set(auth) .expect(400) .then((res) => { - expect(res.body).toEqual({ - error: "Missing required values to proceed", - }); + expect(res.body).toHaveProperty("issues"); + expect(res.body).toHaveProperty("name", "ZodError"); }); }); it("returns copied unique flow data without inserting a new record", async () => { const body = { insert: false, - replaceValue: "T3ST", + replaceValue: "T3ST1", }; await supertest(app) @@ -105,7 +104,7 @@ it("returns copied unique flow data without inserting a new record", async () => it("inserts copied unique flow data", async () => { const body = { insert: true, - replaceValue: "T3ST", + replaceValue: "T3ST1", }; await supertest(app) @@ -154,28 +153,28 @@ const mockFlowData: Flow["data"] = { // the copied flow data with unique nodeIds using the replaceValue const mockCopiedFlowData: Flow["data"] = { _root: { - edges: ["rUilJQT3ST", "kNX8ReT3ST"], + edges: ["rUilJT3ST1", "kNX8RT3ST1"], }, - rUilJQT3ST: { + rUilJT3ST1: { type: 100, data: { text: "Copy or paste?", }, - edges: ["Yh7t91T3ST", "h8DSw4T3ST"], + edges: ["Yh7t9T3ST1", "h8DSwT3ST1"], }, - Yh7t91T3ST: { + Yh7t9T3ST1: { type: 200, data: { text: "Copy", }, }, - h8DSw4T3ST: { + h8DSwT3ST1: { type: 200, data: { text: "Paste", }, }, - kNX8ReT3ST: { + kNX8RT3ST1: { type: 110, data: { title: "Why do you want to copy this flow?", @@ -187,13 +186,13 @@ const mockCopiedFlowData: Flow["data"] = { const mockCopyFlowResponse = { message: `Successfully copied undefined`, // 'undefined' just reflects that we haven't mocked a flow.name here! inserted: false, - replaceValue: "T3ST", + replaceValue: "T3ST1", data: mockCopiedFlowData, }; const mockCopyFlowResponseInserted = { message: `Successfully copied undefined`, inserted: true, - replaceValue: "T3ST", + replaceValue: "T3ST1", data: mockCopiedFlowData, }; diff --git a/api.planx.uk/modules/flows/copyFlow/service.ts b/api.planx.uk/modules/flows/copyFlow/service.ts new file mode 100644 index 0000000000..d79707388a --- /dev/null +++ b/api.planx.uk/modules/flows/copyFlow/service.ts @@ -0,0 +1,35 @@ +import { makeUniqueFlow, getFlowData, insertFlow } from "../../../helpers"; +import { Flow } from "../../../types"; +import { userContext } from "../../auth/middleware"; + +const copyFlow = async ( + flowId: string, + replaceValue: string, + insert: boolean, +) => { + // Fetch the original flow + const flow: Flow = await getFlowData(flowId); + + // Generate new flow data which is an exact "content" copy of the original but with unique nodeIds + const uniqueFlowData = makeUniqueFlow(flow.data, replaceValue); + + // Check if copied flow data should be inserted into `flows` table, or just returned for reference + if (insert) { + const newSlug = flow.slug + "-copy"; + const creatorId = userContext.getStore()?.user?.sub; + if (!creatorId) throw Error("User details missing from request"); + + // Insert the flow and an associated operation + await insertFlow( + flow.team_id, + newSlug, + uniqueFlowData, + parseInt(creatorId), + flowId, + ); + } + + return { flow, uniqueFlowData }; +}; + +export { copyFlow }; diff --git a/api.planx.uk/modules/flows/copyFlowAsPortal/controller.ts b/api.planx.uk/modules/flows/copyFlowAsPortal/controller.ts new file mode 100644 index 0000000000..be3892ca8f --- /dev/null +++ b/api.planx.uk/modules/flows/copyFlowAsPortal/controller.ts @@ -0,0 +1,44 @@ +import { z } from "zod"; +import { Flow } from "../../../types"; +import { ValidatedRequestHandler } from "../../../shared/middleware/validate"; +import { copyPortalAsFlow } from "./service"; +import { ServerError } from "../../../errors"; + +interface CopyFlowAsPortalResponse { + message: string; + data: Flow["data"]; +} + +export const copyFlowAsPortalSchema = z.object({ + params: z.object({ + flowId: z.string(), + portalNodeId: z.string(), + }), +}); + +export type CopyFlowAsPortalController = ValidatedRequestHandler< + typeof copyFlowAsPortalSchema, + CopyFlowAsPortalResponse +>; + +const copyPortalAsFlowController: CopyFlowAsPortalController = async ( + _req, + res, + next, +) => { + try { + const { flowId, portalNodeId } = res.locals.parsedReq.params; + const { flow, portalData } = await copyPortalAsFlow(flowId, portalNodeId); + + res.status(200).send({ + message: `Successfully copied internal portal: ${flow.data[portalNodeId]?.data?.text}`, + data: portalData, + }); + } catch (error) { + return next( + new ServerError({ message: `Failed to copy flow as portal: ${error}` }), + ); + } +}; + +export { copyPortalAsFlowController }; diff --git a/api.planx.uk/editor/copyPortalAsFlow.test.ts b/api.planx.uk/modules/flows/copyFlowAsPortal/copyPortalAsFlow.test.ts similarity index 88% rename from api.planx.uk/editor/copyPortalAsFlow.test.ts rename to api.planx.uk/modules/flows/copyFlowAsPortal/copyPortalAsFlow.test.ts index a13fe8baa5..7d4868aba4 100644 --- a/api.planx.uk/editor/copyPortalAsFlow.test.ts +++ b/api.planx.uk/modules/flows/copyFlowAsPortal/copyPortalAsFlow.test.ts @@ -1,9 +1,9 @@ import supertest from "supertest"; -import { queryMock } from "../tests/graphqlQueryMock"; -import { authHeader } from "../tests/mockJWT"; -import app from "../server"; -import { Flow } from "../types"; +import { queryMock } from "../../../tests/graphqlQueryMock"; +import { authHeader } from "../../../tests/mockJWT"; +import app from "../../../server"; +import { Flow } from "../../../types"; beforeEach(() => { queryMock.mockQuery({ @@ -18,31 +18,30 @@ beforeEach(() => { }); it("requires a user to be logged in", async () => { - await supertest(app).get("/flows/1/copy-portal/eyOm0NyDSl").expect(401); + await supertest(app).put("/flows/1/copy-portal/eyOm0NyDSl").expect(401); }); it("requires a user to have the 'platformAdmin' role", async () => { await supertest(app) - .get("/flows/1/copy-portal/eyOm0NyDSl") + .put("/flows/1/copy-portal/eyOm0NyDSl") .set(authHeader({ role: "teamEditor" })) .expect(403); }); it("throws an error if the portalNodeId parameter is not a portal (type = 300)", async () => { await supertest(app) - .get("/flows/1/copy-portal/eyOm0NyDSl") + .put("/flows/1/copy-portal/eyOm0NyDSl") .set(authHeader({ role: "platformAdmin" })) - .expect(404) + .expect(500) .then((res) => { - expect(res.body).toEqual({ - error: "Unknown portalNodeId", - }); + expect(res.body.error).toMatch(/Failed to copy flow as portal/); + expect(res.body.error).toMatch(/Unknown portalNodeId/); }); }); it("returns transformed, unique flow data for a valid internal portal", async () => { await supertest(app) - .get("/flows/1/copy-portal/MgCe3pSTrt") + .put("/flows/1/copy-portal/MgCe3pSTrt") .set(authHeader({ role: "platformAdmin" })) .expect(200) .then((res) => { diff --git a/api.planx.uk/modules/flows/copyFlowAsPortal/service.ts b/api.planx.uk/modules/flows/copyFlowAsPortal/service.ts new file mode 100644 index 0000000000..118c72bd63 --- /dev/null +++ b/api.planx.uk/modules/flows/copyFlowAsPortal/service.ts @@ -0,0 +1,42 @@ +import { getFlowData, getChildren, makeUniqueFlow } from "../../../helpers"; +import { Flow } from "../../../types"; + +/** + * Copies an internal portal and transforms it to be an independent flow + */ +const copyPortalAsFlow = async (flowId: string, portalNodeId: string) => { + // fetch the parent flow data + const flow = await getFlowData(flowId); + if (!flow) throw Error("Unknown flowId"); + + // confirm that the node id provided is a valid portal + if ( + !Object.keys(flow.data).includes(portalNodeId) || + flow.data[portalNodeId]?.type !== 300 + ) { + throw Error("Unknown portalNodeId"); + } + + // set the portal node as the new "_root", then extract all its' children from the parent flow and add them to the new flow data object + let portalData: Flow["data"] = { + _root: { edges: flow.data[portalNodeId]?.edges }, + }; + Object.entries(portalData).forEach(([_nodeId, node]) => { + portalData = getChildren(node, flow.data, portalData); + }); + + // to avoid the new flow nodes acting as clones of the original internal portal, rename + // the non-root node ids using the first three alphanumeric characters of the portal name + const replacementCharacters = flow.data[portalNodeId]?.data?.text + ?.replace(/\W/g, "") + ?.slice(0, 3); + portalData = makeUniqueFlow(portalData, replacementCharacters); + + // FUTURE: + // - change GET to POST and write portalData directly to a new flow? + // - assume same team as parent flow and use name of internal portal as slug, or pass in body? + // - update the parent flow to remove the original internal portal and reference this new flow as an external portal? + return { flow, portalData }; +}; + +export { copyPortalAsFlow }; diff --git a/api.planx.uk/modules/flows/docs.yaml b/api.planx.uk/modules/flows/docs.yaml new file mode 100644 index 0000000000..76d25589ce --- /dev/null +++ b/api.planx.uk/modules/flows/docs.yaml @@ -0,0 +1,260 @@ +openapi: 3.1.0 +info: + title: Planâś• API + version: 0.1.0 +tags: + name: flows + description: Flow associated requests +components: + parameters: + flowId: + in: path + name: flowId + type: string + required: true + teamId: + in: path + name: teamId + type: string + required: true + portalNodeId: + in: path + name: portalNodeId + type: string + required: true + schemas: + Node: + type: object + properties: + id: string + type: number + data: object + edges: + type: array + items: + type: string + CopyFlow: + type: object + properties: + replaceValue: + type: string + example: ab123 + length: 5 + description: When copying a flow, we make nodeIds unique by replacing part of the original nodeId string + required: true + insert: + type: boolean + description: Operator to indicate if the copied flow should be inserted to the database, or simple returned in the response body + FlowData: + type: object + additionalProperties: true + properties: + _root: + type: object + properties: + edges: + type: array + items: + type: string + responses: + CopyFlow: + content: + application/json: + schema: + type: object + properties: + message: + type: string + inserted: + type: boolean + replaceValue: + type: string + length: 5 + data: + $ref: "#/components/schemas/FlowData" + CopyFlowAsPortal: + content: + application/json: + schema: + type: object + properties: + message: + type: string + data: + $ref: "#/components/schemas/FlowData" + FindAndReplace: + content: + application/json: + schema: + type: object + properties: + message: + type: string + required: true + matches: + oneOf: + - $ref: "#/components/schemas/FlowData" + - type: "null" + updatedFlow: + $ref: "#/components/schemas/FlowData" + required: false + PublishFlow: + content: + application/json: + schema: + type: object + properties: + message: + type: string + required: true + alteredNodes: + oneOf: + - type: array + items: + $ref: "#/components/schemas/Node" + - type: "null" + updatedFlow: + $ref: "#/components/schemas/FlowData" + required: false + ValidateAndDiff: + content: + application/json: + schema: + type: object + properties: + message: + type: string + required: false + description: + type: string + required: false + alteredNodes: + oneOf: + - type: array + items: + $ref: "#/components/schemas/Node" + - type: "null" + updatedFlow: + $ref: "#/components/schemas/FlowData" + required: false +paths: + /flows/{flowId}/copy: + post: + summary: Copy a flow + tags: ["flows"] + security: + - bearerAuth: [] + parameters: + - $ref: "#/components/parameters/flowId" + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/CopyFlow" + responses: + "200": + $ref: "#/components/responses/CopyFlow" + "500": + $ref: "#/components/responses/ErrorMessage" + /flows/{flowId}/copy-portal/{portalNodeId}: + put: + summary: Create a new flow from a portal + description: Copies an internal portal and transforms it to be an independent flow + tags: ["flows"] + security: + - bearerAuth: [] + parameters: + - $ref: "#/components/parameters/flowId" + - $ref: "#/components/parameters/portalNodeId" + responses: + "200": + $ref: "#/components/responses/CopyFlowAsPortal" + "500": + $ref: "#/components/responses/ErrorMessage" + /flows/{flowId}/search: + post: + summary: Find and replace + description: Find and replace a data variable in a flow + tags: ["flows"] + security: + - bearerAuth: [] + parameters: + - $ref: "#/components/parameters/flowId" + - in: query + name: find + type: string + required: true + - in: query + name: replace + type: string + required: false + responses: + "200": + $ref: "#/components/responses/FindAndReplace" + "500": + $ref: "#/components/responses/ErrorMessage" + /flows/{flowId}/move/{teamSlug}: + post: + summary: Move a flow + description: Move ownership of a flow from one team to another + tags: ["flows"] + security: + - bearerAuth: [] + parameters: + - $ref: "#/components/parameters/flowId" + - $ref: "#/components/parameters/teamId" + responses: + "200": + $ref: "#/components/responses/SuccessMessage" + "500": + $ref: "#/components/responses/ErrorMessage" + /flows/{flowId}/publish: + post: + summary: Publish a flow + tags: ["flows"] + security: + - bearerAuth: [] + parameters: + - $ref: "#/components/parameters/flowId" + - in: query + name: summary + type: text + required: false + description: Optional text to summarise the published changes + responses: + "200": + $ref: "#/components/responses/PublishFlow" + "500": + $ref: "#/components/responses/ErrorMessage" + /flows/{flowId}/diff: + post: + summary: Diff and validate a flow + description: Validate and view the diff between the current unpublished version of a flow and the most recently published version + tags: ["flows"] + security: + - bearerAuth: [] + parameters: + - $ref: "#/components/parameters/flowId" + responses: + "200": + $ref: "#/components/responses/ValidateAndDiff" + "500": + $ref: "#/components/responses/ErrorMessage" + /flows/{flowId}/download-schema: + post: + summary: Download flow schema + description: Download a CSV file representing the flow's schema + tags: ["flows"] + security: + - bearerAuth: [] + parameters: + - $ref: "#/components/parameters/flowId" + responses: + "200": + content: + text/csv: + schema: + type: string + "500": + $ref: "#/components/responses/ErrorMessage" diff --git a/api.planx.uk/modules/flows/downloadSchema/controller.ts b/api.planx.uk/modules/flows/downloadSchema/controller.ts new file mode 100644 index 0000000000..95113bacc4 --- /dev/null +++ b/api.planx.uk/modules/flows/downloadSchema/controller.ts @@ -0,0 +1,41 @@ +import { z } from "zod"; +import { ValidatedRequestHandler } from "../../../shared/middleware/validate"; +import { stringify } from "csv-stringify"; +import { getFlowSchema } from "./service"; +import { ServerError } from "../../../errors"; + +interface DownloadFlowSchemaResponse { + message: string; + alteredNodes: Node[] | null; + description?: string; +} + +export const downloadFlowSchema = z.object({ + params: z.object({ + flowId: z.string(), + }), +}); + +export type DownloadFlowSchemaController = ValidatedRequestHandler< + typeof downloadFlowSchema, + DownloadFlowSchemaResponse +>; + +export const downloadFlowSchemaController: DownloadFlowSchemaController = + async (_res, res, next) => { + try { + const { flowId } = res.locals.parsedReq.params; + const flowSchema = await getFlowSchema(flowId); + + // Build a CSV and stream it + stringify(flowSchema, { header: true }).pipe(res); + res.header("Content-type", "text/csv"); + res.attachment(`${flowId}.csv`); + } catch (error) { + return next( + new ServerError({ + message: `Failed to download flow schema: ${error}`, + }), + ); + } + }; diff --git a/api.planx.uk/modules/flows/downloadSchema/service.ts b/api.planx.uk/modules/flows/downloadSchema/service.ts new file mode 100644 index 0000000000..8c5ae5211c --- /dev/null +++ b/api.planx.uk/modules/flows/downloadSchema/service.ts @@ -0,0 +1,35 @@ +import { $public } from "../../../client"; +import { gql } from "graphql-request"; + +interface FlowSchema { + node: string; + type: string; + text: string; + planx_variable: string; +} + +export const getFlowSchema = async (flowId: string) => { + const { flowSchema } = await $public.client.request<{ + flowSchema: FlowSchema[]; + }>( + gql` + query ($flow_id: String!) { + flowSchema: get_flow_schema(args: { published_flow_id: $flow_id }) { + node + type + text + planx_variable + } + } + `, + { flow_id: flowId }, + ); + + if (!flowSchema.length) { + throw Error( + "Can't find a schema for this flow. Make sure it's published or try a different flow id.", + ); + } + + return flowSchema; +}; diff --git a/api.planx.uk/modules/flows/findReplace/controller.ts b/api.planx.uk/modules/flows/findReplace/controller.ts new file mode 100644 index 0000000000..c64387dc64 --- /dev/null +++ b/api.planx.uk/modules/flows/findReplace/controller.ts @@ -0,0 +1,51 @@ +import { Flow } from "../../../types"; +import { ValidatedRequestHandler } from "../../../shared/middleware/validate"; +import { z } from "zod"; +import { ServerError } from "../../../errors"; +import { findAndReplaceInFlow } from "./service"; +import { FlowGraph } from "@opensystemslab/planx-core/types"; + +interface FindAndReplaceResponse { + message: string; + matches: Flow["data"] | null; + updatedFlow?: FlowGraph; +} + +export const findAndReplaceSchema = z.object({ + params: z.object({ + flowId: z.string(), + }), + query: z.object({ + find: z.string(), + replace: z.string().optional(), + }), +}); + +export type FindAndReplaceController = ValidatedRequestHandler< + typeof findAndReplaceSchema, + FindAndReplaceResponse +>; + +const findAndReplaceController: FindAndReplaceController = async ( + _req, + res, + next, +) => { + try { + const { flowId } = res.locals.parsedReq.params; + const { find, replace } = res.locals.parsedReq.query; + const { matches, updatedFlow, message } = await findAndReplaceInFlow( + flowId, + find, + replace, + ); + + res.json({ message, matches, updatedFlow }); + } catch (error) { + return next( + new ServerError({ message: `Failed to find and replace: ${error}` }), + ); + } +}; + +export { findAndReplaceController }; diff --git a/api.planx.uk/editor/findReplace.test.ts b/api.planx.uk/modules/flows/findReplace/findReplace.test.ts similarity index 93% rename from api.planx.uk/editor/findReplace.test.ts rename to api.planx.uk/modules/flows/findReplace/findReplace.test.ts index f1ded631ee..85202976d6 100644 --- a/api.planx.uk/editor/findReplace.test.ts +++ b/api.planx.uk/modules/flows/findReplace/findReplace.test.ts @@ -1,9 +1,9 @@ import supertest from "supertest"; -import { queryMock } from "../tests/graphqlQueryMock"; -import { authHeader } from "../tests/mockJWT"; -import app from "../server"; -import { Flow } from "../types"; +import { queryMock } from "../../../tests/graphqlQueryMock"; +import { authHeader } from "../../../tests/mockJWT"; +import app from "../../../server"; +import { Flow } from "../../../types"; beforeEach(() => { queryMock.mockQuery({ @@ -46,11 +46,10 @@ it("throws an error if missing query parameter `find`", async () => { await supertest(app) .post("/flows/1/search") .set(auth) - .expect(401) + .expect(400) .then((res) => { - expect(res.body).toEqual({ - error: `Expected at least one query parameter "find"`, - }); + expect(res.body).toHaveProperty("issues"); + expect(res.body).toHaveProperty("name", "ZodError"); }); }); @@ -86,6 +85,7 @@ it("does not replace if no matches are found", async () => { .then((res) => { expect(res.body).toEqual({ message: `Didn't find "bananas" in this flow, nothing to replace`, + matches: null, }); }); }); diff --git a/api.planx.uk/modules/flows/findReplace/service.ts b/api.planx.uk/modules/flows/findReplace/service.ts new file mode 100644 index 0000000000..471b2718d7 --- /dev/null +++ b/api.planx.uk/modules/flows/findReplace/service.ts @@ -0,0 +1,116 @@ +import { gql } from "graphql-request"; +import { getFlowData } from "../../../helpers"; +import { getClient } from "../../../client"; +import { FlowGraph } from "@opensystemslab/planx-core/types"; +import { Flow } from "../../../types"; + +interface MatchResult { + matches: Flow["data"]; + flowData: Flow["data"]; +} + +/** + * Find and return the node ids and specific data properties that match a given search term, + * and return an updated copy of the flow data if a replaceValue is provided, else return the original flowData + */ +const getMatches = ( + flowData: Flow["data"], + searchTerm: string, + replaceValue: string | undefined = undefined, +): MatchResult => { + const matches: MatchResult["matches"] = {}; + + const nodes = Object.keys(flowData).filter((key) => key !== "_root"); + nodes.forEach((node) => { + const data = flowData[node]["data"]; + if (data) { + // search all "data" properties independent of component type (eg `fn`, `val`, `text`) + const keys = Object.keys(data); + keys.forEach((k) => { + // if any value strictly matches the searchTerm, add that node id & key to the matches object + if (data[k] === searchTerm) { + matches[node] = { + data: { + [k]: data[k], + }, + }; + // if a replaceValue is provided, additionally update the flowData + if (replaceValue) { + data[k] = replaceValue; + } + } + }); + } + }); + + return { + matches: matches, + flowData: flowData, + }; +}; + +interface UpdateFlow { + flow: { + id: string; + slug: string; + data: FlowGraph; + updatedAt: string; + }; +} + +const findAndReplaceInFlow = async ( + flowId: string, + find: string, + replace?: string, +) => { + const flow = await getFlowData(flowId); + if (!flow) throw Error("Unknown flowId"); + + // Find + if (!replace) { + const { matches } = getMatches(flow.data, find); + const message = `Found ${ + Object.keys(matches).length + } matches of "${find}" in this flow`; + return { matches, message }; + } + + // Find & Replace + const { matches, flowData } = getMatches(flow.data, find, replace); + + if (Object.keys(matches).length === 0) { + const message = `Didn't find "${find}" in this flow, nothing to replace`; + return { matches: null, message }; + } + + // if matches, proceed with mutation to update flow data + const { client: $client } = getClient(); + const response = await $client.request( + gql` + mutation UpdateFlow($data: jsonb = {}, $id: uuid!) { + flow: update_flows_by_pk( + pk_columns: { id: $id } + _set: { data: $data } + ) { + id + slug + data + updatedAt: updated_at + } + } + `, + { + data: flowData, + id: flowId, + }, + ); + + const updatedFlow = response.flow && response.flow.data; + const message = `Found ${ + Object.keys(matches).length + } matches of "${find}" and replaced with "${replace}"`; + + return { matches, message, updatedFlow }; +}; + +export { findAndReplaceInFlow }; diff --git a/api.planx.uk/modules/flows/moveFlow/controller.ts b/api.planx.uk/modules/flows/moveFlow/controller.ts new file mode 100644 index 0000000000..319ac0c1ca --- /dev/null +++ b/api.planx.uk/modules/flows/moveFlow/controller.ts @@ -0,0 +1,37 @@ +import { ValidatedRequestHandler } from "../../../shared/middleware/validate"; +import { z } from "zod"; +import { ServerError } from "../../../errors"; +import { moveFlow } from "./service"; + +interface MoveFlowResponse { + message: string; +} + +export const moveFlowSchema = z.object({ + params: z.object({ + flowId: z.string(), + teamSlug: z.string(), + }), +}); + +export type MoveFlowController = ValidatedRequestHandler< + typeof moveFlowSchema, + MoveFlowResponse +>; + +export const moveFlowController: MoveFlowController = async ( + _req, + res, + next, +) => { + try { + const { flowId, teamSlug } = res.locals.parsedReq.params; + await moveFlow(flowId, teamSlug); + + res.status(200).send({ + message: `Successfully moved flow to ${teamSlug}`, + }); + } catch (error) { + return next(new ServerError({ message: `Failed to move flow: ${error}` })); + } +}; diff --git a/api.planx.uk/editor/moveFlow.test.ts b/api.planx.uk/modules/flows/moveFlow/moveFlow.test.ts similarity index 87% rename from api.planx.uk/editor/moveFlow.test.ts rename to api.planx.uk/modules/flows/moveFlow/moveFlow.test.ts index f8d377777e..f7a95196d0 100644 --- a/api.planx.uk/editor/moveFlow.test.ts +++ b/api.planx.uk/modules/flows/moveFlow/moveFlow.test.ts @@ -1,12 +1,12 @@ import supertest from "supertest"; -import { queryMock } from "../tests/graphqlQueryMock"; -import { authHeader } from "../tests/mockJWT"; -import app from "../server"; +import { queryMock } from "../../../tests/graphqlQueryMock"; +import { authHeader } from "../../../tests/mockJWT"; +import app from "../../../server"; beforeEach(() => { queryMock.mockQuery({ - name: "GetTeam", + name: "GetTeamBySlug", variables: { slug: "new-team", }, diff --git a/api.planx.uk/modules/flows/moveFlow/service.ts b/api.planx.uk/modules/flows/moveFlow/service.ts new file mode 100644 index 0000000000..e77a811422 --- /dev/null +++ b/api.planx.uk/modules/flows/moveFlow/service.ts @@ -0,0 +1,42 @@ +import { gql } from "graphql-request"; +import { Flow, Team } from "../../../types"; +import { $public, getClient } from "../../../client"; + +export const moveFlow = async (flowId: string, teamSlug: string) => { + const team = await $public.team.getBySlug(teamSlug); + if (!team) + throw Error( + `Unable to find a team matching slug ${teamSlug}, exiting move`, + ); + + await updateFlow(flowId, team.id); +}; + +interface UpdateFlow { + flow: Pick; +} + +const updateFlow = async ( + flowId: Flow["id"], + teamId: Team["id"], +): Promise => { + const { client: $client } = getClient(); + const { flow } = await $client.request( + gql` + mutation UpdateFlow($id: uuid!, $team_id: Int!) { + flow: update_flows_by_pk( + pk_columns: { id: $id } + _set: { team_id: $team_id } + ) { + id + } + } + `, + { + id: flowId, + team_id: teamId, + }, + ); + + return flow.id; +}; diff --git a/api.planx.uk/modules/flows/publish/controller.ts b/api.planx.uk/modules/flows/publish/controller.ts new file mode 100644 index 0000000000..2eb95a8ef2 --- /dev/null +++ b/api.planx.uk/modules/flows/publish/controller.ts @@ -0,0 +1,45 @@ +import { Node } from "@opensystemslab/planx-core/types"; +import { ValidatedRequestHandler } from "../../../shared/middleware/validate"; +import { z } from "zod"; +import { publishFlow } from "./service"; +import { ServerError } from "../../../errors"; + +interface PublishFlowResponse { + message: string; + alteredNodes: Node[] | null; +} + +export const publishFlowSchema = z.object({ + params: z.object({ + flowId: z.string(), + }), + query: z.object({ + summary: z.string().optional(), + }), +}); + +export type PublishFlowController = ValidatedRequestHandler< + typeof publishFlowSchema, + PublishFlowResponse +>; + +export const publishFlowController: PublishFlowController = async ( + _req, + res, + next, +) => { + try { + const { flowId } = res.locals.parsedReq.params; + const { summary } = res.locals.parsedReq.query; + const alteredNodes = await publishFlow(flowId, summary); + + return res.json({ + alteredNodes, + message: alteredNodes ? "Changes published" : "No new changes to publish", + }); + } catch (error) { + return next( + new ServerError({ message: `Failed to publish flow: ${error}` }), + ); + } +}; diff --git a/api.planx.uk/modules/flows/publish/publish.test.ts b/api.planx.uk/modules/flows/publish/publish.test.ts new file mode 100644 index 0000000000..3f1f5173c5 --- /dev/null +++ b/api.planx.uk/modules/flows/publish/publish.test.ts @@ -0,0 +1,156 @@ +import supertest from "supertest"; + +import { queryMock } from "../../../tests/graphqlQueryMock"; +import { authHeader, getJWT } from "../../../tests/mockJWT"; +import app from "../../../server"; +import { userContext } from "../../auth/middleware"; +import { mockFlowData } from "../../../tests/mocks/validateAndPublishMocks"; + +beforeAll(() => { + const getStoreMock = jest.spyOn(userContext, "getStore"); + getStoreMock.mockReturnValue({ + user: { + sub: "123", + jwt: getJWT({ role: "teamEditor" }), + }, + }); +}); + +beforeEach(() => { + queryMock.mockQuery({ + name: "GetFlowData", + matchOnVariables: false, + data: { + flow: { + data: mockFlowData, + }, + }, + }); + + queryMock.mockQuery({ + name: "GetMostRecentPublishedFlow", + matchOnVariables: false, + data: { + flow: { + publishedFlows: [ + { + data: mockFlowData, + }, + ], + }, + }, + }); + + queryMock.mockQuery({ + name: "PublishFlow", + matchOnVariables: false, + data: { + publishedFlow: { + data: mockFlowData, + }, + }, + }); +}); + +const auth = authHeader({ role: "platformAdmin" }); + +it("requires a user to be logged in", async () => { + await supertest(app).post("/flows/1/publish").expect(401); +}); + +it("requires a user to have the 'teamEditor' role", async () => { + await supertest(app) + .post("/flows/1/publish") + .set(authHeader({ role: "teamViewer" })) + .expect(403); +}); + +describe("publish", () => { + it("publishes for the first time", async () => { + queryMock.mockQuery({ + name: "GetMostRecentPublishedFlow", + matchOnVariables: false, + data: { + flow: { + publishedFlows: [], + }, + }, + }); + + await supertest(app).post("/flows/1/publish").set(auth).expect(200); + }); + + it("does not update if there are no new changes", async () => { + await supertest(app) + .post("/flows/1/publish") + .set(auth) + .expect(200) + .then((res) => { + expect(res.body).toEqual({ + alteredNodes: null, + message: "No new changes to publish", + }); + }); + }); + + it("updates published flow and returns altered nodes if there have been changes", async () => { + const alteredFlow = { + ...mockFlowData, + ResultNode: { + data: { + flagSet: "Planning permission", + overrides: { + NO_APP_REQUIRED: { + heading: "Some Other Heading", + }, + }, + }, + type: 3, + }, + }; + + queryMock.mockQuery({ + name: "GetFlowData", + matchOnVariables: false, + data: { + flow: { + data: alteredFlow, + }, + }, + }); + + queryMock.mockQuery({ + name: "PublishFlow", + matchOnVariables: false, + data: { + publishedFlow: { + data: alteredFlow, + }, + }, + }); + + await supertest(app) + .post("/flows/1/publish") + .set(auth) + .expect(200) + .then((res) => { + expect(res.body).toEqual({ + message: "Changes published", + alteredNodes: [ + { + id: "ResultNode", + type: 3, + data: { + flagSet: "Planning permission", + overrides: { + NO_APP_REQUIRED: { + heading: "Some Other Heading", + }, + }, + }, + }, + ], + }); + }); + }); +}); diff --git a/api.planx.uk/modules/flows/publish/service.ts b/api.planx.uk/modules/flows/publish/service.ts new file mode 100644 index 0000000000..ee7d03da74 --- /dev/null +++ b/api.planx.uk/modules/flows/publish/service.ts @@ -0,0 +1,69 @@ +import * as jsondiffpatch from "jsondiffpatch"; +import { dataMerged, getMostRecentPublishedFlow } from "../../../helpers"; +import { gql } from "graphql-request"; +import { FlowGraph, Node } from "@opensystemslab/planx-core/types"; +import { userContext } from "../../auth/middleware"; +import { getClient } from "../../../client"; + +interface PublishFlow { + publishedFlow: { + id: string; + flowId: string; + publisherId: string; + createdAt: string; + data: FlowGraph; + }; +} + +export const publishFlow = async (flowId: string, summary?: string) => { + const userId = userContext.getStore()?.user?.sub; + if (!userId) throw Error("User details missing from request"); + + const flattenedFlow = await dataMerged(flowId); + const mostRecent = await getMostRecentPublishedFlow(flowId); + const delta = jsondiffpatch.diff(mostRecent, flattenedFlow); + + if (!delta) return null; + + const { client: $client } = getClient(); + const response = await $client.request( + gql` + mutation PublishFlow( + $data: jsonb = {} + $flow_id: uuid + $publisher_id: Int + $summary: String + ) { + publishedFlow: insert_published_flows_one( + object: { + data: $data + flow_id: $flow_id + publisher_id: $publisher_id + summary: $summary + } + ) { + id + flowId: flow_id + publisherId: publisher_id + createdAt: created_at + data + } + } + `, + { + data: flattenedFlow, + flow_id: flowId, + publisher_id: parseInt(userId), + summary: summary ?? null, + }, + ); + + const publishedFlow = response.publishedFlow && response.publishedFlow.data; + + const alteredNodes: Node[] = Object.keys(delta).map((key) => ({ + id: key, + ...publishedFlow[key], + })); + + return alteredNodes; +}; diff --git a/api.planx.uk/modules/flows/routes.ts b/api.planx.uk/modules/flows/routes.ts new file mode 100644 index 0000000000..d74ad9422c --- /dev/null +++ b/api.planx.uk/modules/flows/routes.ts @@ -0,0 +1,74 @@ +import { Router } from "express"; +import { usePlatformAdminAuth, useTeamEditorAuth } from "../auth/middleware"; +import { publishFlowController } from "./publish/controller"; +import { copyFlowController, copyFlowSchema } from "./copyFlow/controller"; +import { validate } from "../../shared/middleware/validate"; +import { + copyFlowAsPortalSchema, + copyPortalAsFlowController, +} from "./copyFlowAsPortal/controller"; +import { + findAndReplaceController, + findAndReplaceSchema, +} from "./findReplace/controller"; +import { moveFlowController, moveFlowSchema } from "./moveFlow/controller"; +import { + validateAndDiffFlowController, + validateAndDiffSchema, +} from "./validate/controller"; +import { publishFlowSchema } from "./publish/controller"; +import { + downloadFlowSchema, + downloadFlowSchemaController, +} from "./downloadSchema/controller"; +const router = Router(); + +router.post( + "/:flowId/copy", + useTeamEditorAuth, + validate(copyFlowSchema), + copyFlowController, +); + +router.post( + "/:flowId/search", + usePlatformAdminAuth, + validate(findAndReplaceSchema), + findAndReplaceController, +); + +router.put( + "/:flowId/copy-portal/:portalNodeId", + usePlatformAdminAuth, + validate(copyFlowAsPortalSchema), + copyPortalAsFlowController, +); + +router.post( + "/:flowId/move/:teamSlug", + useTeamEditorAuth, + validate(moveFlowSchema), + moveFlowController, +); + +router.post( + "/:flowId/publish", + useTeamEditorAuth, + validate(publishFlowSchema), + publishFlowController, +); + +router.post( + "/:flowId/diff", + useTeamEditorAuth, + validate(validateAndDiffSchema), + validateAndDiffFlowController, +); + +router.get( + "/:flowId/download-schema", + validate(downloadFlowSchema), + downloadFlowSchemaController, +); + +export default router; diff --git a/api.planx.uk/modules/flows/validate/controller.ts b/api.planx.uk/modules/flows/validate/controller.ts new file mode 100644 index 0000000000..5020a798a5 --- /dev/null +++ b/api.planx.uk/modules/flows/validate/controller.ts @@ -0,0 +1,37 @@ +import { Node } from "@opensystemslab/planx-core/types"; +import { ValidatedRequestHandler } from "../../../shared/middleware/validate"; +import { z } from "zod"; +import { validateAndDiffFlow } from "./service"; +import { ServerError } from "../../../errors"; + +interface ValidateAndDiffResponse { + message: string; + alteredNodes: Node[] | null; + description?: string; +} + +export const validateAndDiffSchema = z.object({ + params: z.object({ + flowId: z.string(), + }), +}); + +export type ValidateAndDiffFlowController = ValidatedRequestHandler< + typeof validateAndDiffSchema, + ValidateAndDiffResponse +>; + +export const validateAndDiffFlowController: ValidateAndDiffFlowController = + async (_req, res, next) => { + try { + const { flowId } = res.locals.parsedReq.params; + const result = await validateAndDiffFlow(flowId); + return res.json(result); + } catch (error) { + return next( + new ServerError({ + message: `Failed to validate and diff flow: ${error}`, + }), + ); + } + }; diff --git a/api.planx.uk/editor/publish.ts b/api.planx.uk/modules/flows/validate/service.ts similarity index 59% rename from api.planx.uk/editor/publish.ts rename to api.planx.uk/modules/flows/validate/service.ts index ca4d8976f6..132725e59e 100644 --- a/api.planx.uk/editor/publish.ts +++ b/api.planx.uk/modules/flows/validate/service.ts @@ -1,151 +1,60 @@ import * as jsondiffpatch from "jsondiffpatch"; -import { Request, Response, NextFunction } from "express"; -import { dataMerged, getMostRecentPublishedFlow } from "../helpers"; -import { gql } from "graphql-request"; +import { dataMerged, getMostRecentPublishedFlow } from "../../../helpers"; import intersection from "lodash/intersection"; import { ComponentType, FlowGraph, Node, } from "@opensystemslab/planx-core/types"; -import { userContext } from "../modules/auth/middleware"; import type { Entry } from "type-fest"; -import { getClient } from "../client"; -const validateAndDiffFlow = async ( - req: Request, - res: Response, - next: NextFunction, -): Promise => { - try { - const flattenedFlow = await dataMerged(req.params.flowId); - - const { - isValid: sectionsAreValid, +const validateAndDiffFlow = async (flowId: string) => { + const flattenedFlow = await dataMerged(flowId); + + const { + isValid: sectionsAreValid, + message: sectionsValidationMessage, + description: sectionsValidationDescription, + } = validateSections(flattenedFlow); + if (!sectionsAreValid) { + return { + alteredNodes: null, message: sectionsValidationMessage, description: sectionsValidationDescription, - } = validateSections(flattenedFlow); - if (!sectionsAreValid) { - return res.json({ - alteredNodes: null, - message: sectionsValidationMessage, - description: sectionsValidationDescription, - }); - } + }; + } - const { - isValid: payIsValid, + const { + isValid: payIsValid, + message: payValidationMessage, + description: payValidationDescription, + } = validateInviteToPay(flattenedFlow); + if (!payIsValid) { + return { + alteredNodes: null, message: payValidationMessage, description: payValidationDescription, - } = validateInviteToPay(flattenedFlow); - if (!payIsValid) { - return res.json({ - alteredNodes: null, - message: payValidationMessage, - description: payValidationDescription, - }); - } - - const mostRecent = await getMostRecentPublishedFlow(req.params.flowId); - const delta = jsondiffpatch.diff(mostRecent, flattenedFlow); - - if (delta) { - const alteredNodes = Object.keys(delta).map((key) => ({ - id: key, - ...flattenedFlow[key], - })); - - return res.json({ - alteredNodes, - }); - } else { - return res.json({ - alteredNodes: null, - message: "No new changes to publish", - }); - } - } catch (error) { - return next(error); + }; } -}; -interface PublishFlow { - publishedFlow: { - id: string; - flowId: string; - publisherId: string; - createdAt: string; - data: FlowGraph; - }; -} - -const publishFlow = async ( - req: Request, - res: Response, - next: NextFunction, -): Promise => { - try { - const flattenedFlow = await dataMerged(req.params.flowId); - const mostRecent = await getMostRecentPublishedFlow(req.params.flowId); - const delta = jsondiffpatch.diff(mostRecent, flattenedFlow); - - const userId = userContext.getStore()?.user?.sub; - if (!userId) throw Error("User details missing from request"); - - if (delta) { - const { client: $client } = getClient(); - const response = await $client.request( - gql` - mutation PublishFlow( - $data: jsonb = {} - $flow_id: uuid - $publisher_id: Int - $summary: String - ) { - publishedFlow: insert_published_flows_one( - object: { - data: $data - flow_id: $flow_id - publisher_id: $publisher_id - summary: $summary - } - ) { - id - flowId: flow_id - publisherId: publisher_id - createdAt: created_at - data - } - } - `, - { - data: flattenedFlow, - flow_id: req.params.flowId, - publisher_id: parseInt(userId), - summary: req.query?.summary || null, - }, - ); + const mostRecent = await getMostRecentPublishedFlow(flowId); + const delta = jsondiffpatch.diff(mostRecent, flattenedFlow); - const publishedFlow = - response.publishedFlow && response.publishedFlow.data; + if (!delta) + return { + alteredNodes: null, + message: "No new changes to publish", + }; - const alteredNodes = Object.keys(delta).map((key) => ({ - id: key, - ...publishedFlow[key], - })); + const alteredNodes = Object.keys(delta).map((key) => ({ + id: key, + ...flattenedFlow[key], + })); - return res.json({ - alteredNodes, - }); - } else { - return res.json({ - alteredNodes: null, - message: "No new changes to publish", - }); - } - } catch (error) { - return next(error); - } + return { + alteredNodes, + message: "Changes valid", + }; }; type ValidationResponse = { @@ -320,4 +229,4 @@ const numberOfComponentType = ( return nodeIds?.length; }; -export { validateAndDiffFlow, publishFlow }; +export { validateAndDiffFlow }; diff --git a/api.planx.uk/editor/publish.test.ts b/api.planx.uk/modules/flows/validate/validate.test.ts similarity index 59% rename from api.planx.uk/editor/publish.test.ts rename to api.planx.uk/modules/flows/validate/validate.test.ts index 58a9bb4033..41519f2925 100644 --- a/api.planx.uk/editor/publish.test.ts +++ b/api.planx.uk/modules/flows/validate/validate.test.ts @@ -1,11 +1,12 @@ import supertest from "supertest"; -import { queryMock } from "../tests/graphqlQueryMock"; -import { authHeader, getJWT } from "../tests/mockJWT"; -import app from "../server"; -import { flowWithInviteToPay } from "../tests/mocks/inviteToPayData"; +import { queryMock } from "../../../tests/graphqlQueryMock"; +import { authHeader, getJWT } from "../../../tests/mockJWT"; +import app from "../../../server"; +import { flowWithInviteToPay } from "../../../tests/mocks/inviteToPayData"; +import { userContext } from "../../auth/middleware"; import { FlowGraph } from "@opensystemslab/planx-core/types"; -import { userContext } from "../modules/auth/middleware"; +import { mockFlowData } from "../../../tests/mocks/validateAndPublishMocks"; beforeAll(() => { const getStoreMock = jest.spyOn(userContext, "getStore"); @@ -56,105 +57,16 @@ beforeEach(() => { const auth = authHeader({ role: "platformAdmin" }); it("requires a user to be logged in", async () => { - await supertest(app).post("/flows/1/publish").expect(401); + await supertest(app).post("/flows/1/diff").expect(401); }); it("requires a user to have the 'teamEditor' role", async () => { await supertest(app) - .post("/flows/1/publish") + .post("/flows/1/diff") .set(authHeader({ role: "teamViewer" })) .expect(403); }); -describe("publish", () => { - it("publishes for the first time", async () => { - queryMock.mockQuery({ - name: "GetMostRecentPublishedFlow", - matchOnVariables: false, - data: { - flow: { - publishedFlows: [], - }, - }, - }); - - await supertest(app).post("/flows/1/publish").set(auth).expect(200); - }); - - it("does not update if there are no new changes", async () => { - await supertest(app) - .post("/flows/1/publish") - .set(auth) - .expect(200) - .then((res) => { - expect(res.body).toEqual({ - alteredNodes: null, - message: "No new changes to publish", - }); - }); - }); - - it("updates published flow and returns altered nodes if there have been changes", async () => { - const alteredFlow = { - ...mockFlowData, - ResultNode: { - data: { - flagSet: "Planning permission", - overrides: { - NO_APP_REQUIRED: { - heading: "Some Other Heading", - }, - }, - }, - type: 3, - }, - }; - - queryMock.mockQuery({ - name: "GetFlowData", - matchOnVariables: false, - data: { - flow: { - data: alteredFlow, - }, - }, - }); - - queryMock.mockQuery({ - name: "PublishFlow", - matchOnVariables: false, - data: { - publishedFlow: { - data: alteredFlow, - }, - }, - }); - - await supertest(app) - .post("/flows/1/publish") - .set(auth) - .expect(200) - .then((res) => { - expect(res.body).toEqual({ - alteredNodes: [ - { - id: "ResultNode", - type: 3, - data: { - flagSet: "Planning permission", - overrides: { - NO_APP_REQUIRED: { - heading: "Some Other Heading", - }, - }, - }, - }, - ], - }); - }); - }); -}); - describe("sections validation on diff", () => { it("does not update if there are sections in an external portal", async () => { const alteredFlow = { @@ -383,113 +295,3 @@ describe("invite to pay validation on diff", () => { }); }); }); - -const mockFlowData: FlowGraph = { - _root: { - edges: [ - "SectionOne", - "QuestionOne", - "InternalPortalNode", - "FindPropertyNode", - "PayNode", - "SendNode", - "ResultNode", - "ConfirmationNode", - ], - }, - SectionOne: { - type: 360, - data: { - title: "Section 1", - }, - }, - FindPropertyNode: { - type: 9, - }, - ResultNode: { - data: { - flagSet: "Planning permission", - overrides: { - NO_APP_REQUIRED: { - heading: "Congratulations!", - }, - }, - }, - type: 3, - }, - AnswerOne: { - data: { - text: "?", - }, - type: 200, - }, - QuestionInPortal: { - data: { - text: "internal question", - }, - type: 100, - edges: ["AnswerInPortalOne", "AnswerInPortalTwo"], - }, - AnswerTwo: { - data: { - text: "!!", - }, - type: 200, - }, - InternalPortalNode: { - data: { - text: "portal", - }, - type: 300, - edges: ["QuestionInPortal"], - }, - QuestionOne: { - data: { - text: "Question", - }, - type: 100, - edges: ["AnswerOne", "AnswerTwo"], - }, - PayNode: { - data: { - fn: "application.fee.payable", - url: "http://localhost:7002/pay", - color: "#EFEFEF", - title: "Pay for your application", - description: - '

The planning fee covers the cost of processing your application. Find out more about how planning fees are calculated here.

', - }, - type: 400, - }, - AnswerInPortalOne: { - data: { - text: "?", - }, - type: 200, - }, - AnswerInPortalTwo: { - data: { - text: "*", - }, - type: 200, - }, - ConfirmationNode: { - data: { - heading: "Application sent", - moreInfo: - "

You will be contacted

\n
    \n
  • if there is anything missing from the information you have provided so far
  • \n
  • if any additional information is required
  • \n
  • to arrange a site visit, if required
  • \n
  • to inform you whether a certificate has been granted or not
  • \n
\n", - contactInfo: - '

You can contact us at planning@lambeth.gov.uk

\n', - description: - "A payment receipt has been emailed to you. You will also receive an email to confirm when your application has been received.", - feedbackCTA: "What did you think of this service? (takes 30 seconds)", - }, - type: 725, - }, - SendNode: { - data: { - url: "http://localhost:7002/bops/southwark", - }, - type: 650, - }, -}; diff --git a/api.planx.uk/server.ts b/api.planx.uk/server.ts index 6bb5d70382..38cdf5ed54 100644 --- a/api.planx.uk/server.ts +++ b/api.planx.uk/server.ts @@ -14,9 +14,6 @@ import helmet from "helmet"; import { ServerError } from "./errors"; import { locationSearch } from "./gis/index"; -import { validateAndDiffFlow, publishFlow } from "./editor/publish"; -import { findAndReplaceInFlow } from "./editor/findReplace"; -import { copyPortalAsFlow } from "./editor/copyPortalAsFlow"; import { makePaymentViaProxy, fetchPaymentViaProxy, @@ -28,11 +25,7 @@ import { buildPaymentPayload, fetchPaymentRequestViaProxy, } from "./inviteToPay"; -import { - useHasuraAuth, - usePlatformAdminAuth, - useTeamEditorAuth, -} from "./modules/auth/middleware"; +import { useHasuraAuth } from "./modules/auth/middleware"; import airbrake from "./airbrake"; import { apiLimiter } from "./rateLimit"; @@ -40,9 +33,6 @@ import { sendToBOPS } from "./send/bops"; import { createSendEvents } from "./send/createSendEvents"; import { downloadApplicationFiles, sendToEmail } from "./send/email"; import { sendToUniform } from "./send/uniform"; -import { copyFlow } from "./editor/copyFlow"; -import { moveFlow } from "./editor/moveFlow"; -import { gql } from "graphql-request"; import { classifiedRoadsSearch } from "./gis/classifiedRoads"; import { googleStrategy } from "./modules/auth/strategy/google"; import authRoutes from "./modules/auth/routes"; @@ -52,13 +42,13 @@ import userRoutes from "./modules/user/routes"; import webhookRoutes from "./modules/webhooks/routes"; import analyticsRoutes from "./modules/analytics/routes"; import adminRoutes from "./modules/admin/routes"; +import flowRoutes from "./modules/flows/routes"; import ordnanceSurveyRoutes from "./modules/ordnanceSurvey/routes"; -import fileRoutes from "./modules/file/routes"; -import sendEmailRoutes from "./modules/sendEmail/routes"; import saveAndReturnRoutes from "./modules/saveAndReturn/routes"; +import sendEmailRoutes from "./modules/sendEmail/routes"; +import fileRoutes from "./modules/file/routes"; import { useSwaggerDocs } from "./docs"; import { Role } from "@opensystemslab/planx-core/types"; -import { $public } from "./client"; const router = express.Router(); @@ -181,6 +171,7 @@ app.use(ordnanceSurveyRoutes); app.use("/file", fileRoutes); app.use(saveAndReturnRoutes); app.use(sendEmailRoutes); +app.use("/flows", flowRoutes); app.use("/gis", router); @@ -195,109 +186,21 @@ app.get("/gis/:localAuthority", locationSearch); app.get("/roads", classifiedRoadsSearch); -app.post("/flows/:flowId/copy", useTeamEditorAuth, copyFlow); - -app.post("/flows/:flowId/diff", useTeamEditorAuth, validateAndDiffFlow); - -app.post("/flows/:flowId/move/:teamSlug", useTeamEditorAuth, moveFlow); - -app.post("/flows/:flowId/publish", useTeamEditorAuth, publishFlow); - -/** - * @swagger - * /flows/{flowId}/search: - * post: - * summary: Find and replace - * description: Find and replace a data variable in a flow - * tags: - * - flows - * parameters: - * - in: path - * name: flowId - * type: string - * required: true - * - in: query - * name: find - * type: string - * required: true - * - in: query - * name: replace - * type: string - * required: false - * responses: - * '200': - * description: OK - * content: - * application/json: - * schema: - * type: object - * properties: - * message: - * type: string - * required: true - * matches: - * type: object - * required: true - * additionalProperties: true - * updatedFlow: - * type: object - * required: false - * additionalProperties: true - * properties: - * _root: - * type: object - * properties: - * edges: - * type: array - * items: - * type: string - */ -app.post("/flows/:flowId/search", usePlatformAdminAuth, findAndReplaceInFlow); - -app.get( - "/flows/:flowId/copy-portal/:portalNodeId", - usePlatformAdminAuth, - copyPortalAsFlow, -); - -interface FlowSchema { - node: string; - type: string; - text: string; - planx_variable: string; -} +// 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", + }); + } -app.get("/flows/:flowId/download-schema", async (req, res, next) => { try { - const { flowSchema } = await $public.client.request<{ - flowSchema: FlowSchema[]; - }>( - gql` - query ($flow_id: String!) { - flowSchema: get_flow_schema(args: { published_flow_id: $flow_id }) { - node - type - text - planx_variable - } - } - `, - { flow_id: req.params.flowId }, - ); - - if (!flowSchema.length) { - next({ - status: 404, - message: - "Can't find a schema for this flow. Make sure it's published or try a different flow id.", - }); - } else { - // build a CSV and stream it - stringify(flowSchema, { header: true }).pipe(res); - - res.header("Content-type", "text/csv"); - res.attachment(`${req.params.flowId}.csv`); - } + // 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); } diff --git a/api.planx.uk/tests/mocks/validateAndPublishMocks.ts b/api.planx.uk/tests/mocks/validateAndPublishMocks.ts new file mode 100644 index 0000000000..c137f6095d --- /dev/null +++ b/api.planx.uk/tests/mocks/validateAndPublishMocks.ts @@ -0,0 +1,111 @@ +import { FlowGraph } from "@opensystemslab/planx-core/types"; + +export const mockFlowData: FlowGraph = { + _root: { + edges: [ + "SectionOne", + "QuestionOne", + "InternalPortalNode", + "FindPropertyNode", + "PayNode", + "SendNode", + "ResultNode", + "ConfirmationNode", + ], + }, + SectionOne: { + type: 360, + data: { + title: "Section 1", + }, + }, + FindPropertyNode: { + type: 9, + }, + ResultNode: { + data: { + flagSet: "Planning permission", + overrides: { + NO_APP_REQUIRED: { + heading: "Congratulations!", + }, + }, + }, + type: 3, + }, + AnswerOne: { + data: { + text: "?", + }, + type: 200, + }, + QuestionInPortal: { + data: { + text: "internal question", + }, + type: 100, + edges: ["AnswerInPortalOne", "AnswerInPortalTwo"], + }, + AnswerTwo: { + data: { + text: "!!", + }, + type: 200, + }, + InternalPortalNode: { + data: { + text: "portal", + }, + type: 300, + edges: ["QuestionInPortal"], + }, + QuestionOne: { + data: { + text: "Question", + }, + type: 100, + edges: ["AnswerOne", "AnswerTwo"], + }, + PayNode: { + data: { + fn: "application.fee.payable", + url: "http://localhost:7002/pay", + color: "#EFEFEF", + title: "Pay for your application", + description: + '

The planning fee covers the cost of processing your application. Find out more about how planning fees are calculated here.

', + }, + type: 400, + }, + AnswerInPortalOne: { + data: { + text: "?", + }, + type: 200, + }, + AnswerInPortalTwo: { + data: { + text: "*", + }, + type: 200, + }, + ConfirmationNode: { + data: { + heading: "Application sent", + moreInfo: + "

You will be contacted

\n
    \n
  • if there is anything missing from the information you have provided so far
  • \n
  • if any additional information is required
  • \n
  • to arrange a site visit, if required
  • \n
  • to inform you whether a certificate has been granted or not
  • \n
\n", + contactInfo: + '

You can contact us at planning@lambeth.gov.uk

\n', + description: + "A payment receipt has been emailed to you. You will also receive an email to confirm when your application has been received.", + feedbackCTA: "What did you think of this service? (takes 30 seconds)", + }, + type: 725, + }, + SendNode: { + data: { + url: "http://localhost:7002/bops/southwark", + }, + type: 650, + }, +}; diff --git a/hasura.planx.uk/metadata/tables.yaml b/hasura.planx.uk/metadata/tables.yaml index fade60a7e9..a3b41c7d04 100644 --- a/hasura.planx.uk/metadata/tables.yaml +++ b/hasura.planx.uk/metadata/tables.yaml @@ -240,8 +240,9 @@ columns: - flow_id - node - - type - planx_variable + - text + - type filter: {} - table: schema: public @@ -1217,9 +1218,6 @@ - locked_at: _is_null: true check: null -- table: - schema: public - name: submission_services_summary - table: schema: public name: team_members From b2fc69447d18a9e7a1f9e39b82a0eb4416e58ea4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dafydd=20Ll=C5=B7r=20Pearson?= Date: Fri, 17 Nov 2023 10:42:09 +0000 Subject: [PATCH 6/6] chore: Improve email trigger error logging (#2436) --- api.planx.uk/modules/saveAndReturn/service/utils.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api.planx.uk/modules/saveAndReturn/service/utils.ts b/api.planx.uk/modules/saveAndReturn/service/utils.ts index f6665b8d80..aa61ee7658 100644 --- a/api.planx.uk/modules/saveAndReturn/service/utils.ts +++ b/api.planx.uk/modules/saveAndReturn/service/utils.ts @@ -265,7 +265,7 @@ const setupEmailEventTriggers = async (sessionId: string) => { return hasUserSaved; } catch (error) { throw new Error( - `Error setting up email notifications for session ${sessionId}`, + `Error setting up email notifications for session ${sessionId}. Error: ${error}`, ); } };