diff --git a/api.planx.uk/modules/slack/controller.ts b/api.planx.uk/modules/slack/controller.ts new file mode 100644 index 0000000000..8db6150a81 --- /dev/null +++ b/api.planx.uk/modules/slack/controller.ts @@ -0,0 +1,46 @@ +import SlackNotify from "slack-notify"; +import { z } from "zod"; +import { ServerError } from "../../errors/index.js"; +import { ValidatedRequestHandler } from "../../shared/middleware/validate.js"; + +interface SendSlackNotificationResponse { + message: string; +} + +export const slackNotificationSchema = z.object({ + body: z.object({ + message: z.string(), + }), +}); + +export type SendSlackNotificationController = ValidatedRequestHandler< + typeof slackNotificationSchema, + SendSlackNotificationResponse +>; + +export const sendSlackNotificationController: SendSlackNotificationController = + async (_req, res, next) => { + const { message } = res.locals.parsedReq.body; + + const isProduction = process.env.APP_ENVIRONMENT === "production"; + if (!isProduction) { + return res.status(200).send({ + message: `Staging environment, skipping Slack notification. Message "${message}"`, + }); + } + + try { + const slack = SlackNotify(process.env.SLACK_WEBHOOK_URL!); + await slack.send(message); + + return res.status(200).send({ + message: `Sent Slack notification. Message "${message}"`, + }); + } catch (error) { + return next( + new ServerError({ + message: `Failed to send Slack notification. Error ${error}`, + }), + ); + } + }; diff --git a/api.planx.uk/modules/slack/docs.yaml b/api.planx.uk/modules/slack/docs.yaml new file mode 100644 index 0000000000..62e1c3bb13 --- /dev/null +++ b/api.planx.uk/modules/slack/docs.yaml @@ -0,0 +1,37 @@ +openapi: 3.1.0 +info: + title: Planâś• API + version: 0.1.0 +components: + schemas: + SendSlackNotificationDirect: + type: object + properties: + message: string + responses: + SendSlackNotificationDirect: + content: + application/json: + schema: + type: object + properties: + message: string +paths: + /send-slack-notification: + post: + summary: Send a Slack notification + description: Allows authenticated users to trigger a Slack notification when they update settings in the Planx Editor + tags: ["misc"] + security: + - bearerAuth: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/SendSlackNotificationDirect" + responses: + "200": + $ref: "#/components/responses/SendSlackNotificationDirect" + "500": + $ref: "#/components/responses/ErrorMessage" diff --git a/api.planx.uk/modules/slack/index.test.ts b/api.planx.uk/modules/slack/index.test.ts new file mode 100644 index 0000000000..6866eac6d6 --- /dev/null +++ b/api.planx.uk/modules/slack/index.test.ts @@ -0,0 +1,97 @@ +import SlackNotify from "slack-notify"; +import supertest from "supertest"; + +import app from "../../server.js"; +import { authHeader } from "../../tests/mockJWT.js"; + +const validAuth = authHeader({ role: "teamEditor" }); +const validBody = { + message: ":emoji: *council-a/find-out-if* is now online", +}; +const invalidBody = { + wrong: "message", +}; + +const mockSend = jest.fn(); +jest.mock("slack-notify", () => + jest.fn().mockImplementation(() => { + return { send: mockSend }; + }), +); + +afterEach(jest.clearAllMocks); + +it("returns an error if authorization headers are not set", async () => { + await supertest(app) + .post("/send-slack-notifcation") + .send(validBody) + .expect(404); +}); + +it("returns an error if the user does not have the correct role", async () => { + await supertest(app) + .post("/send-slack-notification") + .send(validBody) + .set(authHeader({ role: "teamViewer" })) + .expect(403); +}); + +it("returns an error if a message isn't provided in the request body", async () => { + await supertest(app) + .post("/send-slack-notification") + .send(invalidBody) + .set(validAuth) + .expect(400) + .then((res) => { + expect(res.body).toHaveProperty("issues"); + expect(res.body).toHaveProperty("name", "ZodError"); + }); +}); + +it("skips the staging environment", async () => { + process.env.APP_ENVIRONMENT = "staging"; + await supertest(app) + .post("/send-slack-notification") + .send(validBody) + .set(validAuth) + .expect(200) + .then((res) => { + expect(res.body).toHaveProperty("message"); + expect(res.body.message).toMatch( + /Staging environment, skipping Slack notification. Message/, + ); + }); +}); + +it("successfully sends a Slack message", async () => { + process.env.APP_ENVIRONMENT = "production"; + await supertest(app) + .post("/send-slack-notification") + .send(validBody) + .set(validAuth) + .expect(200) + .then((res) => { + expect(SlackNotify).toHaveBeenCalledWith(process.env.SLACK_WEBHOOK_URL); + expect(mockSend).toHaveBeenCalledTimes(1); + expect(res.body.message).toEqual( + 'Sent Slack notification. Message ":emoji: *council-a/find-out-if* is now online"', + ); + }); +}); + +it("returns an error when Slack fails", async () => { + process.env.APP_ENVIRONMENT = "production"; + mockSend.mockRejectedValue("Fail!"); + + await supertest(app) + .post("/send-slack-notification") + .send(validBody) + .set(validAuth) + .expect(500) + .then((res) => { + expect(mockSend).toHaveBeenCalledTimes(1); + expect(res.body.error).toMatch( + /Failed to send Slack notification. Error/, + ); + }); +}); diff --git a/api.planx.uk/modules/slack/routes.ts b/api.planx.uk/modules/slack/routes.ts new file mode 100644 index 0000000000..a1210218ba --- /dev/null +++ b/api.planx.uk/modules/slack/routes.ts @@ -0,0 +1,18 @@ +import { Router } from "express"; +import { validate } from "../../shared/middleware/validate.js"; +import { useTeamEditorAuth } from "../auth/middleware.js"; +import { + sendSlackNotificationController, + slackNotificationSchema, +} from "./controller.js"; + +const router = Router(); + +router.post( + "/send-slack-notification", + useTeamEditorAuth, + validate(slackNotificationSchema), + sendSlackNotificationController, +); + +export default router; diff --git a/api.planx.uk/server.ts b/api.planx.uk/server.ts index 7a17abb2dd..fcb477c8a9 100644 --- a/api.planx.uk/server.ts +++ b/api.planx.uk/server.ts @@ -1,39 +1,40 @@ -import "isomorphic-fetch"; -import "express-async-errors"; -import bodyParser from "body-parser"; +import { Role } from "@opensystemslab/planx-core/types"; import assert from "assert"; +import bodyParser from "body-parser"; import cookieParser from "cookie-parser"; import cookieSession from "cookie-session"; import cors, { CorsOptions } from "cors"; import express, { ErrorRequestHandler } from "express"; -import noir from "pino-noir"; +import "express-async-errors"; import pinoLogger from "express-pino-logger"; +import helmet from "helmet"; import { Server } from "http"; +import "isomorphic-fetch"; import passport from "passport"; -import helmet from "helmet"; -import { ServerError } from "./errors/index.js"; +import noir from "pino-noir"; import airbrake from "./airbrake.js"; -import { apiLimiter } from "./rateLimit.js"; -import { registerSessionStubs } from "./session.js"; -import { googleStrategy } from "./modules/auth/strategy/google.js"; -import authRoutes from "./modules/auth/routes.js"; -import teamRoutes from "./modules/team/routes.js"; -import miscRoutes from "./modules/misc/routes.js"; -import userRoutes from "./modules/user/routes.js"; -import webhookRoutes from "./modules/webhooks/routes.js"; -import analyticsRoutes from "./modules/analytics/routes.js"; +import { useSwaggerDocs } from "./docs/index.js"; +import { ServerError } from "./errors/index.js"; import adminRoutes from "./modules/admin/routes.js"; -import flowRoutes from "./modules/flows/routes.js"; -import ordnanceSurveyRoutes from "./modules/ordnanceSurvey/routes.js"; -import saveAndReturnRoutes from "./modules/saveAndReturn/routes.js"; -import sendEmailRoutes from "./modules/sendEmail/routes.js"; +import analyticsRoutes from "./modules/analytics/routes.js"; +import authRoutes from "./modules/auth/routes.js"; +import { googleStrategy } from "./modules/auth/strategy/google.js"; import fileRoutes from "./modules/file/routes.js"; +import flowRoutes from "./modules/flows/routes.js"; import gisRoutes from "./modules/gis/routes.js"; +import miscRoutes from "./modules/misc/routes.js"; +import ordnanceSurveyRoutes from "./modules/ordnanceSurvey/routes.js"; import payRoutes from "./modules/pay/routes.js"; +import saveAndReturnRoutes from "./modules/saveAndReturn/routes.js"; import sendRoutes from "./modules/send/routes.js"; +import sendEmailRoutes from "./modules/sendEmail/routes.js"; +import slackRoutes from "./modules/slack/routes.js"; +import teamRoutes from "./modules/team/routes.js"; import testRoutes from "./modules/test/routes.js"; -import { useSwaggerDocs } from "./docs/index.js"; -import { Role } from "@opensystemslab/planx-core/types"; +import userRoutes from "./modules/user/routes.js"; +import webhookRoutes from "./modules/webhooks/routes.js"; +import { apiLimiter } from "./rateLimit.js"; +import { registerSessionStubs } from "./session.js"; const app = express(); @@ -147,10 +148,11 @@ app.use(payRoutes); app.use(saveAndReturnRoutes); app.use(sendEmailRoutes); app.use(sendRoutes); +app.use(slackRoutes); app.use(teamRoutes); +app.use(testRoutes); app.use(userRoutes); app.use(webhookRoutes); -app.use(testRoutes); const errorHandler: ErrorRequestHandler = (errorObject, _req, res, _next) => { const { status = 500, message = "Something went wrong" } = (() => { diff --git a/editor.planx.uk/src/pages/FlowEditor/components/Settings/ServiceSettings.tsx b/editor.planx.uk/src/pages/FlowEditor/components/Settings/ServiceSettings.tsx index 0709e23070..2f9ae2b08a 100644 --- a/editor.planx.uk/src/pages/FlowEditor/components/Settings/ServiceSettings.tsx +++ b/editor.planx.uk/src/pages/FlowEditor/components/Settings/ServiceSettings.tsx @@ -12,6 +12,7 @@ import Switch, { SwitchProps } from "@mui/material/Switch"; import Tooltip from "@mui/material/Tooltip"; import Typography from "@mui/material/Typography"; import { FlowStatus } from "@opensystemslab/planx-core/types"; +import axios from "axios"; import { useFormik } from "formik"; import React, { useState } from "react"; import { rootFlowPath } from "routes/utils"; @@ -211,6 +212,8 @@ const ServiceSettings: React.FC = () => { updateFlowSettings, flowStatus, updateFlowStatus, + token, + teamSlug, flowSlug, teamDomain, isFlowPublished, @@ -219,6 +222,8 @@ const ServiceSettings: React.FC = () => { state.updateFlowSettings, state.flowStatus, state.updateFlowStatus, + state.jwt, + state.teamSlug, state.flowSlug, state.teamDomain, state.isFlowPublished, @@ -228,7 +233,7 @@ const ServiceSettings: React.FC = () => { const handleClose = ( _event?: React.SyntheticEvent | Event, - reason?: string + reason?: string, ) => { if (reason === "clickaway") { return; @@ -237,6 +242,36 @@ const ServiceSettings: React.FC = () => { setIsAlertOpen(false); }; + const sendFlowStatusSlackNotification = async (status: FlowStatus) => { + const skipTeamSlugs = [ + "open-digital-planning", + "opensystemslab", + "planx", + "templates", + "testing", + "wikihouse", + ]; + if (skipTeamSlugs.includes(teamSlug)) return; + + const emoji = { + online: ":large_green_circle:", + offline: ":no_entry:", + }; + const message = `${emoji[status]} *${teamSlug}/${flowSlug}* is now ${status} (@Silvia)`; + + return axios.post( + `${process.env.REACT_APP_API_URL}/send-slack-notification`, + { + message: message, + }, + { + headers: { + Authorization: `Bearer ${token}`, + }, + }, + ); + }; + const elementsForm = useFormik({ initialValues: { elements: { @@ -272,6 +307,8 @@ const ServiceSettings: React.FC = () => { const isSuccess = await updateFlowStatus(values.status); if (isSuccess) { setIsAlertOpen(true); + // Send a Slack notification to #planx-notifications + sendFlowStatusSlackNotification(values.status); // Reset "dirty" status to disable Save & Reset buttons resetForm({ values }); } @@ -279,7 +316,7 @@ const ServiceSettings: React.FC = () => { }); const publishedLink = `${window.location.origin}${rootFlowPath( - false + false, )}/published`; const subdomainLink = teamDomain && `https://${teamDomain}/${flowSlug}`; @@ -405,7 +442,9 @@ const ServiceSettings: React.FC = () => { onChange={() => statusForm.setFieldValue( "status", - statusForm.values.status === "online" ? "offline" : "online" + statusForm.values.status === "online" + ? "offline" + : "online", ) } /> diff --git a/editor.planx.uk/src/pages/FlowEditor/components/Settings/tests/ServiceSettings/PublicLinks.test.tsx b/editor.planx.uk/src/pages/FlowEditor/components/Settings/tests/ServiceSettings/PublicLinks.test.tsx index e8e9770561..08795c55f5 100644 --- a/editor.planx.uk/src/pages/FlowEditor/components/Settings/tests/ServiceSettings/PublicLinks.test.tsx +++ b/editor.planx.uk/src/pages/FlowEditor/components/Settings/tests/ServiceSettings/PublicLinks.test.tsx @@ -1,8 +1,9 @@ +import { screen } from "@testing-library/react"; import { useStore } from "pages/FlowEditor/lib/store"; + import setupServiceSettingsScreen, { mockWindowLocationObject, } from "../helpers/setupServiceSettingsScreen"; -import { screen } from "@testing-library/react"; const { getState, setState } = useStore; @@ -42,7 +43,7 @@ const activeLinkCheck = async (link: string) => { expect(publicLink.tagName).toBe("A"); }; -describe("A team with a subdomain has an offline, published service. ", () => { +describe("A team with a subdomain has an offline, published service.", () => { beforeEach(async () => { // setup state values that depends on setState({ @@ -63,7 +64,7 @@ describe("A team with a subdomain has an offline, published service. ", () => { it("has a disabled copy button", disabledCopyCheck); }); -describe("A team with a subdomain has an online, unpublished service. ", () => { +describe("A team with a subdomain has an online, unpublished service.", () => { beforeEach(async () => { // setup state values that depends on setState({ @@ -84,7 +85,7 @@ describe("A team with a subdomain has an online, unpublished service. ", () => { it("has a disabled copy button", disabledCopyCheck); }); -describe("A team with a subdomain has an online, published service. ", () => { +describe("A team with a subdomain has an online, published service.", () => { beforeEach(async () => { // setup state values that depends on setState({ @@ -121,11 +122,11 @@ describe("A team with a subdomain has an online, published service. ", () => { expect(await screen.findByText("copied")).toBeVisible(); expect(navigator.clipboard.writeText).toBeCalledWith( - `https://${teamDomain}/${flowSlug}` + `https://${teamDomain}/${flowSlug}`, ); }); }); -describe("A team with a subdomain has an offline, unpublished service. ", () => { +describe("A team with a subdomain has an offline, unpublished service.", () => { beforeEach(async () => { // setup state values that depends on setState({ @@ -145,7 +146,7 @@ describe("A team with a subdomain has an offline, unpublished service. ", () => }); it("has a disabled copy button", disabledCopyCheck); }); -describe("A team without a subdomain has an offline, published service. ", () => { +describe("A team without a subdomain has an offline, published service.", () => { beforeEach(async () => { // setup state values that depends on setState({ @@ -169,7 +170,7 @@ describe("A team without a subdomain has an offline, published service. ", () => it("has a disabled copy button", disabledCopyCheck); }); -describe("A team without a subdomain has an online, unpublished service. ", () => { +describe("A team without a subdomain has an online, unpublished service.", () => { beforeEach(async () => { // setup state values that depends on setState({ @@ -193,7 +194,7 @@ describe("A team without a subdomain has an online, unpublished service. ", () = it("has a disabled copy button", disabledCopyCheck); }); -describe("A team without a subdomain has an online, published service. ", () => { +describe("A team without a subdomain has an online, published service.", () => { beforeEach(async () => { // setup state values that depends on setState({ @@ -235,7 +236,7 @@ describe("A team without a subdomain has an online, published service. ", () => }); }); -describe("A team without a subdomain has an offline, unpublished service. ", () => { +describe("A team without a subdomain has an offline, unpublished service.", () => { beforeEach(async () => { // setup state values that depends on setState({ diff --git a/editor.planx.uk/src/pages/FlowEditor/components/Settings/tests/helpers/setupServiceSettingsScreen.tsx b/editor.planx.uk/src/pages/FlowEditor/components/Settings/tests/helpers/setupServiceSettingsScreen.tsx index 57060f858e..807e63bbf2 100644 --- a/editor.planx.uk/src/pages/FlowEditor/components/Settings/tests/helpers/setupServiceSettingsScreen.tsx +++ b/editor.planx.uk/src/pages/FlowEditor/components/Settings/tests/helpers/setupServiceSettingsScreen.tsx @@ -1,15 +1,16 @@ +import { screen } from "@testing-library/react"; import React from "react"; -import ServiceSettings from "../../ServiceSettings"; -import { setup } from "testUtils"; import { DndProvider } from "react-dnd"; import { HTML5Backend } from "react-dnd-html5-backend"; -import { screen } from "@testing-library/react"; +import { setup } from "testUtils"; + +import ServiceSettings from "../../ServiceSettings"; export default async function setupServiceSettingsScreen() { const { user } = setup( - + , ); await screen.findByText("Your public link");