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/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/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/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..aa61ee7658 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; @@ -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}`, ); } }; 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..38cdf5ed54 100644 --- a/api.planx.uk/server.ts +++ b/api.planx.uk/server.ts @@ -14,10 +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 { routeSendEmailRequest } from "./notify"; import { makePaymentViaProxy, fetchPaymentViaProxy, @@ -29,22 +25,14 @@ import { buildPaymentPayload, fetchPaymentRequestViaProxy, } from "./inviteToPay"; -import { - useHasuraAuth, - useSendEmailAuth, - usePlatformAdminAuth, - useTeamEditorAuth, -} from "./modules/auth/middleware"; +import { useHasuraAuth } 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"; 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"; @@ -54,12 +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 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(); @@ -107,6 +96,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 +170,8 @@ app.use("/admin", adminRoutes); app.use(ordnanceSurveyRoutes); app.use("/file", fileRoutes); app.use(saveAndReturnRoutes); +app.use(sendEmailRoutes); +app.use("/flows", flowRoutes); app.use("/gis", router); @@ -194,122 +186,26 @@ 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); } }); -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) => { 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/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 0000000000..513cd35821 Binary files /dev/null and b/doc/how-to/images/setup-metabase/duplicate_an_existing_dashboard.png differ diff --git a/doc/how-to/images/setup-metabase/only_duplicate_dashboard.png b/doc/how-to/images/setup-metabase/only_duplicate_dashboard.png new file mode 100644 index 0000000000..1c4e9ca3dd Binary files /dev/null and b/doc/how-to/images/setup-metabase/only_duplicate_dashboard.png differ 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 ( - + = { diff --git a/editor.planx.uk/src/ui/ColorPicker.tsx b/editor.planx.uk/src/ui/ColorPicker.tsx index 29bf04bb79..45219d797a 100644 --- a/editor.planx.uk/src/ui/ColorPicker.tsx +++ b/editor.planx.uk/src/ui/ColorPicker.tsx @@ -95,7 +95,7 @@ export default function ColorPicker(props: Props): FCReturn { 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} 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