diff --git a/api.planx.uk/editor/publish.test.ts b/api.planx.uk/editor/publish.test.ts index 504ac123f5..af840293c7 100644 --- a/api.planx.uk/editor/publish.test.ts +++ b/api.planx.uk/editor/publish.test.ts @@ -3,8 +3,8 @@ import supertest from "supertest"; import { queryMock } from "../tests/graphqlQueryMock"; import { authHeader } from "../tests/mockJWT"; import app from "../server"; -import { Flow } from "../types"; import { flowWithInviteToPay } from "../tests/mocks/inviteToPayData"; +import { FlowGraph } from "@opensystemslab/planx-core/types"; beforeEach(() => { queryMock.mockQuery({ @@ -168,7 +168,7 @@ describe("sections validation on diff", () => { }); it("does not update if there are sections, but there is not a section in the first position", async () => { - const flowWithSections: Flow["data"] = { + const flowWithSections: FlowGraph = { _root: { edges: ["questionNode", "sectionNode"], }, @@ -205,7 +205,7 @@ describe("sections validation on diff", () => { describe("invite to pay validation on diff", () => { it("does not update if invite to pay is enabled, but there is not a Send component", async () => { - const { Send, ...invalidatedFlow } = flowWithInviteToPay; + const { Send: _Send, ...invalidatedFlow } = flowWithInviteToPay; invalidatedFlow["_root"].edges?.splice( invalidatedFlow["_root"].edges?.indexOf("Send"), ); @@ -266,7 +266,8 @@ describe("invite to pay validation on diff", () => { }); it("does not update if invite to pay is enabled, but there is not a FindProperty (`_address`) component", async () => { - const { FindProperty, ...invalidatedFlow } = flowWithInviteToPay; + const { FindProperty: _FindProperty, ...invalidatedFlow } = + flowWithInviteToPay; invalidatedFlow["_root"].edges?.splice( invalidatedFlow["_root"].edges?.indexOf("FindProperty"), ); @@ -326,9 +327,9 @@ describe("invite to pay validation on diff", () => { it("does not update if invite to pay is enabled, but there is not a Checklist that sets `proposal.projectType`", async () => { const { - Checklist, - ChecklistOptionOne, - ChecklistOptionTwo, + Checklist: _Checklist, + ChecklistOptionOne: _ChecklistOptionOne, + ChecklistOptionTwo: _ChecklistOptionTwo, ...invalidatedFlow } = flowWithInviteToPay; invalidatedFlow["_root"].edges?.splice( @@ -358,7 +359,7 @@ describe("invite to pay validation on diff", () => { }); }); -const mockFlowData: Flow["data"] = { +const mockFlowData: FlowGraph = { _root: { edges: [ "SectionOne", diff --git a/api.planx.uk/editor/publish.ts b/api.planx.uk/editor/publish.ts index 1c81377389..25efd4b81e 100644 --- a/api.planx.uk/editor/publish.ts +++ b/api.planx.uk/editor/publish.ts @@ -4,8 +4,13 @@ import { adminGraphQLClient as adminClient } from "../hasura"; import { dataMerged, getMostRecentPublishedFlow } from "../helpers"; import { gql } from "graphql-request"; import intersection from "lodash/intersection"; -import { ComponentType } from "@opensystemslab/planx-core/types"; +import { + ComponentType, + FlowGraph, + Node, +} from "@opensystemslab/planx-core/types"; import { userContext } from "../modules/auth/middleware"; +import type { Entry } from "type-fest"; const validateAndDiffFlow = async ( req: Request, @@ -139,9 +144,9 @@ type ValidationResponse = { description?: string; }; -const validateSections = (flow: Record): ValidationResponse => { - if (getSectionNodeIds(flow)?.length > 0) { - if (!sectionIsInFirstPosition(flow)) { +const validateSections = (flowGraph: FlowGraph): ValidationResponse => { + if (getSectionNodeIds(flowGraph)?.length > 0) { + if (!sectionIsInFirstPosition(flowGraph)) { return { isValid: false, message: "Cannot publish an invalid flow", @@ -149,7 +154,7 @@ const validateSections = (flow: Record): ValidationResponse => { }; } - if (!allSectionsOnRoot(flow)) { + if (!allSectionsOnRoot(flowGraph)) { return { isValid: false, message: "Cannot publish an invalid flow", @@ -165,34 +170,35 @@ const validateSections = (flow: Record): ValidationResponse => { }; }; -const getSectionNodeIds = (flow: Record): string[] => { - return Object.entries(flow) - .filter(([_nodeId, nodeData]) => nodeData?.type === ComponentType.Section) - ?.map(([nodeId, _nodeData]) => nodeId); +const getSectionNodeIds = (flowGraph: FlowGraph): string[] => { + const sectionNodes = Object.entries(flowGraph).filter((entry) => + isComponentType(entry, ComponentType.Section), + ); + return sectionNodes.map(([nodeId, _nodeData]) => nodeId); }; -const sectionIsInFirstPosition = (flow: Record): boolean => { - const firstNodeId = flow["_root"].edges[0]; - return flow[firstNodeId].type === ComponentType.Section; +const sectionIsInFirstPosition = (flowGraph: FlowGraph): boolean => { + const firstNodeId = flowGraph["_root"].edges[0]; + return flowGraph[firstNodeId].type === ComponentType.Section; }; -const allSectionsOnRoot = (flow: Record): boolean => { - const sectionTypeNodeIds = getSectionNodeIds(flow); +const allSectionsOnRoot = (flowData: FlowGraph): boolean => { + const sectionTypeNodeIds = getSectionNodeIds(flowData); const intersectingNodeIds = intersection( - flow["_root"].edges, + flowData["_root"].edges, sectionTypeNodeIds, ); return intersectingNodeIds.length === sectionTypeNodeIds.length; }; -const validateInviteToPay = (flow: Record): ValidationResponse => { +const validateInviteToPay = (flowGraph: FlowGraph): ValidationResponse => { const invalidResponseTemplate = { isValid: false, message: "Cannot publish an invalid flow", }; - if (inviteToPayEnabled(flow)) { - if (numberOfComponentType(flow, ComponentType.Pay) > 1) { + if (inviteToPayEnabled(flowGraph)) { + if (numberOfComponentType(flowGraph, ComponentType.Pay) > 1) { return { ...invalidResponseTemplate, description: @@ -200,14 +206,14 @@ const validateInviteToPay = (flow: Record): ValidationResponse => { }; } - if (!hasComponentType(flow, ComponentType.Send)) { + if (!hasComponentType(flowGraph, ComponentType.Send)) { return { ...invalidResponseTemplate, description: "When using Invite to Pay, your flow must have a Send", }; } - if (numberOfComponentType(flow, ComponentType.Send) > 1) { + if (numberOfComponentType(flowGraph, ComponentType.Send) > 1) { return { ...invalidResponseTemplate, description: @@ -215,7 +221,7 @@ const validateInviteToPay = (flow: Record): ValidationResponse => { }; } - if (!hasComponentType(flow, ComponentType.FindProperty)) { + if (!hasComponentType(flowGraph, ComponentType.FindProperty)) { return { ...invalidResponseTemplate, description: @@ -224,7 +230,11 @@ const validateInviteToPay = (flow: Record): ValidationResponse => { } if ( - !hasComponentType(flow, ComponentType.Checklist, "proposal.projectType") + !hasComponentType( + flowGraph, + ComponentType.Checklist, + "proposal.projectType", + ) ) { return { ...invalidResponseTemplate, @@ -241,27 +251,40 @@ const validateInviteToPay = (flow: Record): ValidationResponse => { }; }; -const inviteToPayEnabled = (flow: Record): boolean => { - const payNodeStatuses = Object.entries(flow) - .filter(([_nodeId, nodeData]) => nodeData?.type === ComponentType.Pay) - ?.map(([_nodeId, nodeData]) => nodeData?.data?.allowInviteToPay); +const inviteToPayEnabled = (flowGraph: FlowGraph): boolean => { + const payNodes = Object.entries(flowGraph).filter( + (entry): entry is [string, Node] => + isComponentType(entry, ComponentType.Pay), + ); + const payNodeStatuses = payNodes.map( + ([_nodeId, node]) => node?.data?.allowInviteToPay, + ); return ( payNodeStatuses.length > 0 && payNodeStatuses.every((status) => status === true) ); }; +const isComponentType = ( + entry: Entry, + type: ComponentType, +): entry is [string, Node] => { + const [nodeId, node] = entry; + if (nodeId === "_root") return false; + return Boolean(node?.type === type); +}; + const hasComponentType = ( - flow: Record, + flowGraph: FlowGraph, type: ComponentType, fn?: string, ): boolean => { - const nodeIds = Object.entries(flow).filter( - ([_nodeId, nodeData]) => nodeData?.type === type, + const nodeIds = Object.entries(flowGraph).filter( + (entry): entry is [string, Node] => isComponentType(entry, type), ); if (fn) { nodeIds - ?.filter(([_nodeId, nodeData]) => nodeData?.data.fn === fn) + ?.filter(([_nodeId, nodeData]) => nodeData?.data?.fn === fn) ?.map(([nodeId, _nodeData]) => nodeId); } else { nodeIds?.map(([nodeId, _nodeData]) => nodeId); @@ -270,16 +293,16 @@ const hasComponentType = ( }; const numberOfComponentType = ( - flow: Record, + flowGraph: FlowGraph, type: ComponentType, fn?: string, ): number => { - const nodeIds = Object.entries(flow).filter( - ([_nodeId, nodeData]) => nodeData?.type === type, + const nodeIds = Object.entries(flowGraph).filter( + (entry): entry is [string, Node] => isComponentType(entry, type), ); if (fn) { nodeIds - ?.filter(([_nodeId, nodeData]) => nodeData?.data.fn === fn) + ?.filter(([_nodeId, nodeData]) => nodeData?.data?.fn === fn) ?.map(([nodeId, _nodeData]) => nodeId); } else { nodeIds?.map(([nodeId, _nodeData]) => nodeId); diff --git a/api.planx.uk/gis/classifiedRoads.ts b/api.planx.uk/gis/classifiedRoads.ts index caf45846b3..ac8de8424c 100644 --- a/api.planx.uk/gis/classifiedRoads.ts +++ b/api.planx.uk/gis/classifiedRoads.ts @@ -14,7 +14,7 @@ type OSFeatures = { type: "Feature"; geometry: { type: string; - coordinates: any[]; + coordinates: number[]; }; properties: OSHighwayFeature; }[]; diff --git a/api.planx.uk/gis/local_authorities/metadata/braintree.js b/api.planx.uk/gis/local_authorities/metadata/braintree.js index d2ec5a6378..5cc21ab898 100644 --- a/api.planx.uk/gis/local_authorities/metadata/braintree.js +++ b/api.planx.uk/gis/local_authorities/metadata/braintree.js @@ -9,7 +9,7 @@ https://environment.data.gov.uk/arcgis/rest/services */ const braintreeDomain = "https://mapping.braintree.gov.uk/arcgis"; -const environmentDomain = "https://environment.data.gov.uk"; +const _environmentDomain = "https://environment.data.gov.uk"; const A4_KEY = "PARISH"; const planningConstraints = { @@ -20,7 +20,7 @@ const planningConstraints = { serverIndex: 0, fields: ["OBJECTID"], neg: "is not in, or within, a Listed Building", - pos: (data) => ({ + pos: (_data) => ({ text: "is, or is within, a Listed Building (Grade 1)", description: null, }), @@ -33,7 +33,7 @@ const planningConstraints = { serverIndex: 1, fields: ["OBJECTID"], neg: "is not in, or within, a Listed Building", - pos: (data) => ({ + pos: (_data) => ({ text: "is, or is within, a Listed Building (Grade 2)", description: null, }), @@ -46,7 +46,7 @@ const planningConstraints = { serverIndex: 2, fields: ["OBJECTID"], neg: "is not in, or within, a Listed Building", - pos: (data) => ({ + pos: (_data) => ({ text: "is, or is within, a Listed Building (Grade 2*)", description: null, }), @@ -59,7 +59,7 @@ const planningConstraints = { serverIndex: 3, fields: ["OBJECTID", "DESIGNATED"], neg: "is not in a Conservation Area", - pos: (data) => ({ + pos: (_data) => ({ text: "is in a Conservation Area", description: null, }), @@ -98,7 +98,7 @@ const planningConstraints = { serverIndex: 4, fields: ["OBJECTID"], neg: "is not in a TPO (Tree Preservation Order) Zone", - pos: (data) => ({ + pos: (_data) => ({ text: "is in a TPO (Tree Preservation Order) Zone", description: null, }), @@ -111,7 +111,7 @@ const planningConstraints = { serverIndex: 5, fields: ["OBJECTID", "AREAS"], neg: "is not in a TPO (Tree Preservation Order) Zone", - pos: (data) => ({ + pos: (_data) => ({ text: "is in a TPO (Tree Preservation Order) Zone", description: null, }), @@ -124,7 +124,7 @@ const planningConstraints = { serverIndex: 6, fields: ["OBJECTID", "SPECIES", "REFERENCE_"], neg: "is not in a TPO (Tree Preservation Order) Zone", - pos: (data) => ({ + pos: (_data) => ({ text: "is in a TPO (Tree Preservation Order) Zone", description: null, }), @@ -137,7 +137,7 @@ const planningConstraints = { serverIndex: 7, fields: ["OBJECTID", "GROUPS", "SPECIES"], neg: "is not in a TPO (Tree Preservation Order) Zone", - pos: (data) => ({ + pos: (_data) => ({ text: "is in a TPO (Tree Preservation Order) Zone", description: null, }), diff --git a/api.planx.uk/hasura/metadata/index.ts b/api.planx.uk/hasura/metadata/index.ts index 14829616b7..dc4937d45d 100644 --- a/api.planx.uk/hasura/metadata/index.ts +++ b/api.planx.uk/hasura/metadata/index.ts @@ -16,7 +16,7 @@ interface ScheduledEventArgs { }; webhook: string; schedule_at: Date; - payload: Record; + payload: Record; comment: string; } diff --git a/api.planx.uk/hasura/schema/index.ts b/api.planx.uk/hasura/schema/index.ts index 964190c9e0..15f2052aca 100644 --- a/api.planx.uk/hasura/schema/index.ts +++ b/api.planx.uk/hasura/schema/index.ts @@ -14,9 +14,9 @@ interface SchemaAPIQuery { * POST a request to the Hasura Schema API * https://hasura.io/docs/latest/api-reference/schema-api/index/ */ -const postToSchemaAPI = async ( +const postToSchemaAPI = async ( query: SchemaAPIQuery, -): Promise> => { +): Promise> => { try { return await Axios.post( process.env.HASURA_SCHEMA_URL!, @@ -35,13 +35,20 @@ const postToSchemaAPI = async ( } }; +/** + * https://hasura.io/docs/latest/api-reference/schema-api/run-sql/#response + */ +interface RunSQLResponse { + result?: string[][]; +} + /** * Run custom SQL via Hasura Schema API * https://hasura.io/docs/latest/api-reference/schema-api/run-sql/ */ export const runSQL = async (sql: string) => { try { - const response = await postToSchemaAPI({ + const response = await postToSchemaAPI({ type: "run_sql", args: { source: "default", diff --git a/api.planx.uk/helpers.test.ts b/api.planx.uk/helpers.test.ts index 653cec6185..2d10a91201 100644 --- a/api.planx.uk/helpers.test.ts +++ b/api.planx.uk/helpers.test.ts @@ -159,7 +159,9 @@ describe("dataMerged() function", () => { }); it("handles multiple external portal nodes", async () => { const result = await dataMerged("parent-id"); - const nodeTypes = Object.values(result).map((node) => node.type); + const nodeTypes = Object.values(result).map((node) => + "type" in node ? node.type : undefined, + ); const areAllPortalsFlattened = !nodeTypes.includes( ComponentType.ExternalPortal, ); diff --git a/api.planx.uk/helpers.ts b/api.planx.uk/helpers.ts index f0894a1098..0ab9bd860c 100644 --- a/api.planx.uk/helpers.ts +++ b/api.planx.uk/helpers.ts @@ -2,7 +2,7 @@ import { gql } from "graphql-request"; import { capitalize } from "lodash"; import { adminGraphQLClient as adminClient } from "./hasura"; import { Flow, Node } from "./types"; -import { ComponentType } from "@opensystemslab/planx-core/types"; +import { ComponentType, FlowGraph } from "@opensystemslab/planx-core/types"; // Get a flow's data (unflattened, without external portal nodes) const getFlowData = async (id: string): Promise => { @@ -135,7 +135,10 @@ const getPublishedFlowByDate = async (id: string, created_at: string) => { // Flatten a flow's data to include main content & portals in a single JSON representation // XXX: getFlowData & dataMerged are currently repeated in ../editor.planx.uk/src/lib/dataMergedHotfix.ts // in order to load frontend /preview routes for flows that are not published -const dataMerged = async (id: string, ob: Record = {}) => { +const dataMerged = async ( + id: string, + ob: { [key: string]: Node } = {}, +): Promise => { // get the primary flow data const { slug, data } = await getFlowData(id); @@ -172,7 +175,8 @@ const dataMerged = async (id: string, ob: Record = {}) => { else ob[nodeId] = node; } - return ob; + // TODO: Don't cast here once types updated across API + return ob as FlowGraph; }; /** diff --git a/api.planx.uk/inviteToPay/sendPaymentEmail.test.ts b/api.planx.uk/inviteToPay/sendPaymentEmail.test.ts index cc73b3cffc..32ec62f4c1 100644 --- a/api.planx.uk/inviteToPay/sendPaymentEmail.test.ts +++ b/api.planx.uk/inviteToPay/sendPaymentEmail.test.ts @@ -135,7 +135,7 @@ describe("Send email endpoint for invite to pay templates", () => { }); describe("'Payment Expiry' templates", () => { - const templates = ["payment-expiry", "payment-expiry-agent"]; + const _templates = ["payment-expiry", "payment-expiry-agent"]; it.todo( "soft deletes the payment_request when a payment expiry email is sent", ); diff --git a/api.planx.uk/modules/webhooks/service/sanitiseApplicationData/index.ts b/api.planx.uk/modules/webhooks/service/sanitiseApplicationData/index.ts index af9cecf7c3..5c730e9241 100644 --- a/api.planx.uk/modules/webhooks/service/sanitiseApplicationData/index.ts +++ b/api.planx.uk/modules/webhooks/service/sanitiseApplicationData/index.ts @@ -1,5 +1,4 @@ import SlackNotify from "slack-notify"; -import { Request, Response, NextFunction } from "express"; import { getOperations, operationHandler } from "./operations"; import { OperationResult } from "./types"; diff --git a/api.planx.uk/modules/webhooks/service/sanitiseApplicationData/operations.ts b/api.planx.uk/modules/webhooks/service/sanitiseApplicationData/operations.ts index 7e27194681..31d29ed578 100644 --- a/api.planx.uk/modules/webhooks/service/sanitiseApplicationData/operations.ts +++ b/api.planx.uk/modules/webhooks/service/sanitiseApplicationData/operations.ts @@ -258,7 +258,7 @@ export const deleteHasuraEventLogs: Operation = async () => { AND created_at < now() - interval '6 months' RETURNING id; `); - const [_column_name, ...ids] = response.result.flat(); + const [_column_name, ...ids] = response.result?.flat() || []; return ids; }; @@ -275,6 +275,6 @@ export const deleteHasuraScheduledEventsForSubmittedSessions: Operation = ) RETURNING hse.id; `); - const [_column_name, ...ids] = response.result.flat(); + const [_column_name, ...ids] = response?.result?.flat() || []; return ids; }; diff --git a/api.planx.uk/package.json b/api.planx.uk/package.json index 2a7eaa6d57..50507193ed 100644 --- a/api.planx.uk/package.json +++ b/api.planx.uk/package.json @@ -42,6 +42,7 @@ "string-to-stream": "^3.0.1", "swagger-jsdoc": "^6.2.8", "swagger-ui-express": "^5.0.0", + "type-fest": "^4.6.0", "zod": "^3.22.3" }, "scripts": { diff --git a/api.planx.uk/pnpm-lock.yaml b/api.planx.uk/pnpm-lock.yaml index 14a6d16250..92d73c5312 100644 --- a/api.planx.uk/pnpm-lock.yaml +++ b/api.planx.uk/pnpm-lock.yaml @@ -122,6 +122,9 @@ dependencies: swagger-ui-express: specifier: ^5.0.0 version: 5.0.0(express@4.18.2) + type-fest: + specifier: ^4.6.0 + version: 4.6.0 zod: specifier: ^3.22.3 version: 3.22.3 @@ -7782,8 +7785,8 @@ packages: engines: {node: '>=10'} dev: true - /type-fest@4.4.0: - resolution: {integrity: sha512-HT3RRs7sTfY22KuPQJkD/XjbTbxgP2Je5HPt6H6JEGvcjHd5Lqru75EbrP3tb4FYjNJ+DjLp+MNQTFQU0mhXNw==} + /type-fest@4.6.0: + resolution: {integrity: sha512-rLjWJzQFOq4xw7MgJrCZ6T1jIOvvYElXT12r+y0CC6u67hegDHaxcPqb2fZHOGlqxugGQPNB1EnTezjBetkwkw==} engines: {node: '>=16'} dev: false @@ -8182,7 +8185,7 @@ packages: react: 18.2.0 react-dom: 18.2.0(react@18.2.0) striptags: 3.2.0 - type-fest: 4.4.0 + type-fest: 4.6.0 uuid: 9.0.1 zod: 3.22.4 transitivePeerDependencies: diff --git a/api.planx.uk/saveAndReturn/utils.ts b/api.planx.uk/saveAndReturn/utils.ts index b9409058e0..03fc21adad 100644 --- a/api.planx.uk/saveAndReturn/utils.ts +++ b/api.planx.uk/saveAndReturn/utils.ts @@ -136,7 +136,7 @@ const validateSingleSessionRequest = async ( interface SessionDetails { hasUserSaved: boolean; - address: any; + address: string; projectType: string; id: string; expiryDate: string; diff --git a/api.planx.uk/types.ts b/api.planx.uk/types.ts index b7e17693f8..f1be34cf00 100644 --- a/api.planx.uk/types.ts +++ b/api.planx.uk/types.ts @@ -1,6 +1,9 @@ import { PaymentRequest } from "@opensystemslab/planx-core/dist/types"; import { GovUKPayment } from "@opensystemslab/planx-core/types"; +/** + * @deprecated Migrating to Node from planx-core + */ export interface Node { id?: string; data?: Record; @@ -8,6 +11,9 @@ export interface Node { type?: number; } +/** + * @deprecated Migrating to Flow and FlowGraph from planx-core + */ export interface Flow { id: string; slug: string; @@ -94,7 +100,7 @@ type MinimumNotifyPersonalisation = { interface SaveAndReturnNotifyPersonalisation extends MinimumNotifyPersonalisation { - address?: any; + address?: string; projectType?: string; resumeLink?: string; teamName: string;