diff --git a/apps/newsletters-api/src/app/headers.ts b/apps/newsletters-api/src/app/headers.ts index 7ad475350..66503dcee 100644 --- a/apps/newsletters-api/src/app/headers.ts +++ b/apps/newsletters-api/src/app/headers.ts @@ -23,6 +23,9 @@ export const getCacheControl = ( if (req.routerPath.startsWith('/api/legacy')) { return newsletterTtl; } + if (req.routerPath.startsWith('/api/layouts')) { + return newsletterTtl; + } return undefined; }; diff --git a/apps/newsletters-api/src/app/routes/layouts.ts b/apps/newsletters-api/src/app/routes/layouts.ts new file mode 100644 index 000000000..eabf9097c --- /dev/null +++ b/apps/newsletters-api/src/app/routes/layouts.ts @@ -0,0 +1,92 @@ +import type { FastifyInstance } from 'fastify'; +import { + editionIdSchema, + layoutSchema, +} from '@newsletters-nx/newsletters-data-client'; +import { permissionService } from '../../services/permissions'; +import { layoutStore } from '../../services/storage'; +import { getUserProfile } from '../get-user-profile'; +import { + makeErrorResponse, + makeSuccessResponse, + mapStorageFailureReasonToStatusCode, +} from '../responses'; + +export function registerReadLayoutRoutes(app: FastifyInstance) { + app.get('/api/layouts', async (req, res) => { + const storageResponse = await layoutStore.readAll(); + if (!storageResponse.ok) { + return res + .status(mapStorageFailureReasonToStatusCode(storageResponse.reason)) + .send(makeErrorResponse(storageResponse.message)); + } + return makeSuccessResponse(storageResponse.data); + }); + + app.get<{ + Params: { editionId: string }; + }>('/api/layouts/:editionId', async (req, res) => { + const { editionId } = req.params; + + const idParseResult = editionIdSchema.safeParse(editionId.toUpperCase()); + + if (!idParseResult.success) { + return res + .status(400) + .send(makeErrorResponse(`No such edition ${editionId}`)); + } + + const storageResponse = await layoutStore.read(idParseResult.data); + + if (!storageResponse.ok) { + return res + .status(mapStorageFailureReasonToStatusCode(storageResponse.reason)) + .send(makeErrorResponse(storageResponse.message)); + } + return makeSuccessResponse(storageResponse.data); + }); +} + +export function registerWriteLayoutRoutes(app: FastifyInstance) { + app.post<{ + Body: unknown; + Params: { editionId: string }; + }>('/api/layouts/:editionId', async (req, res) => { + const { editionId } = req.params; + const layout: unknown = req.body; + const user = getUserProfile(req); + const permissions = await permissionService.get(user.profile); + + if (!permissions.editLayouts) { + return res + .status(403) + .send(makeErrorResponse(`You don't have permission to edit layouts.`)); + } + + const idParseResult = editionIdSchema.safeParse(editionId.toUpperCase()); + if (!idParseResult.success) { + return res + .status(400) + .send(makeErrorResponse(`No such edition ${editionId}`)); + } + + const layoutParseResult = layoutSchema.safeParse(layout); + if (!layoutParseResult.success) { + return res.status(400).send(makeErrorResponse(`invalid layout data`)); + } + + // TO DO - need a separate route or param for 'create' requests + // that will fail if the layout exists already rather than replacing with blanks + const storageResponse = await layoutStore.update( + idParseResult.data, + layoutParseResult.data, + ); + + if (!storageResponse.ok) { + return res + .status(mapStorageFailureReasonToStatusCode(storageResponse.reason)) + .send(makeErrorResponse(storageResponse.message)); + } + return makeSuccessResponse(storageResponse.data); + }); +} diff --git a/apps/newsletters-api/src/main.ts b/apps/newsletters-api/src/main.ts index cac10e0b2..67256c983 100644 --- a/apps/newsletters-api/src/main.ts +++ b/apps/newsletters-api/src/main.ts @@ -8,6 +8,10 @@ import { setHeaderHook } from './app/headers'; import { registerCurrentStepRoute } from './app/routes/currentStep'; import { registerDraftsRoutes } from './app/routes/drafts'; import { registerHealthRoute } from './app/routes/health'; +import { + registerReadLayoutRoutes, + registerWriteLayoutRoutes, +} from './app/routes/layouts'; import { registerReadNewsletterRoutes, registerReadWriteNewsletterRoutes, @@ -27,11 +31,13 @@ if (isServingReadWriteEndpoints()) { registerUserRoute(app); registerReadWriteNewsletterRoutes(app); registerNotificationRoutes(app); + registerWriteLayoutRoutes(app); } if (isServingReadEndpoints()) { registerReadNewsletterRoutes(app); registerDraftsRoutes(app); registerRenderingTemplatesRoutes(app); + registerReadLayoutRoutes(app); } app.addHook('onSend', setHeaderHook); diff --git a/apps/newsletters-api/src/register-ui-server.ts b/apps/newsletters-api/src/register-ui-server.ts index 384e77349..eb0ef8b77 100644 --- a/apps/newsletters-api/src/register-ui-server.ts +++ b/apps/newsletters-api/src/register-ui-server.ts @@ -27,4 +27,6 @@ export function registerUIServer(app: FastifyInstance) { app.get('/launched', handleUiRequest); app.get('/templates/*', handleUiRequest); app.get('/templates', handleUiRequest); + app.get('/layouts/*', handleUiRequest); + app.get('/layouts', handleUiRequest); } diff --git a/apps/newsletters-api/src/services/storage/index.ts b/apps/newsletters-api/src/services/storage/index.ts index c60c82261..36fc499d6 100644 --- a/apps/newsletters-api/src/services/storage/index.ts +++ b/apps/newsletters-api/src/services/storage/index.ts @@ -1,23 +1,28 @@ import type { SESClient } from '@aws-sdk/client-ses'; import type { DraftStorage, + EditionsLayouts, EmailEnvInfo, + LayoutStorage, NewsletterData, NewsletterStorage, UserProfile, } from '@newsletters-nx/newsletters-data-client'; import { DraftService, + InMemoryLayoutStorage, InMemoryNewsletterStorage, isNewsletterData, LaunchService, } from '@newsletters-nx/newsletters-data-client'; +import layoutsData from '../../../static/layouts.local.json'; import newslettersData from '../../../static/newsletters.local.json'; import { isUsingInMemoryStorage } from '../../apiDeploymentSettings'; import { makeEmailEnvInfo } from '../notifications/email-env'; import { makeSesClient } from '../notifications/email-service'; import { makeInMemoryStorageInstance } from './inMemoryStorageInstance'; import { + getS3LayoutStore, getS3NewsletterStore, makeS3DraftStorageInstance, } from './s3StorageInstance'; @@ -37,6 +42,10 @@ const newsletterStore: NewsletterStorage = isUsingInMemoryStore ) : getS3NewsletterStore(); +const layoutStore: LayoutStorage = isUsingInMemoryStore + ? new InMemoryLayoutStorage(layoutsData as unknown as EditionsLayouts) + : getS3LayoutStore(); + const makelaunchServiceForUser = (userProfile: UserProfile) => new LaunchService( draftStore, @@ -57,4 +66,5 @@ export { makeDraftServiceForUser, makelaunchServiceForUser, newsletterStore, + layoutStore, }; diff --git a/apps/newsletters-api/src/services/storage/s3StorageInstance.ts b/apps/newsletters-api/src/services/storage/s3StorageInstance.ts index d8a5815e1..e736e9964 100644 --- a/apps/newsletters-api/src/services/storage/s3StorageInstance.ts +++ b/apps/newsletters-api/src/services/storage/s3StorageInstance.ts @@ -1,5 +1,6 @@ import { S3DraftStorage, + S3LayoutStorage, S3NewsletterStorage, } from '@newsletters-nx/newsletters-data-client'; import { getS3Client } from './s3-client-factory'; @@ -21,4 +22,8 @@ const getS3NewsletterStore = (): S3NewsletterStorage => { return new S3NewsletterStorage(getS3BucketName(), getS3Client()); }; -export { makeS3DraftStorageInstance, getS3NewsletterStore }; +const getS3LayoutStore = (): S3LayoutStorage => { + return new S3LayoutStorage(getS3BucketName(), getS3Client()); +}; + +export { makeS3DraftStorageInstance, getS3NewsletterStore, getS3LayoutStore }; diff --git a/apps/newsletters-api/static/layouts.local.json b/apps/newsletters-api/static/layouts.local.json new file mode 100644 index 000000000..8026c462c --- /dev/null +++ b/apps/newsletters-api/static/layouts.local.json @@ -0,0 +1,25 @@ +{ + "UK": { + "groups": [ + { + "title": "Get started", + "newsletters": [ + "bmx-tesla", + "roi-female", + "electric-bicycle", + "dram-security", + "does-not-exist" + ] + }, + { + "title": "In depth", + "newsletters": [ + "muddle-teal", + "van-coordinator", + "does-not-exist-two", + "dram-security" + ] + } + ] + } +} \ No newline at end of file diff --git a/apps/newsletters-ui/src/app/components/Illustration.tsx b/apps/newsletters-ui/src/app/components/Illustration.tsx index 743d66273..3b7636dbf 100644 --- a/apps/newsletters-ui/src/app/components/Illustration.tsx +++ b/apps/newsletters-ui/src/app/components/Illustration.tsx @@ -4,13 +4,17 @@ import { Stack, Typography } from '@mui/material'; interface Props { name: string; url?: string; + height?: number; } -export const Illustration = ({ name, url }: Props) => { +export const Illustration = ({ name, url, height = 200 }: Props) => { const image = url ? ( - + ) : ( - + ); const captionText = url diff --git a/apps/newsletters-ui/src/app/components/JsonEditor.tsx b/apps/newsletters-ui/src/app/components/JsonEditor.tsx index 6e0736da2..5d7bb1a33 100644 --- a/apps/newsletters-ui/src/app/components/JsonEditor.tsx +++ b/apps/newsletters-ui/src/app/components/JsonEditor.tsx @@ -9,27 +9,19 @@ import { TextField, } from '@mui/material'; import { useEffect, useState } from 'react'; -import type { z, ZodIssue, ZodObject, ZodRawShape } from 'zod'; +import type { Schema, ZodIssue } from 'zod'; import { ZodIssuesReport } from './ZodIssuesReport'; -type JsonRecord = Record; +type JsonRecordOrArray = Record | unknown[]; -interface Props { - originalData: SchemaObjectType; - schema: ZodObject; - submit: { (data: SchemaObjectType): void | Promise }; +interface Props { + originalData: T; + schema: Schema; + submit: { (data: T): void | Promise }; } -type SchemaObjectType = { - [k in keyof z.objectUtil.addQuestionMarks<{ - [k in keyof T]: T[k]['_output']; - }>]: z.objectUtil.addQuestionMarks<{ - [k in keyof T]: T[k]['_output']; - }>[k]; -}; - const getFormattedJsonString = ( - data: JsonRecord, + data: JsonRecordOrArray, ): { ok: true; json: string } | { ok: false } => { try { const json = JSON.stringify(data, undefined, 4); @@ -39,9 +31,9 @@ const getFormattedJsonString = ( } }; -const safeJsonParse = (value: string): JsonRecord | undefined => { +const safeJsonParse = (value: string): JsonRecordOrArray | undefined => { try { - return JSON.parse(value) as JsonRecord; + return JSON.parse(value) as JsonRecordOrArray; } catch (err) { return undefined; } @@ -79,7 +71,7 @@ const CheckResultMessage = (props: { ); }; -export const JsonEditor = ({ +export const JsonEditor = ({ originalData, schema, submit, diff --git a/apps/newsletters-ui/src/app/components/edition-layouts/LayoutDisplay.tsx b/apps/newsletters-ui/src/app/components/edition-layouts/LayoutDisplay.tsx new file mode 100644 index 000000000..a27a32cf8 --- /dev/null +++ b/apps/newsletters-ui/src/app/components/edition-layouts/LayoutDisplay.tsx @@ -0,0 +1,38 @@ +import { Box, Typography } from '@mui/material'; +import type { + Layout, + NewsletterData, +} from '@newsletters-nx/newsletters-data-client'; +import { NewsletterCard } from './NewsletterCard'; + +interface Props { + newsletters: NewsletterData[]; + layout: Layout; +} + +export const LayoutDisplay = ({ newsletters, layout }: Props) => { + return ( + + {layout.groups.map((section, index) => ( + + {section.title} + {section.subtitle && ( + {section.subtitle} + )} + + {section.newsletters.map((newsletterId, index) => ( + n.identityName === newsletterId, + )} + /> + ))} + + + ))} + + ); +}; diff --git a/apps/newsletters-ui/src/app/components/edition-layouts/LayoutsMapDisplay.tsx b/apps/newsletters-ui/src/app/components/edition-layouts/LayoutsMapDisplay.tsx new file mode 100644 index 000000000..e6ad7a0a9 --- /dev/null +++ b/apps/newsletters-ui/src/app/components/edition-layouts/LayoutsMapDisplay.tsx @@ -0,0 +1,107 @@ +import { Alert, Box, Button, Divider, Stack, Typography } from '@mui/material'; +import { Link, useNavigate } from 'react-router-dom'; +import type { + EditionId, + EditionsLayouts, + Layout, + NewsletterData, +} from '@newsletters-nx/newsletters-data-client'; +import { editionIds, makeBlankLayout } from '@newsletters-nx/newsletters-data-client'; +import { fetchPostApiData } from '../../api-requests/fetch-api-data'; +import { usePermissions } from '../../hooks/user-hooks'; + +interface Props { + editionsLayouts: EditionsLayouts; + newsletters: NewsletterData[]; +} + +const LayoutOverview = ({ + editionId, + newsletters, + layout, +}: { + editionId: EditionId; + newsletters: NewsletterData[]; + layout?: Layout; +}) => { + const navigate = useNavigate(); + const permissions = usePermissions(); + const newsletterCount = layout?.groups.flatMap( + (section) => section.newsletters, + ).length; + const invalidNewsletterCount = layout?.groups + .flatMap((section) => section.newsletters) + .filter( + (newsletterId) => + !newsletters.some( + (newsletter) => newsletter.identityName === newsletterId, + ), + ).length; + + const handleCreate = async (editionId: EditionId) => { + const result = await fetchPostApiData(`/api/layouts/${editionId}`, makeBlankLayout()); + if (result) { + navigate(`/layouts/${editionId.toLowerCase()}`); + } else { + alert('failed to create layout'); + } + }; + + const title = {editionId} Layout; + + return ( + + {layout ? ( + {title} + ) : ( + + {title} + + {permissions?.editLayouts && ( + + )} + + )} + + {!!newsletterCount && ( + + {newsletterCount} newsletters and {layout?.groups.length ?? 0} groups in + layout + + )} + {newsletterCount === 0 && ( + Layout is empty + )} + {!!invalidNewsletterCount && ( + + {invalidNewsletterCount} newsletters named in the layout do not exist + + )} + {!layout && ( + no layout defined for {editionId} + )} + + ); +}; + +export const LayoutsMapDisplay = ({ editionsLayouts, newsletters }: Props) => { + return ( + } gap={2}> + {editionIds.map((editionId) => ( + + ))} + + ); +}; diff --git a/apps/newsletters-ui/src/app/components/edition-layouts/NewsletterCard.tsx b/apps/newsletters-ui/src/app/components/edition-layouts/NewsletterCard.tsx new file mode 100644 index 000000000..85f9cce94 --- /dev/null +++ b/apps/newsletters-ui/src/app/components/edition-layouts/NewsletterCard.tsx @@ -0,0 +1,122 @@ +import type { AlertProps } from '@mui/material'; +import { + Alert, + Card, + Chip, + Tooltip, + Typography, + useTheme, +} from '@mui/material'; +import { Link } from 'react-router-dom'; +import type { NewsletterData } from '@newsletters-nx/newsletters-data-client'; +import { Illustration } from '../Illustration'; + +interface NewsletterCardProps { + newsletterId: string; + index: number; + newsletter?: NewsletterData; +} + +const statusToAlertVariant = ( + status: NewsletterData['status'], +): AlertProps['severity'] => { + switch (status) { + case 'cancelled': + return 'error'; + case 'paused': + case 'pending': + return 'warning'; + case 'live': + return 'success'; + } +}; + +const statusToToolTipText = ( + status: NewsletterData['status'], +): string | undefined => { + switch (status) { + case 'cancelled': + return 'This newsletter has been cancelled and will not be displayed.'; + case 'paused': + case 'pending': + return 'This newsletter is not yet live - it will not appear until its status is updated.'; + case 'live': + return undefined; + } +}; + +export const NewsletterCard = ({ + newsletterId, + newsletter, + index, +}: NewsletterCardProps) => { + const { palette } = useTheme(); + const paletteSet = newsletter ? palette.primary : palette.warning; + + const tooltipText = newsletter + ? statusToToolTipText(newsletter.status) + : undefined; + + const numberSpan = ( + + {index + 1} + {'. '} + + ); + + return ( + + {newsletter ? ( + <> + + {numberSpan} + {newsletter.name} + + + + + {newsletter.status}{' '} + {tooltipText && ( + + + + )} + + + ) : ( + <> + + {numberSpan} + {newsletterId} + + + + invalid id + + + + + + )} + + ); +}; diff --git a/apps/newsletters-ui/src/app/components/views/LayoutMapView.tsx b/apps/newsletters-ui/src/app/components/views/LayoutMapView.tsx new file mode 100644 index 000000000..f83a61d7a --- /dev/null +++ b/apps/newsletters-ui/src/app/components/views/LayoutMapView.tsx @@ -0,0 +1,25 @@ +import { Typography } from '@mui/material'; +import { useLoaderData } from 'react-router-dom'; +import type { + EditionsLayouts, + NewsletterData, +} from '@newsletters-nx/newsletters-data-client'; +import { ContentWrapper } from '../../ContentWrapper'; +import { LayoutsMapDisplay } from '../edition-layouts/LayoutsMapDisplay'; + +export const LayoutMapView = () => { + const data = useLoaderData() as { + editionsLayouts: EditionsLayouts; + newsletters: NewsletterData[]; + }; + + return ( + + Layouts + + + ); +}; diff --git a/apps/newsletters-ui/src/app/components/views/LayoutView.tsx b/apps/newsletters-ui/src/app/components/views/LayoutView.tsx new file mode 100644 index 000000000..0b3b4c0a4 --- /dev/null +++ b/apps/newsletters-ui/src/app/components/views/LayoutView.tsx @@ -0,0 +1,80 @@ +import { Typography } from '@mui/material'; +import { useState } from 'react'; +import { useLoaderData, useLocation } from 'react-router-dom'; +import type { + Layout, + NewsletterData, +} from '@newsletters-nx/newsletters-data-client'; +import { + editionIds, + layoutSchema, +} from '@newsletters-nx/newsletters-data-client'; +import { fetchPostApiData } from '../../api-requests/fetch-api-data'; +import { ContentWrapper } from '../../ContentWrapper'; +import { usePermissions } from '../../hooks/user-hooks'; +import { LayoutDisplay } from '../edition-layouts/LayoutDisplay'; +import { JsonEditor } from '../JsonEditor'; + +export const LayoutView = () => { + const data = useLoaderData() as + | { layout: Layout; newsletters: NewsletterData[] } + | undefined; + + const [localLayout, setLocalLayout] = useState(data?.layout); + const location = useLocation(); + const permissions = usePermissions(); + + const editionId = location.pathname.split('/').pop()?.toUpperCase(); + const editionIdIsValid = + (editionId && (editionIds as string[]).includes(editionId)) || false; + + if (!data) { + return ( + + {editionIdIsValid ? ( + no layout set for "{editionId}" + ) : ( + no such edition "{editionId}" + )} + + ); + } + + if (!editionId) { + return ( + + edition id not provided" + + ); + } + + const { layout: originalLayout, newsletters } = data; + + const handleUpdate = async (updatedLayout: Layout) => { + const result = await fetchPostApiData( + `/api/layouts/${editionId}`, + updatedLayout, + ); + if (result) { + setLocalLayout(updatedLayout); + } else { + alert('failed to edit layout'); + } + }; + + return ( + + Layout for {editionId} + + {permissions?.editLayouts && ( + { + void handleUpdate(updatedLayout); + }} + /> + )} + + ); +}; diff --git a/apps/newsletters-ui/src/app/hooks/user-hooks.ts b/apps/newsletters-ui/src/app/hooks/user-hooks.ts index 8e1dd06ee..611e8a1fa 100644 --- a/apps/newsletters-ui/src/app/hooks/user-hooks.ts +++ b/apps/newsletters-ui/src/app/hooks/user-hooks.ts @@ -18,8 +18,10 @@ export const usePermissions = () => { }; useEffect(() => { - void fetchAndSet(); - }, []); + if (!userPermissions) { + void fetchAndSet(); + } + }); return userPermissions; }; diff --git a/apps/newsletters-ui/src/app/loaders/layouts.ts b/apps/newsletters-ui/src/app/loaders/layouts.ts new file mode 100644 index 000000000..a686637b7 --- /dev/null +++ b/apps/newsletters-ui/src/app/loaders/layouts.ts @@ -0,0 +1,39 @@ +import type { LoaderFunction } from 'react-router'; +import type { + EditionsLayouts, + Layout, + NewsletterData, +} from '@newsletters-nx/newsletters-data-client'; +import { fetchApiData } from '../api-requests/fetch-api-data'; + +export const mapLoader: LoaderFunction = async (): Promise<{ + editionsLayouts: EditionsLayouts; + newsletters: NewsletterData[]; +}> => { + const [editionsLayouts = {}, newsletters = []] = await Promise.all([ + fetchApiData(`api/layouts`), + fetchApiData(`api/newsletters`), + ]); + + return { editionsLayouts, newsletters }; +}; + +export const layoutLoader: LoaderFunction = async ({ + params, +}): Promise<{ layout: Layout; newsletters: NewsletterData[] } | undefined> => { + const { id } = params; + if (!id) { + return undefined; + } + + const [layout, newsletters] = await Promise.all([ + fetchApiData(`api/layouts/${id}`), + fetchApiData(`api/newsletters`), + ]); + + if (!layout || !newsletters) { + return undefined; + } + + return { layout, newsletters }; +}; diff --git a/apps/newsletters-ui/src/app/routes/layouts.tsx b/apps/newsletters-ui/src/app/routes/layouts.tsx new file mode 100644 index 000000000..b8f01d31d --- /dev/null +++ b/apps/newsletters-ui/src/app/routes/layouts.tsx @@ -0,0 +1,25 @@ +import type { RouteObject } from 'react-router-dom'; +import { LayoutMapView } from '../components/views/LayoutMapView'; +import { LayoutView } from '../components/views/LayoutView'; +import { ErrorPage } from '../ErrorPage'; +import { Layout } from '../Layout'; +import { layoutLoader, mapLoader } from '../loaders/layouts'; + +export const layoutsRoute: RouteObject = { + path: '/layouts', + element: , + errorElement: , + children: [ + { + path: '', + element: , + loader: mapLoader, + }, + + { + path: ':id', + element: , + loader: layoutLoader, + }, + ], +}; diff --git a/apps/newsletters-ui/src/main.tsx b/apps/newsletters-ui/src/main.tsx index 7a806b97b..10f83ec1e 100644 --- a/apps/newsletters-ui/src/main.tsx +++ b/apps/newsletters-ui/src/main.tsx @@ -6,12 +6,18 @@ import { DefaultStyles } from './app/components/DefaultStyles'; import { draftRoute } from './app/routes/drafts'; import { homeRoute } from './app/routes/home'; import { launchedRoute } from './app/routes/launched'; +import { layoutsRoute } from './app/routes/layouts'; import { appTheme } from './app-theme'; import { addGuardianFonts } from './fonts'; addGuardianFonts(document); -const router = createBrowserRouter([homeRoute, draftRoute, launchedRoute]); +const router = createBrowserRouter([ + homeRoute, + draftRoute, + launchedRoute, + layoutsRoute, +]); const root = ReactDOM.createRoot( document.getElementById('root') as HTMLElement, diff --git a/libs/newsletters-data-client/src/index.ts b/libs/newsletters-data-client/src/index.ts index c16961ffb..d7cc92915 100644 --- a/libs/newsletters-data-client/src/index.ts +++ b/libs/newsletters-data-client/src/index.ts @@ -23,3 +23,4 @@ export * from './lib/user-profile'; export * from './lib/wizard-button-type'; export * from './lib/zod-helpers'; export * from './lib/zod-helpers/user-data-schema'; +export * from './lib/layout-storage'; diff --git a/libs/newsletters-data-client/src/lib/generic-s3-functions.ts b/libs/newsletters-data-client/src/lib/generic-s3-functions.ts new file mode 100644 index 000000000..7aa8f765e --- /dev/null +++ b/libs/newsletters-data-client/src/lib/generic-s3-functions.ts @@ -0,0 +1,127 @@ +import type { S3Client } from '@aws-sdk/client-s3'; +import { + DeleteObjectCommand, + GetObjectCommand, + ListObjectsCommand, + PutObjectCommand, +} from '@aws-sdk/client-s3'; + +type S3StorageService = { + readonly s3Client: S3Client; + readonly bucketName: string; + readonly OBJECT_PREFIX: string; +}; + +// TO DO - update the S3DraftStorage and S3NewsletterStorage to use these functions +// rather than the versions tied to their class + +export const deleteObject = + (storage: S3StorageService) => async (key: string) => { + return await storage.s3Client.send( + new DeleteObjectCommand({ + Bucket: storage.bucketName, + Key: key, + }), + ); + }; + +export const fetchObject = + (storage: S3StorageService) => async (key: string) => { + return await storage.s3Client.send( + new GetObjectCommand({ + Bucket: storage.bucketName, + Key: key, + }), + ); + }; + +export const getListOfObjectsKeys = + (storage: S3StorageService) => async () => { + const listOutput = await storage.s3Client.send( + new ListObjectsCommand({ + Bucket: storage.bucketName, + Prefix: storage.OBJECT_PREFIX, + MaxKeys: 500, + }), + ); + const { Contents = [] } = listOutput; + return Contents.map((item) => item.Key) + .filter((key) => typeof key === 'string') + .filter((item) => item !== storage.OBJECT_PREFIX) as string[]; + }; + +export const getNextId = async ( + storage: S3StorageService, +): Promise => { + const existingNewsletterIds = await getObjectKeyIdNumbers( + storage, + ); + const currentHighestId = existingNewsletterIds.sort((a, b) => a - b).pop(); + return currentHighestId ? currentHighestId + 1 : 1; +}; + +const getStringId = (key: string): string => { + const filenameWithExtension = key.split(':').pop(); + if (!filenameWithExtension) throw new Error('Unexpected key format'); + const stringId = filenameWithExtension.split('.')[0]; + if (!stringId) throw new Error('Unexpected key format'); + return stringId; +}; + +export const putObject = + (storage: S3StorageService) => + async (objectData: T, key: string) => { + const createNewNewsletterCommand = new PutObjectCommand({ + Bucket: storage.bucketName, + Key: storage.OBJECT_PREFIX + key, + Body: JSON.stringify(objectData), + ContentType: 'application/json', + }); + + return storage.s3Client.send(createNewNewsletterCommand); + }; + +export const getObjectKeyIdNumbers = async ( + storage: S3StorageService, +): Promise => { + const s3Response = await storage.s3Client.send( + new ListObjectsCommand({ + Bucket: storage.bucketName, + Prefix: storage.OBJECT_PREFIX, + MaxKeys: 500, + }), + ); + const keys = s3Response.Contents?.map((item) => item.Key) ?? []; + + const ids: number[] = []; + return keys.reduce((acc, cur) => { + if (typeof cur === 'string') { + const numericId = parseInt(getStringId(cur), 10); + if (isNaN(numericId)) { + return acc; + } + return [...acc, numericId]; + } + return acc; + }, ids); +}; + +export const objectExists = + (storage: S3StorageService) => async (Key: string) => { + try { + await storage.s3Client.send( + new GetObjectCommand({ + Bucket: storage.bucketName, + Key, + }), + ); + } catch (e: unknown) { + if (e instanceof Error) { + if (e.name === 'NoSuchKey') { + return false; + } + } + throw e; + } + return true; + }; diff --git a/libs/newsletters-data-client/src/lib/layout-storage/InMemoryLayoutStorage.ts b/libs/newsletters-data-client/src/lib/layout-storage/InMemoryLayoutStorage.ts new file mode 100644 index 000000000..d84d00745 --- /dev/null +++ b/libs/newsletters-data-client/src/lib/layout-storage/InMemoryLayoutStorage.ts @@ -0,0 +1,76 @@ +import type { + SuccessfulStorageResponse, + UnsuccessfulStorageResponse, +} from '../storage-response-types'; +import { StorageRequestFailureReason } from '../storage-response-types'; +import type { LayoutStorage } from './LayoutStorage'; +import type { EditionId, EditionsLayouts, Layout } from './types'; +import { layoutSchema } from './types'; + +export class InMemoryLayoutStorage implements LayoutStorage { + private data: EditionsLayouts; + + constructor(data: EditionsLayouts) { + this.data = data; + } + + create( + edition: EditionId, + layout: Layout, + ): Promise | UnsuccessfulStorageResponse> { + const parseResult = layoutSchema.safeParse(layout); + + if (!parseResult.success) { + console.warn(parseResult.error.issues); + return Promise.resolve({ + ok: false, + message: 'layout in wrong format', + reason: StorageRequestFailureReason.InvalidDataInput, + }); + } + + this.data[edition] = parseResult.data; + return Promise.resolve({ + ok: true, + data: structuredClone(parseResult.data), + }); + } + read( + edition: EditionId, + ): Promise | UnsuccessfulStorageResponse> { + const layout = this.data[edition]; + if (!layout) { + return Promise.resolve({ + ok: false, + message: `No layout set for ${edition}`, + reason: StorageRequestFailureReason.NotFound, + }); + } + return Promise.resolve({ ok: true, data: structuredClone(layout) }); + } + readAll(): Promise< + SuccessfulStorageResponse | UnsuccessfulStorageResponse + > { + return Promise.resolve({ ok: true, data: structuredClone(this.data) }); + } + update( + edition: EditionId, + layout: Layout, + ): Promise | UnsuccessfulStorageResponse> { + return this.create(edition, layout); + } + delete( + edition: EditionId, + ): Promise | UnsuccessfulStorageResponse> { + const layout = this.data[edition]; + if (!layout) { + return Promise.resolve({ + ok: false, + message: `No layout set for ${edition}`, + reason: StorageRequestFailureReason.NotFound, + }); + } + delete this.data[edition]; + return Promise.resolve({ ok: true, data: structuredClone(layout) }); + } +} diff --git a/libs/newsletters-data-client/src/lib/layout-storage/LayoutStorage.ts b/libs/newsletters-data-client/src/lib/layout-storage/LayoutStorage.ts new file mode 100644 index 000000000..4ad956e84 --- /dev/null +++ b/libs/newsletters-data-client/src/lib/layout-storage/LayoutStorage.ts @@ -0,0 +1,29 @@ +import type { + SuccessfulStorageResponse, + UnsuccessfulStorageResponse, +} from '../storage-response-types'; +import type { EditionId, EditionsLayouts, Layout } from './types'; + +export abstract class LayoutStorage { + abstract create( + edition: EditionId, + layout: Layout, + ): Promise | UnsuccessfulStorageResponse>; + + abstract read( + edition: EditionId, + ): Promise | UnsuccessfulStorageResponse>; + + abstract readAll(): Promise< + SuccessfulStorageResponse | UnsuccessfulStorageResponse + >; + + abstract update( + edition: EditionId, + layout: Layout, + ): Promise | UnsuccessfulStorageResponse>; + + abstract delete( + edition: EditionId, + ): Promise | UnsuccessfulStorageResponse>; +} diff --git a/libs/newsletters-data-client/src/lib/layout-storage/S3LayoutStorage.ts b/libs/newsletters-data-client/src/lib/layout-storage/S3LayoutStorage.ts new file mode 100644 index 000000000..0984900b4 --- /dev/null +++ b/libs/newsletters-data-client/src/lib/layout-storage/S3LayoutStorage.ts @@ -0,0 +1,213 @@ +import type { S3Client } from '@aws-sdk/client-s3'; +import { + deleteObject, + fetchObject, + getListOfObjectsKeys, + objectExists, + putObject, +} from '../generic-s3-functions'; +import type { + SuccessfulStorageResponse, + UnsuccessfulStorageResponse, +} from '../storage-response-types'; +import { StorageRequestFailureReason } from '../storage-response-types'; +import type { LayoutStorage } from './LayoutStorage'; +import { objectToLayout } from './objectToLayout'; +import type { EditionId, EditionsLayouts, Layout } from './types'; +import { editionIdSchema, layoutSchema } from './types'; + +export class S3LayoutStorage implements LayoutStorage { + readonly s3Client: S3Client; + readonly bucketName: string; + readonly OBJECT_PREFIX = 'layouts/'; + + constructor(bucketName: string, s3Client: S3Client) { + this.bucketName = bucketName; + this.s3Client = s3Client; + } + + async create( + edition: EditionId, + layout: Layout, + ): Promise | UnsuccessfulStorageResponse> { + const parseResult = layoutSchema.safeParse(layout); + if (!parseResult.success) { + return { + ok: false, + message: 'layout in wrong format', + reason: StorageRequestFailureReason.InvalidDataInput, + }; + } + + try { + const layoutWithSameKeyExists = await this.objectExists( + this.editionIdToKey(edition), + ); + if (layoutWithSameKeyExists) { + return { + ok: false, + message: `Layout ${edition} already exists`, + reason: StorageRequestFailureReason.InvalidDataInput, + }; + } + } catch (err) { + return { + ok: false, + message: `failed to check if layout for ${edition} exists`, + reason: StorageRequestFailureReason.S3Failure, + }; + } + + return this.update(edition, layout); + } + + async read( + edition: EditionId, + ): Promise | UnsuccessfulStorageResponse> { + console.log('read', edition); + + try { + const layout = await this.fetchLayout(edition); + + if (!layout) { + return { + ok: false, + message: `failed to read layout with name '${edition}'`, + reason: StorageRequestFailureReason.NotFound, + }; + } + + return { + ok: true, + data: layout, + }; + } catch (error) { + console.error(error); + return { + ok: false, + message: `failed to read layout for ${edition}`, + reason: StorageRequestFailureReason.S3Failure, + }; + } + } + async readAll(): Promise< + SuccessfulStorageResponse | UnsuccessfulStorageResponse + > { + try { + const listOfObjectsKeys = await this.getListOfObjectsKeys(); + const layouts: EditionsLayouts = {}; + await Promise.all( + listOfObjectsKeys.map(async (key) => { + const editionId = this.keyToEditionId(key); + const s3Response = await this.fetchObject(key); + const responseAsLayout = await objectToLayout(s3Response); + if (responseAsLayout && editionId) { + layouts[editionId] = responseAsLayout; + } + }), + ); + + return { + ok: true, + data: layouts, + }; + } catch (error) { + console.error(error); + return { + ok: false, + message: `failed to list newsletters`, + reason: StorageRequestFailureReason.S3Failure, + }; + } + } + async update( + edition: EditionId, + layout: Layout, + ): Promise | UnsuccessfulStorageResponse> { + const filename = this.editionIdToFileName(edition); + + const parseResult = layoutSchema.safeParse(layout); + if (!parseResult.success) { + return { + ok: false, + message: 'layout in wrong format', + reason: StorageRequestFailureReason.InvalidDataInput, + }; + } + + try { + try { + await this.putObject(layout, filename); + } catch (err) { + return { + ok: false, + message: `failed update layout ${edition}.`, + reason: StorageRequestFailureReason.S3Failure, + }; + } + return { + ok: true, + data: layout, + }; + } catch (error) { + console.error(error); + return { + ok: false, + message: `failed to update newsletter ${edition}`, + reason: StorageRequestFailureReason.S3Failure, + }; + } + } + async delete( + edition: EditionId, + ): Promise | UnsuccessfulStorageResponse> { + const key = this.editionIdToKey(edition); + + try { + const layoutToDelete = await this.fetchLayout(edition); + if (!layoutToDelete) { + return { + ok: false, + message: `no layout for ${edition} to delete`, + reason: StorageRequestFailureReason.NotFound, + }; + } + + await this.deleteObject(key); + return { + ok: true, + data: layoutToDelete, + }; + } catch (err) { + return { + ok: false, + message: `failed to delete layout for ${edition}`, + reason: StorageRequestFailureReason.S3Failure, + }; + } + } + + private async fetchLayout(edition: EditionId): Promise { + const key = `${this.OBJECT_PREFIX}${edition}.json`; + const s3Object = await this.fetchObject(key); + return await objectToLayout(s3Object); + } + + private keyToEditionId(key: string): EditionId | undefined { + // format = 'layouts/UK.json' + const unparsedEditionId = key.split('/').pop()?.split('.').shift(); + return editionIdSchema.safeParse(unparsedEditionId).data; + } + private editionIdToKey(edition: string) { + return `${this.OBJECT_PREFIX}${this.editionIdToFileName(edition)}`; + } + private editionIdToFileName(edition: string) { + return `${edition}.json`; + } + + private fetchObject = fetchObject(this); + private putObject = putObject(this); + private objectExists = objectExists(this); + private getListOfObjectsKeys = getListOfObjectsKeys(this); + private deleteObject = deleteObject(this); +} diff --git a/libs/newsletters-data-client/src/lib/layout-storage/index.ts b/libs/newsletters-data-client/src/lib/layout-storage/index.ts new file mode 100644 index 000000000..76cfea381 --- /dev/null +++ b/libs/newsletters-data-client/src/lib/layout-storage/index.ts @@ -0,0 +1,4 @@ +export * from './LayoutStorage'; +export * from './InMemoryLayoutStorage'; +export * from './S3LayoutStorage'; +export * from './types'; diff --git a/libs/newsletters-data-client/src/lib/layout-storage/objectToLayout.ts b/libs/newsletters-data-client/src/lib/layout-storage/objectToLayout.ts new file mode 100644 index 000000000..3c668ff1f --- /dev/null +++ b/libs/newsletters-data-client/src/lib/layout-storage/objectToLayout.ts @@ -0,0 +1,26 @@ +import type { GetObjectCommandOutput } from '@aws-sdk/client-s3'; +import type { Layout } from './types'; +import { layoutSchema } from './types'; + +export const objectToLayout = async ( + getObjectOutput: GetObjectCommandOutput, +): Promise => { + try { + const { Body } = getObjectOutput; + const content = await Body?.transformToString(); + if (!content) { + return undefined; + } + const parsedContent = JSON.parse(content) as unknown; + const layoutParse = layoutSchema.safeParse(parsedContent); + if (!layoutParse.success) { + console.warn(layoutParse.error.issues); + return undefined; + } + return layoutParse.data; + } catch (err) { + console.warn('objectToLayout failed'); + console.warn(err); + return undefined; + } +}; diff --git a/libs/newsletters-data-client/src/lib/layout-storage/types.ts b/libs/newsletters-data-client/src/lib/layout-storage/types.ts new file mode 100644 index 000000000..2b3781a1b --- /dev/null +++ b/libs/newsletters-data-client/src/lib/layout-storage/types.ts @@ -0,0 +1,25 @@ +import { z } from 'zod'; + +export const editionIdSchema = z.enum(['UK', 'US', 'AU', 'INT', 'EUR']); + +export const editionIds = editionIdSchema.options; +export type EditionId = z.infer; + +const layoutGroup = z + .object({ + title: z.string(), + subtitle: z.string().optional(), + newsletters: z.string().array(), + }); + +export const layoutSchema = z.object({ + groups: layoutGroup.array() +}); + +export type Layout = z.infer; + +export type EditionsLayouts = Partial>; + +export const makeBlankLayout = (): Layout => ({ + groups: [] +}) diff --git a/libs/newsletters-data-client/src/lib/user-profile.ts b/libs/newsletters-data-client/src/lib/user-profile.ts index 2bceaa11f..ff0136788 100644 --- a/libs/newsletters-data-client/src/lib/user-profile.ts +++ b/libs/newsletters-data-client/src/lib/user-profile.ts @@ -46,6 +46,7 @@ export type UserPermissions = { editOphan: boolean; editTags: boolean; editSignUpPage: boolean; + editLayouts: boolean; }; export const permissionsDataSchema = z.record( @@ -75,6 +76,7 @@ export const levelToPermissions = ( ].includes(accessLevel), viewMetaData: [UserAccessLevel.Developer].includes(accessLevel), useJsonEditor: [UserAccessLevel.Developer].includes(accessLevel), + editLayouts: [UserAccessLevel.Developer].includes(accessLevel), editBraze: [UserAccessLevel.BrazeEditor].includes(accessLevel), editOphan: [UserAccessLevel.OphanEditor].includes(accessLevel), editTags: [UserAccessLevel.CentralProduction].includes(accessLevel),