Skip to content

Commit

Permalink
feat: send a Slack notification when flow status is updated on produc…
Browse files Browse the repository at this point in the history
…tion (#3535)
  • Loading branch information
jessicamcinchak authored Aug 21, 2024
1 parent 7eb7c9d commit 602c4c9
Show file tree
Hide file tree
Showing 8 changed files with 280 additions and 39 deletions.
46 changes: 46 additions & 0 deletions api.planx.uk/modules/slack/controller.ts
Original file line number Diff line number Diff line change
@@ -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}`,
}),
);
}
};
37 changes: 37 additions & 0 deletions api.planx.uk/modules/slack/docs.yaml
Original file line number Diff line number Diff line change
@@ -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"
97 changes: 97 additions & 0 deletions api.planx.uk/modules/slack/index.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof SlackNotify>("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/,
);
});
});
18 changes: 18 additions & 0 deletions api.planx.uk/modules/slack/routes.ts
Original file line number Diff line number Diff line change
@@ -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;
46 changes: 24 additions & 22 deletions api.planx.uk/server.ts
Original file line number Diff line number Diff line change
@@ -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();

Expand Down Expand Up @@ -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" } = (() => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -211,6 +212,8 @@ const ServiceSettings: React.FC = () => {
updateFlowSettings,
flowStatus,
updateFlowStatus,
token,
teamSlug,
flowSlug,
teamDomain,
isFlowPublished,
Expand All @@ -219,6 +222,8 @@ const ServiceSettings: React.FC = () => {
state.updateFlowSettings,
state.flowStatus,
state.updateFlowStatus,
state.jwt,
state.teamSlug,
state.flowSlug,
state.teamDomain,
state.isFlowPublished,
Expand All @@ -228,7 +233,7 @@ const ServiceSettings: React.FC = () => {

const handleClose = (
_event?: React.SyntheticEvent | Event,
reason?: string
reason?: string,
) => {
if (reason === "clickaway") {
return;
Expand All @@ -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<FlowSettings>({
initialValues: {
elements: {
Expand Down Expand Up @@ -272,14 +307,16 @@ 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 });
}
},
});

const publishedLink = `${window.location.origin}${rootFlowPath(
false
false,
)}/published`;

const subdomainLink = teamDomain && `https://${teamDomain}/${flowSlug}`;
Expand Down Expand Up @@ -405,7 +442,9 @@ const ServiceSettings: React.FC = () => {
onChange={() =>
statusForm.setFieldValue(
"status",
statusForm.values.status === "online" ? "offline" : "online"
statusForm.values.status === "online"
? "offline"
: "online",
)
}
/>
Expand Down
Loading

0 comments on commit 602c4c9

Please sign in to comment.