From 39c724bb5531bda244ed57d6ff4aabf67dd7268b Mon Sep 17 00:00:00 2001 From: Koen Romers Date: Fri, 10 Jan 2025 18:15:23 +0000 Subject: [PATCH] feat(settings): add profile (#155) --- next.config.ts | 2 +- package.json | 1 + pnpm-lock.yaml | 8 ++ src/app/(default)/settings/[tab]/page.tsx | 24 ++-- src/app/actions.ts | 108 ++++++++++++++- src/app/api/auth/callback/tmdb/route.ts | 35 ++++- src/components/Buttons/LoginButton.tsx | 2 +- src/components/Menu/MenuItem.tsx | 2 +- src/components/Menu/Username.tsx | 2 +- src/components/Profile/Form.tsx | 121 ++++++++++++++++ src/components/Profile/Tmdb.tsx | 136 ++++++++++++++++++ src/components/Tabs/Tabs.tsx | 10 +- src/components/Webhook/WebhookForPlex.tsx | 6 +- src/lib/auth.ts | 8 +- src/lib/db/otp/index.ts | 10 +- src/lib/db/session/index.ts | 88 ++++++++++++ src/lib/db/user/index.ts | 159 ++++++++++++++++++---- src/utils/stringBase64Url.ts | 7 + 18 files changed, 675 insertions(+), 54 deletions(-) create mode 100644 src/components/Profile/Form.tsx create mode 100644 src/components/Profile/Tmdb.tsx create mode 100644 src/utils/stringBase64Url.ts diff --git a/next.config.ts b/next.config.ts index 6f613e9f..ce7f306a 100644 --- a/next.config.ts +++ b/next.config.ts @@ -74,7 +74,7 @@ const nextConfig = { }, { source: '/settings', - destination: '/settings/import', + destination: '/settings/profile', permanent: false, }, ]; diff --git a/package.json b/package.json index 64e6b351..b325402a 100644 --- a/package.json +++ b/package.json @@ -29,6 +29,7 @@ "react": "19.0.0", "react-dom": "19.0.0", "react-dropzone": "^14.3.5", + "react-fast-compare": "^3.2.2", "recharts": "^2.15.0", "slugify": "^1.6.6", "sonner": "^1.7.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b09ebba5..f0046200 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -56,6 +56,9 @@ importers: react-dropzone: specifier: ^14.3.5 version: 14.3.5(react@19.0.0) + react-fast-compare: + specifier: ^3.2.2 + version: 3.2.2 recharts: specifier: ^2.15.0 version: 2.15.0(react-dom@19.0.0(react@19.0.0))(react@19.0.0) @@ -3128,6 +3131,9 @@ packages: peerDependencies: react: '>= 16.8 || 18.0.0' + react-fast-compare@3.2.2: + resolution: {integrity: sha512-nsO+KSNgo1SbJqJEYRE9ERzo7YtYbou/OqjSQKxV7jcKox7+usiUVZOAC+XnDOABXggQTno0Y1CpVnuWEc1boQ==} + react-is@16.13.1: resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==} @@ -7822,6 +7828,8 @@ snapshots: prop-types: 15.8.1 react: 19.0.0 + react-fast-compare@3.2.2: {} + react-is@16.13.1: {} react-is@18.3.1: {} diff --git a/src/app/(default)/settings/[tab]/page.tsx b/src/app/(default)/settings/[tab]/page.tsx index 58e16683..2fa69e4d 100644 --- a/src/app/(default)/settings/[tab]/page.tsx +++ b/src/app/(default)/settings/[tab]/page.tsx @@ -3,8 +3,11 @@ import { Suspense } from 'react'; import { notFound, unauthorized } from 'next/navigation'; import ImportContainer from '@/components/Import/ImportContainer'; +import ProfileForm from '@/components/Profile/Form'; import { tabs, type Tab } from '@/components/Tabs/Tabs'; -import WebhookForPlex from '@/components/Webhook/WebhookForPlex'; +import WebhookForPlex, { + plexStyles, +} from '@/components/Webhook/WebhookForPlex'; import auth from '@/lib/auth'; export default async function SettingsPage({ @@ -26,13 +29,9 @@ export default async function SettingsPage({ return notFound(); } - // if (tab === 'profile') { - // return ( - //

- // Soon you'll be able to edit your profile details here. - //

- // ); - // } + if (tab === 'profile') { + return ; + } if (tab === 'import') { return ( @@ -59,7 +58,14 @@ export default async function SettingsPage({

Plex

+
+
+
} > diff --git a/src/app/actions.ts b/src/app/actions.ts index 24354be9..a26ef991 100644 --- a/src/app/actions.ts +++ b/src/app/actions.ts @@ -1,16 +1,24 @@ 'use server'; import { cookies, headers } from 'next/headers'; -import { redirect } from 'next/navigation'; +import { redirect, unauthorized } from 'next/navigation'; +import isEqual from 'react-fast-compare'; +import slugify from 'slugify'; import auth from '@/lib/auth'; import { createOTP, validateOTP } from '@/lib/db/otp'; import { createSession, deleteSession, + removeTmdbFromSession, SESSION_DURATION, } from '@/lib/db/session'; -import { createUser, findUser } from '@/lib/db/user'; +import { + createUser, + findUser, + removeTmdbFromUser, + updateUser, +} from '@/lib/db/user'; import { sendEmail } from '@/lib/email'; import { createRequestToken, @@ -134,3 +142,99 @@ export async function logout() { ]); } } + +export async function updateProfile(_: unknown, formData: FormData) { + const { user } = await auth(); + + if (!user) { + unauthorized(); + } + + const currentUser = { + email: user.email ?? '', + name: user.name ?? '', + username: user.username ?? '', + }; + + const rawFormData = { + email: formData.get('email')?.toString() ?? '', + name: formData.get('name')?.toString() ?? '', + username: formData.get('username')?.toString() ?? '', + }; + + if (isEqual(currentUser, rawFormData)) { + return { + message: 'No changes detected', + success: false, + }; + } + + const slugifiedUsername = slugify(rawFormData.username, { + lower: true, + strict: true, + trim: true, + }); + + if (slugifiedUsername !== rawFormData.username) { + return { + message: 'Username can only contain letters, numbers, and dashes', + success: false, + }; + } + + try { + await updateUser(user, { + email: rawFormData.email, + name: rawFormData.name, + username: slugifiedUsername, + }); + } catch (err) { + const error = err as Error; + return { + message: error.message, + success: false, + }; + } + + return { + message: 'Profile updated successfully', + success: true, + }; +} + +export async function removeTmdbAccount() { + const { user, session } = await auth(); + + if (!user || !session) { + unauthorized(); + } + + const isTmdbUser = + user.tmdbAccountId && user.tmdbAccountObjectId && user.tmdbUsername; + const isTmdbSession = session.tmdbSessionId && session.tmdbAccessToken; + + try { + if (isTmdbUser) { + await removeTmdbFromUser(user); + } + + if (isTmdbSession) { + await Promise.all([ + removeTmdbFromSession(session), + deleteAccessToken(session.tmdbAccessToken), + deleteSessionId(session.tmdbSessionId), + ]); + } + + return { + message: 'TMDb removed from your account', + success: true, + }; + } catch (err) { + const error = err as Error; + return { + message: error.message, + success: false, + }; + } +} diff --git a/src/app/api/auth/callback/tmdb/route.ts b/src/app/api/auth/callback/tmdb/route.ts index 2ab46618..04b27907 100644 --- a/src/app/api/auth/callback/tmdb/route.ts +++ b/src/app/api/auth/callback/tmdb/route.ts @@ -2,8 +2,13 @@ import { cookies } from 'next/headers'; import { redirect } from 'next/navigation'; import { type NextRequest } from 'next/server'; -import { createSession, SESSION_DURATION } from '@/lib/db/session'; -import { createUser, findUser } from '@/lib/db/user'; +import auth from '@/lib/auth'; +import { + addTmdbToSession, + createSession, + SESSION_DURATION, +} from '@/lib/db/session'; +import { addTmdbToUser, createUser, findUser } from '@/lib/db/user'; import { createAccessToken, createSessionId, @@ -32,6 +37,32 @@ export async function GET(request: NextRequest) { const tmdbAccount = await fetchAccountDetails(tmdbSessionId); let user = await findUser({ tmdbAccountId: tmdbAccount.id }); + + // Note: check if we are connecting an authenticated user + const { user: currentUser, session: currentSession } = await auth(); + if (currentUser && currentSession) { + if (user) { + const [baseUrl, queryString] = redirectUri.split('?'); + const searchParams = new URLSearchParams(queryString); + searchParams.append('error', 'tmdbAccountAlreadyLinked'); + return redirect(`${baseUrl}?${searchParams.toString()}`); + } + + await Promise.all([ + addTmdbToSession(currentSession, { + tmdbSessionId, + tmdbAccessToken: accessToken, + }), + addTmdbToUser(currentUser, { + tmdbAccountId: tmdbAccount.id, + tmdbAccountObjectId: accountObjectId, + tmdbUsername: tmdbAccount.username, + }), + ]); + cookieStore.delete('requestTokenTmdb'); + return redirect(redirectUri); + } + if (!user) { user = await createUser({ name: tmdbAccount.name, diff --git a/src/components/Buttons/LoginButton.tsx b/src/components/Buttons/LoginButton.tsx index a7ca4673..5134a147 100644 --- a/src/components/Buttons/LoginButton.tsx +++ b/src/components/Buttons/LoginButton.tsx @@ -11,7 +11,7 @@ export default function LoginButton() { diff --git a/src/components/Menu/MenuItem.tsx b/src/components/Menu/MenuItem.tsx index 4c416360..1bc2adcf 100644 --- a/src/components/Menu/MenuItem.tsx +++ b/src/components/Menu/MenuItem.tsx @@ -11,7 +11,7 @@ const MenuItem = ({ return ( {label} diff --git a/src/components/Menu/Username.tsx b/src/components/Menu/Username.tsx index dae7ff5a..17c02f1c 100644 --- a/src/components/Menu/Username.tsx +++ b/src/components/Menu/Username.tsx @@ -12,7 +12,7 @@ export default async function Username() { } const profileName = - user.tmdbUsername || user.username || user.name || 'anonymous'; + user.username || user.tmdbUsername || user.name || 'anonymous'; return (
diff --git a/src/components/Profile/Form.tsx b/src/components/Profile/Form.tsx new file mode 100644 index 00000000..b5b63058 --- /dev/null +++ b/src/components/Profile/Form.tsx @@ -0,0 +1,121 @@ +'use client'; + +import { useActionState, useEffect } from 'react'; + +import { useRouter } from 'next/navigation'; +import { toast } from 'sonner'; + +import { updateProfile } from '@/app/actions'; +import { type User } from '@/types/user'; + +import Tmdb from './Tmdb'; +import LoadingDots from '../LoadingDots/LoadingDots'; + +const initialState = { + message: '', + success: false, +}; + +export default function ProfileSection({ + user, +}: Readonly<{ + user: User; +}>) { + const router = useRouter(); + const [state, formAction, pending] = useActionState( + async (state: typeof initialState, formData: FormData) => { + const result = await updateProfile(state, formData); + router.refresh(); + return result; + }, + initialState, + ); + + useEffect(() => { + if (state?.message) { + if (state.success) { + toast.success(state.message); + } else { + toast.error(state.message); + } + } + }, [state]); + + return ( +
+
+
+
+ + +
+ +
+ + +
+ +
+ + +
+ + +
+
+ + +
+ ); +} diff --git a/src/components/Profile/Tmdb.tsx b/src/components/Profile/Tmdb.tsx new file mode 100644 index 00000000..b4a3461c --- /dev/null +++ b/src/components/Profile/Tmdb.tsx @@ -0,0 +1,136 @@ +'use client'; + +import { useEffect, useTransition } from 'react'; + +import Image from 'next/image'; +import { useRouter, useSearchParams } from 'next/navigation'; +import { toast } from 'sonner'; +import { twMerge } from 'tailwind-merge'; + +import { loginWithTmdb, removeTmdbAccount } from '@/app/actions'; +import TmdbLogo from '@/assets/tmdb.svg'; +import { type User } from '@/types/user'; + +import LoadingDots from '../LoadingDots/LoadingDots'; + +export default function Tmdb({ user }: Readonly<{ user: User }>) { + const [isPending, startTransition] = useTransition(); + const router = useRouter(); + const searchParams = useSearchParams(); + const errorFromSearchParams = searchParams.get('error'); + + const hasTmdbAccount = + user.tmdbAccountId && user.tmdbAccountObjectId && user.tmdbUsername; + + const handleRemove = () => { + startTransition(async () => { + const result = await removeTmdbAccount(); + if (result?.message) { + if (result.success) { + toast.success(result.message); + } else { + toast.error(result.message); + } + } + router.refresh(); + }); + }; + + const handleAdd = () => { + startTransition(async () => { + await loginWithTmdb('/settings/profile'); + }); + }; + + useEffect(() => { + if (errorFromSearchParams) { + toast.error( + errorFromSearchParams === 'tmdbAccountAlreadyLinked' + ? 'Your TMDb account is already linked.' + : 'An error occurred while linking your TMDb account.', + ); + } + }, [errorFromSearchParams]); + + return ( +
+ {hasTmdbAccount && ( +
+ TMDb + +
+ )} + {hasTmdbAccount ? ( +
+
+
Account ID
+
+ {user.tmdbAccountId} +
+
+
+
+ Account Object ID +
+
+ {user.tmdbAccountObjectId} +
+
+
+
Username
+
+ {user.tmdbUsername} +
+
+
+ ) : ( + + )} +
+ ); +} diff --git a/src/components/Tabs/Tabs.tsx b/src/components/Tabs/Tabs.tsx index caa0aad1..998c4102 100644 --- a/src/components/Tabs/Tabs.tsx +++ b/src/components/Tabs/Tabs.tsx @@ -16,11 +16,11 @@ export const tabStyles = cva('inline-block rounded-t-lg border-b-2 p-4', { export type ButtonVariantProps = VariantProps; export const menuItems = [ - // { - // id: 'profile', - // label: 'Profile', - // href: '/settings/profile', - // }, + { + id: 'profile', + label: 'Profile', + href: '/settings/profile', + }, { id: 'import', label: 'Import', diff --git a/src/components/Webhook/WebhookForPlex.tsx b/src/components/Webhook/WebhookForPlex.tsx index ad2d9a9a..84e4e7c4 100644 --- a/src/components/Webhook/WebhookForPlex.tsx +++ b/src/components/Webhook/WebhookForPlex.tsx @@ -1,3 +1,5 @@ +import { cva } from 'class-variance-authority'; + import { createWebhookToken, findWebhookTokenByUserAndType, @@ -6,6 +8,8 @@ import { import generateWebhookUrl from './generateWebhookUrl'; import Webhook from './Webhook'; +export const plexStyles = cva('bg-gradient-to-br from-[#e5a00d] to-[#b17a0a]'); + export default async function WebhookForPlex({ userId, }: Readonly<{ @@ -20,7 +24,7 @@ export default async function WebhookForPlex({ return ( { const _session = await session(); return { session: _session, user: _session ? await user(_session) : null, }; -} +}); + +export default auth; diff --git a/src/lib/db/otp/index.ts b/src/lib/db/otp/index.ts index b6ac46e0..1f27d98f 100644 --- a/src/lib/db/otp/index.ts +++ b/src/lib/db/otp/index.ts @@ -8,6 +8,8 @@ import { import { marshall, unmarshall } from '@aws-sdk/util-dynamodb'; import { Resource } from 'sst'; +import { encodeToBase64Url } from '@/utils/stringBase64Url'; + import client from '../client'; const OTP_DURATION = 15 * 60; // 15 minutes in seconds @@ -16,10 +18,6 @@ const generateOTP = () => { return randomInt(100000, 999999).toString(); }; -const encodeEmail = (email: string): string => { - return Buffer.from(email.toLowerCase()).toString('base64url'); -}; - export const createOTP = async ( input: Readonly<{ email: string; @@ -32,7 +30,7 @@ export const createOTP = async ( const command = new PutItemCommand({ TableName: Resource.OTP.name, Item: marshall({ - pk: `EMAIL#${encodeEmail(input.email)}`, + pk: `EMAIL#${encodeToBase64Url(input.email)}`, sk: `CODE#${code}`, email: input.email.toLowerCase(), createdAt: now, @@ -57,7 +55,7 @@ export const validateOTP = async ( const command = new GetItemCommand({ TableName: Resource.OTP.name, Key: marshall({ - pk: `EMAIL#${encodeEmail(input.email)}`, + pk: `EMAIL#${encodeToBase64Url(input.email)}`, sk: `CODE#${input.otp}`, }), }); diff --git a/src/lib/db/session/index.ts b/src/lib/db/session/index.ts index 14e2dce3..966a9083 100644 --- a/src/lib/db/session/index.ts +++ b/src/lib/db/session/index.ts @@ -2,6 +2,7 @@ import { DeleteItemCommand, GetItemCommand, PutItemCommand, + UpdateItemCommand, } from '@aws-sdk/client-dynamodb'; import { marshall, unmarshall } from '@aws-sdk/util-dynamodb'; import { Resource } from 'sst'; @@ -109,3 +110,90 @@ export const deleteSession = async (sessionId: string): Promise => { throw new Error('Failed to delete session'); } }; + +export const removeTmdbFromSession = async ( + session: Session, +): Promise => { + const now = new Date().toISOString(); + + try { + await client.send( + new UpdateItemCommand({ + TableName: Resource.Sessions.name, + Key: marshall({ + pk: `SESSION#${session.id}`, + }), + UpdateExpression: + 'REMOVE #tmdbSessionId, #tmdbAccessToken SET updatedAt = :updatedAt, #provider = :provider', + ExpressionAttributeValues: marshall({ + ':updatedAt': now, + ':provider': 'internal', + }), + ExpressionAttributeNames: { + '#tmdbSessionId': 'tmdbSessionId', + '#tmdbAccessToken': 'tmdbAccessToken', + '#provider': 'provider', + }, + }), + ); + + const updatedSession = { + ...session, + updatedAt: now, + provider: 'internal' as const, + }; + + // Remove TMDB fields from the returned session object + delete updatedSession.tmdbSessionId; + delete updatedSession.tmdbAccessToken; + + return updatedSession; + } catch (_error) { + throw new Error('Failed to remove TMDB from session'); + } +}; + +export const addTmdbToSession = async ( + session: Session, + input: Readonly<{ + tmdbAccessToken: string; + tmdbSessionId: string; + }>, +): Promise => { + const now = new Date().toISOString(); + + try { + await client.send( + new UpdateItemCommand({ + TableName: Resource.Sessions.name, + Key: marshall({ + pk: `SESSION#${session.id}`, + }), + UpdateExpression: + 'SET #tmdbSessionId = :tmdbSessionId, #tmdbAccessToken = :tmdbAccessToken, ' + + '#provider = :provider, #updatedAt = :updatedAt', + ExpressionAttributeValues: marshall({ + ':tmdbSessionId': input.tmdbSessionId, + ':tmdbAccessToken': input.tmdbAccessToken, + ':provider': 'tmdb', + ':updatedAt': now, + }), + ExpressionAttributeNames: { + '#tmdbSessionId': 'tmdbSessionId', + '#tmdbAccessToken': 'tmdbAccessToken', + '#provider': 'provider', + '#updatedAt': 'updatedAt', + }, + }), + ); + + return { + ...session, + tmdbSessionId: input.tmdbSessionId, + tmdbAccessToken: input.tmdbAccessToken, + provider: 'tmdb' as const, + }; + } catch (_error) { + throw new Error('Failed to add TMDB to session'); + } +}; diff --git a/src/lib/db/user/index.ts b/src/lib/db/user/index.ts index 918e4f85..a9369c94 100644 --- a/src/lib/db/user/index.ts +++ b/src/lib/db/user/index.ts @@ -5,20 +5,18 @@ import { UpdateItemCommand, } from '@aws-sdk/client-dynamodb'; import { marshall, unmarshall } from '@aws-sdk/util-dynamodb'; +import slugify from 'slugify'; import { Resource } from 'sst'; import { ulid } from 'ulid'; import { type User } from '@/types/user'; import generateUsername from '@/utils/generateUsername'; +import { encodeToBase64Url } from '@/utils/stringBase64Url'; import client from '../client'; const VERSION = 1; -const encodeEmail = (email: string): string => { - return Buffer.from(email.toLowerCase()).toString('base64url'); -}; - export const findUser = async ( input: | { userId: string; email?: never; username?: never; tmdbAccountId?: never } @@ -34,9 +32,9 @@ export const findUser = async ( const [indexName, prefix, value] = input.userId ? [undefined, 'USER#', input.userId] : input.email - ? ['gsi1', 'EMAIL#', encodeEmail(input.email)] + ? ['gsi1', 'EMAIL#', encodeToBase64Url(input.email)] : input.username - ? ['gsi2', 'USERNAME#', input.username.toLowerCase()] + ? ['gsi2', 'USERNAME#', input.username] : ['gsi3', 'TMDB#', input.tmdbAccountId]; const result = await client.send( @@ -66,7 +64,13 @@ export const createUser = async ( ) >, ): Promise => { - let username = input.email ? input.email.split('@')[0] : input.username!; + let username = input.email + ? slugify(input.email.split('@')[0], { + lower: true, + strict: true, + trim: true, + }) + : input.username!; const isUsernameTaken = await findUser({ username }); if (isUsernameTaken) { @@ -102,10 +106,10 @@ export const createUser = async ( role, version: VERSION, ...(input.email && { - gsi1pk: `EMAIL#${encodeEmail(input.email)}`, + gsi1pk: `EMAIL#${encodeToBase64Url(input.email)}`, email: input.email, }), - gsi2pk: `USERNAME#${username.toLowerCase()}`, + gsi2pk: `USERNAME#${username}`, username, ...(input.tmdbAccountId && input.tmdbAccountObjectId && { @@ -152,7 +156,7 @@ export const createUser = async ( }; export const updateUser = async ( - userId: string, + user: User, updates: Readonly<{ email?: string; username?: string; @@ -160,50 +164,47 @@ export const updateUser = async ( }> & ({ email: string } | { username: string } | { name: string }), ): Promise => { - const currentUser = await findUser({ userId }); - if (!currentUser) { - throw new Error('User not found'); - } - if (updates.email) { const existingUser = await findUser({ email: updates.email }); - if (existingUser && existingUser.id !== userId) { + if (existingUser && existingUser.id !== user.id) { throw new Error('Email already taken'); } } if (updates.username) { const existingUser = await findUser({ username: updates.username }); - if (existingUser && existingUser.id !== userId) { + if (existingUser && existingUser.id !== user.id) { throw new Error('Username already taken'); } } const updateExpressions: string[] = []; + const expressionAttributeNames: Record = {}; const values: Record = {}; const now = new Date().toISOString(); values[':updatedAt'] = now; - values[':lastUpdatedAt'] = currentUser.updatedAt || currentUser.createdAt; + values[':lastUpdatedAt'] = user.updatedAt || user.createdAt; updateExpressions.push('updatedAt = :updatedAt'); if (updates.email) { values[':email'] = updates.email; - values[':newEmailIndex'] = `EMAIL#${encodeEmail(updates.email)}`; + values[':newEmailIndex'] = `EMAIL#${encodeToBase64Url(updates.email)}`; updateExpressions.push('email = :email'); updateExpressions.push('gsi1pk = :newEmailIndex'); } if (updates.username) { values[':username'] = updates.username; - values[':newUsernameIndex'] = `USERNAME#${updates.username.toLowerCase()}`; + values[':newUsernameIndex'] = `USERNAME#${updates.username}`; updateExpressions.push('username = :username'); updateExpressions.push('gsi2pk = :newUsernameIndex'); } if (updates.name) { values[':name'] = updates.name; - updateExpressions.push('name = :name'); + expressionAttributeNames['#fullName'] = 'name'; + updateExpressions.push('#fullName = :name'); } try { @@ -211,17 +212,20 @@ export const updateUser = async ( new UpdateItemCommand({ TableName: Resource.Users.name, Key: marshall({ - pk: `USER#${userId}`, + pk: `USER#${user.id}`, }), UpdateExpression: `SET ${updateExpressions.join(', ')}`, ExpressionAttributeValues: marshall(values), + ...(Object.keys(expressionAttributeNames).length > 0 && { + ExpressionAttributeNames: expressionAttributeNames, + }), ConditionExpression: '(updatedAt = :lastUpdatedAt OR attribute_not_exists(updatedAt))', }), ); const updatedUser = { - ...currentUser, + ...user, ...updates, updatedAt: now, }; @@ -236,3 +240,112 @@ export const updateUser = async ( throw error; } }; + +export const addTmdbToUser = async ( + user: User, + input: Readonly<{ + tmdbAccountId: number; + tmdbAccountObjectId: string; + tmdbUsername: string; + }>, +): Promise => { + const now = new Date().toISOString(); + + try { + await client.send( + new UpdateItemCommand({ + TableName: Resource.Users.name, + Key: marshall({ + pk: `USER#${user.id}`, + }), + UpdateExpression: ` + SET #tmdbAccountId = :tmdbAccountId, + #tmdbAccountObjectId = :tmdbAccountObjectId, + #tmdbUsername = :tmdbUsername, + #gsi3pk = :gsi3pk, + #updatedAt = :updatedAt`, + ExpressionAttributeValues: marshall({ + ':tmdbAccountId': input.tmdbAccountId, + ':tmdbAccountObjectId': input.tmdbAccountObjectId, + ':tmdbUsername': input.tmdbUsername, + ':gsi3pk': `TMDB#${input.tmdbAccountId}`, + ':updatedAt': now, + ':lastUpdatedAt': user.updatedAt || user.createdAt, + }), + ExpressionAttributeNames: { + '#tmdbAccountId': 'tmdbAccountId', + '#tmdbAccountObjectId': 'tmdbAccountObjectId', + '#tmdbUsername': 'tmdbUsername', + '#gsi3pk': 'gsi3pk', + '#updatedAt': 'updatedAt', + }, + ConditionExpression: + '(#updatedAt = :lastUpdatedAt OR attribute_not_exists(#updatedAt))', + }), + ); + + return { + ...user, + tmdbAccountId: input.tmdbAccountId, + tmdbAccountObjectId: input.tmdbAccountObjectId, + tmdbUsername: input.tmdbUsername, + updatedAt: now, + }; + } catch (error) { + if (error instanceof ConditionalCheckFailedException) { + throw new Error( + "Looks like you're connecting your TMDB account on another device. Please try again.", + ); + } + throw error; + } +}; + +export const removeTmdbFromUser = async (user: User): Promise => { + const now = new Date().toISOString(); + + try { + await client.send( + new UpdateItemCommand({ + TableName: Resource.Users.name, + Key: marshall({ + pk: `USER#${user.id}`, + }), + UpdateExpression: + 'REMOVE #tmdbAccountId, #tmdbAccountObjectId, #tmdbUsername, #gsi3pk SET #updatedAt = :updatedAt', + ExpressionAttributeValues: marshall({ + ':updatedAt': now, + ':lastUpdatedAt': user.updatedAt || user.createdAt, + }), + ExpressionAttributeNames: { + '#tmdbAccountId': 'tmdbAccountId', + '#tmdbAccountObjectId': 'tmdbAccountObjectId', + '#tmdbUsername': 'tmdbUsername', + '#gsi3pk': 'gsi3pk', + '#updatedAt': 'updatedAt', + }, + ConditionExpression: + '(#updatedAt = :lastUpdatedAt OR attribute_not_exists(#updatedAt))', + }), + ); + + const updatedUser = { + ...user, + updatedAt: now, + }; + + // Remove TMDB fields from the returned user object + delete updatedUser.tmdbAccountId; + delete updatedUser.tmdbAccountObjectId; + delete updatedUser.tmdbUsername; + + return updatedUser; + } catch (error) { + if (error instanceof ConditionalCheckFailedException) { + throw new Error( + "Looks like you're updating your profile on another device. Please try again.", + ); + } + throw error; + } +}; diff --git a/src/utils/stringBase64Url.ts b/src/utils/stringBase64Url.ts new file mode 100644 index 00000000..1bba8715 --- /dev/null +++ b/src/utils/stringBase64Url.ts @@ -0,0 +1,7 @@ +export const encodeToBase64Url = (str: string): string => { + return Buffer.from(str.toLowerCase()).toString('base64url'); +}; + +export const decodeFromBase64Url = (str: string): string => { + return Buffer.from(str, 'base64url').toString(); +};