Calendly & SavvyCal import (#1512)
* Calendly & SavvyCal import * added string keys to import * Update pages/api/import/savvycal.ts Co-authored-by: Omar López <zomars@me.com> * Update pages/api/import/savvycal.ts Co-authored-by: Omar López <zomars@me.com> * Update pages/getting-started.tsx Co-authored-by: Omar López <zomars@me.com> * fixed string * prettier Co-authored-by: Peer Richelsen <peeroke@richelsen.net> Co-authored-by: Omar López <zomars@me.com> Co-authored-by: Peer Richelsen <peeroke@gmail.com>
This commit is contained in:
parent
b5569c6b1c
commit
33694196e1
4 changed files with 274 additions and 37 deletions
78
pages/api/import/calendly.ts
Normal file
78
pages/api/import/calendly.ts
Normal file
|
@ -0,0 +1,78 @@
|
||||||
|
import { PrismaClient } from "@prisma/client";
|
||||||
|
import type { NextApiRequest, NextApiResponse } from "next";
|
||||||
|
|
||||||
|
import { getSession } from "@lib/auth";
|
||||||
|
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
|
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||||
|
const session = await getSession({ req });
|
||||||
|
const authenticatedUser = await prisma.user.findFirst({
|
||||||
|
rejectOnNotFound: true,
|
||||||
|
where: {
|
||||||
|
id: session?.user.id,
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (req.method == "POST") {
|
||||||
|
const userResult = await fetch("https://api.calendly.com/users/me", {
|
||||||
|
method: "GET",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
Authorization: "Bearer " + req.body.token,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (userResult.status == 200) {
|
||||||
|
const userData = await userResult.json();
|
||||||
|
|
||||||
|
await prisma.user.update({
|
||||||
|
where: {
|
||||||
|
id: authenticatedUser.id,
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
name: userData.resource.name,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const eventTypesResult = await fetch(
|
||||||
|
"https://api.calendly.com/event_types?user=" + userData.resource.uri,
|
||||||
|
{
|
||||||
|
method: "GET",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
Authorization: "Bearer " + req.body.token,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const eventTypesData = await eventTypesResult.json();
|
||||||
|
|
||||||
|
eventTypesData.collection.forEach(async (eventType: any) => {
|
||||||
|
await prisma.eventType.create({
|
||||||
|
data: {
|
||||||
|
title: eventType.name,
|
||||||
|
slug: eventType.slug,
|
||||||
|
length: eventType.duration,
|
||||||
|
description: eventType.description_plain,
|
||||||
|
hidden: eventType.secret,
|
||||||
|
users: {
|
||||||
|
connect: {
|
||||||
|
id: authenticatedUser.id,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
userId: authenticatedUser.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
res.status(201).end();
|
||||||
|
} else {
|
||||||
|
res.status(500).end();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
res.status(405).end();
|
||||||
|
}
|
||||||
|
}
|
78
pages/api/import/savvycal.ts
Normal file
78
pages/api/import/savvycal.ts
Normal file
|
@ -0,0 +1,78 @@
|
||||||
|
import { PrismaClient } from "@prisma/client";
|
||||||
|
import type { NextApiRequest, NextApiResponse } from "next";
|
||||||
|
|
||||||
|
import { getSession } from "@lib/auth";
|
||||||
|
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
|
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||||
|
const session = await getSession({ req });
|
||||||
|
const authenticatedUser = await prisma.user.findFirst({
|
||||||
|
rejectOnNotFound: true,
|
||||||
|
where: {
|
||||||
|
id: session?.user.id,
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (req.method === "POST") {
|
||||||
|
const userResult = await fetch("https://api.savvycal.com/v1/me", {
|
||||||
|
method: "GET",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
Authorization: "Bearer " + req.body.token,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (userResult.status === 200) {
|
||||||
|
const userData = await userResult.json();
|
||||||
|
|
||||||
|
await prisma.user.update({
|
||||||
|
where: {
|
||||||
|
id: authenticatedUser.id,
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
name: userData.display_name,
|
||||||
|
timeZone: userData.time_zone,
|
||||||
|
weekStart: userData.first_day_of_week === 0 ? "Sunday" : "Monday",
|
||||||
|
avatar: userData.avatar_url,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const eventTypesResult = await fetch("https://api.savvycal.com/v1/links?limit=100", {
|
||||||
|
method: "GET",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
Authorization: "Bearer " + req.body.token,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const eventTypesData = await eventTypesResult.json();
|
||||||
|
|
||||||
|
eventTypesData.entries.forEach(async (eventType: any) => {
|
||||||
|
await prisma.eventType.create({
|
||||||
|
data: {
|
||||||
|
title: eventType.name,
|
||||||
|
slug: eventType.slug,
|
||||||
|
length: eventType.durations[0],
|
||||||
|
description: eventType.description.replace(/<[^>]*>?/gm, ""),
|
||||||
|
hidden: eventType.state === "active" ? true : false,
|
||||||
|
users: {
|
||||||
|
connect: {
|
||||||
|
id: authenticatedUser.id,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
userId: authenticatedUser.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
res.status(201).end();
|
||||||
|
} else {
|
||||||
|
res.status(500).end();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
res.status(405).end();
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,4 +1,5 @@
|
||||||
import { ArrowRightIcon } from "@heroicons/react/outline";
|
import { ArrowRightIcon } from "@heroicons/react/outline";
|
||||||
|
import { zodResolver } from "@hookform/resolvers/zod/dist/zod";
|
||||||
import { Prisma } from "@prisma/client";
|
import { Prisma } from "@prisma/client";
|
||||||
import classnames from "classnames";
|
import classnames from "classnames";
|
||||||
import dayjs from "dayjs";
|
import dayjs from "dayjs";
|
||||||
|
@ -14,6 +15,7 @@ import { useRouter } from "next/router";
|
||||||
import React, { useEffect, useRef, useState } from "react";
|
import React, { useEffect, useRef, useState } from "react";
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
import TimezoneSelect from "react-timezone-select";
|
import TimezoneSelect from "react-timezone-select";
|
||||||
|
import * as z from "zod";
|
||||||
|
|
||||||
import { getSession } from "@lib/auth";
|
import { getSession } from "@lib/auth";
|
||||||
import { DEFAULT_SCHEDULE } from "@lib/availability";
|
import { DEFAULT_SCHEDULE } from "@lib/availability";
|
||||||
|
@ -71,6 +73,7 @@ export default function Onboarding(props: inferSSRProps<typeof getServerSideProp
|
||||||
const { status } = useSession();
|
const { status } = useSession();
|
||||||
const loading = status === "loading";
|
const loading = status === "loading";
|
||||||
const [ready, setReady] = useState(false);
|
const [ready, setReady] = useState(false);
|
||||||
|
const [selectedImport, setSelectedImport] = useState("");
|
||||||
const [error, setError] = useState<Error | null>(null);
|
const [error, setError] = useState<Error | null>(null);
|
||||||
|
|
||||||
const updateUser = async (data: Prisma.UserUpdateInput) => {
|
const updateUser = async (data: Prisma.UserUpdateInput) => {
|
||||||
|
@ -229,6 +232,14 @@ export default function Onboarding(props: inferSSRProps<typeof getServerSideProp
|
||||||
router.push("/event-types");
|
router.push("/event-types");
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const schema = z.object({
|
||||||
|
token: z.string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const formMethods = useForm<{
|
||||||
|
token: string;
|
||||||
|
}>({ resolver: zodResolver(schema), mode: "onSubmit" });
|
||||||
|
|
||||||
const availabilityForm = useForm({ defaultValues: { schedule: DEFAULT_SCHEDULE } });
|
const availabilityForm = useForm({ defaultValues: { schedule: DEFAULT_SCHEDULE } });
|
||||||
const steps = [
|
const steps = [
|
||||||
{
|
{
|
||||||
|
@ -236,44 +247,110 @@ export default function Onboarding(props: inferSSRProps<typeof getServerSideProp
|
||||||
title: t("welcome_to_calcom"),
|
title: t("welcome_to_calcom"),
|
||||||
description: t("welcome_instructions"),
|
description: t("welcome_instructions"),
|
||||||
Component: (
|
Component: (
|
||||||
<form className="sm:mx-auto sm:w-full">
|
<>
|
||||||
<section className="space-y-8">
|
{selectedImport == "" && (
|
||||||
<fieldset>
|
<div className="grid grid-cols-2 mb-4 gap-x-4">
|
||||||
<label htmlFor="name" className="block text-sm font-medium text-gray-700">
|
<Button color="secondary" onClick={() => setSelectedImport("calendly")}>
|
||||||
{t("full_name")}
|
{t("import_from")} Calendly
|
||||||
</label>
|
</Button>
|
||||||
<input
|
<Button color="secondary" onClick={() => setSelectedImport("savvycal")}>
|
||||||
ref={nameRef}
|
{t("import_from")} SavvyCal
|
||||||
type="text"
|
</Button>
|
||||||
name="name"
|
</div>
|
||||||
id="name"
|
)}
|
||||||
autoComplete="given-name"
|
{selectedImport && (
|
||||||
placeholder={t("your_name")}
|
<div>
|
||||||
defaultValue={props.user.name ?? enteredName}
|
<h2 className="text-2xl text-gray-900 font-cal">
|
||||||
required
|
{t("import_from")} {selectedImport === "calendly" ? "Calendly" : "SavvyCal"}
|
||||||
className="block w-full px-3 py-2 mt-1 border border-gray-300 rounded-sm shadow-sm focus:outline-none focus:ring-neutral-500 focus:border-neutral-500 sm:text-sm"
|
</h2>
|
||||||
/>
|
<p className="mb-2 text-sm text-gray-500">{t("you_will_need_to_generate")}</p>
|
||||||
</fieldset>
|
<form
|
||||||
|
className="flex"
|
||||||
<fieldset>
|
onSubmit={formMethods.handleSubmit(async (values) => {
|
||||||
<section className="flex justify-between">
|
setSubmitting(true);
|
||||||
<label htmlFor="timeZone" className="block text-sm font-medium text-gray-700">
|
const response = await fetch(`/api/import/${selectedImport}`, {
|
||||||
{t("timezone")}
|
method: "POST",
|
||||||
|
body: JSON.stringify({
|
||||||
|
token: values.token,
|
||||||
|
}),
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (response.status === 201) {
|
||||||
|
setSubmitting(false);
|
||||||
|
handleSkipStep();
|
||||||
|
} else {
|
||||||
|
await response.json().catch((e) => {
|
||||||
|
console.log("Error: response.json invalid: " + e);
|
||||||
|
setSubmitting(false);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
})}>
|
||||||
|
<input
|
||||||
|
onChange={async (e) => {
|
||||||
|
formMethods.setValue("token", e.target.value);
|
||||||
|
}}
|
||||||
|
type="text"
|
||||||
|
name="token"
|
||||||
|
id="token"
|
||||||
|
placeholder={t("access_token")}
|
||||||
|
required
|
||||||
|
className="block w-full px-3 py-2 mt-1 border border-gray-300 rounded-sm shadow-sm focus:outline-none focus:ring-neutral-500 focus:border-neutral-500 sm:text-sm"
|
||||||
|
/>
|
||||||
|
<Button type="submit" className="h-10 mt-1 ml-4">
|
||||||
|
{t("import")}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="relative my-4">
|
||||||
|
<div className="absolute inset-0 flex items-center" aria-hidden="true">
|
||||||
|
<div className="w-full border-t border-gray-300" />
|
||||||
|
</div>
|
||||||
|
<div className="relative flex justify-center">
|
||||||
|
<span className="px-2 text-sm text-gray-500 bg-white">or</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<form className="sm:mx-auto sm:w-full">
|
||||||
|
<section className="space-y-8">
|
||||||
|
<fieldset>
|
||||||
|
<label htmlFor="name" className="block text-sm font-medium text-gray-700">
|
||||||
|
{t("full_name")}
|
||||||
</label>
|
</label>
|
||||||
<Text variant="caption">
|
<input
|
||||||
{t("current_time")}:
|
ref={nameRef}
|
||||||
<span className="text-black">{dayjs().tz(selectedTimeZone).format("LT")}</span>
|
type="text"
|
||||||
</Text>
|
name="name"
|
||||||
</section>
|
id="name"
|
||||||
<TimezoneSelect
|
autoComplete="given-name"
|
||||||
id="timeZone"
|
placeholder={t("your_name")}
|
||||||
value={selectedTimeZone}
|
defaultValue={props.user.name ?? enteredName}
|
||||||
onChange={({ value }) => setSelectedTimeZone(value)}
|
required
|
||||||
className="block w-full mt-1 border-gray-300 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500 sm:text-sm"
|
className="block w-full px-3 py-2 mt-1 border border-gray-300 rounded-sm shadow-sm focus:outline-none focus:ring-neutral-500 focus:border-neutral-500 sm:text-sm"
|
||||||
/>
|
/>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
</section>
|
|
||||||
</form>
|
<fieldset>
|
||||||
|
<section className="flex justify-between">
|
||||||
|
<label htmlFor="timeZone" className="block text-sm font-medium text-gray-700">
|
||||||
|
{t("timezone")}
|
||||||
|
</label>
|
||||||
|
<Text variant="caption">
|
||||||
|
{t("current_time")}:
|
||||||
|
<span className="text-black">{dayjs().tz(selectedTimeZone).format("LT")}</span>
|
||||||
|
</Text>
|
||||||
|
</section>
|
||||||
|
<TimezoneSelect
|
||||||
|
id="timeZone"
|
||||||
|
value={selectedTimeZone}
|
||||||
|
onChange={({ value }) => setSelectedTimeZone(value)}
|
||||||
|
className="block w-full mt-1 border-gray-300 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500 sm:text-sm"
|
||||||
|
/>
|
||||||
|
</fieldset>
|
||||||
|
</section>
|
||||||
|
</form>
|
||||||
|
</>
|
||||||
),
|
),
|
||||||
hideConfirm: false,
|
hideConfirm: false,
|
||||||
confirmText: t("continue"),
|
confirmText: t("continue"),
|
||||||
|
|
|
@ -595,5 +595,9 @@
|
||||||
"saml_configuration_update_failed": "SAML configuration update failed",
|
"saml_configuration_update_failed": "SAML configuration update failed",
|
||||||
"saml_configuration_delete_failed": "SAML configuration delete failed",
|
"saml_configuration_delete_failed": "SAML configuration delete failed",
|
||||||
"saml_email_required": "Please enter an email so we can find your SAML Identity Provider",
|
"saml_email_required": "Please enter an email so we can find your SAML Identity Provider",
|
||||||
|
"you_will_need_to_generate": "You will need to generate an access token from the integrations page.",
|
||||||
|
"import": "Import",
|
||||||
|
"import_from": "Import from",
|
||||||
|
"access_token": "Access token",
|
||||||
"visit_roadmap": "Roadmap"
|
"visit_roadmap": "Roadmap"
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in a new issue