Skip to content
New issue

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

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

Already on GitHub? Sign in to your account

fix: hubspot signup form issue when user is invited #5317

Closed
wants to merge 5 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
4 changes: 3 additions & 1 deletion .cspell.json
Expand Up @@ -618,7 +618,9 @@
"usecase",
"zulip",
"uuidv",
"Vonage"
"Vonage",
"firstname",
"lastname"
],
"flagWords": [],
"patterns": [
Expand Down
20 changes: 20 additions & 0 deletions apps/api/src/app/user/dtos/update-profile.dto.ts
@@ -0,0 +1,20 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsEnum, IsOptional, IsString } from 'class-validator';
import { JobTitleEnum } from '@novu/shared';

export class UpdateProfileDto {
@ApiProperty()
@IsOptional()
@IsString()
firstName?: string;

@ApiProperty()
@IsOptional()
@IsString()
lastName?: string;

@ApiProperty()
@IsOptional()
@IsEnum(JobTitleEnum)
jobTitle?: JobTitleEnum;
}
2 changes: 2 additions & 0 deletions apps/api/src/app/user/usecases/index.ts
Expand Up @@ -4,11 +4,13 @@ import { GetMyProfileUsecase } from './get-my-profile/get-my-profile.usecase';
import { UpdateOnBoardingUsecase } from './update-on-boarding/update-on-boarding.usecase';
import { UpdateOnBoardingTourUsecase } from './update-on-boarding-tour/update-on-boarding-tour.usecase';
import { UpdateProfileEmail } from './update-profile-email/update-profile-email.usecase';
import { UpdateProfileUseCase } from './update-profile/update-profile.usecase';

export const USE_CASES = [
CreateUser,
GetMyProfileUsecase,
UpdateOnBoardingUsecase,
UpdateProfileEmail,
UpdateOnBoardingTourUsecase,
UpdateProfileUseCase,
];
@@ -0,0 +1,17 @@
import { IsEnum, IsOptional, IsString } from 'class-validator';
import { JobTitleEnum } from '@novu/shared';
import { AuthenticatedCommand } from '../../../shared/commands/authenticated.command';

export class UpdateProfileCommand extends AuthenticatedCommand {
@IsOptional()
@IsString()
firstName?: string;

@IsOptional()
@IsString()
lastName?: string;

@IsOptional()
@IsEnum(JobTitleEnum)
jobTitle?: JobTitleEnum;
}
@@ -0,0 +1,64 @@
import { BadRequestException, forwardRef, Inject, Injectable, NotFoundException } from '@nestjs/common';

import { UserRepository } from '@novu/dal';
import { AnalyticsService, buildUserKey, InvalidateCacheService } from '@novu/application-generic';
import { IUserEntity } from '@novu/shared';

import { UpdateProfileCommand } from './update-profile.command';

@Injectable()
export class UpdateProfileUseCase {
constructor(
private invalidateCache: InvalidateCacheService,
private readonly userRepository: UserRepository,
@Inject(forwardRef(() => AnalyticsService))
private analyticsService: AnalyticsService
) {}

async execute(command: UpdateProfileCommand) {
const user = await this.userRepository.findById(command.userId);
if (!user) throw new BadRequestException('Invalid UserId');

const valuesToUpdate: Partial<IUserEntity> = {};

if (command.firstName) {
valuesToUpdate.firstName = command?.firstName?.toLowerCase();
}
if (command.lastName) {
valuesToUpdate.lastName = command?.lastName?.toLowerCase();
}
if (command.jobTitle) {
valuesToUpdate.jobTitle = command.jobTitle;
}
await this.userRepository.update(
{
_id: command.userId,
},
{
$set: {
...valuesToUpdate,
},
}
);

const updatedUser = await this.userRepository.findById(command.userId);
if (!updatedUser) throw new NotFoundException('User not found');
await this.invalidateCache.invalidateByKey({
key: buildUserKey({
_id: command.userId,
}),
});

if (command.firstName) {
this.analyticsService.setValue(updatedUser?._id, 'firstName', command.firstName);
}
if (command.lastName) {
this.analyticsService.setValue(updatedUser?._id, 'lastName', command.lastName);
}
if (command.jobTitle) {
this.analyticsService.setValue(updatedUser?._id, 'jobTitle', command.jobTitle);
}

return updatedUser;
}
}
20 changes: 19 additions & 1 deletion apps/api/src/app/user/user.controller.ts
Expand Up @@ -26,6 +26,9 @@ import { ApiCommonResponses, ApiResponse, ApiOkResponse } from '../shared/framew
import { UserOnboardingTourRequestDto } from './dtos/user-onboarding-tour-request.dto';
import { UpdateOnBoardingTourUsecase } from './usecases/update-on-boarding-tour/update-on-boarding-tour.usecase';
import { UpdateOnBoardingTourCommand } from './usecases/update-on-boarding-tour/update-on-boarding-tour.command';
import { UpdateProfileUseCase } from './usecases/update-profile/update-profile.usecase';
import { UpdateProfileCommand } from './usecases/update-profile/update-profile.command';
import { UpdateProfileDto } from './dtos/update-profile.dto';

@ApiCommonResponses()
@Controller('/users')
Expand All @@ -38,7 +41,8 @@ export class UsersController {
private getMyProfileUsecase: GetMyProfileUsecase,
private updateOnBoardingUsecase: UpdateOnBoardingUsecase,
private updateOnBoardingTourUsecase: UpdateOnBoardingTourUsecase,
private updateProfileEmailUsecase: UpdateProfileEmail
private updateProfileEmailUsecase: UpdateProfileEmail,
private updateProfileUsecase: UpdateProfileUseCase
) {}

@Get('/me')
Expand All @@ -59,6 +63,20 @@ export class UsersController {
return await this.getMyProfileUsecase.execute(command);
}

@Put('/profile')
async updateProfile(@UserSession() user: IJwtPayload, @Body() body: UpdateProfileDto): Promise<UserResponseDto> {
const { firstName, lastName, jobTitle } = body;

return await this.updateProfileUsecase.execute(
UpdateProfileCommand.create({
userId: user._id,
firstName: firstName,
lastName: lastName,
jobTitle: jobTitle,
})
);
}

@Put('/profile/email')
async updateProfileEmail(
@UserSession() user: IJwtPayload,
Expand Down
2 changes: 1 addition & 1 deletion apps/web/src/pages/auth/InvitationPage.tsx
Expand Up @@ -45,7 +45,7 @@ export default function InvitationPage() {
useEffect(() => {
// auto accept invitation when logged in as invited user
if (isLoggedInAsInvitedUser) {
submitToken(tokensRef.current.token as string, tokensRef.current.invitationToken as string, true);
submitToken(tokensRef.current.token as string, tokensRef.current.invitationToken as string, true, false);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

it's hard to understand what are there true/false arguments, please change it to the object, which will improve readability

}
}, [isLoggedInAsInvitedUser, submitToken]);

Expand Down
2 changes: 1 addition & 1 deletion apps/web/src/pages/auth/LoginPage.tsx
Expand Up @@ -62,7 +62,7 @@ export default function LoginPage() {
}

if (invitationToken) {
submitToken(token, invitationToken);
submitToken(token, invitationToken, false, false);

return;
}
Expand Down
84 changes: 55 additions & 29 deletions apps/web/src/pages/auth/components/HubspotSignupForm.tsx
Expand Up @@ -5,7 +5,7 @@ import decode from 'jwt-decode';
import { useMantineColorScheme } from '@mantine/core';

import { JobTitleEnum } from '@novu/shared';
import type { ProductUseCases, IResponseError, ICreateOrganizationDto, IJwtPayload } from '@novu/shared';
import type { IResponseError, ICreateOrganizationDto, IJwtPayload, IOrganizationEntity } from '@novu/shared';
import { HubspotForm, useSegment } from '@novu/shared-web';

import { api } from '../../../api/api.client';
Expand All @@ -17,39 +17,52 @@ import SetupLoader from './SetupLoader';

export function HubspotSignupForm() {
const [loading, setLoading] = useState<boolean>();
const [existingOrganization, setExistingOrganization] = useState<IOrganizationEntity>();
const navigate = useNavigate();
const { setToken, token, currentUser } = useAuthContext();
const { startVercelSetup } = useVercelIntegration();
const { isFromVercel } = useVercelParams();
const { colorScheme } = useMantineColorScheme();

const segment = useSegment();

const { mutateAsync: createOrganizationMutation } = useMutation<
{ _id: string },
IResponseError,
ICreateOrganizationDto
>((data: ICreateOrganizationDto) => api.post(`/v1/organizations`, data));

const { mutateAsync: updateUserMutation } = useMutation<{ _id: string }, IResponseError, IUserUpdateForm>(
(data: IUserUpdateForm) => api.put(`/v1/users/profile`, data)
);

const fetchExistingOrganization = async () => {
return api.get(`/v1/organizations/me`);
};

useEffect(() => {
if (token) {
const userData = decode<IJwtPayload>(token);

if (userData.environmentId) {
// if user is invited to an organization, fetch that organization name
(async () => {
const org = await fetchExistingOrganization();
setExistingOrganization(org);
})();

if (isFromVercel) {
startVercelSetup();

return;
}

navigate(ROUTES.HOME);
}
}
}, [token, navigate, isFromVercel, startVercelSetup]);

return () => {};
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

not needed

}, [token, isFromVercel, startVercelSetup]);

async function createOrganization(data: IOrganizationCreateForm) {
const { organizationName, jobTitle, ...rest } = data;
const createDto: ICreateOrganizationDto = { ...rest, name: organizationName, jobTitle };
const { organizationName, ...rest } = data;
const createDto: ICreateOrganizationDto = { ...rest, name: organizationName };
const organization = await createOrganizationMutation(createDto);
const organizationResponseToken = await api.post(`/v1/auth/organizations/${organization._id}/switch`, {});

Expand All @@ -66,10 +79,6 @@ export function HubspotSignupForm() {
const handleCreateOrganization = async (data: IOrganizationCreateForm) => {
if (!data?.organizationName) return;

segment.track('Button Clicked - [Signup]', { action: 'hubspot questionnaire form submit' });

setLoading(true);

if (!jwtHasKey('organizationId')) {
await createOrganization({ ...data });
}
Expand All @@ -80,10 +89,35 @@ export function HubspotSignupForm() {

return;
}
};

const handleFormSubmission = async (values) => {
segment.track('Button Clicked - [Signup]', { action: 'hubspot questionnaire form submit' });
setLoading(true);

const submissionValues = values?.submissionValues as unknown as {
company: string;
role___onboarding: string;
firstname: string;
lastname: string;
};

// update user profile data on form submit
await updateUserMutation({
firstName: submissionValues?.firstname,
lastName: submissionValues?.lastname,
jobTitle: hubspotRoleToJobTitleMapping[submissionValues?.role___onboarding],
});

// skip organization creation if user is signing up with invitation
if (!existingOrganization) {
await handleCreateOrganization({
organizationName: submissionValues?.company,
});
}

navigate(ROUTES.GET_STARTED);
};

if (!currentUser || loading) {
return <SetupLoader title="Loading..." />;
} else {
Expand All @@ -95,26 +129,16 @@ export function HubspotSignupForm() {
lastname: currentUser?.lastName as string,
email: currentUser?.email as string,

company: '',
company: existingOrganization ? existingOrganization.name : '',
role___onboarding: '',
heard_about_novu: '',
use_case___onboarding: '',
role___onboarding__other_: '',
heard_about_novu__other_: '',
}}
readonlyProperties={['email']}
focussedProperty="company"
onFormSubmitted={($form, values) => {
const submissionValues = values?.submissionValues as unknown as {
company: string;
role___onboarding: string;
};

handleCreateOrganization({
organizationName: submissionValues?.company,
jobTitle: hubspotRoleToJobTitleMapping[submissionValues?.role___onboarding],
});
}}
readonlyProperties={existingOrganization ? ['email', 'company'] : ['email']}
focussedProperty={existingOrganization ? 'role___onboarding' : 'company'}
onFormSubmitted={($form, values) => handleFormSubmission(values)}
colorScheme={colorScheme}
/>
);
Expand All @@ -123,9 +147,11 @@ export function HubspotSignupForm() {

interface IOrganizationCreateForm {
organizationName: string;
}
interface IUserUpdateForm {
firstName: string;
lastName: string;
jobTitle: JobTitleEnum;
domain?: string;
productUseCases?: ProductUseCases;
}

const hubspotRoleToJobTitleMapping: Record<string, JobTitleEnum> = {
Expand Down
2 changes: 1 addition & 1 deletion apps/web/src/pages/auth/components/LoginForm.tsx
Expand Up @@ -64,7 +64,7 @@ export function LoginForm({ email, invitationToken }: LoginFormProps) {
}

if (invitationToken) {
submitToken(token, invitationToken);
submitToken(token, invitationToken, false, false);

return;
}
Expand Down
4 changes: 1 addition & 3 deletions apps/web/src/pages/auth/components/SignUpForm.tsx
Expand Up @@ -77,9 +77,7 @@ export function SignUpForm({ invitationToken, email }: SignUpFormProps) {
applyToken(token);

if (invitationToken) {
submitToken(token, invitationToken);

return true;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why did we remove this return statement?

submitToken(token, invitationToken, false, true);
} else {
setToken(token);
}
Expand Down