Skip to content

Commit

Permalink
[DEV-1309] Update WebinarQuestionsForm to use dynamodb (#573)
Browse files Browse the repository at this point in the history
  • Loading branch information
datalek authored Jan 25, 2024
1 parent 8943afe commit dc5ece2
Show file tree
Hide file tree
Showing 13 changed files with 2,655 additions and 555 deletions.
5 changes: 5 additions & 0 deletions .changeset/pink-adults-care.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"nextjs-website": patch
---

[DEV-1309] Update WebinarQuestionsForm logic
5 changes: 3 additions & 2 deletions apps/nextjs-website/.env.default
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@ ALLOW_CRAWLER="false"
NEXT_PUBLIC_COGNITO_USER_POOL_WEB_CLIENT_ID=""
NEXT_PUBLIC_COGNITO_REGION=""
NEXT_PUBLIC_COGNITO_USER_POOL_ID=""
NEXT_PUBLIC_COGNITO_IDENTITY_POOL_ID=""
NEXT_PUBLIC_ENVIRONMENT="dev"
NEXT_PUBLIC_COOKIE_DOMAIN_SCRIPT="a8c87faf-1769-4c95-a2e5-a6fff26eadab-test"
NEXT_PUBLIC_WEBINAR_QUESTION_URL="https://example.com"
NEXT_PUBLIC_WEBINAR_QUESTION_SHEET_NAME="sheet_name"
# three days in seconds
NEXT_PUBLIC_WEBINAR_QUESTION_LIFETIME_IN_SECONDS=259200
6 changes: 6 additions & 0 deletions apps/nextjs-website/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@
"dependencies": {
"@aws-amplify/auth": "^5.6.6",
"@aws-amplify/ui-react": "^5.3.1",
"@aws-sdk/client-dynamodb": "^3.496.0",
"@aws-sdk/credential-providers": "^3.496.0",
"@emotion/react": "^11.10.5",
"@emotion/styled": "^11.10.5",
"@markdoc/markdoc": "0.3.0",
Expand All @@ -27,6 +29,9 @@
"fp-ts": "^2.13.1",
"gitbook-docs": "*",
"io-ts": "^2.2.20",
"io-ts-types": "^0.5.19",
"monocle-ts": "^2.3.13",
"newtype-ts": "^0.3.5",
"next": "^13.4.19",
"next-intl": "^2.20.2",
"react": "18.2.0",
Expand All @@ -44,6 +49,7 @@
"eslint-config-custom": "*",
"eslint-config-next": "13.4.19",
"jest": "^29.5.0",
"jest-mock-extended": "^3.0.5",
"ts-jest": "^29.1.1",
"typescript": "5.1.6"
},
Expand Down
47 changes: 47 additions & 0 deletions apps/nextjs-website/src/AppEnv.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import { pipe } from 'fp-ts/lib/function';
import * as E from 'fp-ts/lib/Either';
import { Config, makeConfig, publicEnv } from './config';
import { WebinarEnv } from './lib/webinars/webinarQuestions';
import { DynamoDBClient } from '@aws-sdk/client-dynamodb';
import { makeAwsCredentialsFromCognito } from './lib/makeAwsCredentialsFromCognito';
import { Auth } from 'aws-amplify';

// This type represents the environment of the application. It contains
// configuration as well as other dependencies required by the application. In
// other words contains all runtime configuration and global functions that may
// be mockable
export type AppEnv = {
readonly config: Config;
} & WebinarEnv;

// given environment variables produce an AppEnv
const makeAppEnv = (
env: Record<string, undefined | string>
): E.Either<string, AppEnv> =>
pipe(
makeConfig(env),
E.map((config) => ({
config,
questionLifetimeInSeconds:
config.NEXT_PUBLIC_WEBINAR_QUESTION_LIFETIME_IN_SECONDS,
nowDate: () => new Date(),
dynamoDBClient: new DynamoDBClient({
region: config.NEXT_PUBLIC_COGNITO_REGION,
credentials: makeAwsCredentialsFromCognito(
config,
// passing Auth.currentSession raise an error because
// Auth.currentSession is not able to retrieve all the information
() => Auth.currentSession()
),
}),
}))
);

// an AppEnv instance ready to be used
export const appEnv = pipe(
makeAppEnv(publicEnv),
E.getOrElseW((errors) => {
// eslint-disable-next-line functional/no-throw-statements
throw errors;
})
);
Original file line number Diff line number Diff line change
@@ -1,15 +1,12 @@
'use client';
import { snackbarAutoHideDurationMs } from '@/config';
import { addWebinarQuestion } from '@/helpers/webinarQuestions.helpers';
import { DevPortalUser } from '@/lib/types/auth';
import { sendWebinarQuestion } from '@/lib/webinarApi';
import { Done } from '@mui/icons-material';
import { LoadingButton } from '@mui/lab';
import { Alert, Card, Snackbar, TextField, Typography } from '@mui/material';
import { useTranslations } from 'next-intl';
import { useCallback, useState } from 'react';
import * as TE from 'fp-ts/TaskEither';
import * as E from 'fp-ts/Either';
import { pipe } from 'fp-ts/function';

type FormState = 'submitting' | 'submitted' | undefined;
type WebinarQuestionsFormProps = {
Expand All @@ -32,13 +29,11 @@ export const WebinarQuestionsForm = ({
if (!question) return;

setFormState('submitting');
return await addWebinarQuestion({
email: user.attributes.email,
return await sendWebinarQuestion({
webinarId: webinarSlug,
givenName: user.attributes.given_name,
familyName: user.attributes.family_name,
question: question,
webinarSlug: webinarSlug,
date: new Date().toISOString(),
});
}, [webinarSlug, user, question]);

Expand Down
46 changes: 41 additions & 5 deletions apps/nextjs-website/src/config.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,9 @@
import * as t from 'io-ts';
import * as tt from 'io-ts-types';
import { pipe } from 'fp-ts/lib/function';
import * as E from 'fp-ts/lib/Either';
import * as PR from 'io-ts/lib/PathReporter';

// TODO: Add environment parser
export const docsPath = process.env.PATH_TO_GITBOOK_DOCS;
export const cookieDomainScript = process.env.NEXT_PUBLIC_COOKIE_DOMAIN_SCRIPT;
Expand Down Expand Up @@ -33,11 +39,6 @@ export const baseUrl = isProduction
export const defaultOgTagImage = `${baseUrl}/images/dev-portal-home.jpg`;
export const resetResendEmailAfterMs = 4_000;

export const webinarQuestionConfig = {
url: process.env.NEXT_PUBLIC_WEBINAR_QUESTION_URL,
resource: process.env.NEXT_PUBLIC_WEBINAR_QUESTION_SHEET_NAME,
};

export const defaultLanguage = { id: 'it', value: 'Italiano' };
export const languages = [defaultLanguage];

Expand All @@ -54,3 +55,38 @@ export const timeOptions: Intl.DateTimeFormatOptions = {
hour: '2-digit',
minute: '2-digit',
};

// TODO: Migrate all the above environment
// publicenv exists to allow nextjs to correctly replace environments at build
// time, without this copy in some cases some NEXT_PUBLIC environments will be
// undefined
// https://nextjs.org/docs/pages/building-your-application/configuring/environment-variables#bundling-environment-variables-for-the-browser
export const publicEnv = {
NEXT_PUBLIC_COGNITO_REGION: process.env.NEXT_PUBLIC_COGNITO_REGION,
NEXT_PUBLIC_COGNITO_USER_POOL_ID:
process.env.NEXT_PUBLIC_COGNITO_USER_POOL_ID,
NEXT_PUBLIC_COGNITO_IDENTITY_POOL_ID:
process.env.NEXT_PUBLIC_COGNITO_IDENTITY_POOL_ID,
NEXT_PUBLIC_WEBINAR_QUESTION_LIFETIME_IN_SECONDS:
process.env.NEXT_PUBLIC_WEBINAR_QUESTION_LIFETIME_IN_SECONDS,
};

const ConfigCodec = t.type({
NEXT_PUBLIC_COGNITO_REGION: t.string,
NEXT_PUBLIC_COGNITO_USER_POOL_ID: t.string,
NEXT_PUBLIC_COGNITO_IDENTITY_POOL_ID: t.string,
NEXT_PUBLIC_WEBINAR_QUESTION_LIFETIME_IN_SECONDS: t.string.pipe(
tt.NumberFromString
),
});

export type Config = t.TypeOf<typeof ConfigCodec>;

// parse config from environment variables
export const makeConfig = (
env: Record<string, undefined | string>
): E.Either<string, Config> =>
pipe(
ConfigCodec.decode(env),
E.mapLeft((errors) => PR.failure(errors).join('\n'))
);
53 changes: 0 additions & 53 deletions apps/nextjs-website/src/helpers/webinarQuestions.helpers.ts

This file was deleted.

26 changes: 26 additions & 0 deletions apps/nextjs-website/src/lib/makeAwsCredentialsFromCognito.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { Config } from '@/config';
import { fromCognitoIdentityPool } from '@aws-sdk/credential-providers';
import { Auth } from 'aws-amplify';

// Create a aws credentials provider given a cognito user
export const makeAwsCredentialsFromCognito = (
config: Config,
getCurrentSession: typeof Auth.currentSession
) => {
const providerName = `cognito-idp.${config.NEXT_PUBLIC_COGNITO_REGION}.amazonaws.com/${config.NEXT_PUBLIC_COGNITO_USER_POOL_ID}`;
// create custom credentials provider
return async () => {
// get session of the user
const session = await getCurrentSession();

const credentials = fromCognitoIdentityPool({
clientConfig: { region: config.NEXT_PUBLIC_COGNITO_REGION },
identityPoolId: config.NEXT_PUBLIC_COGNITO_IDENTITY_POOL_ID,
logins: {
[providerName]: session.getIdToken().getJwtToken(),
},
});

return await credentials();
};
};
27 changes: 27 additions & 0 deletions apps/nextjs-website/src/lib/webinarApi.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
// This file contains application logic adapted to be easily integrated by pages
// or components within the app and components folders, e.g.: provide a valid
// application environment (AppEnv) and transform TaskEither to Promise
import { pipe } from 'fp-ts/function';
import * as TE from 'fp-ts/TaskEither';
import { appEnv } from '@/AppEnv';
import {
InsertWebinarQuestion,
insertWebinarQuestion,
listWebinarQuestions,
} from './webinars/webinarQuestions';

const makePromiseFromTE = <E, A>(input: TE.TaskEither<E, A>) =>
pipe(
input,
TE.fold(
// eslint-disable-next-line functional/no-promise-reject
(error) => () => Promise.reject(error),
(result) => () => Promise.resolve(result)
)
);

export const sendWebinarQuestion = (question: InsertWebinarQuestion) =>
pipe(insertWebinarQuestion(question)(appEnv), makePromiseFromTE)();

export const getWebinarQuestionList = (webinarId: string) =>
pipe(listWebinarQuestions(webinarId)(appEnv), makePromiseFromTE)();
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import { mock } from 'jest-mock-extended';
import * as E from 'fp-ts/lib/Either';
import {
WebinarEnv,
insertWebinarQuestion,
listWebinarQuestions,
} from '../webinarQuestions';
import { PutItemCommand, QueryCommand } from '@aws-sdk/client-dynamodb';
import { makeDynamodbItemFromWebinarQuestion } from '../dynamodb/codec';

const aWebinarQuestion = {
webinarId: 'aWebinarId',
givenName: 'aGivenName',
familyName: 'aFamilyName',
question: 'aQuestion',
createdAt: new Date(),
expireAt: new Date('2024-01-22T11:36:31.000Z'),
};
const aDynamoDBItem = makeDynamodbItemFromWebinarQuestion({
...aWebinarQuestion,
});

const makeTestWebinarEnv = () => {
const nowDate = new Date(0);
const dynamoDBClientMock = mock<WebinarEnv['dynamoDBClient']>();
const nowDateMock = jest.fn();
// default mock behaviour
dynamoDBClientMock.send.mockImplementation((cmd) => {
if (cmd instanceof PutItemCommand) return Promise.resolve({});
else if (cmd instanceof QueryCommand)
return Promise.resolve({ Items: [aDynamoDBItem] });
// eslint-disable-next-line functional/no-promise-reject
else return Promise.reject(new Error('Unsupported command'));
});
nowDateMock.mockImplementation(() => nowDate);
const env = {
questionLifetimeInSeconds: 1000,
dynamoDBClient: dynamoDBClientMock,
nowDate: nowDateMock,
};
return { env, dynamoDBClientMock, nowDateMock, nowDate };
};

describe('webinarQuestions', () => {
describe('insertWebinarQuestion', () => {
it('should send dynamodb put command', async () => {
const { env, dynamoDBClientMock, nowDateMock } = makeTestWebinarEnv();
const actual = await insertWebinarQuestion(aWebinarQuestion)(env)();
const expected = E.right(undefined);

expect(dynamoDBClientMock.send).toBeCalledTimes(1);
expect(nowDateMock).toBeCalledTimes(1);
expect(actual).toStrictEqual(expected);
});
it('should return error if send returns an error', async () => {
const error = new Error();
const { env, dynamoDBClientMock, nowDateMock } = makeTestWebinarEnv();

// override the mock to simulate a rejection
// eslint-disable-next-line functional/no-promise-reject
dynamoDBClientMock.send.mockImplementation(() => Promise.reject(error));

const actual = await insertWebinarQuestion(aWebinarQuestion)(env)();
const expected = E.left(error);

expect(dynamoDBClientMock.send).toBeCalledTimes(1);
expect(nowDateMock).toBeCalledTimes(1);
expect(actual).toStrictEqual(expected);
});
});

describe('listWebinarQuestion', () => {
it('should return a list of webinar questions', async () => {
const { env } = makeTestWebinarEnv();
const { webinarId } = aWebinarQuestion;
const actual = await listWebinarQuestions(webinarId)(env)();
const expected = E.right([aWebinarQuestion]);

expect(actual).toStrictEqual(expected);
});
});
});
Loading

0 comments on commit dc5ece2

Please sign in to comment.