Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

chore: Refactor and migrate Paywall to app router, INTER-911, INTER-459 #161

Merged
merged 4 commits into from
Sep 23, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 19 additions & 1 deletion e2e/paywall.spec.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { test, expect } from '@playwright/test';
import { assertAlert, blockGoogleTagManager, resetScenarios } from './e2eTestUtils';
import { TEST_IDS } from '../src/client/testIDs';
import { PAYWALL_COPY } from '../src/server/paywall/paywallCopy';
import { PAYWALL_COPY } from '../src/app/paywall/api/copy';

test.beforeEach(async ({ page }) => {
await blockGoogleTagManager(page);
Expand All @@ -26,5 +26,23 @@ test.describe('Paywall', () => {
await articles.nth(2).click();
await assertAlert({ page, text: PAYWALL_COPY.limitReached, severity: 'error' });
await expect(page.getByTestId(TEST_IDS.paywall.articleContent)).toBeHidden();
await page.goBack();

// You can still re-read articles you have already viewed
await articles.first().click();
await assertAlert({ page, text: PAYWALL_COPY.lastArticle, severity: 'warning' });
await expect(page.getByTestId(TEST_IDS.paywall.articleContent)).toBeVisible();
});

test('Rereading already viewed article does not increase view count', async ({ page }) => {
const articles = await page.getByTestId(TEST_IDS.paywall.articleCard);

await articles.first().click();
await assertAlert({ page, text: PAYWALL_COPY.nArticlesRemaining(1), severity: 'warning' });
await expect(page.getByTestId(TEST_IDS.paywall.articleContent)).toBeVisible();
await page.reload();

await assertAlert({ page, text: PAYWALL_COPY.nArticlesRemaining(1), severity: 'warning' });
await expect(page.getByTestId(TEST_IDS.paywall.articleContent)).toBeVisible();
});
});
13 changes: 7 additions & 6 deletions src/pages/paywall/index.tsx → src/app/paywall/Paywall.tsx
Original file line number Diff line number Diff line change
@@ -1,18 +1,19 @@
'use client';

import { UseCaseWrapper } from '../../client/components/common/UseCaseWrapper/UseCaseWrapper';
import { ARTICLES } from '../../server/paywall/articles';
import { CustomPageProps } from '../_app';
import { USE_CASES } from '../../client/components/common/content';
import { ArticleCard, ArticleGrid } from '../../client/components/paywall/ArticleGrid';
import { ArticleCard, ArticleGrid } from './components/ArticleGrid';
import { ARTICLES } from './api/articles';

/**
* Main Paywall use case page with article listing
*/
export default function Paywall({ embed }: CustomPageProps) {
export default function Paywall({ embed }: { embed: boolean }) {
const heroArticle = ARTICLES[0];
const gridArticles = ARTICLES.slice(1);
return (
<UseCaseWrapper useCase={USE_CASES.paywall} embed={embed}>
{heroArticle && <ArticleCard article={heroArticle} embed={embed} isHeroArticle />}
<UseCaseWrapper useCase={USE_CASES.paywall}>
{heroArticle && <ArticleCard article={heroArticle} isHeroArticle embed={embed} />}
{gridArticles && <ArticleGrid articles={gridArticles} embed={embed} />}
</UseCaseWrapper>
);
Expand Down
97 changes: 97 additions & 0 deletions src/app/paywall/api/article/[id]/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
import { getAndValidateFingerprintResult, Severity } from '../../../../../server/checks';
import { NextResponse } from 'next/server';
import { ArticleData, ARTICLES } from '../../articles';
import { ArticleViewDbModel } from '../../database';
import { getTodayDateRange } from '../../../../../shared/utils/date';
import { Op } from 'sequelize';
import { PAYWALL_COPY } from '../../copy';

export type ArticleResponse = {
message: string;
severity: Severity;
article?: ArticleData;
remainingViews?: number;
viewedArticles?: number;
};

export type ArticleRequestPayload = {
requestId: string;
};

const ARTICLE_VIEW_LIMIT = 2;

/**
* Fetches article by its ID. Supports paywall logic, which means that we keep track of how many articles were viewed by a given user.
* If a user has exceeded limit of articles that he can view for free, we return an error.
*/
export async function POST(
req: Request,
{ params }: { params: { id: string } },
): Promise<NextResponse<ArticleResponse>> {
const { requestId } = (await req.json()) as ArticleRequestPayload;

// Get the full Identification result from Fingerprint Server API and validate its authenticity
const fingerprintResult = await getAndValidateFingerprintResult({ requestId, req });
if (!fingerprintResult.okay) {
return NextResponse.json({ severity: 'error', message: fingerprintResult.error }, { status: 403 });
}

// Get visitorId from the Server API Identification event
const visitorId = fingerprintResult.data.products?.identification?.data?.visitorId;
if (!visitorId) {
return NextResponse.json({ severity: 'error', message: 'Visitor ID not found.' }, { status: 403 });
}

const articleId = params.id;
const article = ARTICLES.find((article) => article.id === articleId);
if (!article) {
return NextResponse.json({ severity: 'error', message: 'Article not found' }, { status: 404 });
}

// Check how many articles were viewed by this visitor ID today
const oldViewCount = await ArticleViewDbModel.count({
where: { visitorId, timestamp: { [Op.between]: getTodayDateRange() } },
});

// Check if this visitor has already viewed this specific article
const viewedThisArticleBefore = await ArticleViewDbModel.findOne({
where: {
visitorId,
articleId,
timestamp: { [Op.between]: getTodayDateRange() },
},
});

// If the visitor is trying to view a new article beyond the daily limit, return an error
if (oldViewCount >= ARTICLE_VIEW_LIMIT && !viewedThisArticleBefore) {
return NextResponse.json({ severity: 'error', message: PAYWALL_COPY.limitReached }, { status: 403 });
}

// Otherwise, save the article view and return the article
await saveArticleView(articleId, visitorId);
const newViewCount = viewedThisArticleBefore ? oldViewCount : oldViewCount + 1;
const articlesRemaining = ARTICLE_VIEW_LIMIT - newViewCount;
return NextResponse.json({
severity: 'warning',
message: articlesRemaining > 0 ? PAYWALL_COPY.nArticlesRemaining(articlesRemaining) : PAYWALL_COPY.lastArticle,
article,
articlesRemaining,
articlesViewed: newViewCount,
});
}

/**
* Saves article view into the database. If it already exists, we update its timestamp.
*/
async function saveArticleView(articleId: string, visitorId: string) {
const [view, created] = await ArticleViewDbModel.findOrCreate({
where: { articleId, visitorId, timestamp: { [Op.between]: getTodayDateRange() } },
defaults: { articleId, visitorId, timestamp: new Date() },
});

if (!created) {
view.timestamp = new Date();
await view.save();
}
return view;
}
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
/**
* Mock articles "database". In the real world, these articles could be fetched from anywhere.
* */

import ArticleHeroSvg from './images/articleHero.svg';
import GenericAvatarImage from './images/genericAvatar.png';

Expand Down
File renamed without changes.
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { Attributes, DataTypes, InferAttributes, InferCreationAttributes, Model } from 'sequelize';
import { sequelize } from '../server';
import { sequelize } from '../../../server/server';

interface ArticleViewAttributes
extends Model<InferAttributes<ArticleViewAttributes>, InferCreationAttributes<ArticleViewAttributes>> {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,45 +1,43 @@
import { useRouter } from 'next/router';
'use client';

import { Skeleton, SkeletonTypeMap } from '@mui/material';
import { UseCaseWrapper } from '../../../../client/components/common/UseCaseWrapper/UseCaseWrapper';
import { CustomPageProps } from '../../../_app';
import { USE_CASES } from '../../../../client/components/common/content';

import Image from 'next/image';
import styles from '../../paywall.module.scss';
import { Alert } from '../../../../client/components/common/Alert/Alert';
import { ARTICLES } from '../../../../server/paywall/articles';
import { useVisitorData } from '@fingerprintjs/fingerprintjs-pro-react';
import { useQuery } from 'react-query';
import { ArticleResponse } from '../../../api/paywall/article/[id]';
import { TEST_IDS } from '../../../../client/testIDs';
import { ArticleGrid, Byline } from '../../../../client/components/paywall/ArticleGrid';
import { PAYWALL_COPY } from '../../../../server/paywall/paywallCopy';
import { ArticleGrid, Byline } from '../../components/ArticleGrid';
import { BackArrow } from '../../../../client/components/common/BackArrow/BackArrow';
import { ArticleRequestPayload, ArticleResponse } from '../../api/article/[id]/route';
import { ARTICLES } from '../../api/articles';

function ArticleSkeleton({ animation = false }: { animation?: SkeletonTypeMap['props']['animation'] }) {
const skeletons = Array.from({ length: 4 }).map((_, index) => <Skeleton key={index} animation={animation} />);
return <>{skeletons}</>;
}

export default function Article({ embed }: CustomPageProps) {
const router = useRouter();
const articleId = router.query.id;

export function Article({ articleId, embed }: { articleId: string; embed: boolean }) {
const { getData: getVisitorData } = useVisitorData({
ignoreCache: true,
});

const { data: articleData } = useQuery<ArticleResponse>(['GET_ARTICLE_QUERY', articleId], async () => {
const { requestId, visitorId } = await getVisitorData();
return await (
await fetch(`/api/paywall/article/${articleId}`, {
const { data: articleData, error: articleError } = useQuery<ArticleRequestPayload, Error, ArticleResponse>(
['GET_ARTICLE_QUERY', articleId],
async () => {
const { requestId } = await getVisitorData();
const response = await fetch(`/paywall/api/article/${articleId}`, {
method: 'POST',
body: JSON.stringify({ requestId, visitorId }),
})
).json();
});
body: JSON.stringify({ requestId } satisfies ArticleRequestPayload),
});
return await response.json();
},
);

const { article, remainingViews } = articleData?.data ?? {};
const { article } = articleData ?? {};
const returnUrl = `/paywall${embed ? '/embed' : ''}`;
const relatedArticles = ARTICLES.filter((article) => article.id !== articleId).slice(0, 4);

Expand All @@ -48,14 +46,8 @@ export default function Article({ embed }: CustomPageProps) {
<div className={styles.articleContainer}>
<BackArrow as='Link' href={returnUrl} label='Back to articles' testId={TEST_IDS.paywall.goBack} />
{!articleData && <ArticleSkeleton animation='wave' />}
{articleData && articleData.message && articleData.severity !== 'success' && (
<Alert severity={articleData.severity}>{articleData.message}</Alert>
)}
{articleData && articleData.severity === 'success' && remainingViews !== undefined && (
<Alert severity='warning'>
{remainingViews > 0 ? PAYWALL_COPY.nArticlesRemaining(remainingViews) : PAYWALL_COPY.lastArticle}
</Alert>
)}
{articleData && articleData.message && <Alert severity={articleData.severity}>{articleData.message}</Alert>}
{articleError && <Alert severity='error'>{articleError.message}</Alert>}
{article && (
<div className={styles.article} data-testid={TEST_IDS.paywall.articleContent}>
<Image src={article.image} alt={article.title} sizes='100vw' className={styles.articleImage} />
Expand Down
14 changes: 14 additions & 0 deletions src/app/paywall/article/[id]/embed/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { ARTICLES } from '../../../api/articles';
import { USE_CASES } from '../../../../../client/components/common/content';
import { generateUseCaseMetadata } from '../../../../../client/components/common/seo';
import { Article } from '../Article';

export async function generateStaticParams() {
return ARTICLES.map((article) => ({ id: article.id }));
}

export const metadata = generateUseCaseMetadata(USE_CASES.paywall);

export default function ArticlePage({ params }: { params: { id: string } }) {
return <Article articleId={params.id} embed={true} />;
}
14 changes: 14 additions & 0 deletions src/app/paywall/article/[id]/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { ARTICLES } from '../../api/articles';
import { USE_CASES } from '../../../../client/components/common/content';
import { generateUseCaseMetadata } from '../../../../client/components/common/seo';
import { Article } from './Article';

export async function generateStaticParams() {
return ARTICLES.map((article) => ({ id: article.id }));
}

export const metadata = generateUseCaseMetadata(USE_CASES.paywall);

export default function ArticlePage({ params }: { params: { id: string } }) {
return <Article articleId={params.id} embed={false} />;
}
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
import classNames from 'classnames';
import { useRouter } from 'next/router';
import { useRouter } from 'next/navigation';
import { FunctionComponent } from 'react';
import { ArticleData } from '../../../server/paywall/articles';
import { TEST_IDS } from '../../testIDs';
import { TEST_IDS } from '../../../client/testIDs';
import Image from 'next/image';
import styles from './articleGrid.module.scss';
import BylineDot from './dot.svg';
import { ArticleData } from '../api/articles';

function calculateReadingTime(text: string[], wordsPerMinute = 200) {
const words = text
Expand Down Expand Up @@ -36,7 +36,7 @@ export const Byline = ({ article, includeReadingTime }: { article: ArticleData;
*/
type ArticleCardProps = {
article: ArticleData;
embed?: boolean;
embed: boolean;
isHeroArticle?: boolean;
};

Expand Down Expand Up @@ -66,7 +66,7 @@ export const ArticleCard: FunctionComponent<ArticleCardProps> = ({ article, embe
);
};

export const ArticleGrid: FunctionComponent<{ articles: ArticleData[]; embed?: boolean }> = ({ articles, embed }) => {
export const ArticleGrid: FunctionComponent<{ articles: ArticleData[]; embed: boolean }> = ({ articles, embed }) => {
return (
<div className={styles.articles}>
{articles.map((article) => (
Expand Down
9 changes: 9 additions & 0 deletions src/app/paywall/embed/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { USE_CASES } from '../../../client/components/common/content';
import { generateUseCaseMetadata } from '../../../client/components/common/seo';
import Paywall from '../Paywall';

export const metadata = generateUseCaseMetadata(USE_CASES.paywall);

export default function PaywallPage() {
return <Paywall embed={true} />;
}
9 changes: 9 additions & 0 deletions src/app/paywall/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { USE_CASES } from '../../client/components/common/content';
import { generateUseCaseMetadata } from '../../client/components/common/seo';
import Paywall from './Paywall';

export const metadata = generateUseCaseMetadata(USE_CASES.paywall);

export default function PaywallPage() {
return <Paywall embed={false} />;
}
20 changes: 0 additions & 20 deletions src/client/api/loan-risk/use-request-loan.ts

This file was deleted.

2 changes: 1 addition & 1 deletion src/pages/api/admin/reset.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,14 @@ import {
UserSearchHistoryDbModel,
} from '../../../server/personalization/database';
import { LoanRequestDbModel } from '../../../app/loan-risk/api/request-loan/database';
import { ArticleViewDbModel } from '../../../server/paywall/database';
import { CouponClaimDbModel } from '../../../server/coupon-fraud/database';
import { Severity, getAndValidateFingerprintResult } from '../../../server/checks';
import { NextApiRequest, NextApiResponse } from 'next';
import { deleteBlockedIp } from '../../../server/botd-firewall/blockedIpsDatabase';
import { syncFirewallRuleset } from '../../../server/botd-firewall/cloudflareApiHelper';
import { SmsVerificationDatabaseModel } from '../../../server/sms-pumping/database';
import { LoginAttemptDbModel } from '../../../server/credentialStuffing/database';
import { ArticleViewDbModel } from '../../../app/paywall/api/database';

export type ResetResponse = {
message: string;
Expand Down
Loading
Loading