diff --git a/.cspell.json b/.cspell.json index f8502a905a6..a1f626c5a2d 100644 --- a/.cspell.json +++ b/.cspell.json @@ -618,7 +618,9 @@ "usecase", "zulip", "uuidv", - "Vonage" + "Vonage", + "firstname", + "lastname" ], "flagWords": [], "patterns": [ diff --git a/apps/api/src/app/user/dtos/update-profile.dto.ts b/apps/api/src/app/user/dtos/update-profile.dto.ts new file mode 100644 index 00000000000..097c54aa3a9 --- /dev/null +++ b/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; +} diff --git a/apps/api/src/app/user/usecases/index.ts b/apps/api/src/app/user/usecases/index.ts index 54000735076..d952b90a2e2 100644 --- a/apps/api/src/app/user/usecases/index.ts +++ b/apps/api/src/app/user/usecases/index.ts @@ -4,6 +4,7 @@ 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, @@ -11,4 +12,5 @@ export const USE_CASES = [ UpdateOnBoardingUsecase, UpdateProfileEmail, UpdateOnBoardingTourUsecase, + UpdateProfileUseCase, ]; diff --git a/apps/api/src/app/user/usecases/update-profile/update-profile.command.ts b/apps/api/src/app/user/usecases/update-profile/update-profile.command.ts new file mode 100644 index 00000000000..e40c179a2db --- /dev/null +++ b/apps/api/src/app/user/usecases/update-profile/update-profile.command.ts @@ -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; +} diff --git a/apps/api/src/app/user/usecases/update-profile/update-profile.usecase.ts b/apps/api/src/app/user/usecases/update-profile/update-profile.usecase.ts new file mode 100644 index 00000000000..c49e8c59bd4 --- /dev/null +++ b/apps/api/src/app/user/usecases/update-profile/update-profile.usecase.ts @@ -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 = {}; + + 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; + } +} diff --git a/apps/api/src/app/user/user.controller.ts b/apps/api/src/app/user/user.controller.ts index 07a4cd924ec..08ff7940b84 100644 --- a/apps/api/src/app/user/user.controller.ts +++ b/apps/api/src/app/user/user.controller.ts @@ -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') @@ -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') @@ -59,6 +63,20 @@ export class UsersController { return await this.getMyProfileUsecase.execute(command); } + @Put('/profile') + async updateProfile(@UserSession() user: IJwtPayload, @Body() body: UpdateProfileDto): Promise { + 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, diff --git a/apps/web/src/pages/auth/InvitationPage.tsx b/apps/web/src/pages/auth/InvitationPage.tsx index 8288dadff8d..c33e216e5cc 100644 --- a/apps/web/src/pages/auth/InvitationPage.tsx +++ b/apps/web/src/pages/auth/InvitationPage.tsx @@ -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); } }, [isLoggedInAsInvitedUser, submitToken]); diff --git a/apps/web/src/pages/auth/LoginPage.tsx b/apps/web/src/pages/auth/LoginPage.tsx index 9d466b290d1..21343a2b02a 100644 --- a/apps/web/src/pages/auth/LoginPage.tsx +++ b/apps/web/src/pages/auth/LoginPage.tsx @@ -62,7 +62,7 @@ export default function LoginPage() { } if (invitationToken) { - submitToken(token, invitationToken); + submitToken(token, invitationToken, false, false); return; } diff --git a/apps/web/src/pages/auth/components/HubspotSignupForm.tsx b/apps/web/src/pages/auth/components/HubspotSignupForm.tsx index c25cad97f3c..c1bd1948a8e 100644 --- a/apps/web/src/pages/auth/components/HubspotSignupForm.tsx +++ b/apps/web/src/pages/auth/components/HubspotSignupForm.tsx @@ -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'; @@ -17,6 +17,7 @@ import SetupLoader from './SetupLoader'; export function HubspotSignupForm() { const [loading, setLoading] = useState(); + const [existingOrganization, setExistingOrganization] = useState(); const navigate = useNavigate(); const { setToken, token, currentUser } = useAuthContext(); const { startVercelSetup } = useVercelIntegration(); @@ -24,32 +25,44 @@ export function HubspotSignupForm() { 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(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 () => {}; + }, [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`, {}); @@ -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 }); } @@ -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 ; } else { @@ -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} /> ); @@ -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 = { diff --git a/apps/web/src/pages/auth/components/LoginForm.tsx b/apps/web/src/pages/auth/components/LoginForm.tsx index ffcc4448dde..4e105c38ebb 100644 --- a/apps/web/src/pages/auth/components/LoginForm.tsx +++ b/apps/web/src/pages/auth/components/LoginForm.tsx @@ -64,7 +64,7 @@ export function LoginForm({ email, invitationToken }: LoginFormProps) { } if (invitationToken) { - submitToken(token, invitationToken); + submitToken(token, invitationToken, false, false); return; } diff --git a/apps/web/src/pages/auth/components/SignUpForm.tsx b/apps/web/src/pages/auth/components/SignUpForm.tsx index 5ae9274d156..3996f697b85 100644 --- a/apps/web/src/pages/auth/components/SignUpForm.tsx +++ b/apps/web/src/pages/auth/components/SignUpForm.tsx @@ -77,9 +77,7 @@ export function SignUpForm({ invitationToken, email }: SignUpFormProps) { applyToken(token); if (invitationToken) { - submitToken(token, invitationToken); - - return true; + submitToken(token, invitationToken, false, true); } else { setToken(token); } diff --git a/apps/web/src/pages/auth/components/useAcceptInvite.ts b/apps/web/src/pages/auth/components/useAcceptInvite.ts index e9029bf39ac..b99c0c29875 100644 --- a/apps/web/src/pages/auth/components/useAcceptInvite.ts +++ b/apps/web/src/pages/auth/components/useAcceptInvite.ts @@ -12,15 +12,15 @@ import { errorMessage } from '../../../utils/notifications'; export function useAcceptInvite() { const { setToken } = useAuthContext(); - const navigate = useNavigate(); const queryClient = useQueryClient(); + const navigate = useNavigate(); const { isLoading, mutateAsync, error, isError } = useMutation((tokenItem) => api.post(`/v1/invites/${tokenItem}/accept`, {}) ); const submitToken = useCallback( - async (token: string, invitationToken: string, refetch = false) => { + async (token: string, invitationToken: string, refetch = false, isSignUp = true) => { try { // just set the header, user is logged in after token is submitted applyToken(token); @@ -33,8 +33,11 @@ export function useAcceptInvite() { predicate: (query) => query.queryKey.includes('/v1/organizations'), }); } - - navigate(ROUTES.WORKFLOWS); + if (isSignUp) { + navigate(ROUTES.AUTH_APPLICATION); + } else { + navigate(ROUTES.WORKFLOWS); + } } catch (e: unknown) { errorMessage('Failed to accept an invite.');