Skip to content

Commit 866a0e2

Browse files
Udit-takkarhbjORbjalishaz-polymathanikdhabalkeithwillcode
authored
feat: Phone based bookings for everyone (calcom#21320)
* feat: Phone based bookings for everyone * chore: save progress * feat: add UI * chore: UI * chore: remove sms notification * chore: remove none * chore: remove ligs * chore: remove log * test: add unit tests * test: add unit tests * chore: cache _isSMSNotificationEnabled * chore: cache _isSMSNotificationEnabled * refactor: get value from fields * fix: type err * chore: also pass user id * fix: unit test for sms manager --------- Co-authored-by: Benny Joo <sldisek783@gmail.com> Co-authored-by: Syed Ali Shahbaz <52925846+alishaz-polymath@users.noreply.github.com> Co-authored-by: Anik Dhabal Babu <81948346+anikdhabal@users.noreply.github.com> Co-authored-by: Keith Williams <keithwillcode@gmail.com>
1 parent 64bfda9 commit 866a0e2

7 files changed

Lines changed: 432 additions & 67 deletions

File tree

apps/web/public/static/locales/en/common.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -267,6 +267,8 @@
267267
"guests": "Guests",
268268
"guest": "Guest",
269269
"web_conferencing_details_to_follow": "Web conferencing details to follow in the confirmation email.",
270+
"confirmation":"Confirmation",
271+
"what_booker_should_provide": "What your booker should provide to receive confirmations",
270272
"404_the_user": "The username",
271273
"username": "Username",
272274
"is_still_available": "is still available.",
@@ -1710,6 +1712,8 @@
17101712
"show_available_seats_count": "Show the number of available seats",
17111713
"how_booking_questions_as_variables": "How to use booking questions as variables?",
17121714
"format": "Format",
1715+
"questions": "Questions",
1716+
"all_info_your_booker_provide": "All the info your booker should provide before booking with you.",
17131717
"uppercase_for_letters": "Use uppercase for all letters",
17141718
"replace_whitespaces_underscores": "Replace whitespaces with underscores",
17151719
"platform_members": "Platform members",

packages/features/bookings/lib/getBookingFields.ts

Lines changed: 16 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -160,7 +160,22 @@ export const ensureBookingInputsHaveSystemFields = ({
160160
type: "email",
161161
name: "email",
162162
required: !isEmailFieldOptional,
163-
editable: isOrgTeamEvent ? "system-but-optional" : "system",
163+
editable: "system-but-optional",
164+
sources: [
165+
{
166+
label: "Default",
167+
id: "default",
168+
type: "default",
169+
},
170+
],
171+
},
172+
{
173+
defaultLabel: "phone_number",
174+
type: "phone",
175+
name: "attendeePhoneNumber",
176+
required: false,
177+
hidden: true,
178+
editable: "system-but-optional",
164179
sources: [
165180
{
166181
label: "Default",
@@ -169,7 +184,6 @@ export const ensureBookingInputsHaveSystemFields = ({
169184
},
170185
],
171186
},
172-
173187
{
174188
defaultLabel: "location",
175189
type: "radioInput",
@@ -204,23 +218,6 @@ export const ensureBookingInputsHaveSystemFields = ({
204218
],
205219
},
206220
];
207-
if (isOrgTeamEvent) {
208-
systemBeforeFields.splice(2, 0, {
209-
defaultLabel: "phone_number",
210-
type: "phone",
211-
name: "attendeePhoneNumber",
212-
required: false,
213-
hidden: true,
214-
editable: "system-but-optional",
215-
sources: [
216-
{
217-
label: "Default",
218-
id: "default",
219-
type: "default",
220-
},
221-
],
222-
});
223-
}
224221

225222
// These fields should be added after other user fields
226223
const systemAfterFields: typeof bookingFields = [

packages/features/eventtypes/components/tabs/advanced/EventAdvancedTab.tsx

Lines changed: 32 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -576,27 +576,39 @@ export const EventAdvancedTab = ({
576576
isUserLoading={isUserLoading}
577577
/>
578578
)}
579-
<div className="border-subtle space-y-6 rounded-lg border p-6">
580-
<FormBuilder
581-
title={t("booking_questions_title")}
582-
description={t("booking_questions_description")}
583-
addFieldLabel={t("add_a_booking_question")}
584-
formProp="bookingFields"
585-
{...shouldLockDisableProps("bookingFields")}
586-
dataStore={{
587-
options: {
588-
locations: {
589-
// FormBuilder doesn't handle plural for non-english languages. So, use english(Location) only. This is similar to 'Workflow'
590-
source: { label: "Location" },
591-
value: getLocationsOptionsForSelect(formMethods.getValues("locations") ?? [], t),
579+
580+
<div className="border-subtle bg-muted rounded-lg border p-1">
581+
<div className="p-5">
582+
<div className="text-default text-sm font-semibold leading-none ltr:mr-1 rtl:ml-1">
583+
{t("booking_questions_title")}
584+
</div>
585+
<p className="text-subtle mt-1 max-w-[280px] break-words text-sm sm:max-w-[500px]">
586+
{t("booking_questions_description")}
587+
</p>
588+
</div>
589+
<div className="border-subtle rounded-lg border bg-white p-5">
590+
<FormBuilder
591+
showPhoneAndEmailToggle
592+
title={t("confirmation")}
593+
description={t("what_booker_should_provide")}
594+
addFieldLabel={t("add_a_booking_question")}
595+
formProp="bookingFields"
596+
{...shouldLockDisableProps("bookingFields")}
597+
dataStore={{
598+
options: {
599+
locations: {
600+
// FormBuilder doesn't handle plural for non-english languages. So, use english(Location) only. This is similar to 'Workflow'
601+
source: { label: "Location" },
602+
value: getLocationsOptionsForSelect(formMethods.getValues("locations") ?? [], t),
603+
},
592604
},
593-
},
594-
}}
595-
shouldConsiderRequired={(field: BookingField) => {
596-
// Location field has a default value at backend so API can send no location but we don't allow it in UI and thus we want to show it as required to user
597-
return field.name === "location" ? true : field.required;
598-
}}
599-
/>
605+
}}
606+
shouldConsiderRequired={(field: BookingField) => {
607+
// Location field has a default value at backend so API can send no location but we don't allow it in UI and thus we want to show it as required to user
608+
return field.name === "location" ? true : field.required;
609+
}}
610+
/>
611+
</div>
600612
</div>
601613
<RequiresConfirmationController
602614
eventType={eventType}

packages/features/form-builder/FormBuilder.tsx

Lines changed: 66 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import { Badge } from "@calcom/ui/components/badge";
1515
import { Button } from "@calcom/ui/components/button";
1616
import { DialogContent, DialogFooter, DialogHeader, DialogClose } from "@calcom/ui/components/dialog";
1717
import { Editor } from "@calcom/ui/components/editor";
18+
import { ToggleGroup } from "@calcom/ui/components/form";
1819
import {
1920
Switch,
2021
CheckboxField,
@@ -59,13 +60,15 @@ export const FormBuilder = function FormBuilder({
5960
LockedIcon,
6061
dataStore,
6162
shouldConsiderRequired,
63+
showPhoneAndEmailToggle = false,
6264
}: {
6365
formProp: string;
6466
title: string;
6567
description: string;
6668
addFieldLabel: string;
6769
disabled: boolean;
6870
LockedIcon: false | JSX.Element;
71+
showPhoneAndEmailToggle?: boolean;
6972
/**
7073
* A readonly dataStore that is used to lookup the options for the fields. It works in conjunction with the field.getOptionAt property which acts as the key in options
7174
*/
@@ -129,7 +132,68 @@ export const FormBuilder = function FormBuilder({
129132
{title}
130133
{LockedIcon}
131134
</div>
132-
<p className="text-subtle mt-0.5 max-w-[280px] break-words text-sm sm:max-w-[500px]">{description}</p>
135+
<div className="flex items-start justify-between">
136+
<p className="text-subtle mt-1 max-w-[280px] break-words text-sm sm:max-w-[500px]">{description}</p>
137+
{showPhoneAndEmailToggle && (
138+
<ToggleGroup
139+
value={(() => {
140+
const phoneField = fields.find((field) => field.name === "attendeePhoneNumber");
141+
const emailField = fields.find((field) => field.name === "email");
142+
143+
if (phoneField && !phoneField.hidden && phoneField.required && !emailField?.required) {
144+
return "phone";
145+
}
146+
147+
return "email";
148+
})()}
149+
options={[
150+
{
151+
value: "email",
152+
label: "Email",
153+
iconLeft: <Icon name="mail" className="h-4 w-4" />,
154+
},
155+
{
156+
value: "phone",
157+
label: "Phone",
158+
iconLeft: <Icon name="phone" className="h-4 w-4" />,
159+
},
160+
]}
161+
onValueChange={(value) => {
162+
const phoneFieldIndex = fields.findIndex((field) => field.name === "attendeePhoneNumber");
163+
const emailFieldIndex = fields.findIndex((field) => field.name === "email");
164+
if (value === "email") {
165+
update(emailFieldIndex, {
166+
...fields[emailFieldIndex],
167+
hidden: false,
168+
required: true,
169+
});
170+
update(phoneFieldIndex, {
171+
...fields[phoneFieldIndex],
172+
hidden: true,
173+
required: false,
174+
});
175+
} else if (value === "phone") {
176+
update(emailFieldIndex, {
177+
...fields[emailFieldIndex],
178+
hidden: true,
179+
required: false,
180+
});
181+
update(phoneFieldIndex, {
182+
...fields[phoneFieldIndex],
183+
hidden: false,
184+
required: true,
185+
});
186+
}
187+
}}
188+
/>
189+
)}
190+
</div>
191+
<p className="text-default mt-5 text-sm font-semibold leading-none ltr:mr-1 rtl:ml-1">
192+
{t("questions")}
193+
</p>
194+
<p className="text-subtle mt-1 max-w-[280px] break-words text-sm sm:max-w-[500px]">
195+
{t("all_info_your_booker_provide")}
196+
</p>
133197
<ul ref={parent} className="border-subtle divide-subtle mt-4 divide-y rounded-md border">
134198
{fields.map((field, index) => {
135199
let options = field.options ?? null;
@@ -480,6 +544,7 @@ function FieldEditDialog({
480544
const variantsConfig = fieldForm.watch("variantsConfig");
481545

482546
const fieldTypes = Object.values(fieldTypesConfigMap);
547+
const fieldName = fieldForm.getValues("name");
483548

484549
return (
485550
<Dialog open={dialog.isOpen} onOpenChange={onOpenChange} modal={false}>

packages/lib/server/repository/team.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -360,4 +360,18 @@ export class TeamRepository {
360360
inviteToken: inviteTokens.find((token) => token.identifier === `invite-link-for-teamId-${team.id}`),
361361
}));
362362
}
363+
364+
static async findTeamWithOrganizationSettings(teamId: number) {
365+
return await prisma.team.findUnique({
366+
where: { id: teamId },
367+
select: {
368+
parent: {
369+
select: {
370+
isOrganization: true,
371+
organizationSettings: true,
372+
},
373+
},
374+
},
375+
});
376+
}
363377
}

packages/sms/sms-manager.ts

Lines changed: 45 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,8 @@ import { sendSmsOrFallbackEmail } from "@calcom/features/ee/workflows/lib/remind
44
import { checkSMSRateLimit } from "@calcom/lib/checkRateLimitAndThrowError";
55
import { SENDER_ID } from "@calcom/lib/constants";
66
import isSmsCalEmail from "@calcom/lib/isSmsCalEmail";
7+
import { TeamRepository } from "@calcom/lib/server/repository/team";
78
import { TimeFormat } from "@calcom/lib/timeFormat";
8-
import prisma from "@calcom/prisma";
99
import type { CalendarEvent, Person } from "@calcom/types/Calendar";
1010

1111
const handleSendingSMS = async ({
@@ -14,36 +14,25 @@ const handleSendingSMS = async ({
1414
senderID,
1515
teamId,
1616
bookingUid,
17+
organizerUserId,
1718
}: {
1819
reminderPhone: string;
1920
smsMessage: string;
2021
senderID: string;
21-
teamId: number;
22+
teamId?: number;
2223
bookingUid?: string | null;
24+
organizerUserId?: number;
2325
}) => {
24-
const team = await prisma.team.findUnique({
25-
where: { id: teamId },
26-
select: {
27-
parent: {
28-
select: {
29-
isOrganization: true,
30-
organizationSettings: {
31-
select: {
32-
disablePhoneOnlySMSNotifications: true,
33-
},
34-
},
35-
},
36-
},
37-
},
38-
});
39-
40-
if (!team?.parent?.isOrganization || team?.parent?.organizationSettings?.disablePhoneOnlySMSNotifications) {
41-
return; // resolves implicitly (as undefined)
42-
}
43-
4426
try {
27+
// If teamId is provided, we check the rate limit for the team.
28+
// If organizerUserId is provided, we check the rate limit for the organizer.
29+
// If neither is provided(Just in case), we check the rate limit for the reminderPhone.
4530
await checkSMSRateLimit({
46-
identifier: `handleSendingSMS:team:${teamId}`,
31+
identifier: teamId
32+
? `handleSendingSMS:team:${teamId}`
33+
: organizerUserId
34+
? `handleSendingSMS:user:${organizerUserId}`
35+
: `handleSendingSMS:user:${reminderPhone}`,
4736
rateLimitingType: "sms",
4837
});
4938

@@ -52,7 +41,7 @@ const handleSendingSMS = async ({
5241
phoneNumber: reminderPhone,
5342
body: smsMessage,
5443
sender: senderID,
55-
teamId,
44+
...(!!teamId ? { teamId } : { userId: organizerUserId }),
5645
bookingUid,
5746
},
5847
});
@@ -68,11 +57,32 @@ export default abstract class SMSManager {
6857
calEvent: CalendarEvent;
6958
isTeamEvent = false;
7059
teamId: number | undefined = undefined;
60+
organizerUserId: number | undefined = undefined;
61+
private _isSMSNotificationEnabled: boolean | null = null;
7162

7263
constructor(calEvent: CalendarEvent) {
7364
this.calEvent = calEvent;
7465
this.teamId = this.calEvent?.team?.id;
7566
this.isTeamEvent = !!this.calEvent?.team?.id;
67+
this.organizerUserId = this.calEvent?.organizer?.id;
68+
}
69+
70+
private async isSMSNotificationEnabled(): Promise<boolean> {
71+
if (this._isSMSNotificationEnabled !== null) {
72+
return this._isSMSNotificationEnabled;
73+
}
74+
75+
const teamId = this.teamId;
76+
77+
if (teamId) {
78+
const team = await TeamRepository.findTeamWithOrganizationSettings(teamId);
79+
80+
this._isSMSNotificationEnabled = !team?.parent?.organizationSettings?.disablePhoneOnlySMSNotifications;
81+
return this._isSMSNotificationEnabled;
82+
}
83+
84+
this._isSMSNotificationEnabled = true;
85+
return true;
7686
}
7787

7888
getFormattedTime(
@@ -99,17 +109,25 @@ export default abstract class SMSManager {
99109
const attendeePhoneNumber = attendee.phoneNumber;
100110
const isPhoneOnlyBooking = attendeePhoneNumber && isSmsCalEmail(attendee.email);
101111

102-
if (!this.isTeamEvent || !teamId || !attendeePhoneNumber || !isPhoneOnlyBooking) return;
112+
if (!attendeePhoneNumber || !isPhoneOnlyBooking || !(await this.isSMSNotificationEnabled())) return;
103113

104114
const smsMessage = this.getMessage(attendee);
105115
const senderID = getSenderId(attendeePhoneNumber, SENDER_ID);
106-
return handleSendingSMS({ reminderPhone: attendeePhoneNumber, smsMessage, senderID, teamId, bookingUid });
116+
return handleSendingSMS({
117+
reminderPhone: attendeePhoneNumber,
118+
smsMessage,
119+
senderID,
120+
teamId,
121+
bookingUid,
122+
organizerUserId: this.organizerUserId,
123+
});
107124
}
108125

109126
async sendSMSToAttendees() {
110-
if (!this.isTeamEvent) return;
111127
const smsToSend: Promise<unknown>[] = [];
112128

129+
if (!(await this.isSMSNotificationEnabled())) return;
130+
113131
for (const attendee of this.calEvent.attendees) {
114132
smsToSend.push(this.sendSMSToAttendee(attendee, this.calEvent.uid));
115133
}

0 commit comments

Comments
 (0)