Merge remote-tracking branch 'origin/main' into feat/manage-all-booking-inputs

pull/6560/head
Hariom Balhara 2023-02-13 19:16:26 +05:30
commit 02e42cab29
21 changed files with 445 additions and 155 deletions

View File

@ -1,7 +1,5 @@
name: TODO to Issue
on:
push:
branches: [add-todo-to-issue-action] # FIXME: Remove this after merged in main
pull_request_target: # So we can test on forks
branches:
- main

View File

@ -39,6 +39,7 @@
<a href="https://cal.com/figma"><img src="https://img.shields.io/badge/Figma-Design%20System-blueviolet"></a>
<a href="https://calendso.slack.com/archives/C02BY67GMMW"><img src="https://img.shields.io/badge/translations-contribute-brightgreen" /></a>
<a href="https://www.contributor-covenant.org/version/1/4/code-of-conduct/ "><img src="https://img.shields.io/badge/Contributor%20Covenant-1.4-purple" /></a>
<a href="https://console.algora.io/org/cal/bounties?status=open"><img src="https://img.shields.io/endpoint?url=https%3A%2F%2Fconsole.algora.io%2Fapi%2Fshields%2Fcal%2Fbounties%3Fstatus%3Dopen" /></a>
</p>
<!-- ABOUT THE PROJECT -->
@ -76,8 +77,6 @@ That's where Cal.com comes in. Self-hosted or hosted by us. White-label by desig
/>
</a>
#### [Product Hunt](https://www.producthunt.com/posts/calendso?utm_source=badge-top-post-badge&utm_medium=badge&utm_souce=badge-calendso)
<a href="https://www.producthunt.com/posts/calendso?utm_source=badge-top-post-badge&utm_medium=badge&utm_souce=badge-calendso" target="_blank"><img src="https://api.producthunt.com/widgets/embed-image/v1/top-post-badge.svg?post_id=291910&theme=light&period=monthly" alt="Cal.com - The open source Calendly alternative | Product Hunt" style="width: 250px; height: 54px;" width="250" height="54" /></a> <a href="https://www.producthunt.com/posts/calendso?utm_source=badge-featured&utm_medium=badge&utm_souce=badge-calendso" target="_blank"><img src="https://api.producthunt.com/widgets/embed-image/v1/featured.svg?post_id=291910&theme=light" alt="Cal.com - The open source Calendly alternative | Product Hunt" style="width: 250px; height: 54px;" width="250" height="54" /></a> <a href="https://www.producthunt.com/stories/how-this-open-source-calendly-alternative-rocketed-to-product-of-the-day" target="_blank"><img src="https://cal.com/maker-grant.svg" alt="Cal.com - The open source Calendly alternative | Product Hunt" style="width: 250px; height: 54px;" width="250" height="54" /></a>
@ -340,7 +339,18 @@ Please see our [contributing guide](/CONTRIBUTING.md).
### Good First Issues
We have a list of [help wanted](https://github.com/calcom/cal.com/issues?q=is:issue+is:open+label:%22%F0%9F%99%8B%F0%9F%8F%BB%E2%80%8D%E2%99%82%EF%B8%8Fhelp+wanted%22) that contain small features and bugs which have a relatively limited scope. This is a great place to get started, gain experience, and get familiar with our contribution process.
<!-- BOUNTIES -->
### Bounties
<a href="https://console.algora.io/org/cal/bounties?status=open">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://console.algora.io/api/og/cal/bounties.png?p=0&status=open&theme=dark">
<img alt="Bounties of cal" src="https://console.algora.io/api/og/cal/bounties.png?p=0&status=open&theme=light">
</picture>
</a>
<!-- TRANSLATIONS -->

View File

@ -209,7 +209,7 @@ export default function Success(props: SuccessProps) {
}
const status = props.bookingInfo?.status;
const reschedule = props.bookingInfo.status === BookingStatus.ACCEPTED;
const cancellationReason = props.bookingInfo.cancellationReason;
const cancellationReason = props.bookingInfo.cancellationReason || props.bookingInfo.rejectionReason;
const attendeeName =
typeof props?.bookingInfo?.attendees?.[0]?.name === "string"
@ -440,6 +440,17 @@ export default function Success(props: SuccessProps) {
<div className="mt-3">
<p className="text-gray-600 dark:text-gray-300">{getTitle()}</p>
</div>
{props.paymentStatus && (
<h4>
{!props.paymentStatus.success &&
!props.paymentStatus.refunded &&
t("booking_with_payment_cancelled")}
{props.paymentStatus.success &&
!props.paymentStatus.refunded &&
t("booking_with_payment_cancelled_already_paid")}
{props.paymentStatus.refunded && t("booking_with_payment_cancelled_refunded")}
</h4>
)}
<div className="border-bookinglightest text-bookingdark dark:border-darkgray-200 mt-8 grid grid-cols-3 border-t pt-8 text-left dark:text-gray-300">
{(isCancelled || reschedule) && cancellationReason && (
@ -990,6 +1001,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
metadata: true,
cancellationReason: true,
responses: true,
rejectionReason: true,
user: {
select: {
id: true,
@ -1095,6 +1107,20 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
});
}
const payment = await prisma.payment.findFirst({
where: {
bookingId: bookingInfo.id,
},
select: {
success: true,
refunded: true,
},
});
const paymentStatus = {
success: payment?.success,
refunded: payment?.refunded,
};
return {
props: {
hideBranding: eventType.team ? eventType.team.hideBranding : eventType.users[0].hideBranding,
@ -1104,6 +1130,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
trpcState: ssr.dehydrate(),
dynamicEventName: bookingInfo?.eventType?.eventName || "",
bookingInfo,
paymentStatus: payment ? paymentStatus : null,
},
};
}

View File

@ -1595,5 +1595,9 @@
"booking_questions_title": "Booking questions",
"booking_questions_description": "Customize the questions asked on the booking page",
"add_a_booking_question": "Add a question",
"duplicate_email": "Email is duplicate"
"duplicate_email": "Email is duplicate",
"booking_with_payment_cancelled": "Paying for this event is no longer possible",
"booking_with_payment_cancelled_already_paid": "A refund for this booking payment it's on the way.",
"booking_with_payment_cancelled_refunded": "This booking payment has been refunded.",
"booking_confirmation_failed": "Booking confirmation failed"
}

View File

@ -183,14 +183,14 @@ type InputEventType = {
length?: number;
slotInterval?: number;
minimumBookingNotice?: number;
users: { id: number }[];
users?: { id: number }[];
schedulingType?: SchedulingType;
beforeEventBuffer?: number;
afterEventBuffer?: number;
};
type InputBooking = {
userId: number;
userId?: number;
eventTypeId: number;
startTime: string;
endTime: string;
@ -198,6 +198,13 @@ type InputBooking = {
status: BookingStatus;
};
type InputHost = {
id: number;
userId: number;
eventTypeId: number;
isFixed: boolean;
};
const cleanup = async () => {
await prisma.eventType.deleteMany();
await prisma.user.deleteMany();
@ -223,6 +230,7 @@ describe("getSchedule", () => {
const { dateString: plus2DateString } = getDate({ dateIncrement: 2 });
const scenarioData = {
hosts: [],
eventTypes: [
{
id: 1,
@ -337,6 +345,7 @@ describe("getSchedule", () => {
endTime: `${plus2DateString}T06:15:00.000Z`,
},
],
hosts: [],
});
// Day Plus 2 is completely free - It only has non accepted bookings
@ -434,6 +443,7 @@ describe("getSchedule", () => {
schedules: [TestData.schedules.IstWorkHours],
},
],
hosts: [],
});
const { dateString: plus1DateString } = getDate({ dateIncrement: 1 });
const { dateString: plus2DateString } = getDate({ dateIncrement: 2 });
@ -530,6 +540,7 @@ describe("getSchedule", () => {
schedules: [TestData.schedules.IstWorkHours],
},
],
hosts: [],
});
const { dateString: todayDateString } = getDate();
const { dateString: minus1DateString } = getDate({ dateIncrement: -1 });
@ -606,6 +617,7 @@ describe("getSchedule", () => {
selectedCalendars: [TestData.selectedCalendars.google],
},
],
hosts: [],
apps: [TestData.apps.googleCalendar],
};
@ -681,6 +693,7 @@ describe("getSchedule", () => {
},
],
apps: [TestData.apps.googleCalendar],
hosts: [],
};
createBookingScenario(scenarioData);
@ -740,6 +753,7 @@ describe("getSchedule", () => {
schedules: [TestData.schedules.IstWorkHoursWithDateOverride(plus2DateString)],
},
],
hosts: [],
};
createBookingScenario(scenarioData);
@ -762,6 +776,87 @@ describe("getSchedule", () => {
}
);
});
test("that a user is considered busy when there's a booking they host", async () => {
const { dateString: plus1DateString } = getDate({ dateIncrement: 1 });
const { dateString: plus2DateString } = getDate({ dateIncrement: 2 });
createBookingScenario({
eventTypes: [
// A Collective Event Type hosted by this user
{
id: 1,
slotInterval: 45,
schedulingType: "COLLECTIVE",
},
// A default Event Type which this user owns
{
id: 2,
slotInterval: 45,
users: [{ id: 101 }],
},
],
users: [
{
...TestData.users.example,
id: 101,
schedules: [TestData.schedules.IstWorkHours],
},
],
bookings: [
// Create a booking on our Collective Event Type
{
// userId: XX, <- No owner since this is a Collective Event Type
eventTypeId: 1,
status: "ACCEPTED",
startTime: `${plus2DateString}T04:00:00.000Z`,
endTime: `${plus2DateString}T04:15:00.000Z`,
},
],
hosts: [
// This user is a host of our Collective event
{
id: 1,
eventTypeId: 1,
userId: 101,
isFixed: true,
},
],
});
// Requesting this user's availability for their
// individual Event Type
const thisUserAvailability = await getSchedule(
{
eventTypeId: 2,
eventTypeSlug: "",
startTime: `${plus1DateString}T18:30:00.000Z`,
endTime: `${plus2DateString}T18:29:59.999Z`,
timeZone: Timezones["+5:30"],
},
ctx
);
expect(thisUserAvailability).toHaveTimeSlots(
[
// `04:00:00.000Z`, // <- This slot should be occupied by the Collective Event
`04:45:00.000Z`,
`05:30:00.000Z`,
`06:15:00.000Z`,
`07:00:00.000Z`,
`07:45:00.000Z`,
`08:30:00.000Z`,
`09:15:00.000Z`,
`10:00:00.000Z`,
`10:45:00.000Z`,
`11:30:00.000Z`,
`12:15:00.000Z`,
],
{
dateString: plus2DateString,
}
);
});
});
describe("Team Event", () => {
@ -826,6 +921,7 @@ describe("getSchedule", () => {
endTime: `${plus2DateString}T05:45:00.000Z`,
},
],
hosts: [],
});
const scheduleForTeamEventOnADayWithNoBooking = await getSchedule(
@ -963,6 +1059,7 @@ describe("getSchedule", () => {
endTime: `${plus3DateString}T04:15:00.000Z`,
},
],
hosts: [],
});
const scheduleForTeamEventOnADayWithOneBookingForEachUserButOnDifferentTimeslots = await getSchedule(
{
@ -1064,9 +1161,10 @@ function addEventTypes(eventTypes: InputEventType[], usersStore: InputUser[]) {
throw new Error(`eventTypes[number]: id ${eventType.id} is not unique`);
}
foundEvents[eventType.id] = true;
const users = eventType.users.map((userWithJustId) => {
return usersStore.find((user) => user.id === userWithJustId.id);
});
const users =
eventType.users?.map((userWithJustId) => {
return usersStore.find((user) => user.id === userWithJustId.id);
}) || [];
return {
...baseEventType,
...eventType,
@ -1099,11 +1197,32 @@ async function addBookings(bookings: InputBooking[], eventTypes: InputEventType[
bookings
// We can improve this filter to support the entire where clause but that isn't necessary yet. So, handle what we know we pass to `findMany` and is needed
.filter((booking) => {
/**
* A user is considered busy within a given time period if there
* is a booking they own OR host. This function mocks some of the logic
* for each condition. For details see the following ticket:
* https://github.com/calcom/cal.com/issues/6374
*/
// ~~ FIRST CONDITION ensures that this booking is owned by this user
// and that the status is what we want
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
const statusIn = where.status?.in || [];
// Return bookings passing status prisma where
return statusIn.includes(booking.status) && booking.userId === where.userId;
const statusIn = where.OR[0].status?.in || [];
const firstConditionMatches =
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
statusIn.includes(booking.status) && booking.userId === where.OR[0].userId;
// ~~ SECOND CONDITION checks whether this user is a host of this Event Type
// and that booking.status is a match for the returned query
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
let secondConditionMatches = where.OR[1].eventTypeId.in.includes(booking.eventTypeId);
secondConditionMatches = secondConditionMatches && statusIn.includes(booking.status);
// We return this booking if either condition is met
return firstConditionMatches || secondConditionMatches;
})
.map((booking) => ({
uid: uuidv4(),
@ -1116,6 +1235,10 @@ async function addBookings(bookings: InputBooking[], eventTypes: InputEventType[
});
}
function addHosts(hosts: InputHost[]) {
prismaMock.host.findMany.mockResolvedValue(hosts);
}
function addUsers(users: InputUser[]) {
prismaMock.user.findMany.mockResolvedValue(
users.map((user) => {
@ -1131,6 +1254,7 @@ type ScenarioData = {
// TODO: Support multiple bookings and add tests with that.
bookings?: InputBooking[];
users: InputUser[];
hosts: InputHost[];
credentials?: InputCredential[];
apps?: App[];
selectedCalendars?: InputSelectedCalendar[];
@ -1146,6 +1270,8 @@ function createBookingScenario(data: ScenarioData) {
addUsers(data.users);
addHosts(data.hosts);
const eventType = addEventTypes(data.eventTypes, data.users);
if (data.apps) {
prismaMock.app.findMany.mockResolvedValue(data.apps as PrismaApp[]);

View File

@ -50,6 +50,7 @@ export default function FormInputFields(props: Props) {
<div className="flex rounded-sm">
<Component
value={response[field.id]?.value}
placeholder={field.placeholder ?? ""}
// required property isn't accepted by query-builder types
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
/* @ts-ignore */

View File

@ -139,6 +139,15 @@ function Field({
onChange={(e) => setUserChangedIdentifier(e.target.value)}
/>
</div>
<div className="mb-6 w-full">
<TextField
disabled={!!router}
label="Placeholder"
placeholder="This will be the placeholder"
defaultValue={routerField?.placeholder}
{...hookForm.register(`${hookFieldNamespace}.placeholder`)}
/>
</div>
<div className="mb-6 w-full ">
<Controller
name={`${hookFieldNamespace}.type`}

View File

@ -4,6 +4,7 @@ export const zodNonRouterField = z.object({
id: z.string(),
label: z.string(),
identifier: z.string().optional(),
placeholder: z.string().optional(),
type: z.string(),
selectText: z.string().optional(),
required: z.boolean().optional(),

View File

@ -34,43 +34,97 @@ export async function getBusyTimes(params: {
status: BookingStatus.ACCEPTED,
})}`
);
/**
* A user is considered busy within a given time period if there
* is a booking they own OR host.
*
* Therefore this query does the following:
* - Performs a query for all EventType id's where this user is a host
* - Performs a query for all bookings where:
* - The given booking is owned by this user, or..
* - The given booking's EventType is hosted by this user
*
* See further discussion within this GH issue:
* https://github.com/calcom/cal.com/issues/6374
*
* NOTE: Changes here will likely require changes to some mocking
* logic within getSchedule.test.ts:addBookings
*/
performance.mark("prismaBookingGetStart");
const busyTimes: EventBusyDetails[] = await prisma.booking
.findMany({
where: {
userId,
startTime: { gte: new Date(startTime) },
endTime: { lte: new Date(endTime) },
status: {
in: [BookingStatus.ACCEPTED],
},
},
select: {
id: true,
startTime: true,
endTime: true,
title: true,
eventType: {
select: {
id: true,
afterEventBuffer: true,
beforeEventBuffer: true,
const busyTimes: EventBusyDetails[] =
// Getting all EventTypes ID's hosted by this user
await prisma.host
.findMany({
where: {
userId: {
equals: userId,
},
},
},
})
.then((bookings) =>
bookings.map(({ startTime, endTime, title, id, eventType }) => ({
start: dayjs(startTime)
.subtract((eventType?.beforeEventBuffer || 0) + (afterEventBuffer || 0), "minute")
.toDate(),
end: dayjs(endTime)
.add((eventType?.afterEventBuffer || 0) + (beforeEventBuffer || 0), "minute")
.toDate(),
title,
source: `eventType-${eventType?.id}-booking-${id}`,
}))
);
select: {
eventTypeId: true,
},
})
// Converting the response object into an array
.then((thisUserHostedEvents) => thisUserHostedEvents.map((e) => e.eventTypeId))
// Finding all bookings owned OR hosted by this user
.then((thisUserHostedEventIds) => {
// This gets applied to both conditions
const sharedQuery = {
startTime: { gte: new Date(startTime) },
endTime: { lte: new Date(endTime) },
status: {
in: [BookingStatus.ACCEPTED],
},
};
return prisma.booking.findMany({
where: {
OR: [
// Bookings owned by this user
{
...sharedQuery,
userId,
},
// Bookings with an EventType ID that's hosted by this user
{
...sharedQuery,
eventTypeId: {
in: thisUserHostedEventIds,
},
},
],
},
select: {
id: true,
startTime: true,
endTime: true,
title: true,
eventType: {
select: {
id: true,
afterEventBuffer: true,
beforeEventBuffer: true,
},
},
},
});
})
.then((bookings) =>
bookings.map(({ startTime, endTime, title, id, eventType }) => ({
start: dayjs(startTime)
.subtract((eventType?.beforeEventBuffer || 0) + (afterEventBuffer || 0), "minute")
.toDate(),
end: dayjs(endTime)
.add((eventType?.afterEventBuffer || 0) + (beforeEventBuffer || 0), "minute")
.toDate(),
title,
source: `eventType-${eventType?.id}-booking-${id}`,
}))
);
logger.silly(`Busy Time from Cal Bookings ${JSON.stringify(busyTimes)}`);
performance.mark("prismaBookingGetEnd");
performance.measure(`prisma booking get took $1'`, "prismaBookingGetStart", "prismaBookingGetEnd");

View File

@ -231,7 +231,7 @@ export async function getUserAvailability(
? { ...eventType?.schedule }
: {
...userSchedule,
availability: userSchedule.availability.map((a) => ({
availability: userSchedule?.availability.map((a) => ({
...a,
userId: currentUser.id,
})),

View File

@ -4,16 +4,16 @@
<img src="https://user-images.githubusercontent.com/8019099/133430653-24422d2a-3c8d-4052-9ad6-0580597151ee.png" alt="Logo">
</a>
<a href="https://cal.com/enterprise">Get Started with Enterprise</a>
<a href="https://console.cal.com/">Get a License Key</a>
</div>
# Enterprise Edition
Welcome to the Enterprise Edition ("/ee") of Cal.com.
The [/ee](https://github.com/calcom/cal.com/tree/main/packages/features/ee) subfolder is the place for all the **Pro** features from our [hosted](https://cal.com/pricing) plan and [enterprise-grade](https://cal.com/enterprise) features such as SSO, SAML, ADFS, OIDC, SCIM, SIEM, HRIS and much more.
The [/ee](https://github.com/calcom/cal.com/tree/main/packages/features/ee) subfolder is the place for all the **Enterprise Edition** features from our [hosted](https://cal.com/pricing) plan and enterprise-grade features for [Ultimate](https://cal.com/ultimate) such as SSO, SAML, OIDC, SCIM, SIEM and much more or [Platform](https://cal.com/platform) plan to build a marketplace.
> _❗ WARNING: This repository is copyrighted (unlike our [main repo](https://github.com/calcom/cal.com)). You are not allowed to use this code to host your own version of app.cal.com without obtaining a proper [license](https://cal.com/pricing?infra) first❗_
> _❗ WARNING: This repository is copyrighted (unlike our [main repo](https://github.com/calcom/cal.com)). You are not allowed to use this code to host your own version of app.cal.com without obtaining a proper [license](https://console.cal.com/) first❗_
## Setting up Stripe

View File

@ -8,6 +8,8 @@ import type { inferSSRProps } from "@calcom/types/inferSSRProps";
import { ssrInit } from "@server/lib/ssr";
import { BookingStatus } from ".prisma/client";
export type PaymentPageProps = inferSSRProps<typeof getServerSideProps>;
const querySchema = z.object({
@ -44,6 +46,9 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
},
eventTypeId: true,
location: true,
status: true,
rejectionReason: true,
cancellationReason: true,
eventType: {
select: {
id: true,
@ -104,6 +109,19 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
hideBranding: eventType.team?.hideBranding || user?.hideBranding || null,
};
if (
([BookingStatus.CANCELLED, BookingStatus.REJECTED] as BookingStatus[]).includes(
booking.status as BookingStatus
)
) {
return {
redirect: {
destination: `/booking/${booking.uid}`,
permanent: false,
},
};
}
return {
props: {
user,

View File

@ -204,6 +204,11 @@ export const deleteScheduledEmailReminder = async (referenceId: string) => {
status: "cancel",
},
});
await client.request({
url: `/v3/user/scheduled_sends/${referenceId}`,
method: "DELETE",
});
} catch (error) {
console.log(`Error canceling reminder with error ${error}`);
}

View File

@ -100,7 +100,7 @@ function WorkflowPage() {
data: workflow,
isError,
error,
dataUpdatedAt,
isLoading,
} = trpc.viewer.workflows.get.useQuery(
{ id: +workflowId },
{
@ -111,7 +111,7 @@ function WorkflowPage() {
const { data: verifiedNumbers } = trpc.viewer.workflows.getVerifiedNumbers.useQuery();
useEffect(() => {
if (workflow && (workflow.steps.length === 0 || workflow.steps[0].stepNumber === 1)) {
if (workflow && !isLoading) {
setSelectedEventTypes(
workflow.activeOn.map((active) => ({
value: String(active.eventType.id),
@ -155,7 +155,7 @@ function WorkflowPage() {
form.setValue("activeOn", activeOn || []);
setIsAllDataLoaded(true);
}
}, [dataUpdatedAt]);
}, [isLoading]);
const updateMutation = trpc.viewer.workflows.update.useMutation({
onSuccess: async ({ workflow }) => {

View File

@ -797,13 +797,13 @@ export function ShellMain(props: LayoutProps) {
{props.HeadingLeftIcon && <div className="ltr:mr-4">{props.HeadingLeftIcon}</div>}
<div className={classNames("w-full ltr:mr-4 rtl:ml-4 md:block", props.headerClassName)}>
{props.heading && (
<h1
<h3
className={classNames(
"font-cal max-w-28 sm:max-w-72 md:max-w-80 hidden truncate text-xl font-semibold tracking-wide text-black md:block xl:max-w-full",
props.smallHeading ? "text-base" : "text-xl"
)}>
{!isLocaleReady ? <SkeletonText invisible /> : props.heading}
</h1>
</h3>
)}
{props.subtitle && (
<p className="hidden text-sm text-gray-500 md:block">

View File

@ -70,6 +70,14 @@ export const tips = [
description: "Make time work for you and automate tasks",
href: "https://go.cal.com/workflows",
},
{
id: 9,
thumbnailUrl: "https://img.youtube.com/vi/93iOmzHieCU/0.jpg",
mediaLink: "https://go.cal.com/round-robin",
title: "Round-Robin",
description: "Create advanced group meetings with round-robin",
href: "https://go.cal.com/round-robin",
},
];
export default function Tips() {

View File

@ -31,7 +31,7 @@ export default function WebhookTestDisclosure() {
{t("ping_test")}
</Button>
</div>
<div className="space-y-0 rounded-md border border-neutral-200 sm:mx-0">
<div className="space-y-0 rounded-md border border-neutral-200 sm:mx-0">
<div className="flex justify-between border-b p-4">
<div className="flex items-center space-x-1">
<h3 className="self-center text-sm font-semibold leading-4">{t("webhook_response")}</h3>
@ -45,7 +45,7 @@ export default function WebhookTestDisclosure() {
<div className="rounded-b-md bg-black p-4 font-mono text-[13px] leading-4 text-white">
{!mutation.data && <p>{t("no_data_yet")}</p>}
{mutation.status === "success" && (
<div className="overflow-x-auto text-gray-900">{JSON.stringify(mutation.data, null, 4)}</div>
<div className="overflow-x-auto text-white">{JSON.stringify(mutation.data, null, 4)}</div>
)}
</div>
</div>

View File

@ -10,6 +10,8 @@ import {
Workflow,
WorkflowsOnEventTypes,
WorkflowStep,
PrismaPromise,
WorkflowMethods,
} from "@prisma/client";
import type { TFunction } from "next-i18next";
import { z } from "zod";
@ -18,11 +20,14 @@ import appStore from "@calcom/app-store";
import { getCalendar } from "@calcom/app-store/_utils/getCalendar";
import { DailyLocationType } from "@calcom/app-store/locations";
import { scheduleTrigger } from "@calcom/app-store/zapier/lib/nodeScheduler";
import { cancelScheduledJobs } from "@calcom/app-store/zapier/lib/nodeScheduler";
import EventManager from "@calcom/core/EventManager";
import { CalendarEventBuilder } from "@calcom/core/builders/CalendarEvent/builder";
import { CalendarEventDirector } from "@calcom/core/builders/CalendarEvent/director";
import { deleteMeeting } from "@calcom/core/videoClient";
import dayjs from "@calcom/dayjs";
import { deleteScheduledEmailReminder } from "@calcom/ee/workflows/lib/reminders/emailReminderManager";
import { deleteScheduledSMSReminder } from "@calcom/ee/workflows/lib/reminders/smsReminderManager";
import {
sendDeclinedEmails,
sendLocationChangeEmails,
@ -405,6 +410,8 @@ export const bookingsRouter = router({
dynamicGroupSlugRef: true,
destinationCalendar: true,
smsReminderNumber: true,
scheduledJobs: true,
workflowReminders: true,
},
where: {
uid: bookingId,
@ -472,6 +479,30 @@ export const bookingsRouter = router({
},
});
// delete scheduled jobs of cancelled bookings
cancelScheduledJobs(bookingToReschedule);
//cancel workflow reminders
const remindersToDelete: PrismaPromise<Prisma.BatchPayload>[] = [];
bookingToReschedule.workflowReminders.forEach((reminder) => {
if (reminder.scheduled && reminder.referenceId) {
if (reminder.method === WorkflowMethods.EMAIL) {
deleteScheduledEmailReminder(reminder.referenceId);
} else if (reminder.method === WorkflowMethods.SMS) {
deleteScheduledSMSReminder(reminder.referenceId);
}
}
const reminderToDelete = prisma.workflowReminder.deleteMany({
where: {
id: reminder.id,
},
});
remindersToDelete.push(reminderToDelete);
});
await Promise.all(remindersToDelete);
const [mainAttendee] = bookingToReschedule.attendees;
// @NOTE: Should we assume attendees language?
const tAttendees = await getTranslation(mainAttendee.locale ?? "en", "common");
@ -772,12 +803,8 @@ export const bookingsRouter = router({
const isConfirmed = booking.status === BookingStatus.ACCEPTED;
if (isConfirmed) throw new TRPCError({ code: "BAD_REQUEST", message: "Booking already confirmed" });
/** When a booking that requires payment its being confirmed but doesn't have any payment,
* we shouldnt save it on DestinationCalendars
*
* FIXME: This can cause unintended confirmations on rejection.
*/
if (booking.payment.length > 0 && !booking.paid) {
// If booking requires payment and is not paid, we don't allow confirmation
if (confirmed && booking.payment.length > 0 && !booking.paid) {
await prisma.booking.update({
where: {
id: bookingId,
@ -789,6 +816,7 @@ export const bookingsRouter = router({
return { message: "Booking confirmed", status: BookingStatus.ACCEPTED };
}
const attendeesListPromises = booking.attendees.map(async (attendee) => {
return {
name: attendee.name,
@ -1083,66 +1111,66 @@ export const bookingsRouter = router({
if (!!booking.payment.length) {
const successPayment = booking.payment.find((payment) => payment.success);
if (!successPayment) {
throw new Error("Cannot reject a booking without a successful payment");
}
// Disable paymentLink for this booking
} else {
let eventTypeOwnerId;
if (booking.eventType?.owner) {
eventTypeOwnerId = booking.eventType.owner.id;
} else if (booking.eventType?.teamId) {
const teamOwner = await prisma.membership.findFirst({
where: {
teamId: booking.eventType.teamId,
role: MembershipRole.OWNER,
},
select: {
userId: true,
},
});
eventTypeOwnerId = teamOwner?.userId;
}
let eventTypeOwnerId;
if (booking.eventType?.owner) {
eventTypeOwnerId = booking.eventType.owner.id;
} else if (booking.eventType?.teamId) {
const teamOwner = await prisma.membership.findFirst({
if (!eventTypeOwnerId) {
throw new Error("Event Type owner not found for obtaining payment app credentials");
}
const paymentAppCredentials = await prisma.credential.findMany({
where: {
teamId: booking.eventType.teamId,
role: MembershipRole.OWNER,
userId: eventTypeOwnerId,
appId: successPayment.appId,
},
select: {
userId: true,
},
});
eventTypeOwnerId = teamOwner?.userId;
}
if (!eventTypeOwnerId) {
throw new Error("Event Type owner not found for obtaining payment app credentials");
}
const paymentAppCredentials = await prisma.credential.findMany({
where: {
userId: eventTypeOwnerId,
appId: successPayment.appId,
},
select: {
key: true,
appId: true,
app: {
select: {
categories: true,
dirName: true,
key: true,
appId: true,
app: {
select: {
categories: true,
dirName: true,
},
},
},
},
});
});
const paymentAppCredential = paymentAppCredentials.find((credential) => {
return credential.appId === successPayment.appId;
});
const paymentAppCredential = paymentAppCredentials.find((credential) => {
return credential.appId === successPayment.appId;
});
if (!paymentAppCredential) {
throw new Error("Payment app credentials not found");
}
if (!paymentAppCredential) {
throw new Error("Payment app credentials not found");
}
// Posible to refactor TODO:
const paymentApp = appStore[paymentAppCredential?.app?.dirName as keyof typeof appStore];
if (!(paymentApp && "lib" in paymentApp && "PaymentService" in paymentApp.lib)) {
console.warn(`payment App service of type ${paymentApp} is not implemented`);
return null;
}
// Posible to refactor TODO:
const paymentApp = appStore[paymentAppCredential?.app?.dirName as keyof typeof appStore];
if (!(paymentApp && "lib" in paymentApp && "PaymentService" in paymentApp.lib)) {
console.warn(`payment App service of type ${paymentApp} is not implemented`);
return null;
}
const PaymentService = paymentApp.lib.PaymentService;
const paymentInstance = new PaymentService(paymentAppCredential);
const paymentData = await paymentInstance.refund(successPayment.id);
if (!paymentData.refunded) {
throw new Error("Payment could not be refunded");
const PaymentService = paymentApp.lib.PaymentService;
const paymentInstance = new PaymentService(paymentAppCredential);
const paymentData = await paymentInstance.refund(successPayment.id);
if (!paymentData.refunded) {
throw new Error("Payment could not be refunded");
}
}
}
// end handle refunds.

View File

@ -877,7 +877,11 @@ export const workflowsRouter = router({
eventType: true,
},
},
steps: true,
steps: {
orderBy: {
stepNumber: "asc",
},
},
},
});

View File

@ -16,48 +16,45 @@ export type AvatarGroupProps = {
};
export const AvatarGroup = function AvatarGroup(props: AvatarGroupProps) {
const avatars = props.items.slice(0, 4);
const LENGTH = props.items.length;
const truncateAfter = props.truncateAfter || 4;
/**
* First, filter all the avatars object that have image
* Then, slice it until before `truncateAfter` index
*/
const displayedAvatars = props.items.filter((avatar) => avatar.image).slice(0, truncateAfter);
const numTruncatedAvatars = LENGTH - displayedAvatars.length;
return (
<ul className={classNames("flex items-center", props.className)}>
{avatars.map((item, enumerator) => {
if (item.image != null) {
if (LENGTH > truncateAfter && enumerator === truncateAfter - 1) {
return (
<li key={enumerator} className="relative -mr-[4px] inline-block ">
<div className="relative">
<div className="h-90 relative min-w-full scale-105 transform border-gray-200 ">
<Avatar className="" imageSrc={item.image} alt={item.alt || ""} size={props.size} />
</div>
</div>
<div
className={classNames(
"absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 text-white",
props.size === "sm" ? "text-base" : "text-2xl"
)}>
<span>+{LENGTH - truncateAfter - 1}</span>
</div>
</li>
);
}
// Always display the first Four items items
return (
<li key={enumerator} className="-mr-[4px] inline-block">
<Avatar
className="border-gray-200"
imageSrc={item.image}
title={item.title}
alt={item.alt || ""}
accepted={props.accepted}
size={props.size}
href={item.href}
/>
</li>
);
}
})}
{displayedAvatars.map((item, idx) => (
<li key={idx} className="-mr-[4px] inline-block">
<Avatar
className="border-gray-200"
imageSrc={item.image}
title={item.title}
alt={item.alt || ""}
accepted={props.accepted}
size={props.size}
href={item.href}
/>
</li>
))}
{numTruncatedAvatars > 0 && (
<li
className={classNames(
"bg-darkgray-300 relative -mr-[4px] mb-1 inline-flex justify-center overflow-hidden rounded-full",
props.size === "sm" ? "min-w-6 h-6" : "min-w-16 h-16"
)}>
<span
className={classNames(
"m-auto px-1 text-center text-white",
props.size === "sm" ? "text-[12px]" : "text-2xl"
)}>
+{numTruncatedAvatars}
</span>
</li>
)}
</ul>
);
};

View File

@ -2,7 +2,7 @@ import { LOGO_ICON, LOGO } from "@calcom/lib/constants";
export default function Logo({ small, icon }: { small?: boolean; icon?: boolean }) {
return (
<h1 className="logo inline">
<h3 className="logo inline">
<strong>
{icon ? (
<img className="mx-auto w-9" alt="Cal" title="Cal" src={LOGO_ICON} />
@ -10,6 +10,6 @@ export default function Logo({ small, icon }: { small?: boolean; icon?: boolean
<img className={small ? "h-4 w-auto" : "h-5 w-auto"} alt="Cal" title="Cal" src={LOGO} />
)}
</strong>
</h1>
</h3>
);
}