Compare commits

...

3 Commits

Author SHA1 Message Date
Shivam Kalra 29981b9234
feat: Add new events to webhook BOOKING_CONFIRMED, BOOKING_REJECTED (#8884)
* test: booking rejection

* test: check if webhook is called

* feat: BOOKING_REJECTED enum ,constant in backend

* feat: send booking rejected webhook to subscribers

* feat: add Booking rejected migration

* calendar event

* Revert "calendar event"

This reverts commit 28d45dccfd.

* feat: BOOKING_REQUESTED enum, constant, migration

* feat: Send BOOKING REQUESTED Webhook call

* feat: Add booking rejected/requested event in form

* feat: data-testid to rejection confirm btn

* test: BOOKING_REJECTED, BOOKING_REQUESTED

* fix: booking status PENDING, Linting

* feat: add new labels to common.json

* remove: meeting ended hook from request hook

* refactor: abstract handleWebhookTrigger

* fix: create a single file for migration

* refactor: reduce code repetition and fix test

* feat: add team webhooks to subscriberOptions

* refactor: subscriberOptions

---------

Co-authored-by: Joe Au-Yeung <65426560+joeauyeung@users.noreply.github.com>
Co-authored-by: Peer Richelsen <peeroke@gmail.com>
2023-05-25 18:29:49 +00:00
Udit Takkar 9d4219d1b4
refactor: radio area group (#9113)
* refactor: radio area group

Signed-off-by: Udit Takkar <udit.07814802719@cse.mait.ac.in>

* chore: remove select

Signed-off-by: Udit Takkar <udit.07814802719@cse.mait.ac.in>

* fix: e2e test

Signed-off-by: Udit Takkar <udit.07814802719@cse.mait.ac.in>

---------

Signed-off-by: Udit Takkar <udit.07814802719@cse.mait.ac.in>
Co-authored-by: Peer Richelsen <peeroke@gmail.com>
2023-05-25 18:24:42 +00:00
Adithya Krishna 365d4a6f9d
feat: Auto check PR titles if they follow conventional commits spec (#9109)
Signed-off-by: Adithya Krishna <adikrish@redhat.com>
Co-authored-by: Peer Richelsen <peeroke@gmail.com>
2023-05-25 18:24:12 +00:00
17 changed files with 549 additions and 277 deletions

View File

@ -0,0 +1,21 @@
name: "Validate PRs"
on:
pull_request_target:
types:
- opened
- reopened
- edited
- synchronize
permissions:
pull-requests: read
jobs:
validate-pr:
name: Validate PR title
runs-on: ubuntu-latest
steps:
- uses: amannn/action-semantic-pull-request@v5
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

View File

@ -318,6 +318,7 @@ function BookingListItem(booking: BookingItemProps) {
<DialogClose />
<Button
disabled={mutation.isLoading}
data-testid="rejection-confirm"
onClick={() => {
bookingConfirm(false);
}}>

View File

@ -39,10 +39,10 @@ test.describe("Managed Event Types tests", () => {
await page.click("[data-testid=new-event-type-dropdown]");
await page.click("[data-testid=option-team-1]");
// Expecting we can add a managed event type as team owner
await expect(page.locator('input[value="MANAGED"]')).toBeVisible();
await expect(page.locator('button[value="MANAGED"]')).toBeVisible();
// Actually creating a managed event type to test things further
await page.click('input[value="MANAGED"]');
await page.click('button[value="MANAGED"]');
await page.fill("[name=title]", "managed");
await page.click("[type=submit]");
});

View File

@ -1,131 +1,387 @@
import { expect } from "@playwright/test";
import { test } from "./lib/fixtures";
import { createHttpServer, selectFirstAvailableTimeSlotNextMonth, waitFor } from "./lib/testUtils";
import {
bookOptinEvent,
createHttpServer,
selectFirstAvailableTimeSlotNextMonth,
waitFor,
} from "./lib/testUtils";
test.afterEach(({ users }) => users.deleteAll());
test("add webhook & test that creating an event triggers a webhook call", async ({
page,
users,
}, testInfo) => {
const webhookReceiver = createHttpServer();
const user = await users.create();
const [eventType] = user.eventTypes;
await user.login();
await page.goto(`/settings/developer/webhooks`);
test.describe("BOOKING_CREATED", async () => {
test("add webhook & test that creating an event triggers a webhook call", async ({
page,
users,
}, testInfo) => {
const webhookReceiver = createHttpServer();
const user = await users.create();
const [eventType] = user.eventTypes;
await user.login();
await page.goto(`/settings/developer/webhooks`);
// --- add webhook
await page.click('[data-testid="new_webhook"]');
// --- add webhook
await page.click('[data-testid="new_webhook"]');
await page.fill('[name="subscriberUrl"]', webhookReceiver.url);
await page.fill('[name="subscriberUrl"]', webhookReceiver.url);
await page.fill('[name="secret"]', "secret");
await page.fill('[name="secret"]', "secret");
await Promise.all([
page.click("[type=submit]"),
page.waitForURL((url) => url.pathname.endsWith("/settings/developer/webhooks")),
]);
await Promise.all([
page.click("[type=submit]"),
page.waitForURL((url) => url.pathname.endsWith("/settings/developer/webhooks")),
]);
// page contains the url
expect(page.locator(`text='${webhookReceiver.url}'`)).toBeDefined();
// page contains the url
expect(page.locator(`text='${webhookReceiver.url}'`)).toBeDefined();
// --- Book the first available day next month in the pro user's "30min"-event
await page.goto(`/${user.username}/${eventType.slug}`);
await selectFirstAvailableTimeSlotNextMonth(page);
// --- Book the first available day next month in the pro user's "30min"-event
await page.goto(`/${user.username}/${eventType.slug}`);
await selectFirstAvailableTimeSlotNextMonth(page);
// --- fill form
await page.fill('[name="name"]', "Test Testson");
await page.fill('[name="email"]', "test@example.com");
await page.press('[name="email"]', "Enter");
// --- fill form
await page.fill('[name="name"]', "Test Testson");
await page.fill('[name="email"]', "test@example.com");
await page.press('[name="email"]', "Enter");
// --- check that webhook was called
await waitFor(() => {
expect(webhookReceiver.requestList.length).toBe(1);
});
// --- check that webhook was called
await waitFor(() => {
expect(webhookReceiver.requestList.length).toBe(1);
});
const [request] = webhookReceiver.requestList;
const body = request.body as any;
const [request] = webhookReceiver.requestList;
const body = request.body as any;
// remove dynamic properties that differs depending on where you run the tests
const dynamic = "[redacted/dynamic]";
body.createdAt = dynamic;
body.payload.startTime = dynamic;
body.payload.endTime = dynamic;
body.payload.location = dynamic;
for (const attendee of body.payload.attendees) {
attendee.timeZone = dynamic;
attendee.language = dynamic;
}
body.payload.organizer.id = dynamic;
body.payload.organizer.email = dynamic;
body.payload.organizer.timeZone = dynamic;
body.payload.organizer.language = dynamic;
body.payload.uid = dynamic;
body.payload.bookingId = dynamic;
body.payload.additionalInformation = dynamic;
body.payload.requiresConfirmation = dynamic;
body.payload.eventTypeId = dynamic;
body.payload.videoCallData = dynamic;
body.payload.appsStatus = dynamic;
body.payload.metadata.videoCallUrl = dynamic;
// remove dynamic properties that differs depending on where you run the tests
const dynamic = "[redacted/dynamic]";
body.createdAt = dynamic;
body.payload.startTime = dynamic;
body.payload.endTime = dynamic;
body.payload.location = dynamic;
for (const attendee of body.payload.attendees) {
attendee.timeZone = dynamic;
attendee.language = dynamic;
}
body.payload.organizer.id = dynamic;
body.payload.organizer.email = dynamic;
body.payload.organizer.timeZone = dynamic;
body.payload.organizer.language = dynamic;
body.payload.uid = dynamic;
body.payload.bookingId = dynamic;
body.payload.additionalInformation = dynamic;
body.payload.requiresConfirmation = dynamic;
body.payload.eventTypeId = dynamic;
body.payload.videoCallData = dynamic;
body.payload.appsStatus = dynamic;
body.payload.metadata.videoCallUrl = dynamic;
expect(body).toMatchObject({
triggerEvent: "BOOKING_CREATED",
createdAt: "[redacted/dynamic]",
payload: {
type: "30 min",
title: "30 min between Nameless and Test Testson",
description: "",
additionalNotes: "",
customInputs: {},
startTime: "[redacted/dynamic]",
endTime: "[redacted/dynamic]",
organizer: {
id: "[redacted/dynamic]",
name: "Nameless",
email: "[redacted/dynamic]",
timeZone: "[redacted/dynamic]",
language: "[redacted/dynamic]",
},
responses: {
email: {
value: "test@example.com",
label: "email_address",
},
name: {
value: "Test Testson",
label: "your_name",
},
},
userFieldsResponses: {},
attendees: [
{
email: "test@example.com",
name: "Test Testson",
expect(body).toMatchObject({
triggerEvent: "BOOKING_CREATED",
createdAt: "[redacted/dynamic]",
payload: {
type: "30 min",
title: "30 min between Nameless and Test Testson",
description: "",
additionalNotes: "",
customInputs: {},
startTime: "[redacted/dynamic]",
endTime: "[redacted/dynamic]",
organizer: {
id: "[redacted/dynamic]",
name: "Nameless",
email: "[redacted/dynamic]",
timeZone: "[redacted/dynamic]",
language: "[redacted/dynamic]",
},
],
location: "[redacted/dynamic]",
destinationCalendar: null,
hideCalendarNotes: false,
requiresConfirmation: "[redacted/dynamic]",
eventTypeId: "[redacted/dynamic]",
seatsShowAttendees: true,
seatsPerTimeSlot: null,
uid: "[redacted/dynamic]",
eventTitle: "30 min",
eventDescription: null,
price: 0,
currency: "usd",
length: 30,
bookingId: "[redacted/dynamic]",
metadata: { videoCallUrl: "[redacted/dynamic]" },
status: "ACCEPTED",
additionalInformation: "[redacted/dynamic]",
},
});
responses: {
email: {
value: "test@example.com",
label: "email_address",
},
name: {
value: "Test Testson",
label: "your_name",
},
},
userFieldsResponses: {},
attendees: [
{
email: "test@example.com",
name: "Test Testson",
timeZone: "[redacted/dynamic]",
language: "[redacted/dynamic]",
},
],
location: "[redacted/dynamic]",
destinationCalendar: null,
hideCalendarNotes: false,
requiresConfirmation: "[redacted/dynamic]",
eventTypeId: "[redacted/dynamic]",
seatsShowAttendees: true,
seatsPerTimeSlot: null,
uid: "[redacted/dynamic]",
eventTitle: "30 min",
eventDescription: null,
price: 0,
currency: "usd",
length: 30,
bookingId: "[redacted/dynamic]",
metadata: { videoCallUrl: "[redacted/dynamic]" },
status: "ACCEPTED",
additionalInformation: "[redacted/dynamic]",
},
});
webhookReceiver.close();
webhookReceiver.close();
});
});
test.describe("BOOKING_REJECTED", async () => {
test("can book an event that requires confirmation and then that booking can be rejected by organizer", async ({
page,
users,
}) => {
const webhookReceiver = createHttpServer();
// --- create a user
const user = await users.create();
// --- visit user page
await page.goto(`/${user.username}`);
// --- book the user's event
await bookOptinEvent(page);
// --- login as that user
await user.login();
await page.goto(`/settings/developer/webhooks`);
// --- add webhook
await page.click('[data-testid="new_webhook"]');
await page.fill('[name="subscriberUrl"]', webhookReceiver.url);
await page.fill('[name="secret"]', "secret");
await Promise.all([
page.click("[type=submit]"),
page.waitForURL((url) => url.pathname.endsWith("/settings/developer/webhooks")),
]);
// page contains the url
expect(page.locator(`text='${webhookReceiver.url}'`)).toBeDefined();
await page.goto("/bookings/unconfirmed");
await page.click('[data-testid="reject"]');
await page.click('[data-testid="rejection-confirm"]');
await page.waitForResponse((response) => response.url().includes("/api/trpc/bookings/confirm"));
// --- check that webhook was called
await waitFor(() => {
expect(webhookReceiver.requestList.length).toBe(1);
});
const [request] = webhookReceiver.requestList;
const body = request.body as any;
// remove dynamic properties that differs depending on where you run the tests
const dynamic = "[redacted/dynamic]";
body.createdAt = dynamic;
body.payload.startTime = dynamic;
body.payload.endTime = dynamic;
body.payload.location = dynamic;
for (const attendee of body.payload.attendees) {
attendee.timeZone = dynamic;
attendee.language = dynamic;
}
body.payload.organizer.id = dynamic;
body.payload.organizer.email = dynamic;
body.payload.organizer.timeZone = dynamic;
body.payload.organizer.language = dynamic;
body.payload.uid = dynamic;
body.payload.bookingId = dynamic;
body.payload.additionalInformation = dynamic;
body.payload.requiresConfirmation = dynamic;
body.payload.eventTypeId = dynamic;
body.payload.videoCallData = dynamic;
body.payload.appsStatus = dynamic;
// body.payload.metadata.videoCallUrl = dynamic;
expect(body).toMatchObject({
triggerEvent: "BOOKING_REJECTED",
createdAt: "[redacted/dynamic]",
payload: {
type: "Opt in",
title: "Opt in between Nameless and Test Testson",
customInputs: {},
startTime: "[redacted/dynamic]",
endTime: "[redacted/dynamic]",
organizer: {
id: "[redacted/dynamic]",
name: "Unnamed",
email: "[redacted/dynamic]",
timeZone: "[redacted/dynamic]",
language: "[redacted/dynamic]",
},
responses: {
email: {
value: "test@example.com",
label: "email",
},
name: {
value: "Test Testson",
label: "name",
},
},
userFieldsResponses: {},
attendees: [
{
email: "test@example.com",
name: "Test Testson",
timeZone: "[redacted/dynamic]",
language: "[redacted/dynamic]",
},
],
location: "[redacted/dynamic]",
destinationCalendar: null,
// hideCalendarNotes: false,
requiresConfirmation: "[redacted/dynamic]",
eventTypeId: "[redacted/dynamic]",
uid: "[redacted/dynamic]",
eventTitle: "Opt in",
eventDescription: null,
price: 0,
currency: "usd",
length: 30,
bookingId: "[redacted/dynamic]",
// metadata: { videoCallUrl: "[redacted/dynamic]" },
status: "REJECTED",
additionalInformation: "[redacted/dynamic]",
},
});
webhookReceiver.close();
});
});
test.describe("BOOKING_REQUESTED", async () => {
test("can book an event that requires confirmation and get a booking requested event", async ({
page,
users,
}) => {
const webhookReceiver = createHttpServer();
// --- create a user
const user = await users.create();
// --- login as that user
await user.login();
await page.goto(`/settings/developer/webhooks`);
// --- add webhook
await page.click('[data-testid="new_webhook"]');
await page.fill('[name="subscriberUrl"]', webhookReceiver.url);
await page.fill('[name="secret"]', "secret");
await Promise.all([
page.click("[type=submit]"),
page.waitForURL((url) => url.pathname.endsWith("/settings/developer/webhooks")),
]);
// page contains the url
expect(page.locator(`text='${webhookReceiver.url}'`)).toBeDefined();
// --- visit user page
await page.goto(`/${user.username}`);
// --- book the user's opt in
await bookOptinEvent(page);
// --- check that webhook was called
await waitFor(() => {
expect(webhookReceiver.requestList.length).toBe(1);
});
const [request] = webhookReceiver.requestList;
const body = request.body as any;
// remove dynamic properties that differs depending on where you run the tests
const dynamic = "[redacted/dynamic]";
body.createdAt = dynamic;
body.payload.startTime = dynamic;
body.payload.endTime = dynamic;
body.payload.location = dynamic;
for (const attendee of body.payload.attendees) {
attendee.timeZone = dynamic;
attendee.language = dynamic;
}
body.payload.organizer.id = dynamic;
body.payload.organizer.email = dynamic;
body.payload.organizer.timeZone = dynamic;
body.payload.organizer.language = dynamic;
body.payload.uid = dynamic;
body.payload.bookingId = dynamic;
body.payload.additionalInformation = dynamic;
body.payload.requiresConfirmation = dynamic;
body.payload.eventTypeId = dynamic;
body.payload.videoCallData = dynamic;
body.payload.appsStatus = dynamic;
body.payload.metadata.videoCallUrl = dynamic;
expect(body).toMatchObject({
triggerEvent: "BOOKING_REQUESTED",
createdAt: "[redacted/dynamic]",
payload: {
type: "Opt in",
title: "Opt in between Nameless and Test Testson",
customInputs: {},
startTime: "[redacted/dynamic]",
endTime: "[redacted/dynamic]",
organizer: {
id: "[redacted/dynamic]",
name: "Nameless",
email: "[redacted/dynamic]",
timeZone: "[redacted/dynamic]",
language: "[redacted/dynamic]",
},
responses: {
email: {
value: "test@example.com",
label: "email_address",
},
name: {
value: "Test Testson",
label: "your_name",
},
},
userFieldsResponses: {},
attendees: [
{
email: "test@example.com",
name: "Test Testson",
timeZone: "[redacted/dynamic]",
language: "[redacted/dynamic]",
},
],
location: "[redacted/dynamic]",
destinationCalendar: null,
requiresConfirmation: "[redacted/dynamic]",
eventTypeId: "[redacted/dynamic]",
uid: "[redacted/dynamic]",
eventTitle: "Opt in",
eventDescription: null,
price: 0,
currency: "usd",
length: 30,
bookingId: "[redacted/dynamic]",
status: "PENDING",
additionalInformation: "[redacted/dynamic]",
metadata: { videoCallUrl: "[redacted/dynamic]" },
},
});
webhookReceiver.close();
});
});

View File

@ -374,6 +374,8 @@
"booking_rescheduled": "Booking Rescheduled",
"recording_ready":"Recording Download Link Ready",
"booking_created": "Booking Created",
"booking_rejected":"Booking Rejected",
"booking_requested":"Booking Requested",
"meeting_ended": "Meeting Ended",
"form_submitted": "Form Submitted",
"event_triggers": "Event Triggers",

View File

@ -33,6 +33,7 @@ import {
} from "@calcom/emails";
import { getBookingFieldsWithSystemFields } from "@calcom/features/bookings/lib/getBookingFields";
import { getCalEventResponses } from "@calcom/features/bookings/lib/getCalEventResponses";
import { handleWebhookTrigger } from "@calcom/features/bookings/lib/handleWebhookTrigger";
import {
allowDisablingAttendeeConfirmationEmails,
allowDisablingHostConfirmationEmails,
@ -40,6 +41,7 @@ import {
import { deleteScheduledEmailReminder } from "@calcom/features/ee/workflows/lib/reminders/emailReminderManager";
import { scheduleWorkflowReminders } from "@calcom/features/ee/workflows/lib/reminders/reminderScheduler";
import { deleteScheduledSMSReminder } from "@calcom/features/ee/workflows/lib/reminders/smsReminderManager";
import type { GetSubscriberOptions } from "@calcom/features/webhooks/lib/getWebhooks";
import getWebhooks from "@calcom/features/webhooks/lib/getWebhooks";
import { isPrismaObjOrUndefined, parseRecurringEvent } from "@calcom/lib";
import { getVideoCallUrlFromCalEvent } from "@calcom/lib/CalEventParser";
@ -72,7 +74,6 @@ import type { EventResult, PartialReference } from "@calcom/types/EventManager";
import type { WorkingHours, TimeRange as DateOverride } from "@calcom/types/schedule";
import type { EventTypeInfo } from "../../webhooks/lib/sendPayload";
import sendPayload from "../../webhooks/lib/sendPayload";
import getBookingResponsesSchema from "./getBookingResponsesSchema";
const translator = short();
@ -2092,16 +2093,44 @@ async function handler(
videoCallUrl: getVideoCallUrlFromCalEvent(evt),
}
: undefined;
const eventTypeInfo: EventTypeInfo = {
eventTitle: eventType.title,
eventDescription: eventType.description,
requiresConfirmation: requiresConfirmation || null,
price: paymentAppData.price,
currency: eventType.currency,
length: eventType.length,
};
const webhookData = {
...evt,
...eventTypeInfo,
bookingId: booking?.id,
rescheduleUid,
rescheduleStartTime: originalRescheduledBooking?.startTime
? dayjs(originalRescheduledBooking?.startTime).utc().format()
: undefined,
rescheduleEndTime: originalRescheduledBooking?.endTime
? dayjs(originalRescheduledBooking?.endTime).utc().format()
: undefined,
metadata: { ...metadata, ...reqBody.metadata },
eventTypeId,
status: "ACCEPTED",
smsReminderNumber: booking?.smsReminderNumber || undefined,
};
const subscriberOptions: GetSubscriberOptions = {
userId: organizerUser.id,
eventTypeId,
triggerEvent: WebhookTriggerEvents.BOOKING_CREATED,
teamId: eventType.team?.id,
};
if (isConfirmedByDefault) {
const eventTrigger: WebhookTriggerEvents = rescheduleUid
? WebhookTriggerEvents.BOOKING_RESCHEDULED
: WebhookTriggerEvents.BOOKING_CREATED;
const subscriberOptions = {
userId: organizerUser.id,
eventTypeId,
triggerEvent: eventTrigger,
teamId: eventType.team?.id,
};
subscriberOptions.triggerEvent = eventTrigger;
const subscriberOptionsMeetingEnded = {
userId: organizerUser.id,
@ -2125,48 +2154,14 @@ async function handler(
log.error("Error while running scheduledJobs for booking", error);
}
try {
// Send Webhook call if hooked to BOOKING_CREATED & BOOKING_RESCHEDULED
const subscribers = await getWebhooks(subscriberOptions);
console.log("evt:", {
...evt,
metadata: reqBody.metadata,
});
const bookingId = booking?.id;
const eventTypeInfo: EventTypeInfo = {
eventTitle: eventType.title,
eventDescription: eventType.description,
requiresConfirmation: requiresConfirmation || null,
price: paymentAppData.price,
currency: eventType.currency,
length: eventType.length,
};
const promises = subscribers.map((sub) =>
sendPayload(sub.secret, eventTrigger, new Date().toISOString(), sub, {
...evt,
...eventTypeInfo,
bookingId,
rescheduleUid,
rescheduleStartTime: originalRescheduledBooking?.startTime
? dayjs(originalRescheduledBooking?.startTime).utc().format()
: undefined,
rescheduleEndTime: originalRescheduledBooking?.endTime
? dayjs(originalRescheduledBooking?.endTime).utc().format()
: undefined,
metadata: { ...metadata, ...reqBody.metadata },
eventTypeId,
status: "ACCEPTED",
smsReminderNumber: booking?.smsReminderNumber || undefined,
}).catch((e) => {
console.error(`Error executing webhook for event: ${eventTrigger}, URL: ${sub.subscriberUrl}`, e);
})
);
await Promise.all(promises);
} catch (error) {
log.error("Error while sending webhook", error);
}
// Send Webhook call if hooked to BOOKING_CREATED & BOOKING_RESCHEDULED
await handleWebhookTrigger({ subscriberOptions, eventTrigger, webhookData });
} else if (eventType.requiresConfirmation) {
// if eventType requires confirmation we will trigger the BOOKING REQUESTED Webhook
const eventTrigger: WebhookTriggerEvents = WebhookTriggerEvents.BOOKING_REQUESTED;
subscriberOptions.triggerEvent = eventTrigger;
webhookData.status = "PENDING";
await handleWebhookTrigger({ subscriberOptions, eventTrigger, webhookData });
}
// Avoid passing referencesToCreate with id unique constrain values

View File

@ -0,0 +1,29 @@
import getWebhooks from "@calcom/features/webhooks/lib/getWebhooks";
import type { GetSubscriberOptions } from "@calcom/features/webhooks/lib/getWebhooks";
import type { WebhookDataType } from "@calcom/features/webhooks/lib/sendPayload";
import sendPayload from "@calcom/features/webhooks/lib/sendPayload";
import logger from "@calcom/lib/logger";
export async function handleWebhookTrigger(args: {
subscriberOptions: GetSubscriberOptions;
eventTrigger: string;
webhookData: Omit<WebhookDataType, "createdAt" | "triggerEvent">;
}) {
try {
const subscribers = await getWebhooks(args.subscriberOptions);
const promises = subscribers.map((sub) =>
sendPayload(sub.secret, args.eventTrigger, new Date().toISOString(), sub, args.webhookData).catch(
(e) => {
console.error(
`Error executing webhook for event: ${args.eventTrigger}, URL: ${sub.subscriberUrl}`,
e
);
}
)
);
await Promise.all(promises);
} catch (error) {
logger.error("Error while sending webhook", error);
}
}

View File

@ -254,6 +254,9 @@ export default function CreateEventTypeDialog({
/>
)}
<RadioArea.Group
onValueChange={(val: SchedulingType) => {
form.setValue("schedulingType", val);
}}
className={classNames(
"mt-1 flex gap-4",
isAdmin && flags["managed-event-types"] && "flex-col"

View File

@ -32,6 +32,8 @@ const WEBHOOK_TRIGGER_EVENTS_GROUPED_BY_APP_V2: Record<string, WebhookTriggerEve
core: [
{ value: WebhookTriggerEvents.BOOKING_CANCELLED, label: "booking_cancelled" },
{ value: WebhookTriggerEvents.BOOKING_CREATED, label: "booking_created" },
{ value: WebhookTriggerEvents.BOOKING_REJECTED, label: "booking_rejected"},
{ value: WebhookTriggerEvents.BOOKING_REQUESTED, label: "booking_requested"},
{ value: WebhookTriggerEvents.BOOKING_RESCHEDULED, label: "booking_rescheduled" },
{ value: WebhookTriggerEvents.MEETING_ENDED, label: "meeting_ended" },
{ value: WebhookTriggerEvents.RECORDING_READY, label: "recording_ready" },

View File

@ -8,6 +8,8 @@ export const WEBHOOK_TRIGGER_EVENTS_GROUPED_BY_APP = {
WebhookTriggerEvents.BOOKING_CREATED,
WebhookTriggerEvents.BOOKING_RESCHEDULED,
WebhookTriggerEvents.MEETING_ENDED,
WebhookTriggerEvents.BOOKING_REQUESTED,
WebhookTriggerEvents.BOOKING_REJECTED,
WebhookTriggerEvents.RECORDING_READY,
] as const,
"routing-forms": [WebhookTriggerEvents.FORM_SUBMITTED] as const,

View File

@ -16,7 +16,7 @@ export type EventTypeInfo = {
length?: number | null;
};
type WebhookDataType = CalendarEvent &
export type WebhookDataType = CalendarEvent &
EventTypeInfo & {
metadata?: { [key: string]: string };
bookingId?: number;

View File

@ -0,0 +1,3 @@
-- AlterEnum
ALTER TYPE "WebhookTriggerEvents" ADD VALUE 'BOOKING_REQUESTED';
ALTER TYPE "WebhookTriggerEvents" ADD VALUE 'BOOKING_REJECTED';

View File

@ -495,7 +495,9 @@ enum PaymentOption {
enum WebhookTriggerEvents {
BOOKING_CREATED
BOOKING_RESCHEDULED
BOOKING_REQUESTED
BOOKING_CANCELLED
BOOKING_REJECTED
FORM_SUBMITTED
MEETING_ENDED
RECORDING_READY

View File

@ -4,10 +4,12 @@ import appStore from "@calcom/app-store";
import { sendDeclinedEmails } from "@calcom/emails";
import { getCalEventResponses } from "@calcom/features/bookings/lib/getCalEventResponses";
import { handleConfirmation } from "@calcom/features/bookings/lib/handleConfirmation";
import { handleWebhookTrigger } from "@calcom/features/bookings/lib/handleWebhookTrigger";
import type { EventTypeInfo } from "@calcom/features/webhooks/lib/sendPayload";
import { isPrismaObjOrUndefined, parseRecurringEvent } from "@calcom/lib";
import { getTranslation } from "@calcom/lib/server";
import { prisma } from "@calcom/prisma";
import { BookingStatus, MembershipRole, SchedulingType } from "@calcom/prisma/enums";
import { BookingStatus, MembershipRole, SchedulingType, WebhookTriggerEvents } from "@calcom/prisma/enums";
import type { CalendarEvent } from "@calcom/types/Calendar";
import type { IAbstractPaymentService } from "@calcom/types/PaymentService";
@ -304,6 +306,31 @@ export const confirmHandler = async ({ ctx, input }: ConfirmOptions) => {
}
await sendDeclinedEmails(evt);
// send BOOKING_REJECTED webhooks
const subscriberOptions = {
userId: booking.userId,
eventTypeId: booking.eventTypeId,
triggerEvent: WebhookTriggerEvents.BOOKING_REJECTED,
teamId: booking.eventType?.teamId,
};
const eventTrigger: WebhookTriggerEvents = WebhookTriggerEvents.BOOKING_REJECTED;
const eventTypeInfo: EventTypeInfo = {
eventTitle: booking.eventType?.title,
eventDescription: booking.eventType?.description,
requiresConfirmation: booking.eventType?.requiresConfirmation || null,
price: booking.eventType?.price,
currency: booking.eventType?.currency,
length: booking.eventType?.length,
};
const webhookData = {
...evt,
...eventTypeInfo,
bookingId,
eventTypeId: booking.eventType?.id,
status: BookingStatus.REJECTED,
smsReminderNumber: booking.smsReminderNumber || undefined,
};
await handleWebhookTrigger({ subscriberOptions, eventTrigger, webhookData });
}
const message = "Booking " + confirmed ? "confirmed" : "rejected";

View File

@ -1,59 +1,57 @@
import React from "react";
import { useId } from "@radix-ui/react-id";
import * as RadioGroupPrimitive from "@radix-ui/react-radio-group";
import type { ReactNode } from "react";
import classNames from "@calcom/lib/classNames";
type RadioAreaProps = React.InputHTMLAttributes<HTMLInputElement> & { classNames?: { container?: string } };
type RadioAreaProps = RadioGroupPrimitive.RadioGroupItemProps & {
children: ReactNode;
classNames?: { container?: string };
};
const RadioArea = React.forwardRef<HTMLInputElement, RadioAreaProps>(
({ children, className, classNames: innerClassNames, ...props }, ref) => {
return (
<label className={classNames("relative flex", className)}>
<input
ref={ref}
className="text-emphasis bg-subtle border-emphasis focus:ring-none peer absolute top-[0.9rem] left-3 align-baseline"
type="radio"
{...props}
/>
<div
className={classNames(
"text-default peer-checked:border-emphasis border-subtle rounded-md border p-4 pt-3 pl-10",
innerClassNames?.container
)}>
{children}
</div>
</label>
);
}
);
type MaybeArray<T> = T[] | T;
type ChildrenOfType<T extends React.ElementType> = MaybeArray<
React.ReactElement<React.ComponentPropsWithoutRef<T>>
>;
interface RadioAreaGroupProps extends Omit<React.ComponentPropsWithoutRef<"div">, "onChange" | "children"> {
onChange?: (value: string) => void;
children: ChildrenOfType<typeof RadioArea>;
}
const RadioArea = ({ children, className, classNames: innerClassNames, ...props }: RadioAreaProps) => {
const radioAreaId = useId();
const id = props.id ?? radioAreaId;
const RadioAreaGroup = ({ children, className, onChange, ...passThroughProps }: RadioAreaGroupProps) => {
const childrenWithProps = React.Children.map(children, (child) => {
if (onChange && React.isValidElement(child)) {
return React.cloneElement(child, {
onChange: (e: React.ChangeEvent<HTMLInputElement>) => {
onChange(e.target.value);
},
});
}
return child;
});
return (
<div className={className} {...passThroughProps}>
{childrenWithProps}
<div
className={classNames(
"border-subtle [&:has(input:checked)]:border-emphasis relative flex items-start rounded-md border",
className
)}>
<RadioGroupPrimitive.Item
id={id}
{...props}
className={classNames(
"hover:bg-subtle border-default focus:ring-emphasis absolute top-[0.9rem] left-3 mt-0.5 h-4 w-4 flex-shrink-0 rounded-full border focus:ring-2",
props.disabled && "opacity-60"
)}>
<RadioGroupPrimitive.Indicator
className={classNames(
"after:bg-default dark:after:bg-inverted relative flex h-full w-full items-center justify-center rounded-full bg-black after:h-[6px] after:w-[6px] after:rounded-full after:content-['']",
props.disabled ? "after:bg-muted" : "bg-black"
)}
/>
</RadioGroupPrimitive.Item>
<label htmlFor={id} className={classNames("text-default p-4 pt-3 pl-10", innerClassNames?.container)}>
{children}
</label>
</div>
);
};
RadioAreaGroup.displayName = "RadioAreaGroup";
RadioArea.displayName = "RadioArea";
const RadioAreaGroup = ({
children,
className,
onValueChange,
...passThroughProps
}: RadioGroupPrimitive.RadioGroupProps) => {
return (
<RadioGroupPrimitive.Root className={className} onValueChange={onValueChange} {...passThroughProps}>
{children}
</RadioGroupPrimitive.Root>
);
};
const Item = RadioArea;
const Group = RadioAreaGroup;

View File

@ -1,68 +0,0 @@
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@radix-ui/react-collapsible";
import React from "react";
import type { FieldValues, Path, UseFormReturn } from "react-hook-form";
import classNames from "@calcom/lib/classNames";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { ChevronDown } from "@calcom/ui/components/icon";
import { RadioArea, RadioAreaGroup } from "./RadioAreaGroup";
interface OptionProps
extends Pick<React.OptionHTMLAttributes<HTMLOptionElement>, "value" | "label" | "className"> {
description?: string;
}
export type FieldPath<TFieldValues extends FieldValues> = Path<TFieldValues>;
interface RadioAreaSelectProps<TFieldValues extends FieldValues>
extends Omit<React.SelectHTMLAttributes<HTMLSelectElement>, "onChange" | "form"> {
options: OptionProps[]; // allow options to be passed programmatically, like options={}
onChange?: (value: string) => void;
form: UseFormReturn<TFieldValues>;
name: FieldPath<TFieldValues>;
}
export const Select = function RadioAreaSelect<TFieldValues extends FieldValues>(
props: RadioAreaSelectProps<TFieldValues>
) {
const { t } = useLocale();
const {
options,
form,
disabled = !options.length, // if not explicitly disabled and the options length is empty, disable anyway
placeholder = t("select"),
} = props;
const getLabel = (value: string | ReadonlyArray<string> | number | undefined) =>
options.find((option: OptionProps) => option.value === value)?.label;
return (
<Collapsible className={classNames("w-full", props.className)}>
<CollapsibleTrigger
type="button"
disabled={disabled}
className={classNames(
"focus:ring-primary-500 border-default bg-default mb-1 block w-full cursor-pointer rounded-sm border border p-2 text-left shadow-sm sm:text-sm",
disabled && "bg-emphasis cursor-default focus:ring-0 "
)}>
{getLabel(props.value) ?? placeholder}
<ChevronDown className="text-subtle float-right h-5 w-5" />
</CollapsibleTrigger>
<CollapsibleContent>
<RadioAreaGroup className="space-y-2 text-sm" onChange={props.onChange}>
{options.map((option) => (
<RadioArea
{...form.register(props.name)}
{...option}
key={Array.isArray(option.value) ? option.value.join(",") : `${option.value}`}>
<strong className="mb-1 block">{option.label}</strong>
<p>{option.description}</p>
</RadioArea>
))}
</RadioAreaGroup>
</CollapsibleContent>
</Collapsible>
);
};
export default Select;

View File

@ -1,3 +1,2 @@
export * as RadioGroup from "./RadioAreaGroup";
export { default as Select } from "./Select";
export { Group, Indicator, Label, Radio, RadioField } from "./Radio";