diff --git a/api.planx.uk/modules/file/service/uploadFile.ts b/api.planx.uk/modules/file/service/uploadFile.ts index a6ee74227b..ead3cee9af 100644 --- a/api.planx.uk/modules/file/service/uploadFile.ts +++ b/api.planx.uk/modules/file/service/uploadFile.ts @@ -79,9 +79,9 @@ export function generateFileParams( ACL: "public-read", Bucket: process.env.AWS_S3_BUCKET, Key: key, - Body: file.buffer || JSON.stringify(file), + Body: file.buffer, ContentDisposition: `inline;filename="${filename}"`, - ContentType: file.mimetype || "application/json", + ContentType: file.mimetype, }; return { diff --git a/api.planx.uk/modules/file/service/utils.ts b/api.planx.uk/modules/file/service/utils.ts index 0f95e170e5..85e58cd110 100644 --- a/api.planx.uk/modules/file/service/utils.ts +++ b/api.planx.uk/modules/file/service/utils.ts @@ -1,5 +1,6 @@ import { S3 } from "@aws-sdk/client-s3"; import { isLiveEnv } from "../../../helpers.js"; +import { Readable } from "stream"; export function s3Factory() { return new S3({ @@ -40,3 +41,23 @@ export function getS3KeyFromURL(fileURL: string): string { const key = [folder, file].map(decodeURIComponent).join("/"); return key; } + +export const convertObjectToMulterJSONFile = ( + data: Record, + fileName: string, +): Express.Multer.File => { + const buffer = Buffer.from(JSON.stringify(data)); + + return { + buffer: buffer, + originalname: fileName, + mimetype: "application/json", + size: buffer.length, + fieldname: "file", + encoding: "7bit", + stream: Readable.from(buffer), + destination: "", + filename: "", + path: "", + }; +}; diff --git a/api.planx.uk/modules/send/s3/index.test.ts b/api.planx.uk/modules/send/s3/index.test.ts index 5570fb7205..4215e17a84 100644 --- a/api.planx.uk/modules/send/s3/index.test.ts +++ b/api.planx.uk/modules/send/s3/index.test.ts @@ -1,58 +1,47 @@ import supertest from "supertest"; -import type planxCore from "@opensystemslab/planx-core"; import app from "../../../server.js"; import { expectedPlanningPermissionPayload } from "../../../tests/mocks/digitalPlanningDataMocks.js"; import { queryMock } from "../../../tests/graphqlQueryMock.js"; +import { $api } from "../../../client/index.js"; +import { mockLowcalSession } from "../../../tests/mocks/saveAndReturnMocks.js"; +import axios, { type AxiosRequestConfig } from "axios"; +import { markSessionAsSubmitted } from "../../saveAndReturn/service/utils.js"; + +const sessionId = "3188f052-a032-4755-be63-72b0ba497eb6"; +const mockPowerAutomateWebhookURL = "http://www.example.com"; +const mockPowerAutomateAPIKey = "my-power-automate-api-key"; vi.mock("../../saveAndReturn/service/utils", () => ({ markSessionAsSubmitted: vi.fn(), })); -vi.mock("@opensystemslab/planx-core", async (importOriginal) => { - const actualCore = await importOriginal(); - const actualCoreDomainClient = actualCore.CoreDomainClient; - - return { - CoreDomainClient: class extends actualCoreDomainClient { - constructor() { - super(); - this.export.digitalPlanningDataPayload = async () => - vi.fn().mockResolvedValue({ - exportData: expectedPlanningPermissionPayload, - }); - } - }, - }; -}); +vi.mock("../../file/service/uploadFile.js", () => ({ + uploadPrivateFile: vi + .fn() + .mockResolvedValue({ fileUrl: "https://my-file-url.com" }), +})); + +vi.mock("../../client/index.js"); + +vi.mock("axios", () => ({ + default: vi.fn(), +})); +const mockedAxios = vi.mocked(axios, true); -describe(`uploading an application to S3`, () => { +describe("uploading an application to S3", () => { beforeEach(() => { - queryMock.mockQuery({ - name: "GetStagingIntegrations", - data: { - teams: [ - { - integrations: { - powerAutomateWebhookURL: "test.azure.com/whatever", - powerAutomateAPIKey: "secret", - }, - }, - ], - }, - variables: { - slug: "barnet", - }, + $api.team.getIntegrations = vi.fn().mockResolvedValue({ + powerAutomateWebhookURL: mockPowerAutomateWebhookURL, + powerAutomateAPIKey: mockPowerAutomateAPIKey, }); - queryMock.mockQuery({ - name: "GetStagingIntegrations", - data: { - teams: [], - }, - variables: { - slug: "unsupported-team", - }, - }); + $api.session.find = vi.fn().mockResolvedValue(mockLowcalSession); + + $api.export.digitalPlanningDataPayload = vi + .fn() + .mockResolvedValue(expectedPlanningPermissionPayload); + + mockedAxios.mockResolvedValue({ data: { success: true }, status: 200 }); queryMock.mockQuery({ name: "CreateS3Application", @@ -63,39 +52,260 @@ describe(`uploading an application to S3`, () => { }); }); - it("requires auth", async () => { - await supertest(app) - .post("/upload-submission/barnet") - .send({ payload: { sessionId: "123" } }) - .expect(401); - }); + describe("request validation", () => { + it("requires auth", async () => { + await supertest(app) + .post("/upload-submission/barnet") + .send({ payload: { sessionId: "123" } }) + .expect(401); + }); - it("throws an error if payload is missing", async () => { - await supertest(app) - .post("/upload-submission/barnet") - .set({ Authorization: process.env.HASURA_PLANX_API_KEY! }) - .send({ payload: null }) - .expect(400) - .then((res) => { - expect(res.body).toHaveProperty("issues"); - expect(res.body).toHaveProperty("name", "ZodError"); + it("throws an error if payload is missing", async () => { + await supertest(app) + .post("/upload-submission/barnet") + .set({ Authorization: process.env.HASURA_PLANX_API_KEY! }) + .send({ payload: null }) + .expect(400) + .then((res) => { + expect(res.body).toHaveProperty("issues"); + expect(res.body).toHaveProperty("name", "ZodError"); + }); + }); + + it("throws an error if powerAutomateWebhookURL is not set", async () => { + $api.team.getIntegrations = vi.fn().mockResolvedValueOnce({ + powerAutomateWebhookURL: null, + powerAutomateAPIKey: "some-key", }); - }); - it("throws an error if team is unsupported", async () => { - await supertest(app) - .post("/upload-submission/unsupported-team") - .set({ Authorization: process.env.HASURA_PLANX_API_KEY! }) - .send({ - payload: { sessionId: "3188f052-a032-4755-be63-72b0ba497eb6" }, - }) - .expect(500) - .then((res) => { - expect(res.body.error).toMatch( - /No team matching "unsupported-team" found/, - ); + await supertest(app) + .post("/upload-submission/barnet") + .set({ Authorization: process.env.HASURA_PLANX_API_KEY! }) + .send({ + payload: { sessionId }, + }) + .expect(400) + .then((res) => { + expect(res.body.error).toMatch( + /Upload to S3 is not enabled for this local authority/, + ); + }); + }); + + it("throws an error if powerAutomateAPIKey is not set", async () => { + $api.team.getIntegrations = vi.fn().mockResolvedValueOnce({ + powerAutomateWebhookURL: "https://www.example.com", + powerAutomateAPIKey: null, }); + + await supertest(app) + .post("/upload-submission/barnet") + .set({ Authorization: process.env.HASURA_PLANX_API_KEY! }) + .send({ + payload: { sessionId }, + }) + .expect(400) + .then((res) => { + expect(res.body.error).toMatch( + /Upload to S3 is not enabled for this local authority/, + ); + }); + }); + }); + + describe("payload validation", () => { + it("validates statutory payloads", async () => { + await supertest(app) + .post("/upload-submission/barnet") + .set({ Authorization: process.env.HASURA_PLANX_API_KEY! }) + .send({ + payload: { sessionId }, + }); + + // Verify mock session is a statutory application + expect( + mockLowcalSession.data.passport.data?.["application.type"]?.[0], + ).toBe("ldc.proposed"); + + expect($api.export.digitalPlanningDataPayload).toHaveBeenCalledTimes(1); + expect($api.export.digitalPlanningDataPayload).toHaveBeenCalledWith( + sessionId, + false, // Validation not skipped + ); + + // Validation status passed to webhook + expect(mockedAxios).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + payload: "Validated ODP Schema", + }), + }), + ); + }); + + it("does not validate discretionary payloads", async () => { + // Set up mock discretionary payload + const mockDiscretionarySession = structuredClone(mockLowcalSession); + mockDiscretionarySession.data.passport.data["application.type"][0] = + "reportAPlanningBreach"; + $api.session.find = vi + .fn() + .mockResolvedValueOnce(mockDiscretionarySession); + + await supertest(app) + .post("/upload-submission/barnet") + .set({ Authorization: process.env.HASURA_PLANX_API_KEY! }) + .send({ + payload: { sessionId }, + }); + + expect($api.export.digitalPlanningDataPayload).toHaveBeenCalledTimes(1); + expect($api.export.digitalPlanningDataPayload).toHaveBeenCalledWith( + sessionId, + true, // Validation skipped + ); + + // Validation status passed to webhook + expect(mockedAxios).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + payload: "Discretionary", + }), + }), + ); + }); }); - it.todo("succeeds"); // mock uploadPrivateFile ?? + describe("error handling", () => { + it("throws an error if the webhook request fails", async () => { + mockedAxios.mockRejectedValueOnce( + new Error( + "Something went wrong with the webhook request to Power Automate", + ), + ); + + await supertest(app) + .post("/upload-submission/barnet") + .set({ Authorization: process.env.HASURA_PLANX_API_KEY! }) + .send({ + payload: { sessionId }, + }) + .expect(500) + .then((res) => { + expect(res.body.error).toMatch( + /Failed to send submission notification/, + ); + }); + }); + + it("throws an error if an internal process fails", async () => { + $api.session.find = vi + .fn() + .mockRejectedValueOnce(new Error("Something went wrong!")); + + await supertest(app) + .post("/upload-submission/barnet") + .set({ Authorization: process.env.HASURA_PLANX_API_KEY! }) + .send({ + payload: { sessionId }, + }) + .expect(500) + .then((res) => { + expect(res.body.error).toMatch(/Failed to upload submission to S3/); + }); + }); + }); + + describe("success", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + const callAPI = async () => + await supertest(app) + .post("/upload-submission/barnet") + .set({ Authorization: process.env.HASURA_PLANX_API_KEY! }) + .send({ + payload: { sessionId }, + }); + + it("makes a request to the configured Power Automate webhook", async () => { + await callAPI(); + + expect(mockedAxios).toHaveBeenCalledOnce(); + const request = mockedAxios.mock.calls[0][0] as AxiosRequestConfig; + expect(request.url).toEqual(mockPowerAutomateWebhookURL); + }); + + it("sets Power Automate API key in request header", async () => { + await callAPI(); + + expect(mockedAxios).toHaveBeenCalledOnce(); + const request = mockedAxios.mock.calls[0][0] as AxiosRequestConfig; + expect(request.headers).toHaveProperty("apiKey", mockPowerAutomateAPIKey); + }); + + it("generates the expected body for the Power Automate webhook", async () => { + await callAPI(); + + expect(mockedAxios).toHaveBeenCalledOnce(); + const request = mockedAxios.mock.calls[0][0] as AxiosRequestConfig; + expect(request.data).toEqual({ + message: "New submission from PlanX", + environment: "staging", + file: "https://my-file-url.com", + payload: "Validated ODP Schema", + service: "Apply for a Lawful Development Certificate", + }); + }); + + it("passes along the correct application environment details", async () => { + vi.stubEnv("APP_ENVIRONMENT", "production"); + + await callAPI(); + + expect(mockedAxios).toHaveBeenCalledOnce(); + const request = mockedAxios.mock.calls[0][0] as AxiosRequestConfig; + expect(request.data.environment).toEqual("production"); + }); + + it("marks a session as submitted", async () => { + await callAPI(); + + expect(markSessionAsSubmitted).toHaveBeenCalledOnce(); + expect(markSessionAsSubmitted).toHaveBeenCalledWith(sessionId); + }); + + it("writes an audit record the the s3_applications table", async () => { + await callAPI(); + + const graphQLCalls = queryMock.getCalls(); + + expect(graphQLCalls).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + id: "CreateS3Application", + }), + ]), + ); + }); + + it("returns a success message upon completion", async () => { + const res = await callAPI(); + + expect($api.export.digitalPlanningDataPayload).toHaveBeenCalledWith( + sessionId, + false, + ); + + expect(res.status).toBe(200); + expect(res.body).toEqual({ + message: + "Successfully uploaded submission to S3: https://my-file-url.com", + payload: "Validated ODP Schema", + webhookResponse: 200, + auditEntryId: 1, + }); + }); + }); }); diff --git a/api.planx.uk/modules/send/s3/index.ts b/api.planx.uk/modules/send/s3/index.ts index 6f7186ae8b..eed4ea0b0a 100644 --- a/api.planx.uk/modules/send/s3/index.ts +++ b/api.planx.uk/modules/send/s3/index.ts @@ -8,6 +8,7 @@ import { uploadPrivateFile } from "../../file/service/uploadFile.js"; import { markSessionAsSubmitted } from "../../saveAndReturn/service/utils.js"; import { isApplicationTypeSupported } from "../utils/helpers.js"; import type { SendIntegrationController } from "../types.js"; +import { convertObjectToMulterJSONFile } from "../../file/service/utils.js"; interface CreateS3Application { insertS3Application: { @@ -44,16 +45,16 @@ const sendToS3: SendIntegrationController = async (_req, res, next) => { const flowName = session?.flow?.name; // Generate the ODP Schema JSON, skipping validation if not a supported application type - const doValidation = isApplicationTypeSupported(passport); - const exportData = doValidation - ? await $api.export.digitalPlanningDataPayload(sessionId) - : await $api.export.digitalPlanningDataPayload(sessionId, true); + const skipValidation = !isApplicationTypeSupported(passport); + const exportData = await $api.export.digitalPlanningDataPayload( + sessionId, + skipValidation, + ); // Create and upload the data as an S3 file - const { fileUrl } = await uploadPrivateFile( - exportData, - `${sessionId}.json`, - ); + const filename = `${sessionId}.json`; + const file = convertObjectToMulterJSONFile(exportData, filename); + const { fileUrl } = await uploadPrivateFile(file, filename); // Send a notification with the file URL to the Power Automate webhook const webhookRequest: AxiosRequestConfig = { @@ -69,65 +70,61 @@ const sendToS3: SendIntegrationController = async (_req, res, next) => { service: flowName, environment: env, file: fileUrl, - payload: doValidation ? "Validated ODP Schema" : "Discretionary", + payload: skipValidation ? "Discretionary" : "Validated ODP Schema", }, }; - const webhookResponse = await axios(webhookRequest) - .then(async (res) => { - // Mark session as submitted so that reminder and expiry emails are not triggered - markSessionAsSubmitted(sessionId); - // Create an audit entry - const applicationId = await $api.client.request( - gql` - mutation CreateS3Application( - $session_id: String! - $team_slug: String! - $webhook_request: jsonb! - $webhook_response: jsonb = {} - ) { - insertS3Application: insert_s3_applications_one( - object: { - session_id: $session_id - team_slug: $team_slug - webhook_request: $webhook_request - webhook_response: $webhook_response - } - ) { - id - } - } - `, - { - session_id: sessionId, - team_slug: localAuthority, - webhook_request: webhookRequest, - webhook_response: { - status: res.status, - statusText: res.statusText, - headers: res.headers, - config: res.config, - data: res.data, - }, - }, - ); + const webhookResponse = await axios(webhookRequest).catch((error) => { + throw new Error( + `Failed to send submission notification to ${localAuthority}'s Power Automate Webhook (${sessionId}): ${error}`, + ); + }); - return { - id: applicationId.insertS3Application?.id, - axiosResponse: res, - }; - }) - .catch((error) => { - throw new Error( - `Failed to send submission notification to ${localAuthority}'s Power Automate Webhook (${sessionId}): ${error}`, - ); - }); + // Mark session as submitted so that reminder and expiry emails are not triggered + markSessionAsSubmitted(sessionId); + + // Create an audit entry + const { + insertS3Application: { id: auditEntryId }, + } = await $api.client.request( + gql` + mutation CreateS3Application( + $session_id: String! + $team_slug: String! + $webhook_request: jsonb! + $webhook_response: jsonb = {} + ) { + insertS3Application: insert_s3_applications_one( + object: { + session_id: $session_id + team_slug: $team_slug + webhook_request: $webhook_request + webhook_response: $webhook_response + } + ) { + id + } + } + `, + { + session_id: sessionId, + team_slug: localAuthority, + webhook_request: webhookRequest, + webhook_response: { + status: webhookResponse.status, + statusText: webhookResponse.statusText, + headers: webhookResponse.headers, + config: webhookResponse.config, + data: webhookResponse.data, + }, + }, + ); res.status(200).send({ message: `Successfully uploaded submission to S3: ${fileUrl}`, - payload: doValidation ? "Validated ODP Schema" : "Discretionary", - webhookResponse: webhookResponse.axiosResponse.status, - auditEntryId: webhookResponse.id, + payload: skipValidation ? "Discretionary" : "Validated ODP Schema", + webhookResponse: webhookResponse.status, + auditEntryId, }); } catch (error) { return next({ diff --git a/api.planx.uk/modules/webhooks/service/sendNotification/index.ts b/api.planx.uk/modules/webhooks/service/sendNotification/index.ts index c6ff6409b5..462fff7e25 100644 --- a/api.planx.uk/modules/webhooks/service/sendNotification/index.ts +++ b/api.planx.uk/modules/webhooks/service/sendNotification/index.ts @@ -36,7 +36,11 @@ export const sendSlackNotification = async ( const pilotServices = [ "uniform", "happ", + "hapr", + "hret", "ldc", + "minor", + "major", "apply for planning permission", "apply for a lawful development certificate", ]; diff --git a/api.planx.uk/package.json b/api.planx.uk/package.json index 27b1560166..fe0456e5ff 100644 --- a/api.planx.uk/package.json +++ b/api.planx.uk/package.json @@ -25,7 +25,7 @@ "csv-stringify": "^6.5.0", "date-fns": "^3.3.1", "dompurify": "^3.1.6", - "express": "^4.21.1", + "express": "^4.21.2", "express-async-errors": "^3.1.1", "express-jwt": "^8.4.1", "express-pino-logger": "^7.0.0", diff --git a/api.planx.uk/pnpm-lock.yaml b/api.planx.uk/pnpm-lock.yaml index 9279cf8f9e..88eaf57dac 100644 --- a/api.planx.uk/pnpm-lock.yaml +++ b/api.planx.uk/pnpm-lock.yaml @@ -57,11 +57,11 @@ dependencies: specifier: ^3.1.6 version: 3.1.6 express: - specifier: ^4.21.1 - version: 4.21.1 + specifier: ^4.21.2 + version: 4.21.2 express-async-errors: specifier: ^3.1.1 - version: 3.1.1(express@4.21.1) + version: 3.1.1(express@4.21.2) express-jwt: specifier: ^8.4.1 version: 8.4.1 @@ -70,7 +70,7 @@ dependencies: version: 7.0.0 express-rate-limit: specifier: ^7.1.5 - version: 7.1.5(express@4.21.1) + version: 7.1.5(express@4.21.2) form-data: specifier: ^4.0.0 version: 4.0.0 @@ -142,7 +142,7 @@ dependencies: version: 6.2.8(openapi-types@12.1.3) swagger-ui-express: specifier: ^5.0.0 - version: 5.0.0(express@4.21.1) + version: 5.0.0(express@4.21.2) type-fest: specifier: ^4.18.1 version: 4.18.1 @@ -4335,12 +4335,12 @@ packages: strip-final-newline: 3.0.0 dev: true - /express-async-errors@3.1.1(express@4.21.1): + /express-async-errors@3.1.1(express@4.21.2): resolution: {integrity: sha512-h6aK1da4tpqWSbyCa3FxB/V6Ehd4EEB15zyQq9qe75OZBp0krinNKuH4rAY+S/U/2I36vdLAUFSjQJ+TFmODng==} peerDependencies: express: ^4.16.2 dependencies: - express: 4.21.1 + express: 4.21.2 dev: false /express-jwt@8.4.1: @@ -4359,21 +4359,21 @@ packages: pino-http: 6.6.0 dev: false - /express-rate-limit@7.1.5(express@4.21.1): + /express-rate-limit@7.1.5(express@4.21.2): resolution: {integrity: sha512-/iVogxu7ueadrepw1bS0X0kaRC/U0afwiYRSLg68Ts+p4Dc85Q5QKsOnPS/QUjPMHvOJQtBDrZgvkOzf8ejUYw==} engines: {node: '>= 16'} peerDependencies: express: 4 || 5 || ^5.0.0-beta.1 dependencies: - express: 4.21.1 + express: 4.21.2 dev: false /express-unless@2.1.3: resolution: {integrity: sha512-wj4tLMyCVYuIIKHGt0FhCtIViBcwzWejX0EjNxveAa6dG+0XBCQhMbx+PnkLkFCxLC69qoFrxds4pIyL88inaQ==} dev: false - /express@4.21.1: - resolution: {integrity: sha512-YSFlK1Ee0/GC8QaO91tHcDxJiE/X4FbpAyQWkxAvG6AXCuR65YzK8ua6D9hvi/TzUfZMpc+BwuM1IPw8fmQBiQ==} + /express@4.21.2: + resolution: {integrity: sha512-28HqgMZAmih1Czt9ny7qr6ek2qddF4FclbMzwhCREB6OFfH+rXAnuNCwo1/wFvrtbgsQDb4kSbX9de9lFbrXnA==} engines: {node: '>= 0.10.0'} dependencies: accepts: 1.3.8 @@ -4395,7 +4395,7 @@ packages: methods: 1.1.2 on-finished: 2.4.1 parseurl: 1.3.3 - path-to-regexp: 0.1.10 + path-to-regexp: 0.1.12 proxy-addr: 2.0.7 qs: 6.13.0 range-parser: 1.2.1 @@ -5928,8 +5928,8 @@ packages: minipass: 7.1.2 dev: true - /path-to-regexp@0.1.10: - resolution: {integrity: sha512-7lf7qcQidTku0Gu3YDPc8DJ1q7OOucfa/BSsIwjuh56VU7katFvuM8hULfkwB3Fns/rsVF7PwPKVw1sl5KQS9w==} + /path-to-regexp@0.1.12: + resolution: {integrity: sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==} dev: false /path-type@4.0.0: @@ -6728,13 +6728,13 @@ packages: '@scarf/scarf': 1.4.0 dev: false - /swagger-ui-express@5.0.0(express@4.21.1): + /swagger-ui-express@5.0.0(express@4.21.2): resolution: {integrity: sha512-tsU9tODVvhyfkNSvf03E6FAk+z+5cU3lXAzMy6Pv4av2Gt2xA0++fogwC4qo19XuFf6hdxevPuVCSKFuMHJhFA==} engines: {node: '>= v0.10.32'} peerDependencies: express: '>=4.0.0 || >=5.0.0-beta' dependencies: - express: 4.21.1 + express: 4.21.2 swagger-ui-dist: 5.18.2 dev: false diff --git a/api.planx.uk/vitest.config.ts b/api.planx.uk/vitest.config.ts index 77fcbeff21..16b2a43efd 100644 --- a/api.planx.uk/vitest.config.ts +++ b/api.planx.uk/vitest.config.ts @@ -13,10 +13,10 @@ export default defineConfig({ // html reporter required to inspect coverage in Vitest UI dashboard reporter: ["lcov", "html", "text-summary"], thresholds: { - statements: 73.28, - branches: 55.27, - functions: 73.59, - lines: 73.42, + statements: 73.41, + branches: 55.76, + functions: 74.09, + lines: 73.54, autoUpdate: true, }, }, diff --git a/editor.planx.uk/src/ui/editor/RichTextInput/RichTextInput.tsx b/editor.planx.uk/src/ui/editor/RichTextInput/RichTextInput.tsx index 5b679ea204..a04721cafd 100644 --- a/editor.planx.uk/src/ui/editor/RichTextInput/RichTextInput.tsx +++ b/editor.planx.uk/src/ui/editor/RichTextInput/RichTextInput.tsx @@ -200,8 +200,8 @@ export const emptyContent = "

"; const linkSelectionError = (selectionHtml: string): string | null => { if (selectionHtml.startsWith("

") && selectionHtml.endsWith("

")) { const text = selectionHtml.slice(3, -4); - const lowercaseText = text.toLowerCase(); - if (lowercaseText.includes("click") || lowercaseText.includes("here")) { + const lowercaseText = text.toLowerCase().trim().replace(/[.,]/g, ""); + if (lowercaseText === "click here" || lowercaseText === "clicking here") { return "Links must be set over text that accurately describes what the link is for. Avoid generic language such as 'click here'."; } if (text[0] && text[0] !== text[0].toUpperCase() && text.length < 8) { diff --git a/infrastructure/application/services/hasura.ts b/infrastructure/application/services/hasura.ts index d6f9696753..c27698d872 100644 --- a/infrastructure/application/services/hasura.ts +++ b/infrastructure/application/services/hasura.ts @@ -110,6 +110,8 @@ export const createHasuraService = async ({ }, }, desiredCount: 1, + // experiment with non-zero grace period to see if it resolves scale up failure + healthCheckGracePeriodSeconds: 180, }); new cloudflare.Record("hasura", {