Merge branch 'main' into integromat-app

This commit is contained in:
aar2dee2 2023-08-29 19:55:26 +05:30
commit cc252433d8
58 changed files with 677 additions and 437 deletions

View File

@ -13,16 +13,18 @@ const schemaMembershipRequiredParams = z.object({
teamId: z.number(),
});
export const membershipCreateBodySchema = Membership.partial({
accepted: true,
role: true,
disableImpersonation: true,
}).transform((v) => ({
accepted: false,
role: MembershipRole.MEMBER,
disableImpersonation: false,
...v,
}));
export const membershipCreateBodySchema = Membership.omit({ id: true })
.partial({
accepted: true,
role: true,
disableImpersonation: true,
})
.transform((v) => ({
accepted: false,
role: MembershipRole.MEMBER,
disableImpersonation: false,
...v,
}));
export const membershipEditBodySchema = Membership.omit({
/** To avoid complication, let's avoid updating these, instead you can delete and create a new invite */

View File

@ -382,24 +382,22 @@ export const EditLocationDialog = (props: ISetLocationDialog) => {
}}
/>
{selectedLocation && LocationOptions}
<DialogFooter>
<div className="mt-4 flex justify-end space-x-2 rtl:space-x-reverse">
<Button
onClick={() => {
setShowLocationModal(false);
setSelectedLocation?.(undefined);
setEditingLocationType?.("");
locationFormMethods.unregister(["locationType", "locationLink"]);
}}
type="button"
color="secondary">
{t("cancel")}
</Button>
<DialogFooter className="mt-4">
<Button
onClick={() => {
setShowLocationModal(false);
setSelectedLocation?.(undefined);
setEditingLocationType?.("");
locationFormMethods.unregister(["locationType", "locationLink"]);
}}
type="button"
color="secondary">
{t("cancel")}
</Button>
<Button data-testid="update-location" type="submit">
{t("update")}
</Button>
</div>
<Button data-testid="update-location" type="submit">
{t("update")}
</Button>
</DialogFooter>
</Form>
</div>

View File

@ -41,7 +41,7 @@ export const RescheduleDialog = (props: IRescheduleDialog) => {
return (
<Dialog open={isOpenDialog} onOpenChange={setIsOpenDialog}>
<DialogContent>
<DialogContent enableOverflow>
<div className="flex flex-row space-x-3">
<div className="flex h-10 w-10 flex-shrink-0 justify-center rounded-full bg-[#FAFAFA]">
<Clock className="m-auto h-6 w-6" />

View File

@ -57,12 +57,10 @@ export const AdminUser = (props: { onSubmit: () => void; onError: () => void; on
}),
});
const formMethods = useForm<{
username: string;
email_address: string;
full_name: string;
password: string;
}>({
type formSchemaType = z.infer<typeof formSchema>;
const formMethods = useForm<formSchemaType>({
mode: "onChange",
resolver: zodResolver(formSchema),
});
@ -70,7 +68,7 @@ export const AdminUser = (props: { onSubmit: () => void; onError: () => void; on
props.onError();
};
const onSubmit = formMethods.handleSubmit(async (data: z.infer<typeof formSchema>) => {
const onSubmit = formMethods.handleSubmit(async (data) => {
props.onSubmit();
const response = await fetch("/api/auth/setup", {
method: "POST",
@ -130,11 +128,7 @@ export const AdminUser = (props: { onSubmit: () => void; onError: () => void; on
className={classNames("my-0", longWebsiteUrl && "rounded-t-none")}
onBlur={onBlur}
name="username"
onChange={async (e) => {
onChange(e.target.value);
formMethods.setValue("username", e.target.value);
await formMethods.trigger("username");
}}
onChange={(e) => onChange(e.target.value)}
/>
</>
)}
@ -148,11 +142,7 @@ export const AdminUser = (props: { onSubmit: () => void; onError: () => void; on
<TextField
value={value || ""}
onBlur={onBlur}
onChange={async (e) => {
onChange(e.target.value);
formMethods.setValue("full_name", e.target.value);
await formMethods.trigger("full_name");
}}
onChange={(e) => onChange(e.target.value)}
color={formMethods.formState.errors.full_name ? "warn" : ""}
type="text"
name="full_name"
@ -172,11 +162,7 @@ export const AdminUser = (props: { onSubmit: () => void; onError: () => void; on
<EmailField
value={value || ""}
onBlur={onBlur}
onChange={async (e) => {
onChange(e.target.value);
formMethods.setValue("email_address", e.target.value);
await formMethods.trigger("email_address");
}}
onChange={(e) => onChange(e.target.value)}
className="my-0"
name="email_address"
/>
@ -191,11 +177,7 @@ export const AdminUser = (props: { onSubmit: () => void; onError: () => void; on
<PasswordField
value={value || ""}
onBlur={onBlur}
onChange={async (e) => {
onChange(e.target.value);
formMethods.setValue("password", e.target.value);
await formMethods.trigger("password");
}}
onChange={(e) => onChange(e.target.value)}
hintErrors={["caplow", "admin_min", "num"]}
name="password"
className="my-0"

View File

@ -1,6 +1,6 @@
{
"name": "@calcom/web",
"version": "3.2.5",
"version": "3.2.6",
"private": true,
"scripts": {
"analyze": "ANALYZE=true next build",

View File

@ -1,5 +1,4 @@
import type { NextApiRequest, NextApiResponse } from "next";
import { z } from "zod";
import dayjs from "@calcom/dayjs";
import { checkPremiumUsername } from "@calcom/ee/common/lib/checkPremiumUsername";
@ -11,18 +10,9 @@ import { closeComUpsertTeamUser } from "@calcom/lib/sync/SyncServiceManager";
import { validateUsernameInTeam, validateUsername } from "@calcom/lib/validateUsername";
import prisma from "@calcom/prisma";
import { IdentityProvider } from "@calcom/prisma/enums";
import { signupSchema } from "@calcom/prisma/zod-utils";
import { teamMetadataSchema } from "@calcom/prisma/zod-utils";
const signupSchema = z.object({
username: z.string().refine((value) => !value.includes("+"), {
message: "String should not contain a plus symbol (+).",
}),
email: z.string().email(),
password: z.string().min(7),
language: z.string().optional(),
token: z.string().optional(),
});
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
if (req.method !== "POST") {
return res.status(405).end();

View File

@ -34,13 +34,17 @@ const triggerWebhook = async ({
booking: {
userId: number | undefined;
eventTypeId: number | null;
eventTypeParentId: number | null | undefined;
teamId?: number | null;
};
}) => {
const eventTrigger: WebhookTriggerEvents = "RECORDING_READY";
// Send Webhook call if hooked to BOOKING.RECORDING_READY
const triggerForUser = !booking.teamId || (booking.teamId && booking.eventTypeParentId);
const subscriberOptions = {
userId: booking.userId,
userId: triggerForUser ? booking.userId : null,
eventTypeId: booking.eventTypeId,
triggerEvent: eventTrigger,
teamId: booking.teamId,
@ -183,6 +187,7 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
booking: {
userId: booking?.user?.id,
eventTypeId: booking.eventTypeId,
eventTypeParentId: booking.eventType?.parentId,
teamId,
},
});

View File

@ -17,6 +17,7 @@ import { useLocale } from "@calcom/lib/hooks/useLocale";
import slugify from "@calcom/lib/slugify";
import { collectPageParameters, telemetryEventTypes, useTelemetry } from "@calcom/lib/telemetry";
import { teamMetadataSchema } from "@calcom/prisma/zod-utils";
import { signupSchema as apiSignupSchema } from "@calcom/prisma/zod-utils";
import type { inferSSRProps } from "@calcom/types/inferSSRProps";
import { Alert, Button, EmailField, HeadSeo, PasswordField, TextField } from "@calcom/ui";
@ -25,14 +26,7 @@ import PageWrapper from "@components/PageWrapper";
import { IS_GOOGLE_LOGIN_ENABLED } from "../server/lib/constants";
import { ssrInit } from "../server/lib/ssr";
const signupSchema = z.object({
username: z.string().refine((value) => !value.includes("+"), {
message: "String should not contain a plus symbol (+).",
}),
email: z.string().email(),
password: z.string().min(7),
language: z.string().optional(),
token: z.string().optional(),
const signupSchema = apiSignupSchema.extend({
apiError: z.string().optional(), // Needed to display API errors doesnt get passed to the API
});
@ -46,6 +40,7 @@ export default function Signup({ prepopulateFormValues, token, orgSlug }: Signup
const { t, i18n } = useLocale();
const flags = useFlagMap();
const methods = useForm<FormValues>({
mode: "onChange",
resolver: zodResolver(signupSchema),
defaultValues: prepopulateFormValues,
});

View File

@ -158,9 +158,10 @@ test.describe("pro user", () => {
await expect(page.locator("[data-testid=success-page]")).toBeVisible();
additionalGuests.forEach(async (email) => {
const promises = additionalGuests.map(async (email) => {
await expect(page.locator(`[data-testid="attendee-email-${email}"]`)).toHaveText(email);
});
await Promise.all(promises);
});
test("Time slots should be reserved when selected", async ({ context, page }) => {

View File

@ -2030,5 +2030,6 @@
"value": "Value",
"your_organization_updated_sucessfully": "Your organization updated successfully",
"seat_options_doesnt_multiple_durations": "Seat option doesn't support multiple durations",
"include_calendar_event": "Include calendar event",
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Add your new strings above here ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
}

View File

@ -5,15 +5,21 @@ export type GetAppData = (key: string) => unknown;
export type SetAppData = (key: string, value: unknown) => void;
type LockedIcon = JSX.Element | false | undefined;
type Disabled = boolean | undefined;
// eslint-disable-next-line @typescript-eslint/no-empty-function
const EventTypeAppContext = React.createContext<[GetAppData, SetAppData, LockedIcon, Disabled]>([
() => ({}),
() => ({}),
undefined,
undefined,
]);
export type SetAppDataGeneric<TAppData extends ZodType> = <
type AppContext = {
getAppData: GetAppData;
setAppData: SetAppData;
LockedIcon?: LockedIcon;
disabled?: Disabled;
};
// eslint-disable-next-line @typescript-eslint/no-empty-function
const EventTypeAppContext = React.createContext<AppContext>({
getAppData: () => ({}),
setAppData: () => ({}),
});
type SetAppDataGeneric<TAppData extends ZodType> = <
TKey extends keyof z.infer<TAppData>,
TValue extends z.infer<TAppData>[TKey]
>(
@ -21,7 +27,7 @@ export type SetAppDataGeneric<TAppData extends ZodType> = <
value: TValue
) => void;
export type GetAppDataGeneric<TAppData extends ZodType> = <TKey extends keyof z.infer<TAppData>>(
type GetAppDataGeneric<TAppData extends ZodType> = <TKey extends keyof z.infer<TAppData>>(
key: TKey
) => z.infer<TAppData>[TKey];
@ -29,7 +35,12 @@ export const useAppContextWithSchema = <TAppData extends ZodType>() => {
type GetAppData = GetAppDataGeneric<TAppData>;
type SetAppData = SetAppDataGeneric<TAppData>;
// TODO: Not able to do it without type assertion here
const context = React.useContext(EventTypeAppContext) as [GetAppData, SetAppData, LockedIcon, Disabled];
const context = React.useContext(EventTypeAppContext) as {
getAppData: GetAppData;
setAppData: SetAppData;
LockedIcon: LockedIcon;
disabled: Disabled;
};
return context;
};
export default EventTypeAppContext;

View File

@ -1,12 +1,11 @@
import { useAutoAnimate } from "@formkit/auto-animate/react";
import Link from "next/link";
import { useAppContextWithSchema } from "@calcom/app-store/EventTypeAppContext";
import { classNames } from "@calcom/lib";
import type { RouterOutputs } from "@calcom/trpc/react";
import { Switch, Badge, Avatar } from "@calcom/ui";
import type { SetAppDataGeneric } from "../EventTypeAppContext";
import type { eventTypeAppCardZod } from "../eventTypeAppCardZod";
import type { CredentialOwner } from "../types";
import OmniInstallAppButton from "./OmniInstallAppButton";
@ -16,24 +15,20 @@ export default function AppCard({
switchOnClick,
switchChecked,
children,
setAppData,
returnTo,
teamId,
disableSwitch,
LockedIcon,
}: {
app: RouterOutputs["viewer"]["integrations"]["items"][number] & { credentialOwner?: CredentialOwner };
description?: React.ReactNode;
switchChecked?: boolean;
switchOnClick?: (e: boolean) => void;
children?: React.ReactNode;
setAppData: SetAppDataGeneric<typeof eventTypeAppCardZod>;
returnTo?: string;
teamId?: number;
disableSwitch?: boolean;
LockedIcon?: React.ReactNode;
}) {
const [animationRef] = useAutoAnimate<HTMLDivElement>();
const { setAppData, LockedIcon, disabled } = useAppContextWithSchema();
return (
<div
@ -92,7 +87,7 @@ export default function AppCard({
{app?.isInstalled || app.credentialOwner ? (
<div className="ml-auto flex items-center">
<Switch
disabled={!app.enabled || disableSwitch}
disabled={!app.enabled || disabled}
onCheckedChange={(enabled) => {
if (switchOnClick) {
switchOnClick(enabled);

View File

@ -19,7 +19,7 @@ export const EventTypeAppCard = (props: {
const { app, getAppData, setAppData, LockedIcon, disabled } = props;
return (
<ErrorBoundary message={`There is some problem with ${app.name} App`}>
<EventTypeAppContext.Provider value={[getAppData, setAppData, LockedIcon, disabled]}>
<EventTypeAppContext.Provider value={{ getAppData, setAppData, LockedIcon, disabled }}>
<DynamicComponent
slug={app.slug === "stripe" ? "stripepayment" : app.slug}
componentMap={EventTypeAddonMap}

View File

@ -125,8 +125,13 @@ export async function deleteSubscription({
export async function listBookings(appApiKey: ApiKey) {
try {
const where: Prisma.BookingWhereInput = {};
if (appApiKey.teamId) where.eventType = { teamId: appApiKey.teamId };
else where.userId = appApiKey.userId;
if (appApiKey.teamId) {
where.eventType = {
OR: [{ teamId: appApiKey.teamId }, { parent: { teamId: appApiKey.teamId } }],
};
} else {
where.userId = appApiKey.userId;
}
const bookings = await prisma.booking.findMany({
take: 3,
where: {
@ -206,6 +211,7 @@ export async function scheduleTrigger(
) {
try {
//schedule job to call subscriber url at the end of meeting
// FIXME: in-process scheduling - job will vanish on server crash / restart
const job = schedule.scheduleJob(
`${subscriber.appId}_${subscriber.id}`,
booking.endTime,
@ -253,38 +259,39 @@ export async function cancelScheduledJobs(
appId?: string | null,
isReschedule?: boolean
) {
try {
let scheduledJobs = booking.scheduledJobs || [];
if (!booking.scheduledJobs) return;
if (booking.scheduledJobs) {
booking.scheduledJobs.forEach(async (scheduledJob) => {
if (appId) {
if (scheduledJob.startsWith(appId)) {
if (schedule.scheduledJobs[scheduledJob]) {
schedule.scheduledJobs[scheduledJob].cancel();
}
scheduledJobs = scheduledJobs?.filter((job) => scheduledJob !== job) || [];
}
} else {
//if no specific appId given, delete all scheduled jobs of booking
if (schedule.scheduledJobs[scheduledJob]) {
schedule.scheduledJobs[scheduledJob].cancel();
}
scheduledJobs = [];
let scheduledJobs = booking.scheduledJobs || [];
const promises = booking.scheduledJobs.map(async (scheduledJob) => {
if (appId) {
if (scheduledJob.startsWith(appId)) {
if (schedule.scheduledJobs[scheduledJob]) {
schedule.scheduledJobs[scheduledJob].cancel();
}
scheduledJobs = scheduledJobs?.filter((job) => scheduledJob !== job) || [];
}
} else {
//if no specific appId given, delete all scheduled jobs of booking
if (schedule.scheduledJobs[scheduledJob]) {
schedule.scheduledJobs[scheduledJob].cancel();
}
scheduledJobs = [];
}
if (!isReschedule) {
await prisma.booking.update({
where: {
uid: booking.uid,
},
data: {
scheduledJobs: scheduledJobs,
},
});
}
if (!isReschedule) {
await prisma.booking.update({
where: {
uid: booking.uid,
},
data: {
scheduledJobs: scheduledJobs,
},
});
}
});
try {
await Promise.all(promises);
} catch (error) {
console.error("Error cancelling scheduled jobs", error);
}

View File

@ -5,7 +5,7 @@ import { useAppContextWithSchema } from "@calcom/app-store/EventTypeAppContext";
import type { EventTypeAppCardApp } from "../types";
function useIsAppEnabled(app: EventTypeAppCardApp) {
const [getAppData, setAppData] = useAppContextWithSchema();
const { getAppData, setAppData } = useAppContextWithSchema();
const [enabled, setEnabled] = useState(() => {
if (!app.credentialOwner) {
return getAppData("enabled");

View File

@ -9,7 +9,7 @@ import { Select } from "@calcom/ui";
import type { appDataSchema } from "../zod";
const EventTypeAppCard: EventTypeAppCardComponent = function EventTypeAppCard({ app }) {
const [getAppData, setAppData] = useAppContextWithSchema<typeof appDataSchema>();
const { getAppData } = useAppContextWithSchema<typeof appDataSchema>();
const [enabled, setEnabled] = useState(getAppData("enabled"));
const [projects, setProjects] = useState();
const [selectedProject, setSelectedProject] = useState<undefined | { label: string; value: string }>();
@ -32,7 +32,6 @@ const EventTypeAppCard: EventTypeAppCardComponent = function EventTypeAppCard({
return (
<AppCard
setAppData={setAppData}
app={app}
switchOnClick={(e) => {
if (!e) {

View File

@ -7,16 +7,13 @@ import { TextField } from "@calcom/ui";
import type { appDataSchema } from "../zod";
const EventTypeAppCard: EventTypeAppCardComponent = function EventTypeAppCard({ app, eventType }) {
const [getAppData, setAppData, LockedIcon, disabled] = useAppContextWithSchema<typeof appDataSchema>();
const { getAppData, setAppData, disabled } = useAppContextWithSchema<typeof appDataSchema>();
const trackingId = getAppData("trackingId");
const { enabled, updateEnabled } = useIsAppEnabled(app);
return (
<AppCard
setAppData={setAppData}
app={app}
disableSwitch={disabled}
LockedIcon={LockedIcon}
switchOnClick={(e) => {
updateEnabled(e);
}}

View File

@ -7,16 +7,13 @@ import { TextField } from "@calcom/ui";
import type { appDataSchema } from "../zod";
const EventTypeAppCard: EventTypeAppCardComponent = function EventTypeAppCard({ app, eventType }) {
const [getAppData, setAppData, LockedIcon, disabled] = useAppContextWithSchema<typeof appDataSchema>();
const { getAppData, setAppData, disabled } = useAppContextWithSchema<typeof appDataSchema>();
const trackingId = getAppData("trackingId");
const { enabled, updateEnabled } = useIsAppEnabled(app);
return (
<AppCard
setAppData={setAppData}
app={app}
disableSwitch={disabled}
LockedIcon={LockedIcon}
switchOnClick={(e) => {
updateEnabled(e);
}}

View File

@ -8,7 +8,7 @@ import { useLocale } from "@calcom/lib/hooks/useLocale";
import type { appDataSchema } from "../zod";
const EventTypeAppCard: EventTypeAppCardComponent = function EventTypeAppCard({ app, eventType }) {
const [getAppData, setAppData, LockedIcon, disabled] = useAppContextWithSchema<typeof appDataSchema>();
const { getAppData, setAppData, disabled } = useAppContextWithSchema<typeof appDataSchema>();
const thankYouPage = getAppData("thankYouPage");
const { enabled: showGifSelection, updateEnabled: setShowGifSelection } = useIsAppEnabled(app);
@ -16,11 +16,8 @@ const EventTypeAppCard: EventTypeAppCardComponent = function EventTypeAppCard({
return (
<AppCard
setAppData={setAppData}
app={app}
description={t("confirmation_page_gif")}
disableSwitch={disabled}
LockedIcon={LockedIcon}
switchOnClick={(e) => {
setShowGifSelection(e);
}}

View File

@ -7,16 +7,13 @@ import { TextField } from "@calcom/ui";
import type { appDataSchema } from "../zod";
const EventTypeAppCard: EventTypeAppCardComponent = function EventTypeAppCard({ app, eventType }) {
const [getAppData, setAppData, LockedIcon, disabled] = useAppContextWithSchema<typeof appDataSchema>();
const { getAppData, setAppData, disabled } = useAppContextWithSchema<typeof appDataSchema>();
const trackingId = getAppData("trackingId");
const { enabled, updateEnabled } = useIsAppEnabled(app);
return (
<AppCard
setAppData={setAppData}
app={app}
disableSwitch={disabled}
LockedIcon={LockedIcon}
switchOnClick={(e) => {
updateEnabled(e);
}}

View File

@ -7,16 +7,13 @@ import { TextField } from "@calcom/ui";
import type { appDataSchema } from "../zod";
const EventTypeAppCard: EventTypeAppCardComponent = function EventTypeAppCard({ app, eventType }) {
const [getAppData, setAppData, LockedIcon, disabled] = useAppContextWithSchema<typeof appDataSchema>();
const { getAppData, setAppData, disabled } = useAppContextWithSchema<typeof appDataSchema>();
const trackingId = getAppData("trackingId");
const { enabled, updateEnabled } = useIsAppEnabled(app);
return (
<AppCard
setAppData={setAppData}
app={app}
disableSwitch={disabled}
LockedIcon={LockedIcon}
switchOnClick={updateEnabled}
switchChecked={enabled}
teamId={eventType.team?.id || undefined}>

View File

@ -16,7 +16,7 @@ type Option = { value: string; label: string };
const EventTypeAppCard: EventTypeAppCardComponent = function EventTypeAppCard({ app, eventType }) {
const { asPath } = useRouter();
const [getAppData, setAppData] = useAppContextWithSchema<typeof appDataSchema>();
const { getAppData, setAppData } = useAppContextWithSchema<typeof appDataSchema>();
const price = getAppData("price");
const currency = getAppData("currency");
const [selectedCurrency, setSelectedCurrency] = useState(
@ -38,7 +38,6 @@ const EventTypeAppCard: EventTypeAppCardComponent = function EventTypeAppCard({
return (
<AppCard
returnTo={WEBAPP_URL + asPath}
setAppData={setAppData}
app={app}
switchChecked={requirePayment}
switchOnClick={(enabled) => {

View File

@ -7,17 +7,14 @@ import { TextField } from "@calcom/ui";
import type { appDataSchema } from "../zod";
const EventTypeAppCard: EventTypeAppCardComponent = function EventTypeAppCard({ app, eventType }) {
const [getAppData, setAppData, LockedIcon, disabled] = useAppContextWithSchema<typeof appDataSchema>();
const { getAppData, setAppData, disabled } = useAppContextWithSchema<typeof appDataSchema>();
const plausibleUrl = getAppData("PLAUSIBLE_URL");
const trackingId = getAppData("trackingId");
const { enabled, updateEnabled } = useIsAppEnabled(app);
return (
<AppCard
setAppData={setAppData}
app={app}
disableSwitch={disabled}
LockedIcon={LockedIcon}
switchOnClick={(e) => {
updateEnabled(e);
}}

View File

@ -11,7 +11,7 @@ import type { appDataSchema } from "../zod";
const EventTypeAppCard: EventTypeAppCardComponent = function EventTypeAppCard({ eventType, app }) {
const { t } = useLocale();
const [_getAppData, setAppData, LockedIcon, disabled] = useAppContextWithSchema<typeof appDataSchema>();
const { disabled } = useAppContextWithSchema<typeof appDataSchema>();
const [additionalParameters, setAdditionalParameters] = useState("");
const { enabled, updateEnabled } = useIsAppEnabled(app);
@ -37,10 +37,7 @@ const EventTypeAppCard: EventTypeAppCardComponent = function EventTypeAppCard({
return (
<AppCard
setAppData={setAppData}
app={app}
disableSwitch={disabled}
LockedIcon={LockedIcon}
switchOnClick={(e) => {
updateEnabled(e);
}}

View File

@ -15,7 +15,7 @@ type Option = { value: string; label: string };
const EventTypeAppCard: EventTypeAppCardComponent = function EventTypeAppCard({ app, eventType }) {
const pathname = usePathname();
const [getAppData, setAppData, LockedIcon, disabled] = useAppContextWithSchema<typeof appDataSchema>();
const { getAppData, setAppData, disabled } = useAppContextWithSchema<typeof appDataSchema>();
const price = getAppData("price");
const currency = getAppData("currency");
const paymentOption = getAppData("paymentOption");
@ -37,15 +37,13 @@ const EventTypeAppCard: EventTypeAppCardComponent = function EventTypeAppCard({
.trim();
return (
<AppCard
returnTo={WEBAPP_URL + pathname}
setAppData={setAppData}
returnTo={WEBAPP_URL + pathname + "?tabName=apps"}
app={app}
disableSwitch={disabled}
LockedIcon={LockedIcon}
switchChecked={requirePayment}
switchOnClick={(enabled) => {
setRequirePayment(enabled);
}}>
}}
teamId={eventType.team?.id || undefined}>
<>
{recurringEventDefined && (
<Alert className="mt-2" severity="warning" title={t("warning_recurring_event_payment")} />

View File

@ -8,13 +8,12 @@ import { TextField } from "@calcom/ui";
import type { appDataSchema } from "../zod";
const EventTypeAppCard: EventTypeAppCardComponent = function EventTypeAppCard({ app, eventType }) {
const [getAppData, setAppData] = useAppContextWithSchema<typeof appDataSchema>();
const { getAppData, setAppData } = useAppContextWithSchema<typeof appDataSchema>();
const trackingId = getAppData("trackingId");
const [enabled, setEnabled] = useState(getAppData("enabled"));
return (
<AppCard
setAppData={setAppData}
app={app}
switchOnClick={(e) => {
if (!e) {

View File

@ -7,16 +7,13 @@ import { Sunrise, Sunset } from "@calcom/ui/components/icon";
import type { appDataSchema } from "../zod";
const EventTypeAppCard: EventTypeAppCardComponent = function EventTypeAppCard({ eventType, app }) {
const [getAppData, setAppData, LockedIcon, disabled] = useAppContextWithSchema<typeof appDataSchema>();
const { getAppData, setAppData } = useAppContextWithSchema<typeof appDataSchema>();
const isSunrise = getAppData("isSunrise");
const { enabled, updateEnabled } = useIsAppEnabled(app);
return (
<AppCard
setAppData={setAppData}
app={app}
disableSwitch={disabled}
LockedIcon={LockedIcon}
switchOnClick={(e) => {
if (!e) {
updateEnabled(false);

View File

@ -121,18 +121,21 @@ const Reschedule = async (bookingUid: string, cancellationReason: string) => {
const bookingRefsFiltered: BookingReference[] = bookingToReschedule.references.filter(
(ref) => !!credentialsMap.get(ref.type)
);
const promises = bookingRefsFiltered.map(async (bookingRef) => {
if (!bookingRef.uid) return;
if (bookingRef.type.endsWith("_calendar")) {
const calendar = await getCalendar(credentialsMap.get(bookingRef.type));
return calendar?.deleteEvent(bookingRef.uid, builder.calendarEvent);
} else if (bookingRef.type.endsWith("_video")) {
return deleteMeeting(credentialsMap.get(bookingRef.type), bookingRef.uid);
}
});
try {
bookingRefsFiltered.forEach(async (bookingRef) => {
if (bookingRef.uid) {
if (bookingRef.type.endsWith("_calendar")) {
const calendar = await getCalendar(credentialsMap.get(bookingRef.type));
return calendar?.deleteEvent(bookingRef.uid, builder.calendarEvent);
} else if (bookingRef.type.endsWith("_video")) {
return deleteMeeting(credentialsMap.get(bookingRef.type), bookingRef.uid);
}
}
});
await Promise.all(promises);
} catch (error) {
// FIXME: error logging - non-Error type errors are currently discarded
if (error instanceof Error) {
logger.error(error.message);
}

View File

@ -121,17 +121,19 @@ const Reschedule = async (bookingUid: string, cancellationReason: string) => {
const bookingRefsFiltered: BookingReference[] = bookingToReschedule.references.filter(
(ref) => !!credentialsMap.get(ref.type)
);
const promises = bookingRefsFiltered.map(async (bookingRef) => {
if (!bookingRef.uid) return;
if (bookingRef.type.endsWith("_calendar")) {
const calendar = await getCalendar(credentialsMap.get(bookingRef.type));
return calendar?.deleteEvent(bookingRef.uid, builder.calendarEvent);
} else if (bookingRef.type.endsWith("_video")) {
return deleteMeeting(credentialsMap.get(bookingRef.type), bookingRef.uid);
}
});
try {
bookingRefsFiltered.forEach(async (bookingRef) => {
if (bookingRef.uid) {
if (bookingRef.type.endsWith("_calendar")) {
const calendar = await getCalendar(credentialsMap.get(bookingRef.type));
return calendar?.deleteEvent(bookingRef.uid, builder.calendarEvent);
} else if (bookingRef.type.endsWith("_video")) {
return deleteMeeting(credentialsMap.get(bookingRef.type), bookingRef.uid);
}
}
});
await Promise.all(promises);
} catch (error) {
if (error instanceof Error) {
logger.error(error.message);

View File

@ -12,7 +12,7 @@ const libraries = [
},
];
libraries.forEach(async (lib) => {
const promises = libraries.map(async (lib) => {
await build({
build: {
outDir: `./dist/${lib.fileName}`,
@ -29,3 +29,4 @@ libraries.forEach(async (lib) => {
},
});
});
await Promise.all(promises);

View File

@ -10,15 +10,11 @@ export function isPasswordValid(password: string, breakdown?: boolean, strict?:
num = false, // At least one number
min = false, // Eight characters, or fifteen in strict mode.
admin_min = false;
if (password.length > 7 && (!strict || password.length > 14)) min = true;
if (password.length >= 7 && (!strict || password.length > 14)) min = true;
if (strict && password.length > 14) admin_min = true;
for (let i = 0; i < password.length; i++) {
if (!isNaN(parseInt(password[i]))) num = true;
else {
if (password[i] === password[i].toUpperCase()) cap = true;
if (password[i] === password[i].toLowerCase()) low = true;
}
}
if (password.match(/\d/)) num = true;
if (password.match(/[a-z]/)) low = true;
if (password.match(/[A-Z]/)) cap = true;
if (!breakdown) return cap && low && num && min && (strict ? admin_min : true);

View File

@ -1,6 +1,5 @@
import { shallow } from "zustand/shallow";
import type { Dayjs } from "@calcom/dayjs";
import dayjs from "@calcom/dayjs";
import { default as DatePickerComponent } from "@calcom/features/calendars/DatePicker";
import { useNonEmptyScheduleDays } from "@calcom/features/schedules";
@ -24,13 +23,8 @@ export const DatePicker = () => {
return (
<DatePickerComponent
isLoading={schedule.isLoading}
onChange={(date: Dayjs) => {
setSelectedDate(date.format("YYYY-MM-DD"));
}}
onMonthChange={(date: Dayjs) => {
setMonth(date.format("YYYY-MM"));
setSelectedDate(date.format("YYYY-MM-DD"));
}}
onChange={(date) => setSelectedDate(date ? date.format("YYYY-MM-DD") : date)}
onMonthChange={(date) => setMonth(date.format("YYYY-MM"))}
includedDates={nonEmptyScheduleDays}
locale={i18n.language}
browsingDate={month ? dayjs(month) : undefined}

View File

@ -160,7 +160,8 @@ export const useBookerStore = create<BookerStore>((set, get) => ({
updateQueryParam("date", selectedDate ?? "");
// Setting month make sure small calendar in fullscreen layouts also updates.
if (newSelection.month() !== currentSelection.month()) {
// If selectedDate is null, prevents setting month to Invalid-Date
if (selectedDate && newSelection.month() !== currentSelection.month()) {
set({ month: newSelection.format("YYYY-MM") });
updateQueryParam("month", newSelection.format("YYYY-MM"));
}
@ -193,7 +194,6 @@ export const useBookerStore = create<BookerStore>((set, get) => ({
setMonth: (month: string | null) => {
set({ month, selectedTimeslot: null });
updateQueryParam("month", month ?? "");
get().setSelectedDate(null);
},
isTeamEvent: false,
seatedEventData: {

View File

@ -157,8 +157,10 @@ async function handler(req: CustomRequest) {
},
});
const triggerForUser = !teamId || (teamId && bookingToDelete.eventType?.parentId);
const subscriberOptions = {
userId: bookingToDelete.userId,
userId: triggerForUser ? bookingToDelete.userId : null,
eventTypeId: bookingToDelete.eventTypeId as number,
triggerEvent: eventTrigger,
teamId,
@ -428,9 +430,9 @@ async function handler(req: CustomRequest) {
bookingToDelete.recurringEventId &&
allRemainingBookings
) {
bookingToDelete.user.credentials
const promises = bookingToDelete.user.credentials
.filter((credential) => credential.type.endsWith("_calendar"))
.forEach(async (credential) => {
.map(async (credential) => {
const calendar = await getCalendar(credential);
for (const updBooking of updatedBookings) {
const bookingRef = updBooking.references.find((ref) => ref.type.includes("_calendar"));
@ -441,6 +443,13 @@ async function handler(req: CustomRequest) {
}
}
});
try {
await Promise.all(promises);
} catch (error) {
if (error instanceof Error) {
logger.error(error.message);
}
}
} else {
apiDeletes.push(calendar?.deleteEvent(uid, evt, externalCalendarId) as Promise<unknown>);
}
@ -601,11 +610,13 @@ async function handler(req: CustomRequest) {
});
// delete scheduled jobs of cancelled bookings
// FIXME: async calls into ether
updatedBookings.forEach((booking) => {
cancelScheduledJobs(booking);
});
//Workflows - cancel all reminders for cancelled bookings
// FIXME: async calls into ether
updatedBookings.forEach((booking) => {
booking.workflowReminders.forEach((reminder) => {
if (reminder.method === WorkflowMethods.EMAIL) {
@ -620,11 +631,14 @@ async function handler(req: CustomRequest) {
const prismaPromises: Promise<unknown>[] = [bookingReferenceDeletes];
// @TODO: find a way in the future if a promise fails don't stop the rest of the promises
// Also if emails fails try to requeue them
try {
await Promise.all(prismaPromises.concat(apiDeletes));
const settled = await Promise.allSettled(prismaPromises.concat(apiDeletes));
const rejected = settled.filter(({ status }) => status === "rejected") as PromiseRejectedResult[];
if (rejected.length) {
throw new Error(`Reasons: ${rejected.map(({ reason }) => reason)}`);
}
// TODO: if emails fail try to requeue them
await sendCancelledEmails(evt, { eventName: bookingToDelete?.eventType?.eventName });
} catch (error) {
console.error("Error deleting event", error);

View File

@ -293,14 +293,16 @@ export async function handleConfirmation(args: {
},
});
const triggerForUser = !teamId || (teamId && booking.eventType?.parentId);
const subscribersBookingCreated = await getWebhooks({
userId: booking.userId,
userId: triggerForUser ? booking.userId : null,
eventTypeId: booking.eventTypeId,
triggerEvent: WebhookTriggerEvents.BOOKING_CREATED,
teamId,
});
const subscribersMeetingEnded = await getWebhooks({
userId: booking.userId,
userId: triggerForUser ? booking.userId : null,
eventTypeId: booking.eventTypeId,
triggerEvent: WebhookTriggerEvents.MEETING_ENDED,
teamId: booking.eventType?.teamId,

View File

@ -1126,8 +1126,10 @@ async function handler(
const teamId = await getTeamIdFromEventType({ eventType });
const triggerForUser = !teamId || (teamId && eventType.parentId);
const subscriberOptions: GetSubscriberOptions = {
userId: organizerUser.id,
userId: triggerForUser ? organizerUser.id : null,
eventTypeId,
triggerEvent: WebhookTriggerEvents.BOOKING_CREATED,
teamId,
@ -1140,7 +1142,7 @@ async function handler(
subscriberOptions.triggerEvent = eventTrigger;
const subscriberOptionsMeetingEnded = {
userId: organizerUser.id,
userId: triggerForUser ? organizerUser.id : null,
eventTypeId,
triggerEvent: WebhookTriggerEvents.MEETING_ENDED,
teamId,

View File

@ -16,7 +16,7 @@ export type DatePickerProps = {
/** which day of the week to render the calendar. Usually Sunday (=0) or Monday (=1) - default: Sunday */
weekStart?: 0 | 1 | 2 | 3 | 4 | 5 | 6;
/** Fires whenever a selected date is changed. */
onChange: (date: Dayjs) => void;
onChange: (date: Dayjs | null) => void;
/** Fires when the month is changed. */
onMonthChange?: (date: Dayjs) => void;
/** which date or dates are currently selected (not tracked from here) */
@ -30,7 +30,7 @@ export type DatePickerProps = {
/** Defaults to [], which dates are not bookable. Array of valid dates like: ["2022-04-23", "2022-04-24"] */
excludedDates?: string[];
/** defaults to all, which dates are bookable (inverse of excludedDates) */
includedDates?: string[];
includedDates?: string[] | null;
/** allows adding classes to the container */
className?: string;
/** Shows a small loading spinner next to the month name */
@ -121,7 +121,7 @@ const Days = ({
// Create placeholder elements for empty days in first week
const weekdayOfFirst = browsingDate.date(1).day();
const currentDate = minDate.utcOffset(browsingDate.utcOffset());
const availableDates = (includedDates: string[] | undefined) => {
const availableDates = (includedDates: string[] | undefined | null) => {
const dates = [];
const lastDateOfMonth = browsingDate.date(daysInMonth(browsingDate));
for (
@ -148,6 +148,21 @@ const Days = ({
days.push(date);
}
const daysToRenderForTheMonth = days.map((day) => {
if (!day) return { day: null, disabled: true };
return {
day: day,
disabled:
(includedDates && !includedDates.includes(yyyymmdd(day))) || excludedDates.includes(yyyymmdd(day)),
};
});
useHandleInitialDateSelection({
daysToRenderForTheMonth,
selected,
onChange: props.onChange,
});
const [selectedDatesAndTimes] = useBookerStore((state) => [state.selectedDatesAndTimes], shallow);
const isActive = (day: dayjs.Dayjs) => {
@ -177,7 +192,7 @@ const Days = ({
return (
<>
{days.map((day, idx) => (
{daysToRenderForTheMonth.map(({ day, disabled }, idx) => (
<div key={day === null ? `e-${idx}` : `day-${day.format()}`} className="relative w-full pt-[100%]">
{day === null ? (
<div key={`e-${idx}`} />
@ -194,10 +209,7 @@ const Days = ({
onClick={() => {
props.onChange(day);
}}
disabled={
(includedDates && !includedDates.includes(yyyymmdd(day))) ||
excludedDates.includes(yyyymmdd(day))
}
disabled={disabled}
active={isActive(day)}
/>
)}
@ -293,4 +305,41 @@ const DatePicker = ({
);
};
/**
* Takes care of selecting a valid date in the month if the selected date is not available in the month
*/
const useHandleInitialDateSelection = ({
daysToRenderForTheMonth,
selected,
onChange,
}: {
daysToRenderForTheMonth: { day: Dayjs | null; disabled: boolean }[];
selected: Dayjs | Dayjs[] | null | undefined;
onChange: (date: Dayjs | null) => void;
}) => {
// Let's not do something for now in case of multiple selected dates as behaviour is unclear and it's not needed at the moment
if (selected instanceof Array) {
return;
}
const firstAvailableDateOfTheMonth = daysToRenderForTheMonth.find((day) => !day.disabled)?.day;
const isSelectedDateAvailable = selected
? daysToRenderForTheMonth.some(({ day, disabled }) => {
if (day && yyyymmdd(day) === yyyymmdd(selected) && !disabled) return true;
})
: false;
if (firstAvailableDateOfTheMonth) {
// If selected date not available in the month, select the first available date of the month
if (!isSelectedDateAvailable) {
onChange(firstAvailableDateOfTheMonth);
}
} else {
// No date is available and if we were asked to select something inform that it couldn't be selected. This would actually help in not showing the timeslots section(with No Time Available) when no date in the month is available
if (selected) {
onChange(null);
}
}
};
export default DatePicker;

View File

@ -1,10 +1,16 @@
/* Schedule any workflow reminder that falls within 72 hours for email */
import type { Prisma } from "@prisma/client";
import client from "@sendgrid/client";
import sgMail from "@sendgrid/mail";
import { createEvent } from "ics";
import type { DateArray } from "ics";
import type { NextApiRequest, NextApiResponse } from "next";
import { RRule } from "rrule";
import { v4 as uuidv4 } from "uuid";
import dayjs from "@calcom/dayjs";
import { getCalEventResponses } from "@calcom/features/bookings/lib/getCalEventResponses";
import { parseRecurringEvent } from "@calcom/lib";
import { defaultHandler } from "@calcom/lib/server";
import { getTimeFormatStringFromUserTimeFormat } from "@calcom/lib/timeFormat";
import prisma from "@calcom/prisma";
@ -20,6 +26,65 @@ const senderEmail = process.env.SENDGRID_EMAIL as string;
sgMail.setApiKey(sendgridAPIKey);
type Booking = Prisma.BookingGetPayload<{
include: {
eventType: true;
user: true;
attendees: true;
};
}>;
function getiCalEventAsString(booking: Booking) {
let recurrenceRule: string | undefined = undefined;
const recurringEvent = parseRecurringEvent(booking.eventType?.recurringEvent);
if (recurringEvent?.count) {
recurrenceRule = new RRule(recurringEvent).toString().replace("RRULE:", "");
}
const uid = uuidv4();
const icsEvent = createEvent({
uid,
startInputType: "utc",
start: dayjs(booking.startTime.toISOString() || "")
.utc()
.toArray()
.slice(0, 6)
.map((v, i) => (i === 1 ? v + 1 : v)) as DateArray,
duration: {
minutes: dayjs(booking.endTime.toISOString() || "").diff(
dayjs(booking.startTime.toISOString() || ""),
"minute"
),
},
title: booking.eventType?.title || "",
description: booking.description || "",
location: booking.location || "",
organizer: {
email: booking.user?.email || "",
name: booking.user?.name || "",
},
attendees: [
{
name: booking.attendees[0].name,
email: booking.attendees[0].email,
partstat: "ACCEPTED",
role: "REQ-PARTICIPANT",
rsvp: true,
},
],
method: "REQUEST",
...{ recurrenceRule },
status: "CONFIRMED",
});
if (icsEvent.error) {
throw icsEvent.error;
}
return icsEvent.value;
}
async function handler(req: NextApiRequest, res: NextApiResponse) {
const apiKey = req.headers.authorization || req.query.apiKey;
if (process.env.CRON_API_KEY !== apiKey) {
@ -258,6 +323,17 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
enable: sandboxMode,
},
},
attachments: reminder.workflowStep.includeCalendarEvent
? [
{
content: Buffer.from(getiCalEventAsString(reminder.booking) || "").toString("base64"),
filename: "event.ics",
type: "text/calendar; method=REQUEST",
disposition: "attachment",
contentId: uuidv4(),
},
]
: undefined,
});
}

View File

@ -113,6 +113,7 @@ export default function WorkflowDetailsPage(props: Props) {
sender: isSMSAction(action) ? sender || SENDER_ID : SENDER_ID,
senderName: !isSMSAction(action) ? senderName || SENDER_NAME : SENDER_NAME,
numberVerificationPending: false,
includeCalendarEvent: false,
};
steps?.push(step);
form.setValue("steps", steps);

View File

@ -861,6 +861,29 @@ export default function WorkflowStepContainer(props: WorkflowStepProps) {
{form.formState?.errors?.steps[step.stepNumber - 1]?.reminderBody?.message || ""}
</p>
)}
{isEmailSubjectNeeded && (
<div className="mt-2">
<Controller
name={`steps.${step.stepNumber - 1}.includeCalendarEvent`}
control={form.control}
render={() => (
<CheckboxField
disabled={props.readOnly}
defaultChecked={
form.getValues(`steps.${step.stepNumber - 1}.includeCalendarEvent`) || false
}
description={t("include_calendar_event")}
onChange={(e) =>
form.setValue(
`steps.${step.stepNumber - 1}.includeCalendarEvent`,
e.target.checked
)
}
/>
)}
/>
</div>
)}
{!props.readOnly && (
<div className="mt-3 ">
<button type="button" onClick={() => setIsAdditionalInputsDialogOpen(true)}>

View File

@ -1,8 +1,14 @@
import client from "@sendgrid/client";
import type { MailData } from "@sendgrid/helpers/classes/mail";
import sgMail from "@sendgrid/mail";
import { createEvent } from "ics";
import type { ParticipationStatus } from "ics";
import type { DateArray } from "ics";
import { RRule } from "rrule";
import { v4 as uuidv4 } from "uuid";
import dayjs from "@calcom/dayjs";
import { preprocessNameFieldDataWithVariant } from "@calcom/features/form-builder/utils";
import logger from "@calcom/lib/logger";
import prisma from "@calcom/prisma";
import type { TimeUnit } from "@calcom/prisma/enums";
@ -42,6 +48,47 @@ async function getBatchId() {
return batchIdResponse[1].batch_id as string;
}
function getiCalEventAsString(evt: BookingInfo, status?: ParticipationStatus) {
const uid = uuidv4();
let recurrenceRule: string | undefined = undefined;
if (evt.eventType.recurringEvent?.count) {
recurrenceRule = new RRule(evt.eventType.recurringEvent).toString().replace("RRULE:", "");
}
const icsEvent = createEvent({
uid,
startInputType: "utc",
start: dayjs(evt.startTime)
.utc()
.toArray()
.slice(0, 6)
.map((v, i) => (i === 1 ? v + 1 : v)) as DateArray,
duration: { minutes: dayjs(evt.endTime).diff(dayjs(evt.startTime), "minute") },
title: evt.title,
description: evt.additionalNotes || "",
location: evt.location || "",
organizer: { email: evt.organizer.email || "", name: evt.organizer.name },
attendees: [
{
name: preprocessNameFieldDataWithVariant("fullName", evt.attendees[0].name) as string,
email: evt.attendees[0].email,
partstat: status,
role: "REQ-PARTICIPANT",
rsvp: true,
},
],
method: "REQUEST",
...{ recurrenceRule },
status: "CONFIRMED",
});
if (icsEvent.error) {
throw icsEvent.error;
}
return icsEvent.value;
}
type ScheduleEmailReminderAction = Extract<
WorkflowActions,
"EMAIL_HOST" | "EMAIL_ATTENDEE" | "EMAIL_ADDRESS"
@ -62,7 +109,8 @@ export const scheduleEmailReminder = async (
template: WorkflowTemplates,
sender: string,
hideBranding?: boolean,
seatReferenceUid?: string
seatReferenceUid?: string,
includeCalendarEvent?: boolean
) => {
if (action === WorkflowActions.EMAIL_ADDRESS) return;
const { startTime, endTime } = evt;
@ -186,11 +234,19 @@ export const scheduleEmailReminder = async (
const batchId = await getBatchId();
function sendEmail(data: Partial<MailData>) {
function sendEmail(data: Partial<MailData>, triggerEvent?: WorkflowTriggerEvents) {
if (!process.env.SENDGRID_API_KEY) {
console.info("No sendgrid API key provided, skipping email");
return Promise.resolve();
}
const status: ParticipationStatus =
triggerEvent === WorkflowTriggerEvents.AFTER_EVENT
? "COMPLETED"
: triggerEvent === WorkflowTriggerEvents.EVENT_CANCELLED
? "DECLINED"
: "ACCEPTED";
return sgMail.send({
to: data.to,
from: {
@ -206,6 +262,17 @@ export const scheduleEmailReminder = async (
enable: sandboxMode,
},
},
attachments: includeCalendarEvent
? [
{
content: Buffer.from(getiCalEventAsString(evt, status) || "").toString("base64"),
filename: "event.ics",
type: "text/calendar; method=REQUEST",
disposition: "attachment",
contentId: uuidv4(),
},
]
: undefined,
sendAt: data.sendAt,
});
}
@ -218,7 +285,7 @@ export const scheduleEmailReminder = async (
try {
if (!sendTo) throw new Error("No email addresses provided");
const addressees = Array.isArray(sendTo) ? sendTo : [sendTo];
const promises = addressees.map((email) => sendEmail({ to: email }));
const promises = addressees.map((email) => sendEmail({ to: email }, triggerEvent));
// TODO: Maybe don't await for this?
await Promise.all(promises);
} catch (error) {
@ -237,10 +304,13 @@ export const scheduleEmailReminder = async (
) {
try {
// If sendEmail failed then workflowReminer will not be created, failing E2E tests
await sendEmail({
to: sendTo,
sendAt: scheduledDate.unix(),
});
await sendEmail(
{
to: sendTo,
sendAt: scheduledDate.unix(),
},
triggerEvent
);
await prisma.workflowReminder.create({
data: {
bookingUid: uid,

View File

@ -106,7 +106,8 @@ const processWorkflowStep = async (
step.template,
step.sender || SENDER_NAME,
hideBranding,
seatReferenceUid
seatReferenceUid,
step.includeCalendarEvent
);
} else if (isWhatsappAction(step.action)) {
const sendTo = step.action === WorkflowActions.WHATSAPP_ATTENDEE ? smsReminderNumber : step.sendTo;

View File

@ -7,7 +7,7 @@ import type { TimeUnit } from "@calcom/prisma/enums";
import { WorkflowTemplates, WorkflowActions, WorkflowMethods } from "@calcom/prisma/enums";
import { WorkflowTriggerEvents } from "@calcom/prisma/enums";
import { bookingMetadataSchema } from "@calcom/prisma/zod-utils";
import type { CalEventResponses } from "@calcom/types/Calendar";
import type { CalEventResponses, RecurringEvent } from "@calcom/types/Calendar";
import { getSenderId } from "../alphanumericSenderIdSupport";
import * as twilio from "./smsProviders/twilioProvider";
@ -44,6 +44,7 @@ export type BookingInfo = {
};
eventType: {
slug?: string;
recurringEvent?: RecurringEvent | null;
};
startTime: string;
endTime: string;

View File

@ -64,6 +64,7 @@ const formSchema = z.object({
emailSubject: z.string().nullable(),
template: z.nativeEnum(WorkflowTemplates),
numberRequired: z.boolean().nullable(),
includeCalendarEvent: z.boolean().nullable(),
sendTo: z
.string()
.refine((val) => isValidPhoneNumber(val) || val.includes("@"))

View File

@ -8,7 +8,6 @@ import type { ControlProps } from "react-select";
import { components } from "react-select";
import { shallow } from "zustand/shallow";
import type { Dayjs } from "@calcom/dayjs";
import dayjs from "@calcom/dayjs";
import { AvailableTimes } from "@calcom/features/bookings";
import { useBookerStore, useInitializeBookerStore } from "@calcom/features/bookings/Booker/store";
@ -229,10 +228,8 @@ const EmailEmbed = ({ eventType, username }: { eventType?: EventType; username:
<div className="text-default text-sm">{t("select_date")}</div>
<DatePicker
isLoading={schedule.isLoading}
onChange={(date: Dayjs) => {
setSelectedDate(date.format("YYYY-MM-DD"));
}}
onMonthChange={(date: Dayjs) => {
onChange={(date) => setSelectedDate(date ? date.format("YYYY-MM-DD") : date)}
onMonthChange={(date) => {
setMonth(date.format("YYYY-MM"));
setSelectedDate(date.format("YYYY-MM-DD"));
}}

View File

@ -24,7 +24,10 @@ export const getFullName = (name: string | { firstName: string; lastName?: strin
if (typeof name === "string") {
nameString = name;
} else {
nameString = name.firstName + " " + name.lastName;
nameString = name.firstName;
if (name.lastName) {
nameString = nameString + " " + name.lastName;
}
}
return nameString;
};

View File

@ -51,7 +51,11 @@ const DateOverrideForm = ({
const [selectedDates, setSelectedDates] = useState<Dayjs[]>(value ? [dayjs.utc(value[0].start)] : []);
const onDateChange = (newDate: Dayjs) => {
const onDateChange = (newDate: Dayjs | null) => {
// If no date is selected, do nothing
if (!newDate) {
return;
}
// If clicking on a selected date unselect it
if (selectedDates.some((date) => yyyymmdd(date) === yyyymmdd(newDate))) {
setSelectedDates(selectedDates.filter((date) => yyyymmdd(date) !== yyyymmdd(newDate)));

View File

@ -3,7 +3,7 @@ import { useMemo } from "react";
import type { Slots } from "../use-schedule";
export const getNonEmptyScheduleDays = (slots?: Slots) => {
if (typeof slots === "undefined") return [];
if (typeof slots === "undefined") return null;
return Object.keys(slots).filter((day) => slots[day].length > 0);
};

View File

@ -10,16 +10,15 @@ export type GetSubscriberOptions = {
};
const getWebhooks = async (options: GetSubscriberOptions, prisma: PrismaClient = defaultPrisma) => {
const userId = options.teamId ? 0 : options.userId ?? 0;
const userId = options.userId ?? 0;
const eventTypeId = options.eventTypeId ?? 0;
const teamId = options.teamId ?? 0;
// if we have userId and teamId it is a managed event type and should trigger for team and user
const allWebhooks = await prisma.webhook.findMany({
where: {
OR: [
{
userId,
teamId: null,
},
{
eventTypeId,

View File

@ -30,7 +30,9 @@ export type WebhookDataType = CalendarEvent &
downloadLink?: string;
};
function getZapierPayload(data: CalendarEvent & EventTypeInfo & { status?: string }): string {
function getZapierPayload(
data: CalendarEvent & EventTypeInfo & { status?: string; createdAt: string }
): string {
const attendees = data.attendees.map((attendee) => {
return {
name: attendee.name,
@ -69,6 +71,7 @@ function getZapierPayload(data: CalendarEvent & EventTypeInfo & { status?: strin
length: data.length,
},
attendees: attendees,
createdAt: data.createdAt,
};
return JSON.stringify(body);
}
@ -112,7 +115,7 @@ const sendPayload = async (
/* Zapier id is hardcoded in the DB, we send the raw data for this case */
if (appId === "zapier") {
body = getZapierPayload(data);
body = getZapierPayload({ ...data, createdAt });
} else if (template) {
body = applyTemplate(template, { ...data, triggerEvent, createdAt }, contentType);
} else {

View File

@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "WorkflowStep" ADD COLUMN "includeCalendarEvent" BOOLEAN NOT NULL DEFAULT false;

View File

@ -738,6 +738,7 @@ model WorkflowStep {
numberRequired Boolean?
sender String?
numberVerificationPending Boolean @default(true)
includeCalendarEvent Boolean @default(false)
@@index([workflowId])
}

View File

@ -14,6 +14,7 @@ import type {
import { appDataSchemas } from "@calcom/app-store/apps.schemas.generated";
import dayjs from "@calcom/dayjs";
import { isPasswordValid } from "@calcom/features/auth/lib/isPasswordValid";
import type { FieldType as FormBuilderFieldType } from "@calcom/features/form-builder/schema";
import { fieldsSchema as formBuilderFieldsSchema } from "@calcom/features/form-builder/schema";
import { isSupportedTimeZone } from "@calcom/lib/date-fns";
@ -602,6 +603,28 @@ export const emailSchemaRefinement = (value: string) => {
return emailRegex.test(value);
};
export const signupSchema = z.object({
username: z.string().refine((value) => !value.includes("+"), {
message: "String should not contain a plus symbol (+).",
}),
email: z.string().email(),
password: z.string().superRefine((data, ctx) => {
const isStrict = false;
const result = isPasswordValid(data, true, isStrict);
Object.keys(result).map((key: string) => {
if (!result[key as keyof typeof result]) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: [key],
message: key,
});
}
});
}),
language: z.string().optional(),
token: z.string().optional(),
});
export const ZVerifyCodeInputSchema = z.object({
email: z.string().email(),
code: z.string(),

View File

@ -102,168 +102,172 @@ export const requestRescheduleHandler = async ({ ctx, input }: RequestReschedule
throw new TRPCError({ code: "FORBIDDEN", message: "User isn't owner of the current booking" });
}
if (bookingToReschedule) {
let event: Partial<EventType> = {};
if (bookingToReschedule.eventTypeId) {
event = await prisma.eventType.findFirstOrThrow({
select: {
title: true,
users: true,
schedulingType: true,
recurringEvent: true,
},
where: {
id: bookingToReschedule.eventTypeId,
},
});
}
await prisma.booking.update({
if (!bookingToReschedule) return;
let event: Partial<EventType> = {};
if (bookingToReschedule.eventTypeId) {
event = await prisma.eventType.findFirstOrThrow({
select: {
title: true,
users: true,
schedulingType: true,
recurringEvent: true,
},
where: {
id: bookingToReschedule.id,
},
data: {
rescheduled: true,
cancellationReason,
status: BookingStatus.CANCELLED,
updatedAt: dayjs().toISOString(),
id: bookingToReschedule.eventTypeId,
},
});
// delete scheduled jobs of previous booking
cancelScheduledJobs(bookingToReschedule);
//cancel workflow reminders of previous booking
bookingToReschedule.workflowReminders.forEach((reminder) => {
if (reminder.method === WorkflowMethods.EMAIL) {
deleteScheduledEmailReminder(reminder.id, reminder.referenceId);
} else if (reminder.method === WorkflowMethods.SMS) {
deleteScheduledSMSReminder(reminder.id, reminder.referenceId);
} else if (reminder.method === WorkflowMethods.WHATSAPP) {
deleteScheduledWhatsappReminder(reminder.id, reminder.referenceId);
}
});
const [mainAttendee] = bookingToReschedule.attendees;
// @NOTE: Should we assume attendees language?
const tAttendees = await getTranslation(mainAttendee.locale ?? "en", "common");
const usersToPeopleType = (
users: PersonAttendeeCommonFields[],
selectedLanguage: TFunction
): Person[] => {
return users?.map((user) => {
return {
email: user.email || "",
name: user.name || "",
username: user?.username || "",
language: { translate: selectedLanguage, locale: user.locale || "en" },
timeZone: user?.timeZone,
};
});
};
const userTranslation = await getTranslation(user.locale ?? "en", "common");
const [userAsPeopleType] = usersToPeopleType([user], userTranslation);
const builder = new CalendarEventBuilder();
builder.init({
title: bookingToReschedule.title,
type: event && event.title ? event.title : bookingToReschedule.title,
startTime: bookingToReschedule.startTime.toISOString(),
endTime: bookingToReschedule.endTime.toISOString(),
attendees: usersToPeopleType(
// username field doesn't exists on attendee but could be in the future
bookingToReschedule.attendees as unknown as PersonAttendeeCommonFields[],
tAttendees
),
organizer: userAsPeopleType,
});
const director = new CalendarEventDirector();
director.setBuilder(builder);
director.setExistingBooking(bookingToReschedule);
cancellationReason && director.setCancellationReason(cancellationReason);
if (event) {
await director.buildForRescheduleEmail();
} else {
await director.buildWithoutEventTypeForRescheduleEmail();
}
// Handling calendar and videos cancellation
// This can set previous time as available, until virtual calendar is done
const credentials = await getUsersCredentials(user.id);
const credentialsMap = new Map();
credentials.forEach((credential) => {
credentialsMap.set(credential.type, credential);
});
const bookingRefsFiltered: BookingReference[] = bookingToReschedule.references.filter((ref) =>
credentialsMap.has(ref.type)
);
bookingRefsFiltered.forEach(async (bookingRef) => {
if (bookingRef.uid) {
if (bookingRef.type.endsWith("_calendar")) {
const calendar = await getCalendar(credentialsMap.get(bookingRef.type));
return calendar?.deleteEvent(bookingRef.uid, builder.calendarEvent, bookingRef.externalCalendarId);
} else if (bookingRef.type.endsWith("_video")) {
return deleteMeeting(credentialsMap.get(bookingRef.type), bookingRef.uid);
}
}
});
// Send emails
await sendRequestRescheduleEmail(builder.calendarEvent, {
rescheduleLink: builder.rescheduleLink,
});
const evt: CalendarEvent = {
title: bookingToReschedule?.title,
type: event && event.title ? event.title : bookingToReschedule.title,
description: bookingToReschedule?.description || "",
customInputs: isPrismaObjOrUndefined(bookingToReschedule.customInputs),
...getCalEventResponses({
booking: bookingToReschedule,
bookingFields: bookingToReschedule.eventType?.bookingFields ?? null,
}),
startTime: bookingToReschedule?.startTime ? dayjs(bookingToReschedule.startTime).format() : "",
endTime: bookingToReschedule?.endTime ? dayjs(bookingToReschedule.endTime).format() : "",
organizer: userAsPeopleType,
attendees: usersToPeopleType(
// username field doesn't exists on attendee but could be in the future
bookingToReschedule.attendees as unknown as PersonAttendeeCommonFields[],
tAttendees
),
uid: bookingToReschedule?.uid,
location: bookingToReschedule?.location,
destinationCalendar:
bookingToReschedule?.destinationCalendar || bookingToReschedule?.destinationCalendar,
cancellationReason: `Please reschedule. ${cancellationReason}`, // TODO::Add i18-next for this
};
// Send webhook
const eventTrigger: WebhookTriggerEvents = "BOOKING_CANCELLED";
const teamId = await getTeamIdFromEventType({
eventType: {
team: { id: bookingToReschedule.eventType?.teamId ?? null },
parentId: bookingToReschedule?.eventType?.parentId ?? null,
},
});
// Send Webhook call if hooked to BOOKING.CANCELLED
const subscriberOptions = {
userId: bookingToReschedule.userId,
eventTypeId: bookingToReschedule.eventTypeId as number,
triggerEvent: eventTrigger,
teamId,
};
const webhooks = await getWebhooks(subscriberOptions);
const promises = webhooks.map((webhook) =>
sendPayload(webhook.secret, eventTrigger, new Date().toISOString(), webhook, {
...evt,
smsReminderNumber: bookingToReschedule.smsReminderNumber || undefined,
}).catch((e) => {
console.error(`Error executing webhook for event: ${eventTrigger}, URL: ${webhook.subscriberUrl}`, e);
})
);
await Promise.all(promises);
}
await prisma.booking.update({
where: {
id: bookingToReschedule.id,
},
data: {
rescheduled: true,
cancellationReason,
status: BookingStatus.CANCELLED,
updatedAt: dayjs().toISOString(),
},
});
// delete scheduled jobs of previous booking
// FIXME: async fn off into the ether
cancelScheduledJobs(bookingToReschedule);
//cancel workflow reminders of previous booking
// FIXME: more async fns off into the ether
bookingToReschedule.workflowReminders.forEach((reminder) => {
if (reminder.method === WorkflowMethods.EMAIL) {
deleteScheduledEmailReminder(reminder.id, reminder.referenceId);
} else if (reminder.method === WorkflowMethods.SMS) {
deleteScheduledSMSReminder(reminder.id, reminder.referenceId);
} else if (reminder.method === WorkflowMethods.WHATSAPP) {
deleteScheduledWhatsappReminder(reminder.id, reminder.referenceId);
}
});
const [mainAttendee] = bookingToReschedule.attendees;
// @NOTE: Should we assume attendees language?
const tAttendees = await getTranslation(mainAttendee.locale ?? "en", "common");
const usersToPeopleType = (users: PersonAttendeeCommonFields[], selectedLanguage: TFunction): Person[] => {
return users?.map((user) => {
return {
email: user.email || "",
name: user.name || "",
username: user?.username || "",
language: { translate: selectedLanguage, locale: user.locale || "en" },
timeZone: user?.timeZone,
};
});
};
const userTranslation = await getTranslation(user.locale ?? "en", "common");
const [userAsPeopleType] = usersToPeopleType([user], userTranslation);
const builder = new CalendarEventBuilder();
builder.init({
title: bookingToReschedule.title,
type: event && event.title ? event.title : bookingToReschedule.title,
startTime: bookingToReschedule.startTime.toISOString(),
endTime: bookingToReschedule.endTime.toISOString(),
attendees: usersToPeopleType(
// username field doesn't exists on attendee but could be in the future
bookingToReschedule.attendees as unknown as PersonAttendeeCommonFields[],
tAttendees
),
organizer: userAsPeopleType,
});
const director = new CalendarEventDirector();
director.setBuilder(builder);
director.setExistingBooking(bookingToReschedule);
cancellationReason && director.setCancellationReason(cancellationReason);
if (event) {
await director.buildForRescheduleEmail();
} else {
await director.buildWithoutEventTypeForRescheduleEmail();
}
// Handling calendar and videos cancellation
// This can set previous time as available, until virtual calendar is done
const credentials = await getUsersCredentials(user.id);
const credentialsMap = new Map();
credentials.forEach((credential) => {
credentialsMap.set(credential.type, credential);
});
const bookingRefsFiltered: BookingReference[] = bookingToReschedule.references.filter((ref) =>
credentialsMap.has(ref.type)
);
// FIXME: error-handling
await Promise.allSettled(
bookingRefsFiltered.map(async (bookingRef) => {
if (!bookingRef.uid) return;
if (bookingRef.type.endsWith("_calendar")) {
const calendar = await getCalendar(credentialsMap.get(bookingRef.type));
return calendar?.deleteEvent(bookingRef.uid, builder.calendarEvent, bookingRef.externalCalendarId);
} else if (bookingRef.type.endsWith("_video")) {
return deleteMeeting(credentialsMap.get(bookingRef.type), bookingRef.uid);
}
})
);
// Send emails
await sendRequestRescheduleEmail(builder.calendarEvent, {
rescheduleLink: builder.rescheduleLink,
});
const evt: CalendarEvent = {
title: bookingToReschedule?.title,
type: event && event.title ? event.title : bookingToReschedule.title,
description: bookingToReschedule?.description || "",
customInputs: isPrismaObjOrUndefined(bookingToReschedule.customInputs),
...getCalEventResponses({
booking: bookingToReschedule,
bookingFields: bookingToReschedule.eventType?.bookingFields ?? null,
}),
startTime: bookingToReschedule?.startTime ? dayjs(bookingToReschedule.startTime).format() : "",
endTime: bookingToReschedule?.endTime ? dayjs(bookingToReschedule.endTime).format() : "",
organizer: userAsPeopleType,
attendees: usersToPeopleType(
// username field doesn't exists on attendee but could be in the future
bookingToReschedule.attendees as unknown as PersonAttendeeCommonFields[],
tAttendees
),
uid: bookingToReschedule?.uid,
location: bookingToReschedule?.location,
destinationCalendar: bookingToReschedule?.destinationCalendar || bookingToReschedule?.destinationCalendar,
cancellationReason: `Please reschedule. ${cancellationReason}`, // TODO::Add i18-next for this
};
// Send webhook
const eventTrigger: WebhookTriggerEvents = "BOOKING_CANCELLED";
const teamId = await getTeamIdFromEventType({
eventType: {
team: { id: bookingToReschedule.eventType?.teamId ?? null },
parentId: bookingToReschedule?.eventType?.parentId ?? null,
},
});
const triggerForUser = !teamId || (teamId && bookingToReschedule.eventType?.parentId);
// Send Webhook call if hooked to BOOKING.CANCELLED
const subscriberOptions = {
userId: triggerForUser ? bookingToReschedule.userId : null,
eventTypeId: bookingToReschedule.eventTypeId as number,
triggerEvent: eventTrigger,
teamId,
};
const webhooks = await getWebhooks(subscriberOptions);
const promises = webhooks.map((webhook) =>
sendPayload(webhook.secret, eventTrigger, new Date().toISOString(), webhook, {
...evt,
smsReminderNumber: bookingToReschedule.smsReminderNumber || undefined,
}).catch((e) => {
console.error(`Error executing webhook for event: ${eventTrigger}, URL: ${webhook.subscriberUrl}`, e);
})
);
await Promise.all(promises);
};

View File

@ -272,7 +272,7 @@ export const getByViewerHandler = async ({ ctx, input }: GetByViewerOptions) =>
...group.metadata,
teamId: group.teamId,
membershipRole: group.membershipRole,
image: `${bookerUrl}${group.teamId ? "/team" : ""}/${group.profile.slug}/avatar.png`,
image: `${bookerUrl}/${group.profile.slug}/avatar.png`,
})),
};
};

View File

@ -264,13 +264,13 @@ export const updateHandler = async ({ ctx, input }: UpdateOptions) => {
},
});
steps.forEach(async (step) => {
const promiseSteps = steps.map(async (step) => {
if (
step.action !== WorkflowActions.SMS_ATTENDEE &&
step.action !== WorkflowActions.WHATSAPP_ATTENDEE
) {
//as we do not have attendees phone number (user is notified about that when setting this action)
bookingsForReminders.forEach(async (booking) => {
const promiseScheduleReminders = bookingsForReminders.map(async (booking) => {
const defaultLocale = "en";
const bookingInfo = {
uid: booking.uid,
@ -367,29 +367,28 @@ export const updateHandler = async ({ ctx, input }: UpdateOptions) => {
);
}
});
await Promise.all(promiseScheduleReminders);
}
});
await Promise.all(promiseSteps);
}
//create all workflow - eventtypes relationships
activeOnEventTypes.forEach(async (eventType) => {
await ctx.prisma.workflowsOnEventTypes.createMany({
data: {
workflowId: id,
eventTypeId: eventType.id,
},
});
if (eventType.children.length) {
eventType.children.forEach(async (chEventType) => {
await ctx.prisma.workflowsOnEventTypes.createMany({
data: {
workflowId: id,
eventTypeId: chEventType.id,
},
});
});
}
await ctx.prisma.workflowsOnEventTypes.createMany({
data: activeOnEventTypes.map((eventType) => ({
workflowId: id,
eventTypeId: eventType.id,
})),
});
await Promise.all(
activeOnEventTypes.map((eventType) =>
ctx.prisma.workflowsOnEventTypes.createMany({
data: eventType.children.map((chEventType) => ({
workflowId: id,
eventTypeId: chEventType.id,
})),
})
)
);
}
userWorkflow.steps.map(async (oldStep) => {
@ -455,6 +454,7 @@ export const updateHandler = async ({ ctx, input }: UpdateOptions) => {
senderName: newStep.senderName,
}),
numberVerificationPending: false,
includeCalendarEvent: newStep.includeCalendarEvent,
},
});
//cancel all reminders of step and create new ones (not for newEventTypes)
@ -465,11 +465,14 @@ export const updateHandler = async ({ ctx, input }: UpdateOptions) => {
});
//cancel all workflow reminders from steps that were edited
remindersToUpdate.forEach(async (reminder) => {
// FIXME: async calls into ether
remindersToUpdate.forEach((reminder) => {
if (reminder.method === WorkflowMethods.EMAIL) {
deleteScheduledEmailReminder(reminder.id, reminder.referenceId);
} else if (reminder.method === WorkflowMethods.SMS) {
deleteScheduledSMSReminder(reminder.id, reminder.referenceId);
} else if (reminder.method === WorkflowMethods.WHATSAPP) {
deleteScheduledWhatsappReminder(reminder.id, reminder.referenceId);
}
});
const eventTypesToUpdateReminders = activeOn.filter((eventTypeId) => {
@ -497,7 +500,7 @@ export const updateHandler = async ({ ctx, input }: UpdateOptions) => {
user: true,
},
});
bookingsOfEventTypes.forEach(async (booking) => {
const promiseScheduleReminders = bookingsOfEventTypes.map(async (booking) => {
const defaultLocale = "en";
const bookingInfo = {
uid: booking.uid,
@ -594,6 +597,7 @@ export const updateHandler = async ({ ctx, input }: UpdateOptions) => {
);
}
});
await Promise.all(promiseScheduleReminders);
}
}
});
@ -617,7 +621,7 @@ export const updateHandler = async ({ ctx, input }: UpdateOptions) => {
return activeEventType;
}
});
addedSteps.forEach(async (step) => {
const promiseAddedSteps = addedSteps.map(async (step) => {
if (step) {
const { senderName, ...newStep } = step;
newStep.sender = getSender({
@ -749,6 +753,7 @@ export const updateHandler = async ({ ctx, input }: UpdateOptions) => {
}
}
});
await Promise.all(promiseAddedSteps);
}
//update trigger, name, time, timeUnit

View File

@ -24,6 +24,7 @@ export const ZUpdateInputSchema = z.object({
numberRequired: z.boolean().nullable(),
sender: z.string().optional().nullable(),
senderName: z.string().optional().nullable(),
includeCalendarEvent: z.boolean(),
})
.array(),
trigger: z.enum(WORKFLOW_TRIGGER_EVENTS),

View File

@ -3,17 +3,21 @@ import { useFormContext } from "react-hook-form";
import { Check, Circle, Info, X } from "../../icon";
export function HintsOrErrors<T extends FieldValues = FieldValues>(props: {
type hintsOrErrorsProps = {
hintErrors?: string[];
fieldName: string;
t: (key: string) => string;
}) {
};
export function HintsOrErrors<T extends FieldValues = FieldValues>({
hintErrors,
fieldName,
t,
}: hintsOrErrorsProps) {
const methods = useFormContext() as ReturnType<typeof useFormContext> | null;
/* If there's no methods it means we're using these components outside a React Hook Form context */
if (!methods) return null;
const { formState } = methods;
const { hintErrors, fieldName, t } = props;
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
const fieldErrors: FieldErrors<T> | undefined = formState.errors[fieldName];