Zomars/cal 794 normalize emails in db (#1361)
* Email input UX improvements * Makes email queries case insensitive * Lowercases all emails * Type fixes * Re adds lowercase email to login * Removes citext dependency * Updates schema * Migration fixes * Added failsafes to team invites * Team invite improvements * Deleting the index, lowercasing ``` calendso=> UPDATE users SET email=LOWER(email); ERROR: duplicate key value violates unique constraint "users.email_unique" DETAIL: Key (email)=(free@example.com) already exists. ``` vs. ``` calendso=> CREATE UNIQUE INDEX "users.email_unique" ON "users" (email); ERROR: could not create unique index "users.email_unique" DETAIL: Key (email)=(Free@example.com) is duplicated. ``` I think it'll be easier to rectify for users if they try to run the migrations if the index stays in place. Co-authored-by: Alex van Andel <me@alexvanandel.com>pull/1362/head
parent
0dd72888a9
commit
7bc7b241ac
|
@ -29,7 +29,7 @@ import slugify from "@lib/slugify";
|
|||
import { collectPageParameters, telemetryEventTypes, useTelemetry } from "@lib/telemetry";
|
||||
|
||||
import CustomBranding from "@components/CustomBranding";
|
||||
import { Form } from "@components/form/fields";
|
||||
import { EmailInput, Form } from "@components/form/fields";
|
||||
import AvatarGroup from "@components/ui/AvatarGroup";
|
||||
import { Button } from "@components/ui/Button";
|
||||
import PhoneInput from "@components/ui/form/PhoneInput";
|
||||
|
@ -98,9 +98,10 @@ const BookingPage = (props: BookingPageProps) => {
|
|||
|
||||
const [guestToggle, setGuestToggle] = useState(false);
|
||||
|
||||
type Location = { type: LocationType; address?: string };
|
||||
// it would be nice if Prisma at some point in the future allowed for Json<Location>; as of now this is not the case.
|
||||
const locations: { type: LocationType }[] = useMemo(
|
||||
() => (props.eventType.locations as { type: LocationType }[]) || [],
|
||||
const locations: Location[] = useMemo(
|
||||
() => (props.eventType.locations as Location[]) || [],
|
||||
[props.eventType.locations]
|
||||
);
|
||||
|
||||
|
@ -171,14 +172,14 @@ const BookingPage = (props: BookingPageProps) => {
|
|||
const { locationType } = booking;
|
||||
switch (locationType) {
|
||||
case LocationType.Phone: {
|
||||
return booking.phone;
|
||||
return booking.phone || "";
|
||||
}
|
||||
case LocationType.InPerson: {
|
||||
return locationInfo(locationType).address;
|
||||
return locationInfo(locationType)?.address || "";
|
||||
}
|
||||
// Catches all other location types, such as Google Meet, Zoom etc.
|
||||
default:
|
||||
return selectedLocation;
|
||||
return selectedLocation || "";
|
||||
}
|
||||
};
|
||||
|
||||
|
@ -244,12 +245,12 @@ const BookingPage = (props: BookingPageProps) => {
|
|||
<div className="sm:w-1/2 sm:border-r sm:dark:border-gray-800">
|
||||
<AvatarGroup
|
||||
size={14}
|
||||
items={[{ image: props.profile.image, alt: props.profile.name }].concat(
|
||||
items={[{ image: props.profile.image || "", alt: props.profile.name || "" }].concat(
|
||||
props.eventType.users
|
||||
.filter((user) => user.name !== props.profile.name)
|
||||
.map((user) => ({
|
||||
image: user.avatar,
|
||||
title: user.name,
|
||||
image: user.avatar || "",
|
||||
alt: user.name || "",
|
||||
}))
|
||||
)}
|
||||
/>
|
||||
|
@ -283,8 +284,8 @@ const BookingPage = (props: BookingPageProps) => {
|
|||
)}
|
||||
<p className="mb-4 text-green-500">
|
||||
<CalendarIcon className="inline-block w-4 h-4 mr-1 -mt-1" />
|
||||
{date &&
|
||||
parseZone(date).format(timeFormat) +
|
||||
{(date && parseZone(date)?.format(timeFormat)) ||
|
||||
"No date" +
|
||||
", " +
|
||||
dayjs(date).toDate().toLocaleString(i18n.language, { dateStyle: "full" })}
|
||||
</p>
|
||||
|
@ -315,12 +316,8 @@ const BookingPage = (props: BookingPageProps) => {
|
|||
{t("email_address")}
|
||||
</label>
|
||||
<div className="mt-1">
|
||||
<input
|
||||
<EmailInput
|
||||
{...bookingForm.register("email")}
|
||||
type="email"
|
||||
name="email"
|
||||
id="email"
|
||||
inputMode="email"
|
||||
required
|
||||
className="block w-full border-gray-300 rounded-sm shadow-sm dark:bg-black dark:text-white dark:border-gray-900 focus:ring-black focus:border-brand sm:text-sm"
|
||||
placeholder="you@example.com"
|
||||
|
|
|
@ -91,8 +91,25 @@ export const PasswordField = forwardRef<HTMLInputElement, InputFieldProps>(funct
|
|||
return <InputField type="password" placeholder="•••••••••••••" ref={ref} {...props} />;
|
||||
});
|
||||
|
||||
export const EmailInput = forwardRef<HTMLInputElement, JSX.IntrinsicElements["input"]>(function EmailInput(
|
||||
props,
|
||||
ref
|
||||
) {
|
||||
return (
|
||||
<input
|
||||
type="email"
|
||||
autoCapitalize="none"
|
||||
autoComplete="email"
|
||||
autoCorrect="off"
|
||||
inputMode="email"
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
export const EmailField = forwardRef<HTMLInputElement, InputFieldProps>(function EmailField(props, ref) {
|
||||
return <InputField type="email" inputMode="email" ref={ref} {...props} />;
|
||||
return <EmailInput ref={ref} {...props} />;
|
||||
});
|
||||
|
||||
type FormProps<T> = { form: UseFormReturn<T>; handleSubmit: SubmitHandler<T> } & Omit<
|
||||
|
|
|
@ -7,6 +7,7 @@ import { useLocale } from "@lib/hooks/useLocale";
|
|||
import { TeamWithMembers } from "@lib/queries/teams";
|
||||
import { trpc } from "@lib/trpc";
|
||||
|
||||
import { EmailInput } from "@components/form/fields";
|
||||
import Button from "@components/ui/Button";
|
||||
|
||||
export default function MemberInvitationModal(props: { team: TeamWithMembers | null; onExit: () => void }) {
|
||||
|
@ -80,7 +81,7 @@ export default function MemberInvitationModal(props: { team: TeamWithMembers | n
|
|||
<label htmlFor="inviteUser" className="block text-sm font-medium text-gray-700">
|
||||
{t("email_or_username")}
|
||||
</label>
|
||||
<input
|
||||
<EmailInput
|
||||
type="text"
|
||||
name="inviteUser"
|
||||
id="inviteUser"
|
||||
|
|
|
@ -80,9 +80,16 @@ export default function MemberListItem(props: Props) {
|
|||
</div>
|
||||
</div>
|
||||
<div className="flex">
|
||||
<Tooltip content={t("View user availability")}>
|
||||
<Tooltip content={t("team_view_user_availability")}>
|
||||
<Button
|
||||
onClick={() => setShowTeamAvailabilityModal(true)}
|
||||
// Disabled buttons don't trigger Tooltips
|
||||
title={
|
||||
props.member.accepted
|
||||
? t("team_view_user_availability")
|
||||
: t("team_view_user_availability_disabled")
|
||||
}
|
||||
disabled={!props.member.accepted}
|
||||
onClick={() => (props.member.accepted ? setShowTeamAvailabilityModal(true) : null)}
|
||||
color="minimal"
|
||||
className="w-10 h-10 p-0 border border-transparent group text-neutral-400 hover:border-gray-200 hover:bg-white">
|
||||
<ClockIcon className="w-5 h-5 group-hover:text-gray-800" />
|
||||
|
|
|
@ -29,7 +29,7 @@ export default NextAuth({
|
|||
async authorize(credentials) {
|
||||
const user = await prisma.user.findUnique({
|
||||
where: {
|
||||
email: credentials.email,
|
||||
email: credentials.email.toLowerCase(),
|
||||
},
|
||||
});
|
||||
|
||||
|
|
|
@ -16,6 +16,12 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
|
|||
return;
|
||||
}
|
||||
|
||||
/**
|
||||
* TODO:
|
||||
* We should add and extra check for non-paying customers in Stripe so we can
|
||||
* downgrade them here.
|
||||
*/
|
||||
|
||||
await prisma.user.updateMany({
|
||||
data: {
|
||||
plan: "FREE",
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
import { MembershipRole } from "@prisma/client";
|
||||
import { randomBytes } from "crypto";
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
|
||||
|
@ -6,6 +7,7 @@ import { BASE_URL } from "@lib/config/constants";
|
|||
import { sendTeamInviteEmail } from "@lib/emails/email-manager";
|
||||
import { TeamInvite } from "@lib/emails/templates/team-invite-email";
|
||||
import prisma from "@lib/prisma";
|
||||
import slugify from "@lib/slugify";
|
||||
|
||||
import { getTranslation } from "@server/lib/i18n";
|
||||
|
||||
|
@ -16,7 +18,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
|
|||
return res.status(400).json({ message: "Bad request" });
|
||||
}
|
||||
|
||||
const session = await getSession({ req: req });
|
||||
const session = await getSession({ req });
|
||||
if (!session) {
|
||||
return res.status(401).json({ message: "Not authenticated" });
|
||||
}
|
||||
|
@ -31,43 +33,52 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
|
|||
return res.status(404).json({ message: "Invalid team" });
|
||||
}
|
||||
|
||||
const reqBody = req.body as {
|
||||
usernameOrEmail: string;
|
||||
role: MembershipRole;
|
||||
sendEmailInvitation: boolean;
|
||||
};
|
||||
const { role, sendEmailInvitation } = reqBody;
|
||||
// liberal email match
|
||||
const isEmail = (str: string) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(str);
|
||||
const usernameOrEmail = isEmail(reqBody.usernameOrEmail)
|
||||
? reqBody.usernameOrEmail.toLowerCase()
|
||||
: slugify(reqBody.usernameOrEmail);
|
||||
|
||||
const invitee = await prisma.user.findFirst({
|
||||
where: {
|
||||
OR: [{ username: req.body.usernameOrEmail }, { email: req.body.usernameOrEmail }],
|
||||
OR: [{ username: usernameOrEmail }, { email: usernameOrEmail }],
|
||||
},
|
||||
});
|
||||
|
||||
if (!invitee) {
|
||||
// liberal email match
|
||||
const isEmail = (str: string) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(str);
|
||||
|
||||
if (!isEmail(req.body.usernameOrEmail)) {
|
||||
if (!isEmail(usernameOrEmail)) {
|
||||
return res.status(400).json({
|
||||
message: `Invite failed because there is no corresponding user for ${req.body.usernameOrEmail}`,
|
||||
message: `Invite failed because there is no corresponding user for ${usernameOrEmail}`,
|
||||
});
|
||||
}
|
||||
// valid email given, create User
|
||||
await prisma.user
|
||||
.create({
|
||||
data: {
|
||||
email: req.body.usernameOrEmail,
|
||||
},
|
||||
})
|
||||
.then((invitee) =>
|
||||
prisma.membership.create({
|
||||
data: {
|
||||
teamId: parseInt(req.query.team as string),
|
||||
userId: invitee.id,
|
||||
role: req.body.role,
|
||||
await prisma.user.create({
|
||||
data: {
|
||||
email: usernameOrEmail,
|
||||
teams: {
|
||||
create: {
|
||||
team: {
|
||||
connect: {
|
||||
id: parseInt(req.query.team as string),
|
||||
},
|
||||
},
|
||||
role,
|
||||
},
|
||||
})
|
||||
);
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const token: string = randomBytes(32).toString("hex");
|
||||
|
||||
await prisma.verificationRequest.create({
|
||||
data: {
|
||||
identifier: req.body.usernameOrEmail,
|
||||
identifier: usernameOrEmail,
|
||||
token,
|
||||
expires: new Date(new Date().setHours(168)), // +1 week
|
||||
},
|
||||
|
@ -77,7 +88,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
|
|||
const teamInviteEvent: TeamInvite = {
|
||||
language: t,
|
||||
from: session.user.name,
|
||||
to: req.body.usernameOrEmail,
|
||||
to: usernameOrEmail,
|
||||
teamName: team.name,
|
||||
joinLink: `${BASE_URL}/auth/signup?token=${token}&callbackUrl=${BASE_URL + "/settings/teams"}`,
|
||||
};
|
||||
|
@ -94,7 +105,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
|
|||
data: {
|
||||
teamId: parseInt(req.query.team as string),
|
||||
userId: invitee.id,
|
||||
role: req.body.role,
|
||||
role,
|
||||
},
|
||||
});
|
||||
} catch (err: any) {
|
||||
|
@ -109,11 +120,11 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
|
|||
}
|
||||
|
||||
// inform user of membership by email
|
||||
if (req.body.sendEmailInvitation && session?.user?.name && team?.name) {
|
||||
if (sendEmailInvitation && session?.user?.name && team?.name) {
|
||||
const teamInviteEvent: TeamInvite = {
|
||||
language: t,
|
||||
from: session.user.name,
|
||||
to: req.body.usernameOrEmail,
|
||||
to: usernameOrEmail,
|
||||
teamName: team.name,
|
||||
joinLink: BASE_URL + "/settings/teams",
|
||||
};
|
||||
|
|
|
@ -6,7 +6,7 @@ import React, { SyntheticEvent } from "react";
|
|||
import { getSession } from "@lib/auth";
|
||||
import { useLocale } from "@lib/hooks/useLocale";
|
||||
|
||||
import { TextField } from "@components/form/fields";
|
||||
import { EmailField } from "@components/form/fields";
|
||||
import { HeadSeo } from "@components/seo/head-seo";
|
||||
import Button from "@components/ui/Button";
|
||||
|
||||
|
@ -95,14 +95,11 @@ export default function ForgotPassword({ csrfToken }: { csrfToken: string }) {
|
|||
<form className="space-y-6" onSubmit={handleSubmit} action="#">
|
||||
<input name="csrfToken" type="hidden" defaultValue={csrfToken} hidden />
|
||||
|
||||
<TextField
|
||||
<EmailField
|
||||
onChange={handleChange}
|
||||
id="email"
|
||||
name="email"
|
||||
label={t("email_address")}
|
||||
type="email"
|
||||
inputMode="email"
|
||||
autoComplete="email"
|
||||
placeholder="john.doe@example.com"
|
||||
required
|
||||
/>
|
||||
|
|
|
@ -10,6 +10,7 @@ import { inferSSRProps } from "@lib/types/inferSSRProps";
|
|||
|
||||
import AddToHomescreen from "@components/AddToHomescreen";
|
||||
import Loader from "@components/Loader";
|
||||
import { EmailInput } from "@components/form/fields";
|
||||
import { HeadSeo } from "@components/seo/head-seo";
|
||||
|
||||
import { ssrInit } from "@server/lib/ssr";
|
||||
|
@ -75,24 +76,24 @@ export default function Login({ csrfToken }: inferSSRProps<typeof getServerSideP
|
|||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-neutral-50 flex flex-col justify-center py-12 sm:px-6 lg:px-8">
|
||||
<div className="flex flex-col justify-center min-h-screen py-12 bg-neutral-50 sm:px-6 lg:px-8">
|
||||
<HeadSeo title={t("login")} description={t("login")} />
|
||||
|
||||
{isSubmitting && (
|
||||
<div className="z-50 absolute w-full h-screen bg-gray-50 flex items-center">
|
||||
<div className="absolute z-50 flex items-center w-full h-screen bg-gray-50">
|
||||
<Loader />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="sm:mx-auto sm:w-full sm:max-w-md">
|
||||
<img className="h-6 mx-auto" src="/calendso-logo-white-word.svg" alt="Cal.com Logo" />
|
||||
<h2 className="font-cal mt-6 text-center text-3xl font-bold text-neutral-900">
|
||||
<h2 className="mt-6 text-3xl font-bold text-center font-cal text-neutral-900">
|
||||
{t("sign_in_account")}
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div className="mt-8 sm:mx-auto sm:w-full sm:max-w-md">
|
||||
<div className="bg-white py-8 px-4 mx-2 rounded-sm sm:px-10 border border-neutral-200">
|
||||
<div className="px-4 py-8 mx-2 bg-white border rounded-sm sm:px-10 border-neutral-200">
|
||||
<form className="space-y-6" onSubmit={handleSubmit}>
|
||||
<input name="csrfToken" type="hidden" defaultValue={csrfToken || undefined} hidden />
|
||||
<div>
|
||||
|
@ -100,16 +101,13 @@ export default function Login({ csrfToken }: inferSSRProps<typeof getServerSideP
|
|||
{t("email_address")}
|
||||
</label>
|
||||
<div className="mt-1">
|
||||
<input
|
||||
<EmailInput
|
||||
id="email"
|
||||
name="email"
|
||||
type="email"
|
||||
inputMode="email"
|
||||
autoComplete="email"
|
||||
required
|
||||
value={email}
|
||||
onInput={(e) => setEmail(e.currentTarget.value)}
|
||||
className="appearance-none block w-full px-3 py-2 border border-neutral-300 rounded-sm shadow-sm placeholder-gray-400 focus:outline-none focus:ring-neutral-900 focus:border-neutral-900 sm:text-sm"
|
||||
className="block w-full px-3 py-2 placeholder-gray-400 border rounded-sm shadow-sm appearance-none border-neutral-300 focus:outline-none focus:ring-neutral-900 focus:border-neutral-900 sm:text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -123,7 +121,7 @@ export default function Login({ csrfToken }: inferSSRProps<typeof getServerSideP
|
|||
</div>
|
||||
<div className="w-1/2 text-right">
|
||||
<Link href="/auth/forgot-password">
|
||||
<a tabIndex={-1} className="font-medium text-primary-600 text-sm">
|
||||
<a tabIndex={-1} className="text-sm font-medium text-primary-600">
|
||||
{t("forgot")}
|
||||
</a>
|
||||
</Link>
|
||||
|
@ -138,7 +136,7 @@ export default function Login({ csrfToken }: inferSSRProps<typeof getServerSideP
|
|||
required
|
||||
value={password}
|
||||
onInput={(e) => setPassword(e.currentTarget.value)}
|
||||
className="appearance-none block w-full px-3 py-2 border border-neutral-300 rounded-sm shadow-sm placeholder-gray-400 focus:outline-none focus:ring-neutral-900 focus:border-neutral-900 sm:text-sm"
|
||||
className="block w-full px-3 py-2 placeholder-gray-400 border rounded-sm shadow-sm appearance-none border-neutral-300 focus:outline-none focus:ring-neutral-900 focus:border-neutral-900 sm:text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -158,7 +156,7 @@ export default function Login({ csrfToken }: inferSSRProps<typeof getServerSideP
|
|||
inputMode="numeric"
|
||||
value={code}
|
||||
onInput={(e) => setCode(e.currentTarget.value)}
|
||||
className="appearance-none block w-full px-3 py-2 border border-neutral-300 rounded-sm shadow-sm placeholder-gray-400 focus:outline-none focus:ring-neutral-900 focus:border-neutral-900 sm:text-sm"
|
||||
className="block w-full px-3 py-2 placeholder-gray-400 border rounded-sm shadow-sm appearance-none border-neutral-300 focus:outline-none focus:ring-neutral-900 focus:border-neutral-900 sm:text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -168,7 +166,7 @@ export default function Login({ csrfToken }: inferSSRProps<typeof getServerSideP
|
|||
<button
|
||||
type="submit"
|
||||
disabled={isSubmitting}
|
||||
className="w-full flex justify-center py-2 px-4 border border-transparent rounded-sm shadow-sm text-sm font-medium text-white bg-neutral-900 hover:bg-neutral-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-black">
|
||||
className="flex justify-center w-full px-4 py-2 text-sm font-medium text-white border border-transparent rounded-sm shadow-sm bg-neutral-900 hover:bg-neutral-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-black">
|
||||
{t("sign_in")}
|
||||
</button>
|
||||
</div>
|
||||
|
@ -176,7 +174,7 @@ export default function Login({ csrfToken }: inferSSRProps<typeof getServerSideP
|
|||
{errorMessage && <p className="mt-1 text-sm text-red-700">{errorMessage}</p>}
|
||||
</form>
|
||||
</div>
|
||||
<div className="mt-4 text-neutral-600 text-center text-sm">
|
||||
<div className="mt-4 text-sm text-center text-neutral-600">
|
||||
{t("dont_have_an_account")} {/* replace this with your account creation flow */}
|
||||
<a href="https://cal.com/signup" className="font-medium text-neutral-900">
|
||||
{t("create_an_account")}
|
||||
|
|
|
@ -19,6 +19,7 @@ import prisma from "@lib/prisma";
|
|||
import { inferSSRProps } from "@lib/types/inferSSRProps";
|
||||
|
||||
import CustomBranding from "@components/CustomBranding";
|
||||
import { EmailInput } from "@components/form/fields";
|
||||
import { HeadSeo } from "@components/seo/head-seo";
|
||||
import Button from "@components/ui/Button";
|
||||
|
||||
|
@ -254,11 +255,9 @@ export default function Success(props: inferSSRProps<typeof getServerSideProps>)
|
|||
router.push(`https://cal.com/signup?email=` + (e as any).target.email.value);
|
||||
}}
|
||||
className="flex mt-4">
|
||||
<input
|
||||
type="email"
|
||||
<EmailInput
|
||||
name="email"
|
||||
id="email"
|
||||
inputMode="email"
|
||||
defaultValue={router.query.email}
|
||||
className="block w-full text-gray-600 border-gray-300 shadow-sm dark:bg-brand dark:text-brandcontrast dark:border-gray-900 focus:ring-black focus:border-brand sm:text-sm"
|
||||
placeholder="rick.astley@cal.com"
|
||||
|
|
|
@ -39,6 +39,8 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
|
|||
periodEndDate: true,
|
||||
periodCountCalendarDays: true,
|
||||
disableGuests: true,
|
||||
price: true,
|
||||
currency: true,
|
||||
team: {
|
||||
select: {
|
||||
slug: true,
|
||||
|
@ -91,6 +93,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
|
|||
slug: "team/" + eventTypeObject.slug,
|
||||
image: eventTypeObject.team?.logo || null,
|
||||
theme: null /* Teams don't have a theme, and `BookingPage` uses it */,
|
||||
brandColor: null /* Teams don't have a brandColor, and `BookingPage` uses it */,
|
||||
},
|
||||
eventType: eventTypeObject,
|
||||
booking,
|
||||
|
|
|
@ -59,6 +59,7 @@ test.describe("integrations", () => {
|
|||
body.createdAt = dynamic;
|
||||
body.payload.startTime = dynamic;
|
||||
body.payload.endTime = dynamic;
|
||||
body.payload.location = dynamic;
|
||||
for (const attendee of body.payload.attendees) {
|
||||
attendee.timeZone = dynamic;
|
||||
}
|
||||
|
|
|
@ -1 +1 @@
|
|||
{"triggerEvent":"BOOKING_CREATED","createdAt":"[redacted/dynamic]","payload":{"type":"30min","title":"30min between Pro Example and Test Testson","description":"","startTime":"[redacted/dynamic]","endTime":"[redacted/dynamic]","organizer":{"name":"Pro Example","email":"pro@example.com","timeZone":"[redacted/dynamic]"},"attendees":[{"email":"test@example.com","name":"Test Testson","timeZone":"[redacted/dynamic]"}],"destinationCalendar":null,"uid":"[redacted/dynamic]","metadata":{},"additionInformation":"[redacted/dynamic]"}}
|
||||
{"triggerEvent":"BOOKING_CREATED","createdAt":"[redacted/dynamic]","payload":{"type":"30min","title":"30min between Pro Example and Test Testson","description":"","startTime":"[redacted/dynamic]","endTime":"[redacted/dynamic]","organizer":{"name":"Pro Example","email":"pro@example.com","timeZone":"[redacted/dynamic]"},"attendees":[{"email":"test@example.com","name":"Test Testson","timeZone":"[redacted/dynamic]"}],"location":"[redacted/dynamic]","destinationCalendar":null,"uid":"[redacted/dynamic]","metadata":{},"additionInformation":"[redacted/dynamic]"}}
|
|
@ -0,0 +1,2 @@
|
|||
-- UpdateTable
|
||||
UPDATE users SET email=LOWER(email);
|
|
@ -563,5 +563,7 @@
|
|||
"calendar": "Calendar",
|
||||
"not_installed": "Not installed",
|
||||
"error_password_mismatch": "Passwords don't match.",
|
||||
"error_required_field": "This field is required."
|
||||
}
|
||||
"error_required_field": "This field is required.",
|
||||
"team_view_user_availability": "View user availability",
|
||||
"team_view_user_availability_disabled": "User needs to accept invite to view availability"
|
||||
}
|
||||
|
|
Loading…
Reference in New Issue