From 9b320555c07b84d80bbb3f8c47c02ebe4b48e7e5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dafydd=20Ll=C5=B7r=20Pearson?= Date: Mon, 13 Nov 2023 11:59:35 +0000 Subject: [PATCH] chore: Modularise feedback endpoint --- api.planx.uk/modules/admin/controller.ts | 20 +++ api.planx.uk/modules/admin/docs.yaml | 28 +++ .../admin/feedback/downloadFeedbackCSV.ts | 161 ------------------ api.planx.uk/modules/admin/routes.ts | 32 ++-- .../feedback/downloadFeedbackCSV.test.ts | 14 +- .../service/feedback/downloadFeedbackCSV.ts | 85 +++++++++ .../modules/admin/service/feedback/types.ts | 63 +++++++ 7 files changed, 225 insertions(+), 178 deletions(-) delete mode 100644 api.planx.uk/modules/admin/feedback/downloadFeedbackCSV.ts rename api.planx.uk/modules/admin/{ => service}/feedback/downloadFeedbackCSV.test.ts (90%) create mode 100644 api.planx.uk/modules/admin/service/feedback/downloadFeedbackCSV.ts create mode 100644 api.planx.uk/modules/admin/service/feedback/types.ts diff --git a/api.planx.uk/modules/admin/controller.ts b/api.planx.uk/modules/admin/controller.ts index e69de29bb2..62f8cd3484 100644 --- a/api.planx.uk/modules/admin/controller.ts +++ b/api.planx.uk/modules/admin/controller.ts @@ -0,0 +1,20 @@ +import { generateFeedbackCSV } from "./service/feedback/downloadFeedbackCSV"; +import { DownloadFeedbackCSVController } from "./service/feedback/types"; + +export const downloadFeedbackCSV: DownloadFeedbackCSVController = async ( + req, + res, + next, +) => { + try { + const feedbackFishCookie = res.locals.parsedReq.query.cookie; + const csvStream = await generateFeedbackCSV(feedbackFishCookie); + res.header("Content-type", "text/csv"); + return csvStream.pipe(res); + } catch (error) { + return next({ + message: + "Failed to generate FeedbackFish CSV: " + (error as Error).message, + }); + } +}; diff --git a/api.planx.uk/modules/admin/docs.yaml b/api.planx.uk/modules/admin/docs.yaml index e69de29bb2..80a35c1a41 100644 --- a/api.planx.uk/modules/admin/docs.yaml +++ b/api.planx.uk/modules/admin/docs.yaml @@ -0,0 +1,28 @@ +openapi: 3.1.0 +info: + title: Planâś• API + version: 0.1.0 +tags: + - name: admin + description: Admin only utility endpoints +paths: + /admin/feedback: + get: + tags: ["admin"] + security: + - bearerAuth: [] + summary: Download FeedbackFish CSV + parameters: + - in: query + name: cookie + type: string + description: Cookie from FeedbackFish to authenticate request + required: true + responses: + "200": + content: + text/csv: + schema: + type: string + "500": + $ref: "#/components/responses/ErrorMessage" \ No newline at end of file diff --git a/api.planx.uk/modules/admin/feedback/downloadFeedbackCSV.ts b/api.planx.uk/modules/admin/feedback/downloadFeedbackCSV.ts deleted file mode 100644 index 6836fb37a8..0000000000 --- a/api.planx.uk/modules/admin/feedback/downloadFeedbackCSV.ts +++ /dev/null @@ -1,161 +0,0 @@ -import { gql } from "graphql-request"; -import { Request, Response, NextFunction } from "express"; -import Axios from "axios"; -import { stringify } from "csv-stringify"; - -export interface Feedback { - id: number; - text: string; - category: string; - createdAt: string; - location: string; - screenshotUrl: string; - device: Device; - metadata?: Metadata[]; -} - -const METADATA_KEYS = [ - "address", - "uprn", - "title", - "data", - "service", - "team", - "component-metadata", - "reason", - "project-type", - "breadcrumbs", -] as const; - -type MetadataKey = (typeof METADATA_KEYS)[number]; - -interface Metadata { - key: MetadataKey; - value: string | Record; -} - -interface Device { - client: Client; - os: Os; -} - -interface Client { - name: string; - version: string; -} - -interface Os { - name: string; - version: string; -} - -type ParsedFeedback = Feedback & { - [key in MetadataKey]?: string | Record; -}; - -/** - * @swagger - * /admin/feedback: - * get: - * summary: Downloads the FeedbackFish CSV - * description: Downloads the FeedbackFish CSV - * tags: - * - admin - * security: - * - bearerAuth: [] - */ -export const downloadFeedbackCSV = async ( - req: Request, - res: Response, - next: NextFunction, -) => { - if (!req.query.cookie) - return next({ status: 401, message: "Missing cookie" }); - - try { - const feedback = await fetchFeedback(req.query.cookie as string); - const parsedFeedback = parseFeedback(feedback); - const csvStream = stringify(parsedFeedback, { - header: true, - columns: [ - "id", - "text", - "category", - "createdAt", - "location", - "screenshotUrl", - "device", - ...METADATA_KEYS, - ], - }); - res.header("Content-type", "text/csv"); - csvStream.pipe(res); - } catch (error) { - return next({ - message: - "Failed to generate FeedbackFish CSV: " + (error as Error).message, - }); - } -}; - -const fetchFeedback = async (cookie: string): Promise => { - const feedbackFishGraphQLEndpoint = "https://graphcdn.api.feedback.fish/"; - const body = { - query: gql` - query getFeedback($projectId: String!) { - feedback(projectId: $projectId) { - id - text - category - createdAt - location - screenshotUrl - metadata { - key - value - } - device { - client { - name - version - } - os { - name - version - } - } - } - } - `, - operationName: "getFeedback", - variables: { - projectId: "65f02de00b90d1", - }, - }; - - try { - const result = await Axios.post(feedbackFishGraphQLEndpoint, body, { - headers: { cookie }, - }); - const feedback: Feedback[] = result.data.data.feedback; - return feedback; - } catch (error) { - throw Error( - "Failed to connect to FeedbackFish: " + (error as Error).message, - ); - } -}; - -const generateMetadata = (feedback: ParsedFeedback): ParsedFeedback => { - // Transform metadata into kv pairs - feedback.metadata?.forEach(({ key, value }) => (feedback[key] = value)); - // Drop redundant raw metadata - delete feedback.metadata; - return feedback; -}; - -export const parseFeedback = (feedback: Feedback[]): ParsedFeedback[] => { - const parsedFeedback: ParsedFeedback[] = [...feedback]; - parsedFeedback.map(generateMetadata); - return parsedFeedback; -}; diff --git a/api.planx.uk/modules/admin/routes.ts b/api.planx.uk/modules/admin/routes.ts index 4f0ad79d91..ced4e2dfdf 100644 --- a/api.planx.uk/modules/admin/routes.ts +++ b/api.planx.uk/modules/admin/routes.ts @@ -1,6 +1,5 @@ import { Router } from "express"; import { usePlatformAdminAuth } from "../auth/middleware"; -import { downloadFeedbackCSV } from "./feedback/downloadFeedbackCSV"; import { getOneAppXML } from "./session/oneAppXML"; import { getBOPSPayload } from "./session/bops"; import { getCSVData, getRedactedCSVData } from "./session/csv"; @@ -8,21 +7,30 @@ import { getHTMLExport, getRedactedHTMLExport } from "./session/html"; import { generateZip } from "./session/zip"; import { getSessionSummary } from "./session/summary"; import { getDigitalPlanningApplicationPayload } from "./session/digitalPlanningData"; +import { validate } from "../../shared/middleware/validate"; +import { downloadFeedbackCSVSchema } from "./service/feedback/types"; +import { downloadFeedbackCSV } from "./controller"; const router = Router(); -router.use("/admin", usePlatformAdminAuth); -router.get("/admin/feedback", downloadFeedbackCSV); -router.get("/admin/session/:sessionId/xml", getOneAppXML); -router.get("/admin/session/:sessionId/bops", getBOPSPayload); -router.get("/admin/session/:sessionId/csv", getCSVData); -router.get("/admin/session/:sessionId/csv-redacted", getRedactedCSVData); -router.get("/admin/session/:sessionId/html", getHTMLExport); -router.get("/admin/session/:sessionId/html-redacted", getRedactedHTMLExport); -router.get("/admin/session/:sessionId/zip", generateZip); -router.get("/admin/session/:sessionId/summary", getSessionSummary); +router.use(usePlatformAdminAuth); router.get( - "/admin/session/:sessionId/digital-planning-application", + "/feedback", + validate(downloadFeedbackCSVSchema), + downloadFeedbackCSV, +); + +// TODO: Split the routes below into controller and service components +router.get("/session/:sessionId/xml", getOneAppXML); +router.get("/session/:sessionId/bops", getBOPSPayload); +router.get("/session/:sessionId/csv", getCSVData); +router.get("/session/:sessionId/csv-redacted", getRedactedCSVData); +router.get("/session/:sessionId/html", getHTMLExport); +router.get("/session/:sessionId/html-redacted", getRedactedHTMLExport); +router.get("/session/:sessionId/zip", generateZip); +router.get("/session/:sessionId/summary", getSessionSummary); +router.get( + "/session/:sessionId/digital-planning-application", getDigitalPlanningApplicationPayload, ); diff --git a/api.planx.uk/modules/admin/feedback/downloadFeedbackCSV.test.ts b/api.planx.uk/modules/admin/service/feedback/downloadFeedbackCSV.test.ts similarity index 90% rename from api.planx.uk/modules/admin/feedback/downloadFeedbackCSV.test.ts rename to api.planx.uk/modules/admin/service/feedback/downloadFeedbackCSV.test.ts index cf3328a91b..508baac373 100644 --- a/api.planx.uk/modules/admin/feedback/downloadFeedbackCSV.test.ts +++ b/api.planx.uk/modules/admin/service/feedback/downloadFeedbackCSV.test.ts @@ -1,8 +1,9 @@ -import { Feedback, parseFeedback } from "./downloadFeedbackCSV"; +import { parseFeedback } from "./downloadFeedbackCSV"; import supertest from "supertest"; -import app from "../../../server"; -import { authHeader } from "../../../tests/mockJWT"; +import app from "../../../../server"; +import { authHeader } from "../../../../tests/mockJWT"; import Axios from "axios"; +import { Feedback } from "./types"; jest.mock("axios"); const mockAxios = Axios as jest.Mocked; @@ -72,8 +73,11 @@ describe("Download feedback CSV endpoint", () => { await supertest(app) .get(ENDPOINT) .set(auth) - .expect(401) - .then((res) => expect(res.body).toEqual({ error: "Missing cookie" })); + .expect(400) + .then((res) => { + expect(res.body).toHaveProperty("issues"); + expect(res.body).toHaveProperty("name", "ZodError"); + }); }); it("returns an error if request to FeedbackFish fails", async () => { diff --git a/api.planx.uk/modules/admin/service/feedback/downloadFeedbackCSV.ts b/api.planx.uk/modules/admin/service/feedback/downloadFeedbackCSV.ts new file mode 100644 index 0000000000..022270e31b --- /dev/null +++ b/api.planx.uk/modules/admin/service/feedback/downloadFeedbackCSV.ts @@ -0,0 +1,85 @@ +import { gql } from "graphql-request"; +import Axios from "axios"; +import { stringify } from "csv-stringify"; +import { Feedback, METADATA_KEYS, ParsedFeedback } from "./types"; + +export const generateFeedbackCSV = async (feedbackFishCookie: string) => { + const feedback = await fetchFeedback(feedbackFishCookie); + const parsedFeedback = parseFeedback(feedback); + const csvStream = stringify(parsedFeedback, { + header: true, + columns: [ + "id", + "text", + "category", + "createdAt", + "location", + "screenshotUrl", + "device", + ...METADATA_KEYS, + ], + }); + return csvStream; +}; + +const fetchFeedback = async (cookie: string): Promise => { + const feedbackFishGraphQLEndpoint = "https://graphcdn.api.feedback.fish/"; + const body = { + query: gql` + query getFeedback($projectId: String!) { + feedback(projectId: $projectId) { + id + text + category + createdAt + location + screenshotUrl + metadata { + key + value + } + device { + client { + name + version + } + os { + name + version + } + } + } + } + `, + operationName: "getFeedback", + variables: { + projectId: "65f02de00b90d1", + }, + }; + + try { + const result = await Axios.post(feedbackFishGraphQLEndpoint, body, { + headers: { cookie }, + }); + const feedback: Feedback[] = result.data.data.feedback; + return feedback; + } catch (error) { + throw Error( + "Failed to connect to FeedbackFish: " + (error as Error).message, + ); + } +}; + +const generateMetadata = (feedback: ParsedFeedback): ParsedFeedback => { + // Transform metadata into kv pairs + feedback.metadata?.forEach(({ key, value }) => (feedback[key] = value)); + // Drop redundant raw metadata + delete feedback.metadata; + return feedback; +}; + +export const parseFeedback = (feedback: Feedback[]): ParsedFeedback[] => { + const parsedFeedback: ParsedFeedback[] = [...feedback]; + parsedFeedback.map(generateMetadata); + return parsedFeedback; +}; diff --git a/api.planx.uk/modules/admin/service/feedback/types.ts b/api.planx.uk/modules/admin/service/feedback/types.ts new file mode 100644 index 0000000000..00ac41afe5 --- /dev/null +++ b/api.planx.uk/modules/admin/service/feedback/types.ts @@ -0,0 +1,63 @@ +import { ValidatedRequestHandler } from "../../../../shared/middleware/validate"; +import { z } from "zod"; + +export interface Feedback { + id: number; + text: string; + category: string; + createdAt: string; + location: string; + screenshotUrl: string; + device: Device; + metadata?: Metadata[]; +} + +export const METADATA_KEYS = [ + "address", + "uprn", + "title", + "data", + "service", + "team", + "component-metadata", + "reason", + "project-type", + "breadcrumbs", +] as const; + +export type MetadataKey = (typeof METADATA_KEYS)[number]; + +export interface Metadata { + key: MetadataKey; + value: string | Record; +} + +export interface Device { + client: Client; + os: Os; +} + +export interface Client { + name: string; + version: string; +} + +export interface Os { + name: string; + version: string; +} + +export type ParsedFeedback = Feedback & { + [key in MetadataKey]?: string | Record; +}; + +export const downloadFeedbackCSVSchema = z.object({ + query: z.object({ + cookie: z.string(), + }), +}); + +export type DownloadFeedbackCSVController = ValidatedRequestHandler< + typeof downloadFeedbackCSVSchema, + NodeJS.ReadableStream +>;