From 9dd668ec79656c7bb0a069e8545188b22328f694 Mon Sep 17 00:00:00 2001 From: Juraj Uhlar Date: Fri, 4 Oct 2024 11:17:13 +0200 Subject: [PATCH] Chore: Refactor and migrate Personalization to `app` router INTER-911, INTER-459 (#165) * chore: move personalization client to app * chore: move personalization api to app part 1 * chore: move personalization api to app part 2 * chore: move personalization api to app part 3 * fix search terms query * fix products query * rough refactor done * move files to `app` * move files to `app`, remove deprecated code * chore: self review fixes * chore: self review fixes --- e2e/coupon-fraud.spec.ts | 2 +- e2e/credential-stuffing.spec.ts | 2 +- e2e/e2eTestUtils.ts | 2 +- .../reset.ts => app/api/admin/reset/route.ts} | 49 +++----- .../api/get-blocked-ips/blockedIpsDatabase.ts | 2 +- .../api/get-bot-visits/botVisitDatabase.ts | 2 +- .../coupon-fraud/api/claim}/copy.ts | 0 .../coupon-fraud/api/claim}/database.ts | 2 +- src/app/coupon-fraud/api/claim/route.ts | 4 +- .../api/authenticate}/copy.ts | 0 .../api/authenticate}/database.ts | 2 +- .../api/authenticate/route.ts | 6 +- src/app/loan-risk/LoanRisk.tsx | 20 +++- .../loan-risk/api/request-loan/database.ts | 2 +- .../payment-fraud/api/place-order/database.ts | 2 +- .../payment-fraud/api/place-order/route.ts | 2 +- src/app/paywall/api/database.ts | 2 +- .../personalization/Personalization.tsx} | 29 +++-- .../api/cart/add-item/route.ts | 75 ++++++++++++ .../api/cart/get-items/route.ts | 48 ++++++++ .../api/cart/remove-item/route.ts | 58 ++++++++++ .../personalization/api}/database.ts | 47 ++------ .../api/get-products/route.ts} | 51 +++++---- .../api/get-search-history/route.ts | 50 ++++++++ .../personalization/api}/seed.ts | 2 +- .../components}/productCard.module.scss | 0 .../components}/productCard.tsx | 18 +-- .../components}/searchComponents.module.scss | 0 .../components}/searchComponents.tsx | 4 +- src/app/personalization/embed/page.tsx | 9 ++ src/app/personalization/hooks/use-cart.ts | 85 ++++++++++++++ .../use-personalization-notification.tsx | 4 +- src/app/personalization/hooks/use-products.ts | 31 +++++ .../hooks/use-search-history.ts | 30 +++++ .../personalization}/img/heart.svg | 0 .../personalization}/img/search.svg | 0 src/app/personalization/page.tsx | 9 ++ .../personalization.module.scss | 0 src/app/sms-pumping/api/database.ts | 2 +- src/app/web-scraping/WebScraping.tsx | 9 +- src/client/api/api.ts | 18 --- src/client/api/personalization/use-cart.ts | 60 ---------- .../api/personalization/use-products.ts | 26 ----- .../api/personalization/use-search-history.ts | 35 ------ .../personalization/use-user-preferences.ts | 80 ------------- src/client/components/snackbar-action.tsx | 12 -- src/client/hooks/useReset/useReset.tsx | 2 +- src/client/loan-risk/validation.ts | 14 --- .../api/personalization/cart/add-item.ts | 53 --------- .../api/personalization/cart/get-items.ts | 35 ------ .../api/personalization/cart/remove-item.ts | 43 ------- .../api/personalization/get-search-history.ts | 33 ------ .../personalization/get-user-preferences.ts | 39 ------- .../update-user-preferences.ts | 43 ------- src/pages/personalization/embed.tsx | 13 --- src/server/checkResult.ts | 55 --------- src/server/checks.test.ts | 75 +++--------- src/server/checks.ts | 108 ++---------------- .../personalization-endpoint.ts | 35 ------ .../personalization/visitor-validations.ts | 71 ------------ src/server/response.ts | 39 ------- src/server/sequelize.ts | 9 ++ src/server/server.ts | 105 ----------------- 63 files changed, 552 insertions(+), 1113 deletions(-) rename src/{pages/api/admin/reset.ts => app/api/admin/reset/route.ts} (55%) rename src/{server/coupon-fraud => app/coupon-fraud/api/claim}/copy.ts (100%) rename src/{server/coupon-fraud => app/coupon-fraud/api/claim}/database.ts (93%) rename src/{server/credentialStuffing => app/credential-stuffing/api/authenticate}/copy.ts (100%) rename src/{server/credentialStuffing => app/credential-stuffing/api/authenticate}/database.ts (94%) rename src/{pages/personalization/index.tsx => app/personalization/Personalization.tsx} (82%) create mode 100644 src/app/personalization/api/cart/add-item/route.ts create mode 100644 src/app/personalization/api/cart/get-items/route.ts create mode 100644 src/app/personalization/api/cart/remove-item/route.ts rename src/{server/personalization => app/personalization/api}/database.ts (70%) rename src/{pages/api/personalization/get-products.ts => app/personalization/api/get-products/route.ts} (53%) create mode 100644 src/app/personalization/api/get-search-history/route.ts rename src/{server/personalization => app/personalization/api}/seed.ts (94%) rename src/{client/components/personalization => app/personalization/components}/productCard.module.scss (100%) rename src/{client/components/personalization => app/personalization/components}/productCard.tsx (84%) rename src/{client/components/personalization => app/personalization/components}/searchComponents.module.scss (100%) rename src/{client/components/personalization => app/personalization/components}/searchComponents.tsx (94%) create mode 100644 src/app/personalization/embed/page.tsx create mode 100644 src/app/personalization/hooks/use-cart.ts rename src/{client/hooks/personalization => app/personalization/hooks}/use-personalization-notification.tsx (89%) create mode 100644 src/app/personalization/hooks/use-products.ts create mode 100644 src/app/personalization/hooks/use-search-history.ts rename src/{client => app/personalization}/img/heart.svg (100%) rename src/{client => app/personalization}/img/search.svg (100%) create mode 100644 src/app/personalization/page.tsx rename src/{pages => app}/personalization/personalization.module.scss (100%) delete mode 100644 src/client/api/api.ts delete mode 100644 src/client/api/personalization/use-cart.ts delete mode 100644 src/client/api/personalization/use-products.ts delete mode 100644 src/client/api/personalization/use-search-history.ts delete mode 100644 src/client/api/personalization/use-user-preferences.ts delete mode 100644 src/client/components/snackbar-action.tsx delete mode 100644 src/client/loan-risk/validation.ts delete mode 100644 src/pages/api/personalization/cart/add-item.ts delete mode 100644 src/pages/api/personalization/cart/get-items.ts delete mode 100644 src/pages/api/personalization/cart/remove-item.ts delete mode 100644 src/pages/api/personalization/get-search-history.ts delete mode 100644 src/pages/api/personalization/get-user-preferences.ts delete mode 100644 src/pages/api/personalization/update-user-preferences.ts delete mode 100644 src/pages/personalization/embed.tsx delete mode 100644 src/server/checkResult.ts delete mode 100644 src/server/personalization/personalization-endpoint.ts delete mode 100644 src/server/personalization/visitor-validations.ts delete mode 100644 src/server/response.ts create mode 100644 src/server/sequelize.ts delete mode 100644 src/server/server.ts diff --git a/e2e/coupon-fraud.spec.ts b/e2e/coupon-fraud.spec.ts index f881fa72..2067a694 100644 --- a/e2e/coupon-fraud.spec.ts +++ b/e2e/coupon-fraud.spec.ts @@ -1,7 +1,7 @@ import { Page, test, expect } from '@playwright/test'; import { blockGoogleTagManager, resetScenarios } from './e2eTestUtils'; import { TEST_IDS } from '../src/client/testIDs'; -import { COUPON_FRAUD_COPY } from '../src/server/coupon-fraud/copy'; +import { COUPON_FRAUD_COPY } from '../src/app/coupon-fraud/api/claim/copy'; const insertCoupon = async (page: Page, coupon: string) => { await page.getByTestId(TEST_IDS.couponFraud.couponCode).fill(coupon); diff --git a/e2e/credential-stuffing.spec.ts b/e2e/credential-stuffing.spec.ts index 65875bd1..d77515b2 100644 --- a/e2e/credential-stuffing.spec.ts +++ b/e2e/credential-stuffing.spec.ts @@ -1,7 +1,7 @@ import { Page, test } from '@playwright/test'; import { blockGoogleTagManager, resetScenarios } from './e2eTestUtils'; import { TEST_IDS } from '../src/client/testIDs'; -import { CREDENTIAL_STUFFING_COPY } from '../src/server/credentialStuffing/copy'; +import { CREDENTIAL_STUFFING_COPY } from '../src/app/credential-stuffing/api/authenticate/copy'; const submitForm = async (page: Page) => { // Waits for the button to be clickable out of the box diff --git a/e2e/e2eTestUtils.ts b/e2e/e2eTestUtils.ts index 3f6a240f..f3ca58ec 100644 --- a/e2e/e2eTestUtils.ts +++ b/e2e/e2eTestUtils.ts @@ -1,6 +1,6 @@ import { Page, expect } from '@playwright/test'; import { TEST_ATTRIBUTES, TEST_IDS } from '../src/client/testIDs'; -import { Severity } from '../src/server/server'; +import { Severity } from '../src/server/checks'; /** * diff --git a/src/pages/api/admin/reset.ts b/src/app/api/admin/reset/route.ts similarity index 55% rename from src/pages/api/admin/reset.ts rename to src/app/api/admin/reset/route.ts index 362d2d37..fea10ccf 100644 --- a/src/pages/api/admin/reset.ts +++ b/src/app/api/admin/reset/route.ts @@ -1,19 +1,14 @@ -import { isValidPostRequest } from '../../../server/server'; -import { - UserCartItemDbModel, - UserPreferencesDbModel, - UserSearchHistoryDbModel, -} from '../../../server/personalization/database'; -import { LoanRequestDbModel } from '../../../app/loan-risk/api/request-loan/database'; -import { CouponClaimDbModel } from '../../../server/coupon-fraud/database'; -import { Severity, getAndValidateFingerprintResult } from '../../../server/checks'; -import { NextApiRequest, NextApiResponse } from 'next'; -import { LoginAttemptDbModel } from '../../../server/credentialStuffing/database'; -import { ArticleViewDbModel } from '../../../app/paywall/api/database'; -import { SmsVerificationDatabaseModel } from '../../../app/sms-pumping/api/database'; -import { syncFirewallRuleset } from '../../../app/bot-firewall/api/block-ip/cloudflareApiHelper'; -import { deleteBlockedIp } from '../../../app/bot-firewall/api/get-blocked-ips/blockedIpsDatabase'; -import { PaymentAttemptDbModel } from '../../../app/payment-fraud/api/place-order/database'; +import { UserCartItemDbModel, UserSearchHistoryDbModel } from '../../../personalization/api/database'; +import { LoanRequestDbModel } from '../../../loan-risk/api/request-loan/database'; +import { CouponClaimDbModel } from '../../../coupon-fraud/api/claim/database'; +import { Severity, getAndValidateFingerprintResult } from '../../../../server/checks'; +import { LoginAttemptDbModel } from '../../../credential-stuffing/api/authenticate/database'; +import { ArticleViewDbModel } from '../../../paywall/api/database'; +import { SmsVerificationDatabaseModel } from '../../../sms-pumping/api/database'; +import { syncFirewallRuleset } from '../../../bot-firewall/api/block-ip/cloudflareApiHelper'; +import { deleteBlockedIp } from '../../../bot-firewall/api/get-blocked-ips/blockedIpsDatabase'; +import { PaymentAttemptDbModel } from '../../../payment-fraud/api/place-order/database'; +import { NextRequest, NextResponse } from 'next/server'; export type ResetResponse = { message: string; @@ -25,15 +20,8 @@ export type ResetRequest = { requestId: string; }; -export default async function handler(req: NextApiRequest, res: NextApiResponse) { - // This API route accepts only POST requests. - const reqValidation = isValidPostRequest(req); - if (!reqValidation.okay) { - res.status(405).send({ severity: 'error', message: reqValidation.error }); - return; - } - - const { requestId } = req.body as ResetRequest; +export async function POST(req: NextRequest): Promise> { + const { requestId } = (await req.json()) as ResetRequest; // Get the full Identification result from Fingerprint Server API and validate its authenticity const fingerprintResult = await getAndValidateFingerprintResult({ @@ -42,19 +30,17 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse< options: { minConfidenceScore: 0.3 }, }); if (!fingerprintResult.okay) { - res.status(403).send({ severity: 'error', message: fingerprintResult.error }); - return; + return NextResponse.json({ severity: 'error', message: fingerprintResult.error }, { status: 403 }); } const { visitorId, ip } = fingerprintResult.data.products?.identification?.data ?? {}; if (!visitorId) { - res.status(403).send({ severity: 'error', message: 'Visitor ID not found.' }); - return; + return NextResponse.json({ severity: 'error', message: 'Visitor ID not found.' }, { status: 403 }); } const deleteResult = await deleteVisitorData(visitorId, ip ?? ''); - res.status(200).json({ + return NextResponse.json({ message: 'Visitor data deleted successfully.', severity: 'success', result: deleteResult, @@ -72,9 +58,8 @@ const deleteVisitorData = async (visitorId: string, ip: string) => { deletedCouponsClaims: await tryToDestroy(() => CouponClaimDbModel.destroy(options)), deletedPersonalizationRecords: await tryToDestroy(async () => { const deletedCartItemsCount = await UserCartItemDbModel.destroy(options); - const deletedUserPreferencesCount = await UserPreferencesDbModel.destroy(options); const deletedUserSearchHistoryCount = await UserSearchHistoryDbModel.destroy(options); - return deletedCartItemsCount + deletedUserPreferencesCount + deletedUserSearchHistoryCount; + return deletedCartItemsCount + deletedUserSearchHistoryCount; }), deletedLoanRequests: await tryToDestroy(() => LoanRequestDbModel.destroy(options)), deletedArticleViews: await tryToDestroy(() => ArticleViewDbModel.destroy(options)), diff --git a/src/app/bot-firewall/api/get-blocked-ips/blockedIpsDatabase.ts b/src/app/bot-firewall/api/get-blocked-ips/blockedIpsDatabase.ts index 09a04bcd..2b9c0682 100644 --- a/src/app/bot-firewall/api/get-blocked-ips/blockedIpsDatabase.ts +++ b/src/app/bot-firewall/api/get-blocked-ips/blockedIpsDatabase.ts @@ -1,5 +1,5 @@ import { Attributes, DataTypes, InferAttributes, InferCreationAttributes, Model } from 'sequelize'; -import { sequelize } from '../../../../server/server'; +import { sequelize } from '../../../../server/sequelize'; import { MAX_BLOCKED_IPS } from '../block-ip/buildFirewallRules'; interface BlockedIpAttributes diff --git a/src/app/bot-firewall/api/get-bot-visits/botVisitDatabase.ts b/src/app/bot-firewall/api/get-bot-visits/botVisitDatabase.ts index aaf1246f..2dda40c5 100644 --- a/src/app/bot-firewall/api/get-bot-visits/botVisitDatabase.ts +++ b/src/app/bot-firewall/api/get-bot-visits/botVisitDatabase.ts @@ -1,5 +1,5 @@ import { Attributes, DataTypes, FindOptions, InferAttributes, InferCreationAttributes, Model } from 'sequelize'; -import { sequelize } from '../../../../server/server'; +import { sequelize } from '../../../../server/sequelize'; import { EventResponseBotData } from '../../../../shared/types'; interface BotVisitAttributes diff --git a/src/server/coupon-fraud/copy.ts b/src/app/coupon-fraud/api/claim/copy.ts similarity index 100% rename from src/server/coupon-fraud/copy.ts rename to src/app/coupon-fraud/api/claim/copy.ts diff --git a/src/server/coupon-fraud/database.ts b/src/app/coupon-fraud/api/claim/database.ts similarity index 93% rename from src/server/coupon-fraud/database.ts rename to src/app/coupon-fraud/api/claim/database.ts index f2c182b8..9d265d6b 100644 --- a/src/server/coupon-fraud/database.ts +++ b/src/app/coupon-fraud/api/claim/database.ts @@ -1,4 +1,4 @@ -import { sequelize } from '../server'; +import { sequelize } from '../../../../server/sequelize'; import { Attributes, DataTypes, InferAttributes, InferCreationAttributes, Model } from 'sequelize'; export const COUPON_CODES = ['Promo3000', 'BlackFriday'] as const; diff --git a/src/app/coupon-fraud/api/claim/route.ts b/src/app/coupon-fraud/api/claim/route.ts index 424d4a94..d0f0b095 100644 --- a/src/app/coupon-fraud/api/claim/route.ts +++ b/src/app/coupon-fraud/api/claim/route.ts @@ -1,7 +1,7 @@ import { Op } from 'sequelize'; -import { COUPON_CODES, CouponClaimDbModel, CouponCodeString } from '../../../../server/coupon-fraud/database'; +import { COUPON_CODES, CouponClaimDbModel, CouponCodeString } from './database'; import { Severity, getAndValidateFingerprintResult } from '../../../../server/checks'; -import { COUPON_FRAUD_COPY } from '../../../../server/coupon-fraud/copy'; +import { COUPON_FRAUD_COPY } from './copy'; import { NextResponse } from 'next/server'; export type CouponClaimPayload = { diff --git a/src/server/credentialStuffing/copy.ts b/src/app/credential-stuffing/api/authenticate/copy.ts similarity index 100% rename from src/server/credentialStuffing/copy.ts rename to src/app/credential-stuffing/api/authenticate/copy.ts diff --git a/src/server/credentialStuffing/database.ts b/src/app/credential-stuffing/api/authenticate/database.ts similarity index 94% rename from src/server/credentialStuffing/database.ts rename to src/app/credential-stuffing/api/authenticate/database.ts index c871ab36..d0ba7cb1 100644 --- a/src/server/credentialStuffing/database.ts +++ b/src/app/credential-stuffing/api/authenticate/database.ts @@ -1,5 +1,5 @@ import { Attributes, DataTypes, InferAttributes, InferCreationAttributes, Model } from 'sequelize'; -import { sequelize } from '../server'; +import { sequelize } from '../../../../server/sequelize'; export type LoginAttemptResult = | 'RequestIdValidationFailed' diff --git a/src/app/credential-stuffing/api/authenticate/route.ts b/src/app/credential-stuffing/api/authenticate/route.ts index 32d4ffe6..1726b748 100644 --- a/src/app/credential-stuffing/api/authenticate/route.ts +++ b/src/app/credential-stuffing/api/authenticate/route.ts @@ -2,9 +2,9 @@ import { Op } from 'sequelize'; import { NextResponse } from 'next/server'; import { env } from 'process'; import { Severity, getAndValidateFingerprintResult } from '../../../../server/checks'; -import { CREDENTIAL_STUFFING_COPY } from '../../../../server/credentialStuffing/copy'; -import { LoginAttemptResult, LoginAttemptDbModel } from '../../../../server/credentialStuffing/database'; -import { sequelize } from '../../../../server/server'; +import { CREDENTIAL_STUFFING_COPY } from './copy'; +import { LoginAttemptResult, LoginAttemptDbModel } from './database'; +import { sequelize } from '../../../../server/sequelize'; export type LoginPayload = { username: string; diff --git a/src/app/loan-risk/LoanRisk.tsx b/src/app/loan-risk/LoanRisk.tsx index 71b16ec6..b6aee4ce 100644 --- a/src/app/loan-risk/LoanRisk.tsx +++ b/src/app/loan-risk/LoanRisk.tsx @@ -2,11 +2,6 @@ import { UseCaseWrapper } from '../../client/components/common/UseCaseWrapper/UseCaseWrapper'; import { FunctionComponent, useMemo, useState } from 'react'; -import { - loanDurationValidation, - loanValueValidation, - monthlyIncomeValidation, -} from '../../client/loan-risk/validation'; import { calculateMonthInstallment } from '../../shared/loan-risk/calculate-month-installment'; import React from 'react'; import { USE_CASES } from '../../client/components/common/content'; @@ -66,6 +61,21 @@ const SliderField: FunctionComponent = ({ ); }; +const loanValueValidation = { + min: 1000, + max: 1_0_000, +}; + +const monthlyIncomeValidation = { + min: 500, + max: 3_0_000, +}; + +const loanDurationValidation = { + min: 2, + max: 48, +}; + export function LoanRisk() { const { getData: getVisitorData, isLoading: isVisitorDataLoading } = useVisitorData( { ignoreCache: true }, diff --git a/src/app/loan-risk/api/request-loan/database.ts b/src/app/loan-risk/api/request-loan/database.ts index 15fc2992..dfed4056 100644 --- a/src/app/loan-risk/api/request-loan/database.ts +++ b/src/app/loan-risk/api/request-loan/database.ts @@ -1,5 +1,5 @@ import { Model, InferAttributes, InferCreationAttributes, DataTypes, Attributes } from 'sequelize'; -import { sequelize } from '../../../../server/server'; +import { sequelize } from '../../../../server/sequelize'; interface LoanRequestAttributes extends Model, InferCreationAttributes> { diff --git a/src/app/payment-fraud/api/place-order/database.ts b/src/app/payment-fraud/api/place-order/database.ts index da9b59b6..6a854dd8 100644 --- a/src/app/payment-fraud/api/place-order/database.ts +++ b/src/app/payment-fraud/api/place-order/database.ts @@ -1,5 +1,5 @@ import { Model, InferAttributes, InferCreationAttributes, DataTypes, Attributes } from 'sequelize'; -import { sequelize } from '../../../../server/server'; +import { sequelize } from '../../../../server/sequelize'; interface PaymentAttemptAttributes extends Model, InferCreationAttributes> { diff --git a/src/app/payment-fraud/api/place-order/route.ts b/src/app/payment-fraud/api/place-order/route.ts index d12ed5c1..cb70c93b 100644 --- a/src/app/payment-fraud/api/place-order/route.ts +++ b/src/app/payment-fraud/api/place-order/route.ts @@ -3,7 +3,7 @@ import { getAndValidateFingerprintResult, Severity } from '../../../../server/ch import { PaymentAttemptData, PaymentAttemptDbModel } from './database'; import { PAYMENT_FRAUD_COPY } from './copy'; import { Op } from 'sequelize'; -import { sequelize } from '../../../../server/server'; +import { sequelize } from '../../../../server/sequelize'; type Card = { number: string; diff --git a/src/app/paywall/api/database.ts b/src/app/paywall/api/database.ts index 89e7343b..d9ad31ee 100644 --- a/src/app/paywall/api/database.ts +++ b/src/app/paywall/api/database.ts @@ -1,5 +1,5 @@ import { Attributes, DataTypes, InferAttributes, InferCreationAttributes, Model } from 'sequelize'; -import { sequelize } from '../../../server/server'; +import { sequelize } from '../../../server/sequelize'; interface ArticleViewAttributes extends Model, InferCreationAttributes> { diff --git a/src/pages/personalization/index.tsx b/src/app/personalization/Personalization.tsx similarity index 82% rename from src/pages/personalization/index.tsx rename to src/app/personalization/Personalization.tsx index 6b7353ca..c2b2a5c9 100644 --- a/src/pages/personalization/index.tsx +++ b/src/app/personalization/Personalization.tsx @@ -1,27 +1,27 @@ +'use client'; + import { useEffect, useState } from 'react'; import { useDebounce, useSessionStorage } from 'react-use'; -import { useSearchHistory } from '../../client/api/personalization/use-search-history'; import { UseCaseWrapper } from '../../client/components/common/UseCaseWrapper/UseCaseWrapper'; import { Dialog, DialogActions, DialogContent, DialogContentText, DialogTitle } from '@mui/material'; -import { useProducts } from '../../client/api/personalization/use-products'; -import { usePersonalizationNotification } from '../../client/hooks/personalization/use-personalization-notification'; import { useSnackbar } from 'notistack'; -import { useUserPreferences } from '../../client/api/personalization/use-user-preferences'; -import { useCart } from '../../client/api/personalization/use-cart'; +import { useCart } from './hooks/use-cart'; import React from 'react'; import { USE_CASES } from '../../client/components/common/content'; -import { CustomPageProps } from '../_app'; import styles from './personalization.module.scss'; import Image from 'next/image'; import Button from '../../client/components/common/Button/Button'; import CartIcon from '../../client/img/cart.svg'; import { Cart, CartProduct } from '../../client/components/common/Cart/Cart'; -import { Search, SearchHistory } from '../../client/components/personalization/searchComponents'; -import { ProductCard } from '../../client/components/personalization/productCard'; +import { Search, SearchHistory } from './components/searchComponents'; +import { ProductCard } from './components/productCard'; import { useVisitorData } from '@fingerprintjs/fingerprintjs-pro-react'; import { Spinner } from '../../client/components/common/Spinner/Spinner'; +import { useSearchHistory } from './hooks/use-search-history'; +import { useProducts } from './hooks/use-products'; +import { usePersonalizationNotification } from './hooks/use-personalization-notification'; -export default function Index({ embed }: CustomPageProps) { +export function Personalization() { const { enqueueSnackbar } = useSnackbar(); const { isLoading: isFpDataLoading, data } = useVisitorData({ extendedResult: true }); @@ -34,7 +34,6 @@ export default function Index({ embed }: CustomPageProps) { const searchHistoryQuery = useSearchHistory(); const { addCartItemMutation, removeCartItemMutation, cartQuery } = useCart(); const productsQuery = useProducts(searchQuery); - const { hasDarkMode } = useUserPreferences(); const isLoading = productsQuery.isLoading || isFpDataLoading; @@ -59,7 +58,7 @@ export default function Index({ embed }: CustomPageProps) { data?.incognito && data?.visitorFound && !userWelcomed && - (searchHistoryQuery.data?.data?.length || hasDarkMode || cartQuery.data?.data?.length) + (searchHistoryQuery.data?.data?.length || cartQuery.data?.data?.length) ) { enqueueSnackbar('Welcome back! We synced your cart and search terms.', { variant: 'info', @@ -68,9 +67,9 @@ export default function Index({ embed }: CustomPageProps) { setUserWelcomed(true); } - }, [cartQuery.data, data, enqueueSnackbar, hasDarkMode, searchHistoryQuery.data, userWelcomed]); + }, [cartQuery.data, data, enqueueSnackbar, searchHistoryQuery.data, userWelcomed]); - const cartItems: CartProduct[] | undefined = cartQuery.data?.data.map((item) => { + const cartItems: CartProduct[] | undefined = cartQuery.data?.data?.map((item) => { return { id: item.id, name: item.product.name, @@ -103,13 +102,13 @@ export default function Index({ embed }: CustomPageProps) { - +
searchTerm.query)} + searchHistory={searchHistoryQuery.data?.data?.map((searchTerm) => searchTerm.query)} setSearchHistory={(searchTerm) => setSearch(searchTerm)} />
diff --git a/src/app/personalization/api/cart/add-item/route.ts b/src/app/personalization/api/cart/add-item/route.ts new file mode 100644 index 00000000..5a815249 --- /dev/null +++ b/src/app/personalization/api/cart/add-item/route.ts @@ -0,0 +1,75 @@ +import { ProductDbModel, UserCartItemAttributes, UserCartItemDbModel } from '../../database'; +import { Op } from 'sequelize'; +import { NextRequest, NextResponse } from 'next/server'; +import { getAndValidateFingerprintResult, Severity } from '../../../../../server/checks'; + +export type AddCartItemPayload = { + requestId: string; + productId: number; +}; + +export type AddCartItemResponse = { + severity: Severity; + message: string; + data?: UserCartItemAttributes; +}; + +export async function POST(req: NextRequest): Promise> { + const { requestId, productId } = (await req.json()) as AddCartItemPayload; + + // Get the full Identification result from Fingerprint Server API and validate its authenticity + const fingerprintResult = await getAndValidateFingerprintResult({ + requestId, + req, + options: { minConfidenceScore: 0.3, disableFreshnessCheck: true }, + }); + 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 product = await ProductDbModel.findOne({ + where: { + id: { + [Op.eq]: productId, + }, + }, + }); + + if (!product) { + return NextResponse.json({ severity: 'error', message: 'Product not found' }, { status: 500 }); + } + + const [cartItem, created] = await UserCartItemDbModel.findOrCreate({ + where: { + visitorId: { + [Op.eq]: visitorId ?? '', + }, + productId: { + [Op.eq]: productId, + }, + }, + defaults: { + visitorId: visitorId ?? '', + count: 1, + timestamp: new Date(), + productId, + }, + }); + + if (!created) { + cartItem.count++; + await cartItem.save(); + } + + return NextResponse.json({ + severity: 'success', + message: 'Item added', + data: cartItem, + }); +} diff --git a/src/app/personalization/api/cart/get-items/route.ts b/src/app/personalization/api/cart/get-items/route.ts new file mode 100644 index 00000000..c8175fab --- /dev/null +++ b/src/app/personalization/api/cart/get-items/route.ts @@ -0,0 +1,48 @@ +import { UserCartItem, ProductDbModel, UserCartItemDbModel } from '../../database'; +import { Op } from 'sequelize'; +import { NextRequest, NextResponse } from 'next/server'; +import { getAndValidateFingerprintResult, Severity } from '../../../../../server/checks'; + +export type GetCartItemsPayload = { + requestId: string; +}; + +export type GetCartItemsResponse = { + severity: Severity; + message?: string; + data?: UserCartItem[]; + size?: number; +}; + +// Returns cart items for the given visitorId +export async function POST(req: NextRequest): Promise> { + const { requestId } = (await req.json()) as GetCartItemsPayload; + + const fingerprintResult = await getAndValidateFingerprintResult({ + requestId, + req, + options: { minConfidenceScore: 0.3, disableFreshnessCheck: true }, + }); + const visitorId = fingerprintResult.okay ? fingerprintResult.data.products.identification?.data?.visitorId : null; + + if (!visitorId) { + return NextResponse.json({ data: [], size: 0, severity: 'success', message: 'Visitor ID not available' }); + } + + const cartItems = (await UserCartItemDbModel.findAll({ + where: { + visitorId: { + [Op.eq]: visitorId, + }, + }, + order: [['timestamp', 'DESC']], + include: ProductDbModel, + // To-do: Clean this up later, find out how to represent DB associations in TypeScript correctly + })) as unknown as UserCartItem[]; + + return NextResponse.json({ + severity: 'success', + data: cartItems, + size: cartItems.length, + }); +} diff --git a/src/app/personalization/api/cart/remove-item/route.ts b/src/app/personalization/api/cart/remove-item/route.ts new file mode 100644 index 00000000..dae05aa3 --- /dev/null +++ b/src/app/personalization/api/cart/remove-item/route.ts @@ -0,0 +1,58 @@ +import { UserCartItemAttributes, UserCartItemDbModel } from '../../database'; +import { getAndValidateFingerprintResult, Severity } from '../../../../../server/checks'; +import { NextRequest, NextResponse } from 'next/server'; + +export type RemoveCartItemPayload = { + requestId: string; + itemId: number; +}; + +export type RemoveCartItemResponse = { + severity: Severity; + message?: string; + data?: UserCartItemAttributes; + removed?: boolean; +}; + +// Removes an item from cart for given visitorId +export async function POST(req: NextRequest): Promise> { + const { requestId, itemId } = (await req.json()) as RemoveCartItemPayload; + + const fingerprintResult = await getAndValidateFingerprintResult({ + requestId, + req, + options: { minConfidenceScore: 0.3, disableFreshnessCheck: true }, + }); + const visitorId = fingerprintResult.okay ? fingerprintResult.data.products.identification?.data?.visitorId : null; + + if (!visitorId) { + return NextResponse.json( + { severity: 'error', message: 'Visitor ID not available', removed: false }, + { status: 400 }, + ); + } + + const item = await UserCartItemDbModel.findOne({ + where: { id: itemId }, + }); + + if (!item) { + return NextResponse.json({ severity: 'error', message: 'Item not found', removed: false }, { status: 400 }); + } + + item.count--; + let removed = false; + + if (item.count <= 0) { + removed = true; + await item.destroy(); + } else { + await item.save(); + } + + return NextResponse.json({ + severity: 'success', + data: item, + removed, + }); +} diff --git a/src/server/personalization/database.ts b/src/app/personalization/api/database.ts similarity index 70% rename from src/server/personalization/database.ts rename to src/app/personalization/api/database.ts index 6273c30e..de6f30bf 100644 --- a/src/server/personalization/database.ts +++ b/src/app/personalization/api/database.ts @@ -7,7 +7,7 @@ import { ForeignKey, CreationOptional, } from 'sequelize'; -import { sequelize } from '../server'; +import { sequelize } from '../../../server/sequelize'; interface ProductAttributes extends Model, InferCreationAttributes> { @@ -46,26 +46,6 @@ export const ProductDbModel = sequelize.define('product', { export type Product = Attributes; -interface UserPreferencesAttributes - extends Model, InferCreationAttributes> { - visitorId: string; - hasDarkMode: boolean; - timestamp: Date; -} - -// Defines db model for user preferences. -export const UserPreferencesDbModel = sequelize.define('user_data', { - visitorId: { - type: DataTypes.STRING, - }, - hasDarkMode: { - type: DataTypes.BOOLEAN, - }, - timestamp: { - type: DataTypes.DATE, - }, -}); - interface UserSearchHistoryAttributes extends Model, InferCreationAttributes> { visitorId: string; @@ -86,7 +66,9 @@ export const UserSearchHistoryDbModel = sequelize.define; + +export interface UserCartItemAttributes extends Model, InferCreationAttributes> { id: CreationOptional; visitorId: string; @@ -122,22 +104,7 @@ export type UserCartItem = Attributes & { product: Produ ProductDbModel.hasMany(UserCartItemDbModel); UserCartItemDbModel.belongsTo(ProductDbModel); -let didInit = false; +const productModels = [ProductDbModel, UserCartItemDbModel, UserCartItemDbModel, UserSearchHistoryDbModel]; -const productModels = [ - ProductDbModel, - UserCartItemDbModel, - UserPreferencesDbModel, - UserCartItemDbModel, - UserSearchHistoryDbModel, -]; - -export async function initProducts() { - if (didInit) { - return; - } - - didInit = true; - - await Promise.all(productModels.map((model) => model.sync({ force: false }))).catch(console.error); -} +// Create DB tables +productModels.map((model) => model.sync({ force: false })); diff --git a/src/pages/api/personalization/get-products.ts b/src/app/personalization/api/get-products/route.ts similarity index 53% rename from src/pages/api/personalization/get-products.ts rename to src/app/personalization/api/get-products/route.ts index 5b0032c7..cdd72d73 100644 --- a/src/pages/api/personalization/get-products.ts +++ b/src/app/personalization/api/get-products/route.ts @@ -1,7 +1,8 @@ -import { Product, ProductDbModel, UserSearchHistoryDbModel } from '../../../server/personalization/database'; +import { Product, ProductDbModel, UserSearchHistoryDbModel } from '../database'; import { Op } from 'sequelize'; -import { personalizationEndpoint } from '../../../server/personalization/personalization-endpoint'; -import { seedProducts } from '../../../server/personalization/seed'; +import { seedProducts } from '../seed'; +import { NextRequest, NextResponse } from 'next/server'; +import { getAndValidateFingerprintResult } from '../../../../server/checks'; function searchProducts(query: string) { if (!query) { @@ -45,7 +46,7 @@ async function persistSearchPhrase(query: string, visitorId: string) { }); } -export type GetProductResponse = { +export type GetProductsResponse = { data: { products: Product[]; querySaved: boolean; @@ -53,34 +54,44 @@ export type GetProductResponse = { size: number; }; +export type GetProductsPayload = { + query: string; + requestId: string; +}; + // Returns products from database, supports simple search query. // If search query is provided and visitorId is valid it is saved in database. -export default personalizationEndpoint(async (req, res, { usePersonalizedData, visitorId }) => { +export async function POST(req: NextRequest): Promise> { let querySaved = false; - const { query } = JSON.parse(req.body); + const { query, requestId } = (await req.json()) as GetProductsPayload; const productsCount = await ProductDbModel.count(); - if (!productsCount) { await seedProducts(); } const products = await searchProducts(query); - if (query && usePersonalizedData && visitorId) { - await persistSearchPhrase(query.trim(), visitorId); - - querySaved = true; + // Get the full Identification result from Fingerprint Server API and validate its authenticity + const fingerprintResult = await getAndValidateFingerprintResult({ + requestId, + req, + options: { minConfidenceScore: 0.3, disableFreshnessCheck: true }, + }); + const visitorId = fingerprintResult.okay ? fingerprintResult.data.products.identification?.data?.visitorId : null; + + if (query) { + if (visitorId) { + await persistSearchPhrase(query.trim(), visitorId); + querySaved = true; + } else { + console.error('Could not retrieve visitor ID to save the search term. Returning found products anyway.'); + } } - const response: GetProductResponse = { - data: { - products, - querySaved, - }, + return NextResponse.json({ + data: { products, querySaved }, size: products.length, - }; - - return res.status(200).json(response); -}); + }); +} diff --git a/src/app/personalization/api/get-search-history/route.ts b/src/app/personalization/api/get-search-history/route.ts new file mode 100644 index 00000000..c30e2e6f --- /dev/null +++ b/src/app/personalization/api/get-search-history/route.ts @@ -0,0 +1,50 @@ +import { UserSearchHistoryDbModel, UserSearchTerm } from '../database'; +import { Op } from 'sequelize'; +import { NextRequest, NextResponse } from 'next/server'; +import { getAndValidateFingerprintResult, Severity } from '../../../../server/checks'; + +export type SearchHistoryPayload = { + requestId: string; +}; + +export type SearchHistoryResponse = { + severity: Severity; + message?: string; + data?: UserSearchTerm[]; + size?: number; +}; + +export async function POST(req: NextRequest): Promise> { + const { requestId } = (await req.json()) as SearchHistoryPayload; + + // Get the full Identification result from Fingerprint Server API and validate its authenticity + const fingerprintResult = await getAndValidateFingerprintResult({ + requestId, + req, + options: { minConfidenceScore: 0.3, disableFreshnessCheck: true }, + }); + 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 history = await UserSearchHistoryDbModel.findAll({ + order: [['timestamp', 'DESC']], + where: { + visitorId: { + [Op.eq]: visitorId, + }, + }, + }); + + return NextResponse.json({ + severity: 'success', + data: history, + size: history.length, + }); +} diff --git a/src/server/personalization/seed.ts b/src/app/personalization/api/seed.ts similarity index 94% rename from src/server/personalization/seed.ts rename to src/app/personalization/api/seed.ts index 47a3c737..dbac9ef2 100644 --- a/src/server/personalization/seed.ts +++ b/src/app/personalization/api/seed.ts @@ -1,5 +1,5 @@ import { ProductDbModel } from './database'; -import { sequelize } from '../server'; +import { sequelize } from '../../../server/sequelize'; export async function seedProducts() { await Promise.all([ diff --git a/src/client/components/personalization/productCard.module.scss b/src/app/personalization/components/productCard.module.scss similarity index 100% rename from src/client/components/personalization/productCard.module.scss rename to src/app/personalization/components/productCard.module.scss diff --git a/src/client/components/personalization/productCard.tsx b/src/app/personalization/components/productCard.tsx similarity index 84% rename from src/client/components/personalization/productCard.tsx rename to src/app/personalization/components/productCard.tsx index 999ec93c..0b5210af 100644 --- a/src/client/components/personalization/productCard.tsx +++ b/src/app/personalization/components/productCard.tsx @@ -1,15 +1,15 @@ import { FunctionComponent, useState } from 'react'; import { useDebounce } from 'react-use'; -import { useCart } from '../../api/personalization/use-cart'; -import { usePersonalizationNotification } from '../../hooks/personalization/use-personalization-notification'; -import { ButtonMinusSvg } from '../../img/buttonMinusSvg'; -import { ButtonPlusSvg } from '../../img/buttonPlusSvg'; +import { useCart } from '../hooks/use-cart'; +import { ButtonMinusSvg } from '../../../client/img/buttonMinusSvg'; +import { ButtonPlusSvg } from '../../../client/img/buttonPlusSvg'; import Image from 'next/image'; import styles from './productCard.module.scss'; -import Button from '../common/Button/Button'; -import HeartIcon from '../../img/heart.svg'; -import { TEST_IDS } from '../../testIDs'; -import { UserCartItem } from '../../../server/personalization/database'; +import Button from '../../../client/components/common/Button/Button'; +import HeartIcon from '../img/heart.svg'; +import { TEST_IDS } from '../../../client/testIDs'; +import { UserCartItem } from '../api/database'; +import { usePersonalizationNotification } from '../hooks/use-personalization-notification'; type Product = { price: number; @@ -41,7 +41,7 @@ export const ProductCard: FunctionComponent<{ product: Product }> = ({ product } const { showNotification } = usePersonalizationNotification(); const [wasAdded, setWasAdded] = useState(false); - const cartItem: UserCartItem | undefined = cartQuery.data?.data.find((item) => item.productId === product.id); + const cartItem: UserCartItem | undefined = cartQuery.data?.data?.find((item) => item.productId === product.id); const addToCart = async () => { await addCartItemMutation.mutateAsync({ productId: product.id }); diff --git a/src/client/components/personalization/searchComponents.module.scss b/src/app/personalization/components/searchComponents.module.scss similarity index 100% rename from src/client/components/personalization/searchComponents.module.scss rename to src/app/personalization/components/searchComponents.module.scss diff --git a/src/client/components/personalization/searchComponents.tsx b/src/app/personalization/components/searchComponents.tsx similarity index 94% rename from src/client/components/personalization/searchComponents.tsx rename to src/app/personalization/components/searchComponents.tsx index e5551e6a..865c9402 100644 --- a/src/client/components/personalization/searchComponents.tsx +++ b/src/app/personalization/components/searchComponents.tsx @@ -1,8 +1,8 @@ import { FunctionComponent } from 'react'; import Image from 'next/image'; -import SearchIcon from '../../img/search.svg'; +import SearchIcon from '../img/search.svg'; import styles from './searchComponents.module.scss'; -import { TEST_IDS } from '../../testIDs'; +import { TEST_IDS } from '../../../client/testIDs'; type SearchProps = { search: string; diff --git a/src/app/personalization/embed/page.tsx b/src/app/personalization/embed/page.tsx new file mode 100644 index 00000000..eff35348 --- /dev/null +++ b/src/app/personalization/embed/page.tsx @@ -0,0 +1,9 @@ +import { USE_CASES } from '../../../client/components/common/content'; +import { generateUseCaseMetadata } from '../../../client/components/common/seo'; +import { Personalization } from '../Personalization'; + +export const metadata = generateUseCaseMetadata(USE_CASES.personalization); + +export default function PaywallPage() { + return ; +} diff --git a/src/app/personalization/hooks/use-cart.ts b/src/app/personalization/hooks/use-cart.ts new file mode 100644 index 00000000..a8111868 --- /dev/null +++ b/src/app/personalization/hooks/use-cart.ts @@ -0,0 +1,85 @@ +import { useMutation, useQuery } from 'react-query'; +import { useCallback } from 'react'; +import { useVisitorData } from '@fingerprintjs/fingerprintjs-pro-react'; +import { GetCartItemsPayload, GetCartItemsResponse } from '../api/cart/get-items/route'; +import { AddCartItemPayload, AddCartItemResponse } from '../api/cart/add-item/route'; +import { RemoveCartItemPayload, RemoveCartItemResponse } from '../api/cart/remove-item/route'; + +const GET_CART_QUERY = 'GET_CART_QUERY'; +const ADD_CART_ITEM_MUTATION = 'ADD_CART_ITEM_MUTATION'; +const REMOVE_CART_ITEM_MUTATION = 'REMOVE_CART_ITEM_MUTATION'; + +export function useCart() { + const { data: visitorData } = useVisitorData(); + + const cartQuery = useQuery({ + queryKey: [GET_CART_QUERY], + queryFn: async () => { + if (!visitorData) { + throw new Error('Visitor data is undefined'); + } + const response = await fetch('/personalization/api/cart/get-items', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + requestId: visitorData.requestId, + } satisfies GetCartItemsPayload), + }); + return await response.json(); + }, + enabled: Boolean(visitorData), + }); + + const refetchCartOnSuccess = useCallback( + async (data: AddCartItemResponse | RemoveCartItemResponse) => { + if (data) { + await cartQuery.refetch(); + } + }, + [cartQuery], + ); + + const addCartItemMutation = useMutation({ + mutationKey: [ADD_CART_ITEM_MUTATION], + mutationFn: async ({ productId }: { productId: number }) => { + if (!visitorData) { + throw new Error('Visitor data is undefined'); + } + const response = await fetch('/personalization/api/cart/add-item', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + requestId: visitorData.requestId, + productId, + } satisfies AddCartItemPayload), + }); + return (await response.json()) as AddCartItemResponse; + }, + onSuccess: (data: AddCartItemResponse) => refetchCartOnSuccess(data), + }); + + const removeCartItemMutation = useMutation({ + mutationKey: [REMOVE_CART_ITEM_MUTATION], + mutationFn: async ({ itemId }: { itemId: number }) => { + if (!visitorData) { + throw new Error('Visitor data is undefined'); + } + const response = await fetch('/personalization/api/cart/remove-item', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + requestId: visitorData.requestId, + itemId, + } satisfies RemoveCartItemPayload), + }); + return (await response.json()) as RemoveCartItemResponse; + }, + onSuccess: refetchCartOnSuccess, + }); + + return { + cartQuery, + addCartItemMutation, + removeCartItemMutation, + }; +} diff --git a/src/client/hooks/personalization/use-personalization-notification.tsx b/src/app/personalization/hooks/use-personalization-notification.tsx similarity index 89% rename from src/client/hooks/personalization/use-personalization-notification.tsx rename to src/app/personalization/hooks/use-personalization-notification.tsx index 43449998..dae033c1 100644 --- a/src/client/hooks/personalization/use-personalization-notification.tsx +++ b/src/app/personalization/hooks/use-personalization-notification.tsx @@ -1,8 +1,8 @@ import { useCallback } from 'react'; import { SnackbarKey, useSnackbar } from 'notistack'; import { useCopyToClipboard } from 'react-use'; -import Button from '../../components/common/Button/Button'; -import { CloseSnackbarButton } from '../../components/common/Alert/Alert'; +import Button from '../../../client/components/common/Button/Button'; +import { CloseSnackbarButton } from '../../../client/components/common/Alert/Alert'; export function usePersonalizationNotification() { const { enqueueSnackbar, closeSnackbar } = useSnackbar(); diff --git a/src/app/personalization/hooks/use-products.ts b/src/app/personalization/hooks/use-products.ts new file mode 100644 index 00000000..57a4b901 --- /dev/null +++ b/src/app/personalization/hooks/use-products.ts @@ -0,0 +1,31 @@ +import { useQuery, useQueryClient } from 'react-query'; +import { GetProductsResponse, GetProductsPayload } from '../../../app/personalization/api/get-products/route'; +import { useVisitorData } from '@fingerprintjs/fingerprintjs-pro-react'; +import { SEARCH_HISTORY_QUERY } from './use-search-history'; + +export const GET_PRODUCTS_QUERY = 'GET_PRODUCTS_QUERY'; + +export function useProducts(query: string) { + const { data: visitorData } = useVisitorData(); + const queryClient = useQueryClient(); + return useQuery({ + // Make a new request every time `query` changes + queryKey: [GET_PRODUCTS_QUERY, query], + queryFn: async () => { + if (!visitorData) { + throw new Error('Visitor data is undefined'); + } + const { requestId } = visitorData; + const response = await fetch('/personalization/api/get-products', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ requestId, query } satisfies GetProductsPayload), + }); + return await response.json(); + }, + enabled: Boolean(visitorData), + onSuccess: async () => { + await queryClient.refetchQueries([SEARCH_HISTORY_QUERY]); + }, + }); +} diff --git a/src/app/personalization/hooks/use-search-history.ts b/src/app/personalization/hooks/use-search-history.ts new file mode 100644 index 00000000..c1782c7e --- /dev/null +++ b/src/app/personalization/hooks/use-search-history.ts @@ -0,0 +1,30 @@ +import { useQuery } from 'react-query'; +import { useVisitorData } from '@fingerprintjs/fingerprintjs-pro-react'; +import { SearchHistoryPayload, SearchHistoryResponse } from '../../../app/personalization/api/get-search-history/route'; + +export const SEARCH_HISTORY_QUERY = 'SEARCH_HISTORY_QUERY'; + +export function useSearchHistory() { + const { data: visitorData } = useVisitorData(); + return useQuery({ + queryKey: [SEARCH_HISTORY_QUERY], + queryFn: async () => { + if (!visitorData) { + throw new Error('Visitor data is undefined'); + } + const { requestId } = visitorData; + const response = await fetch('/personalization/api/get-search-history', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ requestId } satisfies SearchHistoryPayload), + }); + return await response.json(); + }, + enabled: Boolean(visitorData), + initialData: { + severity: 'success', + data: [], + size: 0, + }, + }); +} diff --git a/src/client/img/heart.svg b/src/app/personalization/img/heart.svg similarity index 100% rename from src/client/img/heart.svg rename to src/app/personalization/img/heart.svg diff --git a/src/client/img/search.svg b/src/app/personalization/img/search.svg similarity index 100% rename from src/client/img/search.svg rename to src/app/personalization/img/search.svg diff --git a/src/app/personalization/page.tsx b/src/app/personalization/page.tsx new file mode 100644 index 00000000..2b39e6f2 --- /dev/null +++ b/src/app/personalization/page.tsx @@ -0,0 +1,9 @@ +import { USE_CASES } from '../../client/components/common/content'; +import { generateUseCaseMetadata } from '../../client/components/common/seo'; +import { Personalization } from './Personalization'; + +export const metadata = generateUseCaseMetadata(USE_CASES.personalization); + +export default function PersonalizationPage() { + return ; +} diff --git a/src/pages/personalization/personalization.module.scss b/src/app/personalization/personalization.module.scss similarity index 100% rename from src/pages/personalization/personalization.module.scss rename to src/app/personalization/personalization.module.scss diff --git a/src/app/sms-pumping/api/database.ts b/src/app/sms-pumping/api/database.ts index 3b52fa73..ac030459 100644 --- a/src/app/sms-pumping/api/database.ts +++ b/src/app/sms-pumping/api/database.ts @@ -1,5 +1,5 @@ import { Attributes, DataTypes, InferAttributes, InferCreationAttributes, Model } from 'sequelize'; -import { sequelize } from '../../../server/server'; +import { sequelize } from '../../../server/sequelize'; interface SmsVerificationAttributes extends Model, InferCreationAttributes> { diff --git a/src/app/web-scraping/WebScraping.tsx b/src/app/web-scraping/WebScraping.tsx index 5b8557b5..7ff4ac16 100644 --- a/src/app/web-scraping/WebScraping.tsx +++ b/src/app/web-scraping/WebScraping.tsx @@ -4,7 +4,6 @@ import { UseCaseWrapper } from '../../client/components/common/UseCaseWrapper/Us import { useVisitorData } from '@fingerprintjs/fingerprintjs-pro-react'; import { useQueryState } from 'next-usequerystate'; import { useQuery, UseQueryResult } from 'react-query'; -import { CheckResultObject } from '../../server/checkResult'; import { USE_CASES } from '../../client/components/common/content'; import { Select, SelectItem } from '../../client/components/common/Select/Select'; import ArrowIcon from '../../client/img/arrowRight.svg'; @@ -18,8 +17,14 @@ import { FunctionComponent, Suspense } from 'react'; import { useSearchParams } from 'next/navigation'; import { AIRPORTS } from './data/airports'; import { Flight, FlightCard } from './components/FlightCard'; +import { Severity } from '../../server/checks'; -type FlightQueryResult = CheckResultObject; +type FlightQueryResult = { + message: string; + severity: Severity; + type: string; + data?: Flight[]; +}; const WebScraping: FunctionComponent = () => { const searchParams = useSearchParams(); diff --git a/src/client/api/api.ts b/src/client/api/api.ts deleted file mode 100644 index c2080e98..00000000 --- a/src/client/api/api.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { FingerprintJSPro } from '@fingerprintjs/fingerprintjs-pro-react'; - -export function apiRequest( - pathname: string, - fpData: FingerprintJSPro.GetResult | undefined, - body: Record = {}, - abortSignal: AbortSignal | null = null, -) { - return fetch(pathname, { - method: 'POST', - signal: abortSignal, - body: JSON.stringify({ - requestId: fpData?.requestId, - visitorId: fpData?.visitorId, - ...body, - }), - }).then((res) => res.json()); -} diff --git a/src/client/api/personalization/use-cart.ts b/src/client/api/personalization/use-cart.ts deleted file mode 100644 index 9a21daaf..00000000 --- a/src/client/api/personalization/use-cart.ts +++ /dev/null @@ -1,60 +0,0 @@ -import { apiRequest } from '../api'; -import { useMutation, useQuery } from 'react-query'; -import { useCallback } from 'react'; -import { UserCartItem } from '../../../server/personalization/database'; -import { useVisitorData, FingerprintJSPro } from '@fingerprintjs/fingerprintjs-pro-react'; - -function getCart(fpData?: FingerprintJSPro.GetResult) { - return apiRequest('/api/personalization/cart/get-items', fpData); -} - -function addCartItem(productId: number, fpData?: FingerprintJSPro.GetResult) { - return apiRequest('/api/personalization/cart/add-item', fpData, { - productId, - }); -} - -function removeCartItem(itemId: number, fpData?: FingerprintJSPro.GetResult) { - return apiRequest('/api/personalization/cart/remove-item', fpData, { itemId }); -} - -const GET_CART_QUERY = 'GET_CART_QUERY'; -const ADD_CART_ITEM_MUTATION = 'ADD_CART_ITEM_MUTATION'; -const REMOVE_CART_ITEM_MUTATION = 'REMOVE_CART_ITEM_MUTATION'; - -export function useCart() { - const { data: visitorData } = useVisitorData(); - - const cartQuery = useQuery<{ data: UserCartItem[]; size: number }>(GET_CART_QUERY, () => getCart(visitorData), { - enabled: Boolean(visitorData), - }); - const refetchCartOnSuccess = useCallback( - async (data: UserCartItem[]) => { - if (data) { - await cartQuery.refetch(); - } - }, - [cartQuery], - ); - - const addCartItemMutation = useMutation( - ADD_CART_ITEM_MUTATION, - ({ productId }) => addCartItem(productId, visitorData), - { - onSuccess: refetchCartOnSuccess, - }, - ); - const removeCartItemMutation = useMutation( - REMOVE_CART_ITEM_MUTATION, - ({ itemId }) => removeCartItem(itemId, visitorData), - { - onSuccess: refetchCartOnSuccess, - }, - ); - - return { - cartQuery, - addCartItemMutation, - removeCartItemMutation, - }; -} diff --git a/src/client/api/personalization/use-products.ts b/src/client/api/personalization/use-products.ts deleted file mode 100644 index c9bb6e24..00000000 --- a/src/client/api/personalization/use-products.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { useQuery, useQueryClient } from 'react-query'; -import { SEARCH_HISTORY_QUERY } from './use-search-history'; -import { apiRequest } from '../api'; -import { GetProductResponse } from '../../../pages/api/personalization/get-products'; -import { useVisitorData, FingerprintJSPro } from '@fingerprintjs/fingerprintjs-pro-react'; - -function getProducts(fpData: FingerprintJSPro.GetResult | undefined, query: string): Promise { - return apiRequest('/api/personalization/get-products', fpData, { - query, - }); -} - -export const GET_PRODUCTS_QUERY = 'GET_PRODUCTS_QUERY'; - -export function useProducts(search: string) { - const { data: fpData } = useVisitorData(); - - const queryClient = useQueryClient(); - - return useQuery([GET_PRODUCTS_QUERY, search], () => getProducts(fpData, search), { - enabled: Boolean(fpData), - onSuccess: async () => { - await queryClient.refetchQueries([SEARCH_HISTORY_QUERY]); - }, - }); -} diff --git a/src/client/api/personalization/use-search-history.ts b/src/client/api/personalization/use-search-history.ts deleted file mode 100644 index 4919dd66..00000000 --- a/src/client/api/personalization/use-search-history.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { useQuery } from 'react-query'; -import { apiRequest } from '../api'; -import { useVisitorData, FingerprintJSPro } from '@fingerprintjs/fingerprintjs-pro-react'; - -function getSearchHistory(fpData: FingerprintJSPro.GetResult | undefined) { - return apiRequest('/api/personalization/get-search-history', fpData); -} - -export const SEARCH_HISTORY_QUERY = 'SEARCH_HISTORY_QUERY'; - -export type SearchTermData = { - id: number; - visitorId: string; - query: string; - timestamp: string; - createdAt: string; - updatedAt: string; -}; - -export type SearchHistoryResponse = { - data: SearchTermData[]; - size: number; -}; - -export function useSearchHistory() { - const { data: fpData } = useVisitorData(); - - return useQuery(SEARCH_HISTORY_QUERY, () => getSearchHistory(fpData), { - enabled: Boolean(fpData), - initialData: { - data: [], - size: 0, - }, - }); -} diff --git a/src/client/api/personalization/use-user-preferences.ts b/src/client/api/personalization/use-user-preferences.ts deleted file mode 100644 index 974ef964..00000000 --- a/src/client/api/personalization/use-user-preferences.ts +++ /dev/null @@ -1,80 +0,0 @@ -import { useMutation, useQuery, useQueryClient } from 'react-query'; -import { apiRequest } from '../api'; -import { useCallback, useEffect, useMemo } from 'react'; -import { useVisitorData, FingerprintJSPro } from '@fingerprintjs/fingerprintjs-pro-react'; - -type UserPreferences = { - hasDarkMode: boolean; -}; - -type UserPreferencesResponse = { - data: UserPreferences; -}; - -const GET_USER_PREFERENCES_QUERY = 'GET_USER_PREFERENCES_QUERY'; - -function getUserPreferencesFromServer( - fpData: FingerprintJSPro.GetResult | undefined, - abortSignal: AbortSignal, -): Promise { - return apiRequest('/api/personalization/get-user-preferences', fpData, undefined, abortSignal); -} - -function updateUserPreferencesOnServer(fpData: FingerprintJSPro.GetResult | undefined, preferences: UserPreferences) { - return apiRequest('/api/personalization/update-user-preferences', fpData, preferences); -} - -export function useUserPreferences() { - // Get visitorId - const { data: fingerprintResult } = useVisitorData(); - - // An abort controller to cancel the query if the user changes dark mode while the query is still loading - const abortController = useMemo(() => new AbortController(), []); - - // Query database for user preferences for this visitorId - const { data: preferencesResponse } = useQuery( - GET_USER_PREFERENCES_QUERY, - () => getUserPreferencesFromServer(fingerprintResult, abortController.signal), - { - enabled: Boolean(fingerprintResult), - onSuccess: (data) => { - // Store the result in localStorage to get it faster on page reload - localStorage.setItem('hasDarkMode', String(data?.data?.hasDarkMode)); - }, - }, - ); - - // Use the same query object to update preferences synchronously further down - const queryClient = useQueryClient(); - const updateUserPreferencesOnClient = useCallback( - (data: UserPreferences) => { - queryClient.setQueryData(GET_USER_PREFERENCES_QUERY, { data }); - }, - [queryClient], - ); - - // On component mount, set the query state of the user preferences to the value stored in localStorage - // (to prevent a long flash of light mode while waiting for userPreferencesQuery) - useEffect(() => { - updateUserPreferencesOnClient({ hasDarkMode: localStorage.getItem('hasDarkMode') === 'true' }); - }, [updateUserPreferencesOnClient]); - - // Mutation to update user preferences in the database - const updateUserPreferencesMutation = useMutation( - (preferences) => updateUserPreferencesOnServer(fingerprintResult, preferences), - { - onMutate: (data) => { - // Also optimistically updates the query data and localStorage switches to dark mode immediately, regardless of the database request result) - updateUserPreferencesOnClient(data); - localStorage.setItem('hasDarkMode', String(data.hasDarkMode)); - // If user changes dark mode while the query is still loading, cancel the query - abortController.abort(); - }, - }, - ); - - return { - hasDarkMode: Boolean(preferencesResponse?.data?.hasDarkMode), - updateUserPreferences: updateUserPreferencesMutation.mutate, - }; -} diff --git a/src/client/components/snackbar-action.tsx b/src/client/components/snackbar-action.tsx deleted file mode 100644 index e9e70a06..00000000 --- a/src/client/components/snackbar-action.tsx +++ /dev/null @@ -1,12 +0,0 @@ -import { SnackbarKey, useSnackbar } from 'notistack'; -import Button from '@mui/material/Button'; - -export function SnackbarAction({ snackbarId }: { snackbarId: SnackbarKey }) { - const { closeSnackbar } = useSnackbar(); - - return ( - - ); -} diff --git a/src/client/hooks/useReset/useReset.tsx b/src/client/hooks/useReset/useReset.tsx index 82a78732..955dafe2 100644 --- a/src/client/hooks/useReset/useReset.tsx +++ b/src/client/hooks/useReset/useReset.tsx @@ -2,7 +2,7 @@ import { useVisitorData } from '@fingerprintjs/fingerprintjs-pro-react'; import { useMutation } from 'react-query'; -import { ResetRequest, ResetResponse } from '../../../pages/api/admin/reset'; +import { ResetRequest, ResetResponse } from '../../../app/api/admin/reset/route'; import { useSnackbar } from 'notistack'; import styles from './userReset.module.scss'; import { PLAYGROUND_METADATA, USE_CASES } from '../../components/common/content'; diff --git a/src/client/loan-risk/validation.ts b/src/client/loan-risk/validation.ts deleted file mode 100644 index dbb84f40..00000000 --- a/src/client/loan-risk/validation.ts +++ /dev/null @@ -1,14 +0,0 @@ -export const loanValueValidation = { - min: 1000, - max: 1_0_000, -}; - -export const monthlyIncomeValidation = { - min: 500, - max: 3_0_000, -}; - -export const loanDurationValidation = { - min: 2, - max: 48, -}; diff --git a/src/pages/api/personalization/cart/add-item.ts b/src/pages/api/personalization/cart/add-item.ts deleted file mode 100644 index 9e11e726..00000000 --- a/src/pages/api/personalization/cart/add-item.ts +++ /dev/null @@ -1,53 +0,0 @@ -import { personalizationEndpoint } from '../../../../server/personalization/personalization-endpoint'; -import { ProductDbModel, UserCartItemDbModel } from '../../../../server/personalization/database'; -import { Op } from 'sequelize'; - -// Adds an item to cart for the given visitorId -export default personalizationEndpoint(async (req, res, { usePersonalizedData, visitorId }) => { - if (!usePersonalizedData) { - return res.status(400); - } - - const { productId } = JSON.parse(req.body); - - const product = await ProductDbModel.findOne({ - where: { - id: { - [Op.eq]: productId as string, - }, - }, - }); - - if (!product) { - return res.status(500).json({ - error: new Error('Product not found'), - }); - } - - const [cartItem, created] = await UserCartItemDbModel.findOrCreate({ - where: { - visitorId: { - [Op.eq]: visitorId ?? '', - }, - productId: { - [Op.eq]: productId, - }, - }, - defaults: { - visitorId: visitorId ?? '', - count: 1, - timestamp: new Date(), - productId, - }, - }); - - if (!created) { - cartItem.count++; - - await cartItem.save(); - } - - return res.status(200).json({ - data: cartItem, - }); -}); diff --git a/src/pages/api/personalization/cart/get-items.ts b/src/pages/api/personalization/cart/get-items.ts deleted file mode 100644 index 37d9379c..00000000 --- a/src/pages/api/personalization/cart/get-items.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { personalizationEndpoint } from '../../../../server/personalization/personalization-endpoint'; -import { UserCartItem, ProductDbModel, UserCartItemDbModel } from '../../../../server/personalization/database'; -import { Op } from 'sequelize'; - -// Returns cart items for the given visitorId -export default personalizationEndpoint(async (_req, res, { usePersonalizedData, visitorId }) => { - if (!usePersonalizedData) { - return res.status(200).json({ - data: [], - size: 0, - }); - } - - if (!visitorId) { - return res.status(400).json({ - error: 'Visitor ID not found', - }); - } - - const cartItems = (await UserCartItemDbModel.findAll({ - where: { - visitorId: { - [Op.eq]: visitorId, - }, - }, - order: [['timestamp', 'DESC']], - include: ProductDbModel, - // To-do: Clean this up later, find out how to represent DB associations in TypeScript correctly - })) as unknown as UserCartItem[]; - - return res.status(200).json({ - data: cartItems, - size: cartItems.length, - }); -}); diff --git a/src/pages/api/personalization/cart/remove-item.ts b/src/pages/api/personalization/cart/remove-item.ts deleted file mode 100644 index 42129b71..00000000 --- a/src/pages/api/personalization/cart/remove-item.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { personalizationEndpoint } from '../../../../server/personalization/personalization-endpoint'; -import { UserCartItemDbModel } from '../../../../server/personalization/database'; -import { Op } from 'sequelize'; - -// Removes an item from cart for given visitorId -export default personalizationEndpoint(async (req, res, { usePersonalizedData }) => { - if (!usePersonalizedData) { - return res.status(400); - } - - let removed = false; - - const { itemId } = JSON.parse(req.body); - - const item = await UserCartItemDbModel.findOne({ - where: { - id: { - [Op.eq]: itemId, - }, - }, - }); - - if (!item) { - return res.status(400).json({ - error: 'Item not found', - }); - } - - item.count--; - - if (item.count <= 0) { - removed = true; - - await item.destroy(); - } else { - await item.save(); - } - - return res.status(200).json({ - data: item, - removed, - }); -}); diff --git a/src/pages/api/personalization/get-search-history.ts b/src/pages/api/personalization/get-search-history.ts deleted file mode 100644 index 7f51f9bb..00000000 --- a/src/pages/api/personalization/get-search-history.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { UserSearchHistoryDbModel } from '../../../server/personalization/database'; -import { Op } from 'sequelize'; -import { personalizationEndpoint } from '../../../server/personalization/personalization-endpoint'; - -// Endpoint for fetching user search history for given visitorId -export default personalizationEndpoint(async (_req, res, { usePersonalizedData, visitorId }) => { - if (!usePersonalizedData) { - return res.status(200).json({ - data: [], - size: 0, - }); - } - - if (!visitorId) { - return res.status(400).json({ - error: 'Visitor ID not available', - }); - } - - const history = await UserSearchHistoryDbModel.findAll({ - order: [['timestamp', 'DESC']], - where: { - visitorId: { - [Op.eq]: visitorId, - }, - }, - }); - - return res.status(200).json({ - data: history, - size: history.length, - }); -}); diff --git a/src/pages/api/personalization/get-user-preferences.ts b/src/pages/api/personalization/get-user-preferences.ts deleted file mode 100644 index e6a39e55..00000000 --- a/src/pages/api/personalization/get-user-preferences.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { UserPreferencesDbModel } from '../../../server/personalization/database'; -import { Op } from 'sequelize'; -import { personalizationEndpoint } from '../../../server/personalization/personalization-endpoint'; -import { NextApiRequest, NextApiResponse } from 'next'; - -// Fetches user preferences (for now only dark mode preference) for given visitorId -export default personalizationEndpoint( - async (_req: NextApiRequest, res: NextApiResponse, { visitorId, usePersonalizedData }) => { - if (!usePersonalizedData) { - return res.status(200).json({ - data: null, - }); - } - - if (!visitorId) { - return res.status(400).json({ - error: 'Visitor ID not available', - }); - } - - const result = await UserPreferencesDbModel.findOne({ - where: { - visitorId: { - [Op.eq]: visitorId, - }, - }, - }); - - if (!result) { - return res.status(200).json({ - data: null, - }); - } - - return res.status(200).json({ - data: result, - }); - }, -); diff --git a/src/pages/api/personalization/update-user-preferences.ts b/src/pages/api/personalization/update-user-preferences.ts deleted file mode 100644 index 4908f210..00000000 --- a/src/pages/api/personalization/update-user-preferences.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { Op } from 'sequelize'; -import { UserPreferencesDbModel } from '../../../server/personalization/database'; -import { personalizationEndpoint } from '../../../server/personalization/personalization-endpoint'; - -// Updates user preferences (for now only dark mode preference) for given visitorId -export default personalizationEndpoint(async (req, res, { usePersonalizedData, visitorId }) => { - if (!usePersonalizedData) { - return res.status(400).json({ - data: null, - }); - } - - if (!visitorId) { - return res.status(400).json({ - error: 'Visitor ID not available', - }); - } - - const { hasDarkMode } = JSON.parse(req.body); - const hasDarkModeBool = Boolean(hasDarkMode); - - const [userPreferences, created] = await UserPreferencesDbModel.findOrCreate({ - where: { - visitorId: { - [Op.eq]: visitorId, - }, - }, - defaults: { - visitorId, - hasDarkMode: hasDarkModeBool, - timestamp: new Date(), - }, - }); - - if (!created) { - userPreferences.hasDarkMode = hasDarkModeBool; - await userPreferences.save(); - } - - return res.status(200).json({ - data: userPreferences, - }); -}); diff --git a/src/pages/personalization/embed.tsx b/src/pages/personalization/embed.tsx deleted file mode 100644 index c8fe37ec..00000000 --- a/src/pages/personalization/embed.tsx +++ /dev/null @@ -1,13 +0,0 @@ -import { GetStaticProps } from 'next'; -import Personalization from './index'; -import { CustomPageProps } from '../_app'; - -export default Personalization; - -export const getStaticProps: GetStaticProps = async () => { - return { - props: { - embed: true, - }, - }; -}; diff --git a/src/server/checkResult.ts b/src/server/checkResult.ts deleted file mode 100644 index b28b6d18..00000000 --- a/src/server/checkResult.ts +++ /dev/null @@ -1,55 +0,0 @@ -import { Severity } from './checks'; - -export type CheckResultObject = { - message: string; - severity: Severity; - type: string; - data?: Data; -}; - -export class CheckResult { - message: string; - severity: Severity; - type: string; - data?: any; - - constructor(message: string, severity: Severity, type: string, data?: Record) { - this.message = message; - this.severity = severity; - this.type = type; - this.data = data; - } - - toJsonResponse(): CheckResultObject { - return { - message: this.message, - severity: this.severity, - type: this.type, - data: this.data, - }; - } -} - -export const checkResultType = Object.freeze({ - LowConfidenceScore: 'LowConfidenceScore', - RequestIdMismatch: 'RequestIdMismatch', - OldTimestamp: 'OldTimestamp', - ForeignOrigin: 'ForeignOrigin', - Challenged: 'Challenged', - IpMismatch: 'IpMismatch', - Passed: 'Passed', - MaliciousBotDetected: 'MaliciousBotDetected', - GoodBotDetected: 'GoodBotDetected', - ServerError: 'ServerError', - // Payment specific checks. - TooManyChargebacks: 'TooManyChargebacks', - TooManyUnsuccessfulPayments: 'TooManyUnsuccessfulPayments', - PaidWithStolenCard: 'PaidWithStolenCard', - IncorrectCardDetails: 'IncorrectCardDetails', - - // Loan risk specific checks. - PossibleLoanFraud: 'PossibleLoanFraud', - - // Paywall specific checks. - ArticleViewLimitExceeded: 'ArticleViewLimitExceeded', -}); diff --git a/src/server/checks.test.ts b/src/server/checks.test.ts index d897a976..e380a303 100644 --- a/src/server/checks.test.ts +++ b/src/server/checks.test.ts @@ -1,6 +1,5 @@ import { beforeEach, describe, expect, it } from 'vitest'; -import { checkIpAddressIntegrity } from './checks'; -import { CheckResult } from './checkResult'; +import { visitIpMatchesRequestIp } from './checks'; describe('checks', () => { describe('checkIpAddressIntegrity', () => { @@ -9,74 +8,30 @@ describe('checks', () => { NODE_ENV: 'production', }); }); - const sampleIps = { ipv6: ['2001:db8:3333:4444:5555:6666:7777:8888.'], ipv4: ['192.0.2.146', '192.1.2.122'], }; it('should skip ipv6 addresses', () => { - const result = checkIpAddressIntegrity( - { - products: { - identification: { - // @ts-ignore Mocked test object - data: { - ip: sampleIps.ipv4[0], - }, - }, - }, - }, - - { - headers: { - 'x-forwarded-for': sampleIps.ipv6[0], - }, - }, - ); - expect(result).toBeFalsy(); + const result = visitIpMatchesRequestIp(sampleIps.ipv4[0], { + headers: new Headers([['x-forwarded-for', sampleIps.ipv6[0]]]), + } as unknown as Request); + expect(result).toBe(true); }); - it('should return undefined if ipv4 matches', () => { - const result = checkIpAddressIntegrity( - { - products: { - identification: { - // @ts-ignore Mocked test object - data: { - ip: sampleIps.ipv4[0], - }, - }, - }, - }, - { - headers: { - 'x-forwarded-for': sampleIps.ipv4[0], - }, - }, - ); - expect(result).toBeFalsy(); + it('should return true if ipv4 matches', () => { + const result = visitIpMatchesRequestIp(sampleIps.ipv4[0], { + headers: new Headers([['x-forwarded-for', sampleIps.ipv4[0]]]), + } as unknown as Request); + expect(result).toBe(true); }); - it('should return CheckResult if ipv4 does not match', () => { - const result = checkIpAddressIntegrity( - { - products: { - identification: { - // @ts-ignore Mocked test object - data: { - ip: sampleIps.ipv4[0], - }, - }, - }, - }, - { - headers: { - 'x-forwarded-for': sampleIps.ipv4[1], - }, - }, - ); - expect(result).toBeInstanceOf(CheckResult); + it('should return false if ipv4 does not match', () => { + const result = visitIpMatchesRequestIp(sampleIps.ipv4[0], { + headers: new Headers([['x-forwarded-for', sampleIps.ipv4[1]]]), + } as unknown as Request); + expect(result).toBe(false); }); }); }); diff --git a/src/server/checks.ts b/src/server/checks.ts index 285d366b..65265c19 100644 --- a/src/server/checks.ts +++ b/src/server/checks.ts @@ -4,8 +4,6 @@ import { Region, isEventError, } from '@fingerprintjs/fingerprintjs-pro-server-api'; -import { CheckResult, checkResultType } from './checkResult'; -import { NextApiRequest, NextApiResponse } from 'next'; import { ValidationDataResult } from '../shared/types'; import { decryptSealedResult } from './decryptSealedResult'; import { env } from '../env'; @@ -34,97 +32,7 @@ export function areVisitorIdAndRequestIdValid(visitorId: string, requestId: stri return isRequestIdFormatValid(requestId) && isVisitorIdFormatValid(visitorId); } -/** - * @deprecated Use getAndValidateFingerprintResult() for new use cases - */ -export type RequestCallback = (req: NextApiRequest, res: NextApiResponse, visitorData: EventResponse) => void; - -/** - * @deprecated Use getAndValidateFingerprintResult() for new use cases - */ -export type RuleCheck = ( - eventResponse: EventResponse, - req: NextApiRequest, - ...args: any -) => (CheckResult | undefined) | Promise; - -/** - * @deprecated Use getAndValidateFingerprintResult() for new use cases - */ -export const checkFreshIdentificationRequest: RuleCheck = (eventResponse) => { - const timestamp = eventResponse?.products?.identification?.data?.timestamp; - if (!eventResponse || !timestamp) { - return new CheckResult( - 'Hmmm, sneaky trying to forge information from the client-side, no luck this time, no sensitive action was performed.', - 'error', - checkResultType.RequestIdMismatch, - ); - } - - const requestTimestampDiff = new Date().getTime() - timestamp; - - if (requestTimestampDiff > ALLOWED_REQUEST_TIMESTAMP_DIFF_MS) { - return new CheckResult('Old requestId detected. Action ignored and logged.', 'error', checkResultType.OldTimestamp); - } - - return undefined; -}; - -/** - * @deprecated Use getAndValidateFingerprintResult() for new use cases - */ -export const checkConfidenceScore: RuleCheck = (eventResponse) => { - const confidenceScore = eventResponse?.products?.identification?.data?.confidence?.score; - if (!confidenceScore || confidenceScore < env.MIN_CONFIDENCE_SCORE) { - return new CheckResult( - "Low confidence score, we'd rather verify you with the second factor,", - 'error', - checkResultType.LowConfidenceScore, - ); - } - - return undefined; -}; - -/** - * @deprecated Use getAndValidateFingerprintResult() for new use cases - */ -export const checkIpAddressIntegrity: RuleCheck = (eventResponse, request) => { - if (!visitIpMatchesRequestIp(eventResponse.products?.identification?.data?.ip, request)) { - return new CheckResult( - 'IP mismatch. An attacker might have tried to phish the victim.', - 'error', - checkResultType.IpMismatch, - ); - } - - return undefined; -}; - -/** - * @deprecated Use getAndValidateFingerprintResult() for new use cases - */ -export const checkOriginsIntegrity: RuleCheck = (eventResponse, request) => { - if (!originIsAllowed(eventResponse.products?.identification?.data?.url, request)) { - return new CheckResult( - 'Origin mismatch. An attacker might have tried to phish the victim.', - 'error', - checkResultType.ForeignOrigin, - ); - } - - return undefined; -}; - -const isRequest = (request: Request | NextApiRequest): request is Request => { - return typeof request.headers.get == 'function'; -}; - -const getHeader = (request: Request | NextApiRequest, header: string) => { - return isRequest(request) ? request.headers.get(header) : request.headers[header]; -}; - -export function visitIpMatchesRequestIp(visitIp = '', request: NextApiRequest | Request) { +export function visitIpMatchesRequestIp(visitIp = '', request: Request) { // This check is skipped on purpose in the Stackblitz and localhost environments. if (IS_DEVELOPMENT) { return true; @@ -139,7 +47,7 @@ export function visitIpMatchesRequestIp(visitIp = '', request: NextApiRequest | * https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/X-Forwarded-For * https://adam-p.ca/blog/2022/03/x-forwarded-for/. */ - const xForwardedFor = getHeader(request, 'x-forwarded-for'); + const xForwardedFor = request.headers.get('x-forwarded-for'); const requestIp = Array.isArray(xForwardedFor) ? xForwardedFor[0] : xForwardedFor?.split(',')[0] ?? ''; // IPv6 addresses are not supported yet, skip the check @@ -150,13 +58,13 @@ export function visitIpMatchesRequestIp(visitIp = '', request: NextApiRequest | return requestIp === visitIp; } -export function originIsAllowed(url = '', request: NextApiRequest | Request) { +export function originIsAllowed(url = '', request: Request) { // This check is skipped on purpose in the Stackblitz and localhost environments. if (IS_DEVELOPMENT) { return true; } - const headerOrigin = getHeader(request, 'origin'); + const headerOrigin = request.headers.get('origin'); const visitDataOrigin = new URL(url).origin; return ( visitDataOrigin === headerOrigin && OUR_ORIGINS.includes(visitDataOrigin) && OUR_ORIGINS.includes(headerOrigin) @@ -173,7 +81,7 @@ export function originIsAllowed(url = '', request: NextApiRequest | Request) { type GetFingerprintResultArgs = { requestId: string; - req: NextApiRequest | Request; + req: Request; sealedResult?: string; serverApiKey?: string; region?: Region; @@ -181,6 +89,7 @@ type GetFingerprintResultArgs = { blockTor?: boolean; blockBots?: boolean; minConfidenceScore?: number; + disableFreshnessCheck?: boolean; }; }; @@ -258,7 +167,10 @@ export const getAndValidateFingerprintResult = async ({ * An attacker might have acquired a valid requestId and visitorId via phishing. * It's recommended to check freshness of the identification request to prevent replay attacks. */ - if (Date.now() - Number(new Date(identification.time)) > ALLOWED_REQUEST_TIMESTAMP_DIFF_MS) { + if ( + Date.now() - Number(new Date(identification.time)) > ALLOWED_REQUEST_TIMESTAMP_DIFF_MS && + !options?.disableFreshnessCheck + ) { return { okay: false, error: 'Old identification request, potential replay attack.' }; } diff --git a/src/server/personalization/personalization-endpoint.ts b/src/server/personalization/personalization-endpoint.ts deleted file mode 100644 index a050d176..00000000 --- a/src/server/personalization/personalization-endpoint.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { NextApiRequest, NextApiResponse } from 'next'; -import { ensurePostRequest } from '../server'; -import { initProducts } from './database'; -import { PersonalizationValidationResult, validatePersonalizationRequest } from './visitor-validations'; - -// Provides common logic used in personalization use-case -export const personalizationEndpoint = - ( - requestCallback: ( - req: NextApiRequest, - res: NextApiResponse, - validationResult: PersonalizationValidationResult, - ) => void, - ) => - async (req: NextApiRequest, res: NextApiResponse) => { - if (!ensurePostRequest(req, res)) { - return; - } - - // Ensure that DB models are initialized - await initProducts(); - - res.setHeader('Content-Type', 'application/json'); - - const validationResult = await validatePersonalizationRequest(req, res); - - /** - * FIXME Caused by getForbiddenResponse - * */ - if (res.headersSent) { - return; - } - - return requestCallback(req, res, validationResult); - }; diff --git a/src/server/personalization/visitor-validations.ts b/src/server/personalization/visitor-validations.ts deleted file mode 100644 index 6db43c06..00000000 --- a/src/server/personalization/visitor-validations.ts +++ /dev/null @@ -1,71 +0,0 @@ -import { ensureValidRequestIdAndVisitorId, getIdentificationEvent } from '../server'; -import { checkResultType } from '../checkResult'; -import { checkConfidenceScore, checkIpAddressIntegrity, checkOriginsIntegrity } from '../checks'; -import { NextApiRequest, NextApiResponse } from 'next'; - -export type PersonalizationValidationResult = { - visitorId: string | null; - usePersonalizedData: boolean; -}; - -/** - * Custom logic for validation personalization request. - * - * Since for personalization we don't need to throw errors if security check didn't pass, we just return flag that indicates if personalized content should be used or not. - * */ -export async function validatePersonalizationRequest( - req: NextApiRequest, - res: NextApiResponse, -): Promise { - const result: PersonalizationValidationResult = { - usePersonalizedData: false, - visitorId: null, - }; - - const { requestId, visitorId } = JSON.parse(req.body); - - if (!ensureValidRequestIdAndVisitorId(req, res, visitorId, requestId)) { - return result; - } - - const checks = [ - /** - * We don't need to check if the request is "fresh" for this use case. - * It's better to fetch visitor data once, and re-use it for every request that uses personalization to reduce the amount of API calls. - * - * Note: Our libraries for common frontend frameworks provide out of the box caching. - * */ - //checkFreshIdentificationRequest, - checkConfidenceScore, - checkIpAddressIntegrity, - checkOriginsIntegrity, - ]; - - const eventResponse = await getIdentificationEvent(requestId); - const serverVisitorId = eventResponse.products?.identification?.data?.visitorId; - - if (!serverVisitorId) { - return result; - } - - result.visitorId = serverVisitorId; - - for (const check of checks) { - const checkResult = await check(eventResponse, req); - - if (checkResult) { - switch (checkResult.type) { - case checkResultType.Passed: - case checkResultType.Challenged: - continue; - - default: - return result; - } - } - } - - result.usePersonalizedData = true; - - return result; -} diff --git a/src/server/response.ts b/src/server/response.ts deleted file mode 100644 index 7076d881..00000000 --- a/src/server/response.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { NextApiResponse } from 'next'; -import { CheckResult } from './checkResult'; - -export function sendOkResponse(res: NextApiResponse, result: CheckResult) { - if (res.headersSent) { - console.warn('Attempted to send a OK response after headers were sent.', { - result, - }); - - return; - } - - return res.status(200).json(result.toJsonResponse()); -} - -export function sendForbiddenResponse(res: NextApiResponse, result: CheckResult) { - if (res.headersSent) { - console.warn('Attempted to send a forbidden response after headers were sent.', { - result, - }); - - return; - } - - return res.status(403).json(result.toJsonResponse()); -} - -export function sendErrorResponse(res: NextApiResponse, result: CheckResult) { - if (res.headersSent) { - console.warn('Attempted to send an error response after headers were sent.', { - result, - }); - - return; - } - - res.statusMessage = result.message; - return res.status(500).json(result.toJsonResponse()); -} diff --git a/src/server/sequelize.ts b/src/server/sequelize.ts new file mode 100644 index 00000000..2b404f2f --- /dev/null +++ b/src/server/sequelize.ts @@ -0,0 +1,9 @@ +import { Sequelize } from 'sequelize'; +// Provision the database. +// In the Stackblitz environment, this db is stored locally in your browser. +// On the deployed demo, db is cleaned after each deployment. +export const sequelize = new Sequelize('database', '', '', { + dialect: 'sqlite', + storage: '.data/database.sqlite', + logging: false, +}); diff --git a/src/server/server.ts b/src/server/server.ts deleted file mode 100644 index 89837590..00000000 --- a/src/server/server.ts +++ /dev/null @@ -1,105 +0,0 @@ -import { Sequelize } from 'sequelize'; -import { areVisitorIdAndRequestIdValid } from './checks'; -import { fingerprintServerApiClient } from './fingerprint-server-api'; -import { CheckResult, checkResultType } from './checkResult'; -import { sendForbiddenResponse } from './response'; -import { NextApiRequest, NextApiResponse } from 'next'; -import { ValidationResult } from '../shared/types'; - -// Provision the database. -// In the Stackblitz environment, this db is stored locally in your browser. -// On the deployed demo, db is cleaned after each deployment. -export const sequelize = new Sequelize('database', '', '', { - dialect: 'sqlite', - storage: '.data/database.sqlite', - logging: false, -}); - -// Demo origins. -// It is recommended to use production origins instead. -export const ourOrigins = [ - 'https://fingerprinthub.com', - 'https://demo.fingerprint.com', - 'https://localhost:3000', - 'http://localhost:3000', - 'https://staging.fingerprinthub.com', -]; - -export type Severity = 'success' | 'warning' | 'error' | 'info'; - -export const messageSeverity = Object.freeze({ - Success: 'success', - Warning: 'warning', - Error: 'error', -}); - -export function ensureValidRequestIdAndVisitorId( - req: NextApiRequest, - res: NextApiResponse, - visitorId: string, - requestId: string, -) { - if (!areVisitorIdAndRequestIdValid(visitorId, requestId)) { - reportSuspiciousActivity(req); - sendForbiddenResponse( - res, - new CheckResult( - 'Forged visitorId or requestId detected. Try harder next time.', - messageSeverity.Error, - checkResultType.RequestIdMismatch, - ), - ); - - return false; - } - - return true; -} - -// Every identification request should be validated using the Fingerprint Pro Server API. - -export async function getIdentificationEvent(requestId?: string) { - // Do not request Server API if provided data is obviously forged, - // throw an error instead. - if (!requestId) { - throw new Error('requestId not provided.'); - } - - // Use Fingerprint Node SDK get the identification event from the Server API. - return fingerprintServerApiClient.getEvent(requestId); -} - -// Report suspicious user activity according to internal processes here. -// Possibly this action could also lock the user's account temporarily or ban a specific action. -export function reportSuspiciousActivity(_context: any) { - return _context; -} - -export function ensurePostRequest(req: NextApiRequest, res: NextApiResponse): boolean { - const validation = isValidPostRequest(req); - if (!validation.okay) { - res.status(405).send({ message: validation.error }); - return false; - } - - return true; -} - -export function ensureGetRequest(req: NextApiRequest, res: NextApiResponse): boolean { - if (req.method !== 'GET') { - res.status(405).send({ message: 'Only GET requests allowed' }); - return false; - } - - return true; -} - -export const isValidPostRequest = (req: NextApiRequest): ValidationResult => { - if (req.method !== 'POST') { - return { okay: false, error: 'Only POST requests allowed' }; - } - if (!req.body) { - return { okay: false, error: 'Missing body' }; - } - return { okay: true }; -};