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),