diff --git a/.changeset/sour-nights-speak.md b/.changeset/sour-nights-speak.md new file mode 100644 index 0000000000..a70a401fb4 --- /dev/null +++ b/.changeset/sour-nights-speak.md @@ -0,0 +1,5 @@ +--- +"@calcom/atoms": minor +--- + +This PR adds customReplyEmailTo feature for EventTypeSettings atom diff --git a/apps/api/v2/src/modules/atoms/atoms.module.ts b/apps/api/v2/src/modules/atoms/atoms.module.ts index e08b08532b..9ff33f789b 100644 --- a/apps/api/v2/src/modules/atoms/atoms.module.ts +++ b/apps/api/v2/src/modules/atoms/atoms.module.ts @@ -17,6 +17,7 @@ import { OrganizationsTeamsRepository } from "@/modules/organizations/teams/inde import { PrismaModule } from "@/modules/prisma/prisma.module"; import { RedisService } from "@/modules/redis/redis.service"; import { TeamsEventTypesModule } from "@/modules/teams/event-types/teams-event-types.module"; +import { TeamsRepository } from "@/modules/teams/teams/teams.repository"; import { UsersService } from "@/modules/users/services/users.service"; import { UsersRepository } from "@/modules/users/users.repository"; import { Module } from "@nestjs/common"; @@ -36,6 +37,7 @@ import { Module } from "@nestjs/common"; SchedulesAtomsService, VerificationAtomsService, RedisService, + TeamsRepository, ], exports: [EventTypesAtomService], controllers: [ diff --git a/apps/api/v2/src/modules/atoms/atoms.repository.ts b/apps/api/v2/src/modules/atoms/atoms.repository.ts index 5a113d4e0f..1b1d2e81fb 100644 --- a/apps/api/v2/src/modules/atoms/atoms.repository.ts +++ b/apps/api/v2/src/modules/atoms/atoms.repository.ts @@ -64,4 +64,47 @@ export class AtomsRepository { return userTeams; } + + async getSecondaryEmails(userId: number) { + return await this.dbRead.prisma.secondaryEmail.findMany({ + where: { + userId, + emailVerified: { + not: null, + }, + }, + }); + } + + async getExistingSecondaryEmailByUserAndEmail(userId: number, email: string) { + const existingSecondaryEmailRecord = await this.dbRead.prisma.secondaryEmail.findUnique({ + where: { + userId_email: { userId, email }, + }, + }); + + return existingSecondaryEmailRecord?.email; + } + + async getExistingSecondaryEmail(email: string) { + const existingSecondaryEmailRecord = await this.dbRead.prisma.secondaryEmail.findUnique({ + where: { + email, + }, + }); + + return existingSecondaryEmailRecord?.email; + } + + async addSecondaryEmail(userId: number, email: string) { + const existingSecondaryEmailRecord = await this.dbWrite.prisma.secondaryEmail.create({ + data: { + userId, + email, + emailVerified: new Date(), + }, + }); + + return existingSecondaryEmailRecord?.email; + } } diff --git a/apps/api/v2/src/modules/atoms/controllers/atoms.verification.controller.ts b/apps/api/v2/src/modules/atoms/controllers/atoms.verification.controller.ts index 2e497b7b44..77829716d4 100644 --- a/apps/api/v2/src/modules/atoms/controllers/atoms.verification.controller.ts +++ b/apps/api/v2/src/modules/atoms/controllers/atoms.verification.controller.ts @@ -1,8 +1,11 @@ import { API_VERSIONS_VALUES } from "@/lib/api-versions"; import { Throttle } from "@/lib/endpoint-throttler-decorator"; +import { AddVerifiedEmailInput } from "@/modules/atoms/inputs/add-verified-email.input"; import { CheckEmailVerificationRequiredParams } from "@/modules/atoms/inputs/check-email-verification-required-params"; +import { GetVerifiedEmailsParams } from "@/modules/atoms/inputs/get-verified-emails-params"; import { SendVerificationEmailInput } from "@/modules/atoms/inputs/send-verification-email.input"; import { VerifyEmailCodeInput } from "@/modules/atoms/inputs/verify-email-code.input"; +import { GetVerifiedEmailsOutput } from "@/modules/atoms/outputs/get-verified-emails-output"; import { SendVerificationEmailOutput } from "@/modules/atoms/outputs/send-verification-email.output"; import { VerifyEmailCodeOutput } from "@/modules/atoms/outputs/verify-email-code.output"; import { VerificationAtomsService } from "@/modules/atoms/services/verification-atom.service"; @@ -105,4 +108,44 @@ export class AtomsVerificationController { status: SUCCESS_STATUS, }; } + + @Get("/emails/verified-emails") + @Version(VERSION_NEUTRAL) + @UseGuards(ApiAuthGuard) + @HttpCode(HttpStatus.OK) + async getVerifiedEmails( + @Query() query: GetVerifiedEmailsParams, + @GetUser() user: UserWithProfile + ): Promise { + const verifiedEmails = await this.verificationService.getVerifiedEmails({ + userId: user.id, + userEmail: user.email, + teamId: query.teamId ? Number(query.teamId) : undefined, + }); + + return { + data: verifiedEmails, + status: SUCCESS_STATUS, + }; + } + + @Post("/emails/verified-emails") + @Version(VERSION_NEUTRAL) + @UseGuards(ApiAuthGuard) + @HttpCode(HttpStatus.OK) + async addVerifiedEmails( + @Body() body: AddVerifiedEmailInput, + @GetUser() user: UserWithProfile + ): Promise> { + const emailVerified = await this.verificationService.addVerifiedEmail({ + userId: user.id, + existingPrimaryEmail: user.email, + email: body.email, + }); + + return { + data: { emailVerified }, + status: SUCCESS_STATUS, + }; + } } diff --git a/apps/api/v2/src/modules/atoms/inputs/add-verified-email.input.ts b/apps/api/v2/src/modules/atoms/inputs/add-verified-email.input.ts new file mode 100644 index 0000000000..c0ff14336d --- /dev/null +++ b/apps/api/v2/src/modules/atoms/inputs/add-verified-email.input.ts @@ -0,0 +1,8 @@ +import { ApiProperty } from "@nestjs/swagger"; +import { IsEmail } from "class-validator"; + +export class AddVerifiedEmailInput { + @ApiProperty({ example: "user@example.com" }) + @IsEmail() + email!: string; +} diff --git a/apps/api/v2/src/modules/atoms/inputs/get-verified-emails-params.ts b/apps/api/v2/src/modules/atoms/inputs/get-verified-emails-params.ts new file mode 100644 index 0000000000..06581c99ee --- /dev/null +++ b/apps/api/v2/src/modules/atoms/inputs/get-verified-emails-params.ts @@ -0,0 +1,25 @@ +import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger"; +import { IsNumber, IsOptional, IsString, IsEmail } from "class-validator"; + +export class GetVerifiedEmailsParams { + @ApiPropertyOptional({ example: "12345" }) + @IsOptional() + @IsString() + teamId?: string; +} + +export class GetVerifiedEmailsInput { + @ApiProperty() + @IsNumber() + userId!: number; + + @ApiProperty() + @IsEmail() + @IsString() + userEmail!: string; + + @ApiPropertyOptional() + @IsOptional() + @IsNumber() + teamId?: number; +} diff --git a/apps/api/v2/src/modules/atoms/outputs/get-verified-emails-output.ts b/apps/api/v2/src/modules/atoms/outputs/get-verified-emails-output.ts new file mode 100644 index 0000000000..ad483b24c5 --- /dev/null +++ b/apps/api/v2/src/modules/atoms/outputs/get-verified-emails-output.ts @@ -0,0 +1,17 @@ +import { ApiProperty } from "@nestjs/swagger"; +import { Expose } from "class-transformer"; +import { IsString, IsArray } from "class-validator"; + +import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants"; + +export class GetVerifiedEmailsOutput { + @ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] }) + @IsString() + @Expose() + readonly status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS; + + @IsArray() + @IsString({ each: true }) + @ApiProperty({ type: [String] }) + readonly data!: string[]; +} diff --git a/apps/api/v2/src/modules/atoms/services/verification-atom.service.ts b/apps/api/v2/src/modules/atoms/services/verification-atom.service.ts index 2602e71bd3..fd22c643ad 100644 --- a/apps/api/v2/src/modules/atoms/services/verification-atom.service.ts +++ b/apps/api/v2/src/modules/atoms/services/verification-atom.service.ts @@ -1,6 +1,9 @@ +import { AtomsRepository } from "@/modules/atoms/atoms.repository"; import { CheckEmailVerificationRequiredParams } from "@/modules/atoms/inputs/check-email-verification-required-params"; +import { GetVerifiedEmailsInput } from "@/modules/atoms/inputs/get-verified-emails-params"; import { SendVerificationEmailInput } from "@/modules/atoms/inputs/send-verification-email.input"; import { VerifyEmailCodeInput } from "@/modules/atoms/inputs/verify-email-code.input"; +import { TeamsRepository } from "@/modules/teams/teams/teams.repository"; import { UserWithProfile } from "@/modules/users/users.repository"; import { Injectable, BadRequestException, UnauthorizedException } from "@nestjs/common"; @@ -13,6 +16,11 @@ import { @Injectable() export class VerificationAtomsService { + constructor( + private readonly atomsRepository: AtomsRepository, + private readonly teamsRepository: TeamsRepository + ) {} + async checkEmailVerificationRequired(input: CheckEmailVerificationRequiredParams) { return await checkEmailVerificationRequired(input); } @@ -61,4 +69,78 @@ export class VerificationAtomsService { isVerifyingEmail: input.isVerifyingEmail, }); } + + async getVerifiedEmails(input: GetVerifiedEmailsInput): Promise { + const { userId, userEmail, teamId } = input; + const userEmailWithoutOauthClientId = this.removeClientIdFromEmail(userEmail); + + if (teamId) { + const verifiedEmails: string[] = []; + const teamMembers = await this.teamsRepository.getTeamMemberEmails(teamId); + + if (teamMembers.length === 0) { + return verifiedEmails; + } + + teamMembers.forEach((member) => { + const memberEmailWithoutOauthClientId = this.removeClientIdFromEmail(member.email); + + verifiedEmails.push(memberEmailWithoutOauthClientId); + member.secondaryEmails.forEach((secondaryEmail) => { + verifiedEmails.push(this.removeClientIdFromEmail(secondaryEmail.email)); + }); + }); + + return verifiedEmails; + } + + let verifiedEmails = [userEmailWithoutOauthClientId]; + + const secondaryEmails = await this.atomsRepository.getSecondaryEmails(userId); + verifiedEmails = verifiedEmails.concat( + secondaryEmails.map((secondaryEmail) => this.removeClientIdFromEmail(secondaryEmail.email)) + ); + + return verifiedEmails; + } + + async addVerifiedEmail({ + userId, + existingPrimaryEmail, + email, + }: { + userId: number; + existingPrimaryEmail: string; + email: string; + }): Promise { + const existingSecondaryEmail = await this.atomsRepository.getExistingSecondaryEmailByUserAndEmail( + userId, + email + ); + const alreadyExistingEmail = await this.atomsRepository.getExistingSecondaryEmail(email); + + if (alreadyExistingEmail) { + throw new BadRequestException("Email already exists"); + } + + if (existingPrimaryEmail === email || existingSecondaryEmail === email) { + return true; + } + + await this.atomsRepository.addSecondaryEmail(userId, email); + + return true; + } + + removeClientIdFromEmail(email: string): string { + const [localPart, domain] = email.split("@"); + const localPartSegments = localPart.split("+"); + + localPartSegments.pop(); + + const baseEmail = localPartSegments.join("+"); + const normalizedEmail = `${baseEmail}@${domain}`; + + return normalizedEmail; + } } diff --git a/apps/api/v2/src/modules/teams/teams/teams.repository.ts b/apps/api/v2/src/modules/teams/teams/teams.repository.ts index 8d25133132..8d6cad1dfa 100644 --- a/apps/api/v2/src/modules/teams/teams/teams.repository.ts +++ b/apps/api/v2/src/modules/teams/teams/teams.repository.ts @@ -120,4 +120,30 @@ export class TeamsRepository { }, }); } + + async getTeamMemberEmails(teamId: number) { + return this.dbRead.prisma.user.findMany({ + where: { + teams: { + some: { + teamId, + }, + }, + }, + select: { + id: true, + email: true, + secondaryEmails: { + where: { + emailVerified: { + not: null, + }, + }, + select: { + email: true, + }, + }, + }, + }); + } } diff --git a/apps/web/public/static/locales/en/common.json b/apps/web/public/static/locales/en/common.json index 20195a0ecd..d90113fd23 100644 --- a/apps/web/public/static/locales/en/common.json +++ b/apps/web/public/static/locales/en/common.json @@ -230,6 +230,7 @@ "org_upgraded_successfully": "Your Organization was upgraded successfully!", "use_link_to_reset_password": "Use the link below to reset your password", "hey_there": "Hey there", + "there": "there", "forgot_your_password_calcom": "Forgot your password? - {{appName}}", "delete_webhook_confirmation_message": "Are you sure you want to delete this webhook? You will no longer receive {{appName}} meeting data at a specified URL, in real-time, when an event is scheduled or canceled.", "confirm_delete_webhook": "Yes, delete webhook", @@ -3344,6 +3345,8 @@ "hide_organizer_email_description": "Hide organizer's email address from the booking screen, email notifications, and calendar events", "you_and_conjunction": "You &", "select_verified_email": "Select a verified email", + "add_verified_email": "Add verified email", + "add_verified_emails": "Add verified emails", "custom_reply_to_email_title": "Custom 'Reply-To' email", "custom_reply_to_email_description": "Use a different email address as the replyTo for confirmation emails instead of the organizer's email", "email_survey_triggered_by_workflow": "This survey was triggered by a Workflow in Cal.", diff --git a/packages/features/eventtypes/components/AddVerifiedEmail.tsx b/packages/features/eventtypes/components/AddVerifiedEmail.tsx new file mode 100644 index 0000000000..738b1e0d60 --- /dev/null +++ b/packages/features/eventtypes/components/AddVerifiedEmail.tsx @@ -0,0 +1,106 @@ +import { useState } from "react"; + +import { VerifyCodeDialog } from "@calcom/features/bookings/components/VerifyCodeDialog"; +import { useLocale } from "@calcom/lib/hooks/useLocale"; +import classNames from "@calcom/ui/classNames"; +import { Button } from "@calcom/ui/components/button"; +import { TextField, Label } from "@calcom/ui/components/form"; +import { Tooltip } from "@calcom/ui/components/tooltip"; + +import { useAddVerifiedEmail } from "../../../platform/atoms/event-types/hooks/useAddVerifiedEmail"; +import { useGetVerifiedEmails } from "../../../platform/atoms/event-types/hooks/useGetVerifiedEmails"; +import { useVerifyCode } from "../../../platform/atoms/hooks/useVerifyCode"; +import { useVerifyEmail } from "../../../platform/atoms/hooks/useVerifyEmail"; + +type AddVerifiedEmailProps = { + username?: string; + showToast: (message: string, variant: "success" | "warning" | "error") => void; +}; + +const AddVerifiedEmail = ({ username, showToast }: AddVerifiedEmailProps) => { + const { t } = useLocale(); + const [verifiedEmail, setVerifiedEmail] = useState(""); + const [isEmailVerificationModalVisible, setIsEmailVerificationModalVisible] = useState(false); + const isValidEmail = (email: string) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email); + + const { refetch: refetchVerifiedEmails } = useGetVerifiedEmails(); + const { mutateAsync: addVerifiedEmail } = useAddVerifiedEmail({ + onSuccess: () => { + refetchVerifiedEmails(); + showToast(t("email_verified"), "success"); + }, + onError: () => { + showToast(t("something_went_wrong"), "error"); + }, + }); + + const verifyEmail = useVerifyEmail({ + email: verifiedEmail, + name: username || t("there"), + requiresBookerEmailVerification: true, + }); + + const { handleVerifyEmail } = verifyEmail; + + const verifyCode = useVerifyCode({ + onSuccess: async () => { + setIsEmailVerificationModalVisible(false); + setVerifiedEmail(""); + + await addVerifiedEmail({ + email: verifiedEmail, + }); + }, + }); + + return ( +
+
+ + setVerifiedEmail(e.target.value)} + data-testid="add-verified-emails" + addOnSuffix={ + +
+ <> + + +
+ ); +}; + +export default AddVerifiedEmail; diff --git a/packages/features/eventtypes/components/tabs/advanced/EventAdvancedTab.tsx b/packages/features/eventtypes/components/tabs/advanced/EventAdvancedTab.tsx index 85930f7cfb..bb5a24a282 100644 --- a/packages/features/eventtypes/components/tabs/advanced/EventAdvancedTab.tsx +++ b/packages/features/eventtypes/components/tabs/advanced/EventAdvancedTab.tsx @@ -62,6 +62,7 @@ import { } from "@calcom/ui/components/form"; import { Icon } from "@calcom/ui/components/icon"; +import AddVerifiedEmail from "../../AddVerifiedEmail"; import type { CustomEventTypeModalClassNames } from "./CustomEventTypeModal"; import CustomEventTypeModal from "./CustomEventTypeModal"; import type { EmailNotificationToggleCustomClassNames } from "./DisableAllEmailsSetting"; @@ -508,7 +509,7 @@ export const EventAdvancedTab = ({ "allowReschedulingCancelledBookings" ); - const { isLocked, ...eventNameLocked } = shouldLockDisableProps("eventName"); + const { isLocked: _isLocked, ...eventNameLocked } = shouldLockDisableProps("eventName"); if (isManagedEventType) { multiplePrivateLinksLocked.disabled = true; @@ -1134,52 +1135,53 @@ export const EventAdvancedTab = ({ /> )} /> - {!isPlatform && ( - <> - ( - <> - { - onChange( - e - ? customReplyToEmail || eventType.customReplyToEmail || verifiedEmails?.[0] || null - : null - ); - }}> -
- onChange(option?.value || null)} - options={verifiedEmails?.map((email) => ({ label: email, value: email })) || []} - /> -
-
- - )} - /> - - )} + <> + ( + <> + { + onChange( + e + ? customReplyToEmail || eventType.customReplyToEmail || verifiedEmails?.[0] || null + : null + ); + }}> + {isPlatform && ( + + )} +
+ onChange(option?.value || null)} + options={verifiedEmails?.map((email) => ({ label: email, value: email })) || []} + /> +
+
+ + )} + /> + ( diff --git a/packages/platform/atoms/event-types/hooks/useAddVerifiedEmail.ts b/packages/platform/atoms/event-types/hooks/useAddVerifiedEmail.ts new file mode 100644 index 0000000000..fd0810267a --- /dev/null +++ b/packages/platform/atoms/event-types/hooks/useAddVerifiedEmail.ts @@ -0,0 +1,64 @@ +import { useMutation } from "@tanstack/react-query"; + +import { SUCCESS_STATUS } from "@calcom/platform-constants"; +import type { ApiErrorResponse, ApiResponse } from "@calcom/platform-types"; + +import { useAtomsContext } from "../../hooks/useAtomsContext"; +import { appendClientIdToEmail } from "../../lib/appendClientIdToEmail"; +import http from "../../lib/http"; + +interface IUseAddVerifiedEmail { + onSuccess?: (res: ApiResponse) => void; + onError?: (err: ApiErrorResponse | Error) => void; +} + +export const useAddVerifiedEmail = ( + { onSuccess, onError }: IUseAddVerifiedEmail = { + onSuccess: () => { + return; + }, + onError: () => { + return; + }, + } +) => { + const { clientId } = useAtomsContext(); + + const verifiedEmailEntry = useMutation< + ApiResponse<{ + status: string; + data: { + emailVerified: boolean; + }; + }>, + unknown, + { + email: string; + } + >({ + mutationFn: (data) => { + const { email } = data; + const emailToSend = appendClientIdToEmail(email, clientId); + + return http + .post(`/atoms/emails/verified-emails`, { + email: emailToSend, + }) + .then((res) => { + return res.data; + }); + }, + onSuccess: (data) => { + if (data.status === SUCCESS_STATUS) { + onSuccess?.(data); + } else { + onError?.(data); + } + }, + onError: (err) => { + onError?.(err as ApiErrorResponse); + }, + }); + + return verifiedEmailEntry; +}; diff --git a/packages/platform/atoms/event-types/hooks/useGetVerifiedEmails.ts b/packages/platform/atoms/event-types/hooks/useGetVerifiedEmails.ts new file mode 100644 index 0000000000..2ce663f086 --- /dev/null +++ b/packages/platform/atoms/event-types/hooks/useGetVerifiedEmails.ts @@ -0,0 +1,27 @@ +import { useQuery } from "@tanstack/react-query"; + +import { SUCCESS_STATUS } from "@calcom/platform-constants"; +import type { ApiResponse, ApiSuccessResponse } from "@calcom/platform-types"; + +import { useAtomsContext } from "../../hooks/useAtomsContext"; +import http from "../../lib/http"; + +export const QUERY_KEY = "use-get-verified-emails"; +export const useGetVerifiedEmails = (teamId?: number) => { + const pathname = `/atoms/emails/verified-emails${!!teamId ? `?teamId=${teamId}` : ""}`; + const { isInit, accessToken } = useAtomsContext(); + + return useQuery({ + queryKey: [QUERY_KEY, teamId], + queryFn: () => { + return http?.get>(pathname).then((res) => { + if (res.data.status === SUCCESS_STATUS) { + return (res.data as ApiSuccessResponse).data; + } + throw new Error(res.data.error.message); + }); + }, + enabled: isInit && !!accessToken, + staleTime: 5000, + }); +}; diff --git a/packages/platform/atoms/event-types/wrappers/EventAdvancedPlatformWrapper.tsx b/packages/platform/atoms/event-types/wrappers/EventAdvancedPlatformWrapper.tsx index e50f729c54..3968da7397 100644 --- a/packages/platform/atoms/event-types/wrappers/EventAdvancedPlatformWrapper.tsx +++ b/packages/platform/atoms/event-types/wrappers/EventAdvancedPlatformWrapper.tsx @@ -2,14 +2,18 @@ import type { EventAdvancedBaseProps } from "@calcom/features/eventtypes/compone import { EventAdvancedTab } from "@calcom/features/eventtypes/components/tabs/advanced/EventAdvancedTab"; import { useConnectedCalendars } from "../../hooks/useConnectedCalendars"; +import { useGetVerifiedEmails } from "../hooks/useGetVerifiedEmails"; const EventAdvancedPlatformWrapper = (props: EventAdvancedBaseProps) => { const { isPending, data: connectedCalendarsQuery, error } = useConnectedCalendars({}); + const { data: verifiedEmails } = useGetVerifiedEmails(props.team?.id); + return ( ); }; diff --git a/packages/platform/atoms/lib/appendClientIdToEmail.ts b/packages/platform/atoms/lib/appendClientIdToEmail.ts new file mode 100644 index 0000000000..0204a32b6e --- /dev/null +++ b/packages/platform/atoms/lib/appendClientIdToEmail.ts @@ -0,0 +1,4 @@ +export function appendClientIdToEmail(email: string, clientId: string): string { + const [localPart, domain] = email.split("@"); + return `${localPart}+${clientId}@${domain}`; +}