From 435029aac4f06434b622c45044a51a2494db9c48 Mon Sep 17 00:00:00 2001 From: kiremitrov123 Date: Mon, 5 Dec 2022 14:20:30 +0100 Subject: [PATCH] feat: registration flow --- .../AccountModal/forms/Registration.tsx | 5 +- src/i18n/locales/en_US/account.json | 3 +- src/services/cleeng.account.service.ts | 75 ++++--- src/services/inplayer.account.service.ts | 194 +++++++++++++++--- src/stores/AccountController.ts | 134 +++++++----- types/account.d.ts | 19 +- types/cleeng.d.ts | 1 + types/inplayer.d.ts | 9 + 8 files changed, 329 insertions(+), 111 deletions(-) diff --git a/src/containers/AccountModal/forms/Registration.tsx b/src/containers/AccountModal/forms/Registration.tsx index cc0f213fd..7195680bd 100644 --- a/src/containers/AccountModal/forms/Registration.tsx +++ b/src/containers/AccountModal/forms/Registration.tsx @@ -54,10 +54,13 @@ const Registration = () => { } catch (error: unknown) { if (error instanceof Error) { const errorMessage = error.message.toLowerCase(); - if (errorMessage.includes('customer already exists.')) { + if (errorMessage.includes('already exists')) { setErrors({ form: t('registration.user_exists') }); } else if (errorMessage.includes('invalid param password')) { setErrors({ password: t('registration.invalid_password') }); + } else { + // in case the endpoint fails + setErrors({ password: t('registration.failed_to_create') }); } setValue('password', ''); } diff --git a/src/i18n/locales/en_US/account.json b/src/i18n/locales/en_US/account.json index 49ae21785..8f4ca0bf6 100644 --- a/src/i18n/locales/en_US/account.json +++ b/src/i18n/locales/en_US/account.json @@ -122,7 +122,8 @@ "sign_up": "Sign up", "user_exists": "There is already a user with this email address", "view_password": "View password", - "email_updates": "Yes, I want to receive {{siteName}} updates by email." + "email_updates": "Yes, I want to receive {{siteName}} updates by email.", + "failed_to_create": "Unable to register at the moment." }, "renew_subscription": { "explanation": "By clicking the button below you can renew your plan.", diff --git a/src/services/cleeng.account.service.ts b/src/services/cleeng.account.service.ts index 719344579..66e5270c7 100644 --- a/src/services/cleeng.account.service.ts +++ b/src/services/cleeng.account.service.ts @@ -21,6 +21,10 @@ import type { AuthData, JwtDetails, RegisterPayload, + GetPublisherConsentsResponse, + GetCustomerConsentsResponse, + GetCaptureStatusResponse, + Capture, } from '#types/account'; import type { Config } from '#types/Config'; @@ -82,24 +86,61 @@ export async function getUser({ config, auth }: { config: Config; auth: AuthData } export const getFreshJwtToken = async ({ config, auth }: { config: Config; auth: AuthData }) => { - const result = await refreshToken({ refreshToken: auth.refreshToken }, !!config.integrations.cleeng?.useSandbox); + const response = await refreshToken({ refreshToken: auth.refreshToken }, !!config.integrations.cleeng?.useSandbox); - if (result.errors.length) throw new Error(result.errors[0]); + if (response.errors.length) throw new Error(response.errors[0]); - return result?.responseData; + return response?.responseData; }; -// export const register: Register = async (payload, sandbox) => { -// payload.customerIP = getOverrideIP(); -// return post(sandbox, '/customers', JSON.stringify(payload)); -// }; +export const getPublisherConsents: GetPublisherConsents = async (config) => { + const { cleeng } = config.integrations; + const response: ServiceResponse = await get(!!cleeng?.useSandbox, `/publishers/${cleeng?.id}/consents`); -export const fetchPublisherConsents: GetPublisherConsents = async (payload, sandbox) => { - return get(sandbox, `/publishers/${payload.publisherId}/consents`); + if (response.errors.length) throw new Error(response.errors[0]); + + return response; +}; + +export const getCustomerConsents: GetCustomerConsents = async (payload) => { + const { config, customer, jwt } = payload; + const { cleeng } = config.integrations; + + const response: ServiceResponse = await get(!!cleeng?.useSandbox, `/customers/${customer?.id}/consents`, jwt); + if (response.errors.length) throw new Error(response.errors[0]); + + return response; }; -export const fetchCustomerConsents: GetCustomerConsents = async (payload, sandbox, jwt) => { - return get(sandbox, `/customers/${payload.customerId}/consents`, jwt); +export const updateCustomerConsents: UpdateCustomerConsents = async (payload) => { + const { config, customer, jwt } = payload; + const { cleeng } = config.integrations; + + const _payload = { + id: customer.id, + consents: payload.consents, + }; + + const response: ServiceResponse = await put(!!cleeng?.useSandbox, `/customers/${customer?.id}/consents`, JSON.stringify(_payload), jwt); + if (response.errors.length) throw new Error(response.errors[0]); + + return response; +}; + +export const getCaptureStatus: GetCaptureStatus = async ({ customer }, sandbox, jwt) => { + const response: ServiceResponse = await get(sandbox, `/customers/${customer?.id}/capture/status`, jwt); + + if (response.errors.length > 0) throw new Error(response.errors[0]); + + return response; +}; + +export const updateCaptureAnswers: UpdateCaptureAnswers = async ({ customer, ...payload }, sandbox, jwt) => { + const response: ServiceResponse = await put(sandbox, `/customers/${customer.id}/capture`, JSON.stringify(payload), jwt); + + if (response.errors.length > 0) throw new Error(response.errors[0]); + + return response; }; export const resetPassword: ResetPassword = async (payload, sandbox) => { @@ -114,10 +155,6 @@ export const updateCustomer: UpdateCustomer = async (payload, sandbox, jwt) => { return patch(sandbox, `/customers/${payload.id}`, JSON.stringify(payload), jwt); }; -export const updateCustomerConsents: UpdateCustomerConsents = async (payload, sandbox, jwt) => { - return put(sandbox, `/customers/${payload.id}/consents`, JSON.stringify(payload), jwt); -}; - export const getCustomer: GetCustomer = async (payload, sandbox, jwt) => { return get(sandbox, `/customers/${payload.customerId}`, jwt); }; @@ -129,11 +166,3 @@ export const refreshToken: RefreshToken = async (payload, sandbox) => { export const getLocales: GetLocales = async (sandbox) => { return get(sandbox, `/locales${getOverrideIP() ? '?customerIP=' + getOverrideIP() : ''}`); }; - -export const getCaptureStatus: GetCaptureStatus = async ({ customerId }, sandbox, jwt) => { - return get(sandbox, `/customers/${customerId}/capture/status`, jwt); -}; - -export const updateCaptureAnswers: UpdateCaptureAnswers = async ({ customerId, ...payload }, sandbox, jwt) => { - return put(sandbox, `/customers/${customerId}/capture`, JSON.stringify(payload), jwt); -}; diff --git a/src/services/inplayer.account.service.ts b/src/services/inplayer.account.service.ts index f517191c0..1a2b52ae5 100644 --- a/src/services/inplayer.account.service.ts +++ b/src/services/inplayer.account.service.ts @@ -1,8 +1,23 @@ -import InPlayer, { AccountData, Env } from '@inplayer-org/inplayer.js'; +import InPlayer, { AccountData, Env, GetRegisterField } from '@inplayer-org/inplayer.js'; -import type { AuthData, Customer, CustomerConsent, Login, Register, UpdateCustomer } from '#types/account'; +import type { + AuthData, + Capture, + Consent, + Customer, + CustomerConsent, + GetCaptureStatus, + GetCaptureStatusResponse, + GetCustomerConsentsPayload, + GetPublisherConsents, + Login, + Register, + UpdateCaptureAnswers, + UpdateCustomer, + UpdateCustomerConsents, +} from '#types/account'; import type { Config } from '#types/Config'; -import type { InPlayerAuthData } from '#types/inplayer'; +import type { InPlayerAuthData, InPlayerError } from '#types/inplayer'; enum InPlayerEnv { Development = 'development', @@ -25,8 +40,8 @@ export const login: Login = async ({ config, email, password }) => { }); return { - auth: processInPlayerAuth(data), - user: processInplayerAccount(data.account), + auth: processAuth(data), + user: processAccount(data.account), }; } catch { throw new Error('Failed to authenticate user.'); @@ -39,18 +54,19 @@ export const register: Register = async ({ config, email, password }) => { email, password, passwordConfirmation: password, - fullName: '', + fullName: 'New User', type: 'consumer', clientId: config.integrations.inplayer?.clientId || '', referrer: window.location.href, }); return { - auth: processInPlayerAuth(data), - user: processInplayerAccount(data.account), + auth: processAuth(data), + user: processAccount(data.account), }; - } catch { - throw new Error('Failed to create user.'); + } catch (error: unknown) { + const { response } = error as InPlayerError; + throw new Error(response.data.message); } }; @@ -65,7 +81,7 @@ export const logout = async () => { export const getUser = async (): Promise => { try { const { data } = await InPlayer.Account.getAccountInfo(); - return processInplayerAccount(data); + return processAccount(data); } catch { throw new Error('Failed to fetch user data.'); } @@ -75,18 +91,14 @@ export const getFreshJwtToken = async ({ auth }: { auth: AuthData }) => auth; export const updateCustomer: UpdateCustomer = async (values) => { try { - const consents: { [key: string]: string } = {}; - values.consents?.map((consent: CustomerConsent) => { - if (consent.label) { - const { customerId, date, newestVersion, needsUpdate, ...rest } = consent; - consents[`consents_${consent.name}`] = JSON.stringify(rest); - } - }); + const fullName = `${values.firstName} ${values.lastName}`; + const consents = processCustomerConsents(values?.consents || []); + const response = await InPlayer.Account.updateAccount({ - fullName: `${values.firstName} ${values.lastName}`, + fullName, metadata: { - first_name: values.firstName as string, - last_name: values.lastName as string, + firstName: values.firstName as string, + lastName: values.lastName as string, ...consents, }, }); @@ -94,15 +106,106 @@ export const updateCustomer: UpdateCustomer = async (values) => { return { errors: [], // @ts-ignore - // wrong data type from InPlayer SDK - responseData: processInplayerAccount(response.data), + // wrong data type from InPlayer SDK (will be updated in the SDK) + responseData: processAccount(response.data), }; } catch { - throw new Error('Failed to fetch user data.'); + throw new Error('Failed to update user data.'); + } +}; + +export const getPublisherConsents: GetPublisherConsents = async (config) => { + try { + const { inplayer } = config.integrations; + const { data } = await InPlayer.Account.getRegisterFields(inplayer?.clientId || ''); + + // @ts-ignore + // wrong data type from InPlayer SDK (will be updated in the SDK) + const result: Array = data?.collection + .filter((field: GetRegisterField) => field.type === 'checkbox') + .map((consent: GetRegisterField) => processPublisherConsents(consent)); + + return { + errors: [], + responseData: { + consents: [getTermsConsent(), ...result], + }, + }; + } catch { + throw new Error('Failed to fetch publisher consents.'); + } +}; + +export const getCustomerConsents = async (payload: GetCustomerConsentsPayload) => { + try { + const { customer } = payload; + + if (!customer?.metadata) { + return { + errors: [], + responseData: { + consents: [], + }, + }; + } + + const consents = Object.keys(customer.metadata) + .filter((key) => key.includes('consents_')) + .map((key) => JSON.parse(customer.metadata?.[key] as string)); + + return { + errors: [], + responseData: { + consents, + }, + }; + } catch { + throw new Error('Unable to fetch Customer consents.'); + } +}; + +export const updateCustomerConsents: UpdateCustomerConsents = async (payload) => { + try { + const { customer, consents } = payload; + + const data = { + consents, + firstName: customer.metadata?.firstName as string, + lastName: customer.metadata?.lastName as string, + }; + + return (await updateCustomer(data, true, '')) as ServiceResponse; + } catch { + throw new Error('Unable to update Customer`s consents'); } }; -// responsible to convert the InPlayer object to be compatible to the store -function processInplayerAccount(account: AccountData): Customer { + +export const getCaptureStatus: GetCaptureStatus = async ({ customer }) => { + return { + errors: [], + responseData: { + isCaptureEnabled: true, + shouldCaptureBeDisplayed: true, + settings: [ + { + answer: { + firstName: customer.firstName || null, + lastName: customer.lastName || null, + }, + enabled: true, + key: 'firstNameLastName', + required: true, + }, + ], + }, + } as ServiceResponse; +}; + +export const updateCaptureAnswers: UpdateCaptureAnswers = async ({ ...metadata }) => { + return (await updateCustomer(metadata, true, '')) as ServiceResponse; +}; + +function processAccount(account: AccountData): Customer { const { id, email, full_name: fullName, metadata, created_at: createdAt } = account; const regDate = new Date(createdAt * 1000).toLocaleString(); @@ -110,15 +213,16 @@ function processInplayerAccount(account: AccountData): Customer { id: id.toString(), email, fullName, - firstName: metadata?.first_name as string, - lastName: metadata?.last_name as string, + metadata, + firstName: metadata?.firstName as string, + lastName: metadata?.lastName as string, regDate, country: '', lastUserIp: '', }; } -function processInPlayerAuth(auth: InPlayerAuthData): AuthData { +function processAuth(auth: InPlayerAuthData): AuthData { const { access_token: jwt } = auth; return { jwt, @@ -126,3 +230,35 @@ function processInPlayerAuth(auth: InPlayerAuthData): AuthData { refreshToken: '', }; } + +function processPublisherConsents(consent: Partial) { + return { + broadcasterId: 0, + enabledByDefault: false, + label: consent.label, + name: consent.name, + required: consent.required, + value: '', + version: '1', + } as Consent; +} + +function processCustomerConsents(consents: CustomerConsent[]) { + const result: { [key: string]: string } = {}; + consents?.forEach((consent: CustomerConsent) => { + if (consent.name) { + const { name, version, state } = consent; + result[`consents_${consent.name}`] = JSON.stringify({ name, version, state }); + } + }); + return result; +} + +function getTermsConsent(): Consent { + const label = 'I accept the Terms and Conditions of InPlayer.'; + return processPublisherConsents({ + required: true, + name: 'terms', + label, + }); +} diff --git a/src/stores/AccountController.ts b/src/stores/AccountController.ts index 307bcf6bb..56d8a21e8 100644 --- a/src/stores/AccountController.ts +++ b/src/stores/AccountController.ts @@ -6,7 +6,16 @@ import * as cleengAccountService from '#src/services/cleeng.account.service'; import * as inplayerAccountService from '#src/services/inplayer.account.service'; import { useFavoritesStore } from '#src/stores/FavoritesStore'; import { useWatchHistoryStore } from '#src/stores/WatchHistoryStore'; -import type { AuthData, Capture, Customer, CustomerConsent, JwtDetails } from '#types/account'; +import type { + AuthData, + Capture, + Customer, + CustomerConsent, + GetCaptureStatusResponse, + GetCustomerConsentsResponse, + GetPublisherConsentsResponse, + JwtDetails, +} from '#types/account'; import { useConfigStore } from '#src/stores/ConfigStore'; import * as persist from '#src/utils/persist'; import { useAccountStore } from '#src/stores/AccountStore'; @@ -178,15 +187,23 @@ export const logout = async () => { await restoreFavorites(); await restoreWatchHistory(); + + // it's needed for the InPlayer SDK await accountService.logout(); }); }; export const register = async (email: string, password: string) => { await withAccountService(async ({ accountService, config }) => { - await accountService.register({ config, email, password }); - await updatePersonalShelves(); + useAccountStore.setState({ loading: true }); + const { auth, user } = await accountService.register({ config, email, password }); + useAccountStore.setState({ + auth, + user, + }); + // await updatePersonalShelves(); }); + useAccountStore.setState({ loading: true }); }; export const updatePersonalShelves = async () => { @@ -212,67 +229,81 @@ export const updatePersonalShelves = async () => { }); }; -export const updateConsents = async (customerConsents: CustomerConsent[]) => { - return await useLoginContext(async ({ cleengSandbox, customerId, auth: { jwt } }) => { - const response = await cleengAccountService.updateCustomerConsents( - { - id: customerId, +export const updateConsents = async (customerConsents: CustomerConsent[]): Promise> => { + return await useAccountContext(async ({ customer, auth: { jwt, refreshToken } }) => { + return await withAccountService(async ({ accountService, config }) => { + useAccountStore.setState({ loading: true }); + + const response = await accountService.updateCustomerConsents({ + jwt, + config, + customer, consents: customerConsents, - }, - cleengSandbox, - jwt, - ); + }); + + // if user does not have refresh token (InPlayer user) the response data is user object + if (response?.responseData && !refreshToken) { + useAccountStore.setState({ user: response.responseData, loading: false }); + } - await getCustomerConsents(); + await getCustomerConsents(); - return response; + return response; + }); }); }; -export async function getCustomerConsents() { - return await useLoginContext(async ({ cleengSandbox, customerId, auth: { jwt } }) => { - const response = await cleengAccountService.fetchCustomerConsents({ customerId }, cleengSandbox, jwt); +export async function getCustomerConsents(): Promise> { + return await useAccountContext(async ({ customer, auth: { jwt } }) => { + return await withAccountService(async ({ accountService, config }) => { + const response = await accountService.getCustomerConsents({ config, customer, jwt }); - if (response && !response.errors?.length) { - useAccountStore.setState({ customerConsents: response.responseData.consents }); - } + if (response && !response.errors?.length) { + useAccountStore.setState({ customerConsents: response.responseData.consents }); + } - return response; + return response; + }); }); } -export async function getPublisherConsents() { - return await useConfig(async ({ cleengId, cleengSandbox }) => { - const response = await cleengAccountService.fetchPublisherConsents({ publisherId: cleengId }, cleengSandbox); +export const getPublisherConsents = async (): Promise> => { + return await withAccountService(async ({ accountService, config }) => { + const response = await accountService.getPublisherConsents(config); - if (response && !response.errors?.length) { - useAccountStore.setState({ publisherConsents: response.responseData.consents }); - } + useAccountStore.setState({ publisherConsents: response.responseData.consents }); return response; }); -} - -export const getCaptureStatus = async () => { - return await useLoginContext(async ({ cleengSandbox, customerId, auth: { jwt } }) => { - const response = await cleengAccountService.getCaptureStatus({ customerId }, cleengSandbox, jwt); +}; - if (response.errors.length > 0) throw new Error(response.errors[0]); +export const getCaptureStatus = async (): Promise => { + return await useAccountContext(async ({ customer, auth: { jwt } }) => { + return await withAccountService(async ({ accountService, sandbox }) => { + const { responseData } = await accountService.getCaptureStatus({ customer }, sandbox, jwt); - return response.responseData; + return responseData; + }); }); }; -export const updateCaptureAnswers = async (capture: Capture) => { - return await useLoginContext(async ({ cleengSandbox, customerId, auth }) => { - const response = await cleengAccountService.updateCaptureAnswers({ customerId, ...capture }, cleengSandbox, auth.jwt); +export const updateCaptureAnswers = async (capture: Capture): Promise => { + return await useAccountContext(async ({ customer, auth }) => { + return await withAccountService(async ({ accountService, accessModel, sandbox }) => { + const response = await accountService.updateCaptureAnswers({ customer, ...capture }, sandbox, auth.jwt); - if (response.errors.length > 0) throw new Error(response.errors[0]); + if (response.errors.length > 0) throw new Error(response.errors[0]); - // @todo why is this needed? - await getAccount(auth); + // if no refresh token present (InPlayer config), update account store + // otherwise fetch account + if (!auth.refreshToken) { + await afterLogin(auth, customer, accessModel); + } else { + await getAccount(auth); + } - return response.responseData; + return response.responseData; + }); }); }; @@ -380,17 +411,13 @@ export async function getMediaItems(watchlistId: string | undefined | null, medi return getMediaByWatchlist(watchlistId, mediaIds); } -async function getAccountExtras(accessModel: string) { - return await Promise.allSettled([accessModel === 'SVOD' ? reloadActiveSubscription() : Promise.resolve(), getCustomerConsents(), getPublisherConsents()]); -} - -async function afterLogin(auth: AuthData, response: Customer, accessModel: string) { +async function afterLogin(auth: AuthData, user: Customer, accessModel: string) { useAccountStore.setState({ - auth: auth, - user: response, + auth, + user, }); - return await getAccountExtras(accessModel); + return await Promise.allSettled([accessModel === 'SVOD' ? reloadActiveSubscription() : Promise.resolve(), getCustomerConsents(), getPublisherConsents()]); } async function getActiveSubscription({ cleengSandbox, customerId, jwt }: { cleengSandbox: boolean; customerId: string; jwt: string }) { @@ -433,6 +460,14 @@ function useLoginContext(callback: (args: { cleengId: string; cleengSandbox: return useConfig((config) => callback({ ...config, customerId: user.id, auth })); } +function useAccountContext(callback: (args: { customerId: string; customer: Customer; auth: AuthData }) => T): T { + const { user, auth } = useAccountStore.getState(); + + if (!user?.id || !auth?.jwt) throw new Error('user not logged in'); + + return callback({ customerId: user.id, customer: user, auth }); +} + function withAccountService( callback: (args: { accountService: typeof inplayerAccountService | typeof cleengAccountService; @@ -442,7 +477,6 @@ function withAccountService( }) => T, ): T { const { config, accessModel } = useConfigStore.getState(); - const { cleeng, inplayer } = config.integrations; if (inplayer?.clientId) { diff --git a/types/account.d.ts b/types/account.d.ts index 86372ba1a..5dc594d42 100644 --- a/types/account.d.ts +++ b/types/account.d.ts @@ -119,7 +119,9 @@ export type GetPublisherConsentsResponse = { }; export type GetCustomerConsentsPayload = { - customerId: string; + config: Config; + customer: Customer; + jwt: string; }; export type GetCustomerConsentsResponse = { @@ -160,7 +162,9 @@ export type ExternalData = { }; export type UpdateCustomerConsentsPayload = { - id?: string; + jwt: string; + config: Config; + customer: Customer; consents: CustomerConsent[]; }; @@ -176,6 +180,7 @@ export type Customer = { lastLoginDate?: string; lastUserIp: string; firstName?: string; + metadata?: Record; lastName?: string; fullName?: string; externalId?: string; @@ -212,7 +217,7 @@ export type LocalesData = { }; export type GetCaptureStatusPayload = { - customerId: string; + customer: Customer; }; export type GetCaptureStatusResponse = { @@ -242,20 +247,20 @@ export type Capture = { }; export type UpdateCaptureAnswersPayload = { - customerId: string; + customer: Customer; } & Capture; // TODO: Convert these all to generic non-cleeng calls // type Login = CleengRequest; type Login = (args: AuthArgs) => Promise<{ auth: AuthData; user: Customer }>; type Register = (args: AuthArgs) => Promise<{ auth: AuthData; user: Customer }>; -type GetPublisherConsents = CleengRequest; -type GetCustomerConsents = CleengAuthRequest; +type GetPublisherConsents = ServiceRequest; +type GetCustomerConsents = ServiceRequest; +type UpdateCustomerConsents = ServiceRequest; type ResetPassword = CleengRequest>; type ChangePassword = CleengRequest>; type GetCustomer = CleengAuthRequest; type UpdateCustomer = CleengAuthRequest; -type UpdateCustomerConsents = CleengAuthRequest; type RefreshToken = CleengRequest; type GetLocales = CleengEmptyRequest; type GetCaptureStatus = CleengAuthRequest; diff --git a/types/cleeng.d.ts b/types/cleeng.d.ts index 69aa45728..2ded33f80 100644 --- a/types/cleeng.d.ts +++ b/types/cleeng.d.ts @@ -2,6 +2,7 @@ interface ApiResponse { errors: string[]; } type ServiceResponse = { responseData: R } & ApiResponse; +type ServiceRequest = (payload: P) => Promise>; type CleengEmptyRequest = (sandbox: boolean) => Promise>; type CleengEmptyAuthRequest = (sandbox: boolean, jwt: string) => Promise>; type CleengRequest = (payload: P, sandbox: boolean) => Promise>; diff --git a/types/inplayer.d.ts b/types/inplayer.d.ts index 326ee1a6d..75b30a0da 100644 --- a/types/inplayer.d.ts +++ b/types/inplayer.d.ts @@ -2,3 +2,12 @@ export type InPlayerAuthData = { access_token: string; expires?: number; }; + +export type InPlayerError = { + response: { + data: { + code: number; + message: string; + }; + }; +};