Merge remote-tracking branch 'origin/main' into feat/manage-all-booking-inputs
commit
02e42cab29
|
@ -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
|
||||
|
|
14
README.md
14
README.md
|
@ -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 -->
|
||||
|
||||
|
|
|
@ -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,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
|
@ -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"
|
||||
}
|
||||
|
|
|
@ -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[]);
|
||||
|
|
|
@ -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 */
|
||||
|
|
|
@ -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`}
|
||||
|
|
|
@ -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(),
|
||||
|
|
|
@ -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");
|
||||
|
|
|
@ -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,
|
||||
})),
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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}`);
|
||||
}
|
||||
|
|
|
@ -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 }) => {
|
||||
|
|
|
@ -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">
|
||||
|
|
|
@ -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() {
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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 shouldn’t 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.
|
||||
|
|
|
@ -877,7 +877,11 @@ export const workflowsRouter = router({
|
|||
eventType: true,
|
||||
},
|
||||
},
|
||||
steps: true,
|
||||
steps: {
|
||||
orderBy: {
|
||||
stepNumber: "asc",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
Loading…
Reference in New Issue