Merge branch 'main' of github.com:calendso/calendso
|
@ -1,9 +1,8 @@
|
||||||
import { GiftIcon } from "@heroicons/react/outline";
|
import { GiftIcon } from "@heroicons/react/outline";
|
||||||
export default function DonateBanner() {
|
export default function DonateBanner() {
|
||||||
|
if (location.hostname.endsWith(".calendso.com")) {
|
||||||
if (location.hostname.endsWith(".calendso.com")) {
|
return null;
|
||||||
return null;
|
}
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
@ -17,21 +16,19 @@ return null;
|
||||||
<GiftIcon className="h-6 w-6 text-white" aria-hidden="true" />
|
<GiftIcon className="h-6 w-6 text-white" aria-hidden="true" />
|
||||||
</span>
|
</span>
|
||||||
<p className="ml-3 font-medium text-white truncate">
|
<p className="ml-3 font-medium text-white truncate">
|
||||||
<span className="md:hidden">
|
<span className="md:hidden">Support the ongoing development</span>
|
||||||
Support the ongoing development
|
|
||||||
</span>
|
|
||||||
<span className="hidden md:inline">
|
<span className="hidden md:inline">
|
||||||
You're using the free self-hosted version. Support the
|
You're using the free self-hosted version. Support the ongoing development by making
|
||||||
ongoing development by making a donation.
|
a donation.
|
||||||
</span>
|
</span>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="order-3 mt-2 flex-shrink-0 w-full sm:order-2 sm:mt-0 sm:w-auto">
|
<div className="order-3 mt-2 flex-shrink-0 w-full sm:order-2 sm:mt-0 sm:w-auto">
|
||||||
<a
|
<a
|
||||||
target="_blank"
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
href="https://calendso.com/donate"
|
href="https://calendso.com/donate"
|
||||||
className="flex items-center justify-center px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-blue-600 bg-white hover:bg-blue-50"
|
className="flex items-center justify-center px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-blue-600 bg-white hover:bg-blue-50">
|
||||||
>
|
|
||||||
Donate
|
Donate
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -1,7 +1,14 @@
|
||||||
export default function Logo({small} : {small?: boolean}){
|
export default function Logo({ small }: { small?: boolean }) {
|
||||||
return <h1 className="brand-logo inline">
|
return (
|
||||||
<strong>
|
<h1 className="brand-logo inline">
|
||||||
<img className={small ? "h-4 w-auto" : "h-5 w-auto"} alt="Calendso" title="Calendso" src="/calendso-logo-white-word.svg" />
|
<strong>
|
||||||
</strong>
|
<img
|
||||||
</h1>;
|
className={small ? "h-4 w-auto" : "h-5 w-auto"}
|
||||||
|
alt="Calendso"
|
||||||
|
title="Calendso"
|
||||||
|
src="/calendso-logo-white-word.svg"
|
||||||
|
/>
|
||||||
|
</strong>
|
||||||
|
</h1>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,66 +1,63 @@
|
||||||
import { Fragment } from 'react'
|
import { Fragment } from "react";
|
||||||
import { Dialog, Transition } from '@headlessui/react'
|
import { Dialog, Transition } from "@headlessui/react";
|
||||||
import { CheckIcon } from '@heroicons/react/outline'
|
import { CheckIcon } from "@heroicons/react/outline";
|
||||||
|
|
||||||
export default function Modal(props) {
|
export default function Modal(props) {
|
||||||
return (
|
return (
|
||||||
<Transition.Root show={props.open} as={Fragment}>
|
<Transition.Root show={props.open} as={Fragment}>
|
||||||
<Dialog as="div" static className="fixed z-50 inset-0 overflow-y-auto" open={props.open} onClose={props.handleClose}>
|
<Dialog
|
||||||
<div className="flex items-end justify-center min-h-screen pt-4 px-4 pb-20 text-center sm:block sm:p-0">
|
as="div"
|
||||||
<Transition.Child
|
static
|
||||||
as={Fragment}
|
className="fixed z-50 inset-0 overflow-y-auto"
|
||||||
enter="ease-out duration-300"
|
open={props.open}
|
||||||
enterFrom="opacity-0"
|
onClose={props.handleClose}>
|
||||||
enterTo="opacity-100"
|
<div className="flex items-end justify-center min-h-screen pt-4 px-4 pb-20 text-center sm:block sm:p-0">
|
||||||
leave="ease-in duration-200"
|
<Transition.Child
|
||||||
leaveFrom="opacity-100"
|
as={Fragment}
|
||||||
leaveTo="opacity-0"
|
enter="ease-out duration-300"
|
||||||
>
|
enterFrom="opacity-0"
|
||||||
<Dialog.Overlay className="fixed inset-0 bg-gray-500 z-0 bg-opacity-75 transition-opacity" />
|
enterTo="opacity-100"
|
||||||
</Transition.Child>
|
leave="ease-in duration-200"
|
||||||
|
leaveFrom="opacity-100"
|
||||||
|
leaveTo="opacity-0">
|
||||||
|
<Dialog.Overlay className="fixed inset-0 bg-gray-500 z-0 bg-opacity-75 transition-opacity" />
|
||||||
|
</Transition.Child>
|
||||||
|
|
||||||
{/* This element is to trick the browser into centering the modal contents. */}
|
{/* This element is to trick the browser into centering the modal contents. */}
|
||||||
<span className="hidden sm:inline-block sm:align-middle sm:h-screen" aria-hidden="true">
|
<span className="hidden sm:inline-block sm:align-middle sm:h-screen" aria-hidden="true">
|
||||||
​
|
​
|
||||||
</span>
|
</span>
|
||||||
<Transition.Child
|
<Transition.Child
|
||||||
as={Fragment}
|
as={Fragment}
|
||||||
enter="ease-out duration-300"
|
enter="ease-out duration-300"
|
||||||
enterFrom="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
|
enterFrom="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
|
||||||
enterTo="opacity-100 translate-y-0 sm:scale-100"
|
enterTo="opacity-100 translate-y-0 sm:scale-100"
|
||||||
leave="ease-in duration-200"
|
leave="ease-in duration-200"
|
||||||
leaveFrom="opacity-100 translate-y-0 sm:scale-100"
|
leaveFrom="opacity-100 translate-y-0 sm:scale-100"
|
||||||
leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
|
leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95">
|
||||||
>
|
<div className="inline-block align-bottom bg-white rounded-lg px-4 pt-5 pb-4 text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-sm sm:w-full sm:p-6">
|
||||||
<div className="inline-block align-bottom bg-white rounded-lg px-4 pt-5 pb-4 text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-sm sm:w-full sm:p-6">
|
<div>
|
||||||
<div>
|
<div className="mx-auto flex items-center justify-center h-12 w-12 rounded-full bg-green-100">
|
||||||
<div className="mx-auto flex items-center justify-center h-12 w-12 rounded-full bg-green-100">
|
<CheckIcon className="h-6 w-6 text-green-600" aria-hidden="true" />
|
||||||
<CheckIcon className="h-6 w-6 text-green-600" aria-hidden="true" />
|
|
||||||
</div>
|
|
||||||
<div className="mt-3 text-center sm:mt-5">
|
|
||||||
<Dialog.Title as="h3" className="text-lg leading-6 font-medium text-gray-900">
|
|
||||||
{props.heading}
|
|
||||||
</Dialog.Title>
|
|
||||||
<div className="mt-2">
|
|
||||||
<p className="text-sm text-gray-500">
|
|
||||||
{props.description}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="mt-5 sm:mt-6">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="btn-wide btn-primary"
|
|
||||||
onClick={() => props.handleClose()}
|
|
||||||
>
|
|
||||||
Dismiss
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Transition.Child>
|
|
||||||
</div>
|
</div>
|
||||||
</Dialog>
|
<div className="mt-3 text-center sm:mt-5">
|
||||||
</Transition.Root>
|
<Dialog.Title as="h3" className="text-lg leading-6 font-medium text-gray-900">
|
||||||
)
|
{props.heading}
|
||||||
}
|
</Dialog.Title>
|
||||||
|
<div className="mt-2">
|
||||||
|
<p className="text-sm text-gray-500">{props.description}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-5 sm:mt-6">
|
||||||
|
<button type="button" className="btn-wide btn-primary" onClick={() => props.handleClose()}>
|
||||||
|
Dismiss
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Transition.Child>
|
||||||
|
</div>
|
||||||
|
</Dialog>
|
||||||
|
</Transition.Root>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { CreditCardIcon, UserIcon, CodeIcon, KeyIcon, UserGroupIcon } from "@heroicons/react/solid";
|
import { CodeIcon, CreditCardIcon, KeyIcon, UserGroupIcon, UserIcon } from "@heroicons/react/solid";
|
||||||
import { useRouter } from "next/router";
|
import { useRouter } from "next/router";
|
||||||
import classNames from "@lib/classNames";
|
import classNames from "@lib/classNames";
|
||||||
|
|
||||||
|
|
|
@ -7,13 +7,13 @@ import { collectPageParameters, telemetryEventTypes, useTelemetry } from "../lib
|
||||||
import { SelectorIcon } from "@heroicons/react/outline";
|
import { SelectorIcon } from "@heroicons/react/outline";
|
||||||
import {
|
import {
|
||||||
CalendarIcon,
|
CalendarIcon,
|
||||||
ClockIcon,
|
|
||||||
PuzzleIcon,
|
|
||||||
CogIcon,
|
|
||||||
ChatAltIcon,
|
ChatAltIcon,
|
||||||
LogoutIcon,
|
ClockIcon,
|
||||||
|
CogIcon,
|
||||||
ExternalLinkIcon,
|
ExternalLinkIcon,
|
||||||
LinkIcon,
|
LinkIcon,
|
||||||
|
LogoutIcon,
|
||||||
|
PuzzleIcon,
|
||||||
} from "@heroicons/react/solid";
|
} from "@heroicons/react/solid";
|
||||||
import Logo from "./Logo";
|
import Logo from "./Logo";
|
||||||
import classNames from "@lib/classNames";
|
import classNames from "@lib/classNames";
|
||||||
|
|
|
@ -1,105 +1,131 @@
|
||||||
import {useEffect, useState} from "react";
|
import { useEffect, useState } from "react";
|
||||||
import {UsersIcon,UserRemoveIcon} from "@heroicons/react/outline";
|
import { UserRemoveIcon, UsersIcon } from "@heroicons/react/outline";
|
||||||
import {useSession} from "next-auth/client";
|
import { useSession } from "next-auth/client";
|
||||||
|
|
||||||
export default function EditTeamModal(props) {
|
export default function EditTeamModal(props) {
|
||||||
|
const [session] = useSession();
|
||||||
|
const [members, setMembers] = useState([]);
|
||||||
|
const [checkedDisbandTeam, setCheckedDisbandTeam] = useState(false);
|
||||||
|
|
||||||
const [ session, loading ] = useSession();
|
const loadMembers = () =>
|
||||||
const [ members, setMembers ] = useState([]);
|
fetch("/api/teams/" + props.team.id + "/membership")
|
||||||
const [ checkedDisbandTeam, setCheckedDisbandTeam ] = useState(false);
|
.then((res: any) => res.json())
|
||||||
|
.then((data) => setMembers(data.members));
|
||||||
|
|
||||||
const loadMembers = () => fetch('/api/teams/' + props.team.id + '/membership')
|
useEffect(() => {
|
||||||
.then( (res: any) => res.json() ).then( (data) => setMembers(data.members) );
|
|
||||||
|
|
||||||
useEffect( () => {
|
|
||||||
loadMembers();
|
loadMembers();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const deleteTeam = (e) => {
|
const deleteTeam = (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
return fetch('/api/teams/' + props.team.id, {
|
return fetch("/api/teams/" + props.team.id, {
|
||||||
method: 'DELETE',
|
method: "DELETE",
|
||||||
}).then(props.onExit);
|
}).then(props.onExit);
|
||||||
}
|
};
|
||||||
|
|
||||||
const removeMember = (member) => {
|
const removeMember = (member) => {
|
||||||
return fetch('/api/teams/' + props.team.id + '/membership', {
|
return fetch("/api/teams/" + props.team.id + "/membership", {
|
||||||
method: 'DELETE',
|
method: "DELETE",
|
||||||
body: JSON.stringify({ userId: member.id }),
|
body: JSON.stringify({ userId: member.id }),
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json'
|
"Content-Type": "application/json",
|
||||||
}
|
},
|
||||||
}).then(loadMembers);
|
}).then(loadMembers);
|
||||||
}
|
};
|
||||||
|
|
||||||
return (<div className="fixed z-50 inset-0 overflow-y-auto" aria-labelledby="modal-title" role="dialog" aria-modal="true">
|
return (
|
||||||
<div className="flex items-end justify-center min-h-screen pt-4 px-4 pb-20 text-center sm:block sm:p-0">
|
<div
|
||||||
<div className="fixed inset-0 bg-gray-500 z-0 bg-opacity-75 transition-opacity" aria-hidden="true"></div>
|
className="fixed z-50 inset-0 overflow-y-auto"
|
||||||
|
aria-labelledby="modal-title"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true">
|
||||||
|
<div className="flex items-end justify-center min-h-screen pt-4 px-4 pb-20 text-center sm:block sm:p-0">
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 bg-gray-500 z-0 bg-opacity-75 transition-opacity"
|
||||||
|
aria-hidden="true"></div>
|
||||||
|
|
||||||
<span className="hidden sm:inline-block sm:align-middle sm:h-screen" aria-hidden="true">​</span>
|
<span className="hidden sm:inline-block sm:align-middle sm:h-screen" aria-hidden="true">
|
||||||
|
​
|
||||||
|
</span>
|
||||||
|
|
||||||
<div className="inline-block align-bottom bg-white rounded-lg px-4 pt-5 pb-4 text-left shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-lg sm:w-full sm:p-6">
|
<div className="inline-block align-bottom bg-white rounded-lg px-4 pt-5 pb-4 text-left shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-lg sm:w-full sm:p-6">
|
||||||
<div className="sm:flex sm:items-start mb-4">
|
<div className="sm:flex sm:items-start mb-4">
|
||||||
<div className="mx-auto flex-shrink-0 flex items-center justify-center h-12 w-12 rounded-full bg-black bg-opacity-10 sm:mx-0 sm:h-10 sm:w-10">
|
<div className="mx-auto flex-shrink-0 flex items-center justify-center h-12 w-12 rounded-full bg-black bg-opacity-10 sm:mx-0 sm:h-10 sm:w-10">
|
||||||
<UsersIcon className="h-6 w-6 text-black" />
|
<UsersIcon className="h-6 w-6 text-black" />
|
||||||
|
</div>
|
||||||
|
<div className="mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left">
|
||||||
|
<h3 className="text-lg leading-6 font-medium text-gray-900" id="modal-title">
|
||||||
|
Edit the {props.team.name} team
|
||||||
|
</h3>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-gray-400">Manage and delete your team.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left">
|
<form>
|
||||||
<h3 className="text-lg leading-6 font-medium text-gray-900" id="modal-title">Edit the {props.team.name} team</h3>
|
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm text-gray-400">
|
<div className="mb-4">
|
||||||
Manage and delete your team.
|
{members.length > 0 && (
|
||||||
</p>
|
<div>
|
||||||
|
<div className="flex justify-between mb-2">
|
||||||
|
<h2 className="text-lg font-medium text-gray-900">Members</h2>
|
||||||
|
</div>
|
||||||
|
<table className="table-auto mb-2 w-full text-sm">
|
||||||
|
<tbody>
|
||||||
|
{members.map((member) => (
|
||||||
|
<tr key={member.email}>
|
||||||
|
<td className="p-1">
|
||||||
|
{member.name} {member.name && "(" + member.email + ")"}
|
||||||
|
{!member.name && member.email}
|
||||||
|
</td>
|
||||||
|
<td className="capitalize">{member.role.toLowerCase()}</td>
|
||||||
|
<td className="text-right py-2 px-1">
|
||||||
|
{member.email !== session.user.email && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => removeMember(member)}
|
||||||
|
className="btn-sm text-xs bg-transparent px-3 py-1 rounded ml-2">
|
||||||
|
<UserRemoveIcon className="text-red-400 group-hover:text-gray-500 flex-shrink-0 -mt-1 mr-1 h-4 w-4 inline" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="mb-4 border border-red-400 rounded p-2 px-4">
|
||||||
|
<p className="block text-sm font-medium text-gray-700">Tick the box to disband this team.</p>
|
||||||
|
<label className="mt-1">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
onChange={(e) => setCheckedDisbandTeam(e.target.checked)}
|
||||||
|
className="shadow-sm mr-2 focus:ring-blue-500 focus:border-blue-500 sm:text-sm border-gray-300 rounded-md"
|
||||||
|
/>
|
||||||
|
Disband this team
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div className="mt-5 sm:mt-4 sm:flex sm:flex-row-reverse">
|
||||||
</div>
|
{/*!checkedDisbandTeam && <button type="submit" className="btn btn-primary">
|
||||||
<form>
|
|
||||||
<div>
|
|
||||||
<div className="mb-4">
|
|
||||||
{members.length > 0 && <div>
|
|
||||||
<div className="flex justify-between mb-2">
|
|
||||||
<h2 className="text-lg font-medium text-gray-900">Members</h2>
|
|
||||||
</div>
|
|
||||||
<table className="table-auto mb-2 w-full text-sm">
|
|
||||||
<tbody>
|
|
||||||
{members.map( (member) => <tr key={member.email}>
|
|
||||||
<td className="p-1">{member.name} {member.name && '(' + member.email + ')' }{!member.name && member.email}</td>
|
|
||||||
<td className="capitalize">{member.role.toLowerCase()}</td>
|
|
||||||
<td className="text-right py-2 px-1">
|
|
||||||
{member.email !== session.user.email &&
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={(e) => removeMember(member)}
|
|
||||||
className="btn-sm text-xs bg-transparent px-3 py-1 rounded ml-2">
|
|
||||||
<UserRemoveIcon className="text-red-400 group-hover:text-gray-500 flex-shrink-0 -mt-1 mr-1 h-4 w-4 inline"/>
|
|
||||||
</button>
|
|
||||||
}
|
|
||||||
</td>
|
|
||||||
</tr>)}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>}
|
|
||||||
</div>
|
|
||||||
<div className="mb-4 border border-red-400 rounded p-2 px-4">
|
|
||||||
<p className="block text-sm font-medium text-gray-700">Tick the box to disband this team.</p>
|
|
||||||
<label className="mt-1">
|
|
||||||
<input type="checkbox" onChange={(e) => setCheckedDisbandTeam(e.target.checked)} className="shadow-sm mr-2 focus:ring-black focus:border-black sm:text-sm border-gray-300 rounded-md" />
|
|
||||||
Disband this team
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="mt-5 sm:mt-4 sm:flex sm:flex-row-reverse">
|
|
||||||
{/*!checkedDisbandTeam && <button type="submit" className="btn btn-primary">
|
|
||||||
Update
|
Update
|
||||||
</button>*/}
|
</button>*/}
|
||||||
{checkedDisbandTeam && <button onClick={deleteTeam} className="btn bg-red-700 rounded text-white px-2 font-medium text-sm">
|
{checkedDisbandTeam && (
|
||||||
Disband Team
|
<button
|
||||||
</button>}
|
onClick={deleteTeam}
|
||||||
<button onClick={props.onExit} type="button" className="btn btn-white mr-2">
|
className="btn bg-red-700 rounded text-white px-2 font-medium text-sm">
|
||||||
Close
|
Disband Team
|
||||||
</button>
|
</button>
|
||||||
</div>
|
)}
|
||||||
</form>
|
<button onClick={props.onExit} type="button" className="btn btn-white mr-2">
|
||||||
|
Close
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -2,11 +2,9 @@ import { UsersIcon } from "@heroicons/react/outline";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
|
||||||
export default function MemberInvitationModal(props) {
|
export default function MemberInvitationModal(props) {
|
||||||
|
const [errorMessage, setErrorMessage] = useState("");
|
||||||
const [ errorMessage, setErrorMessage ] = useState('');
|
|
||||||
|
|
||||||
const handleError = async (res) => {
|
const handleError = async (res) => {
|
||||||
|
|
||||||
const responseData = await res.json();
|
const responseData = await res.json();
|
||||||
|
|
||||||
if (res.ok === false) {
|
if (res.ok === false) {
|
||||||
|
@ -18,24 +16,26 @@ export default function MemberInvitationModal(props) {
|
||||||
};
|
};
|
||||||
|
|
||||||
const inviteMember = (e) => {
|
const inviteMember = (e) => {
|
||||||
|
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
||||||
const payload = {
|
const payload = {
|
||||||
role: e.target.elements['role'].value,
|
role: e.target.elements["role"].value,
|
||||||
usernameOrEmail: e.target.elements['inviteUser'].value,
|
usernameOrEmail: e.target.elements["inviteUser"].value,
|
||||||
sendEmailInvitation: e.target.elements['sendInviteEmail'].checked,
|
sendEmailInvitation: e.target.elements["sendInviteEmail"].checked,
|
||||||
}
|
};
|
||||||
|
|
||||||
return fetch('/api/teams/' + props.team.id + '/invite', {
|
return fetch("/api/teams/" + props.team.id + "/invite", {
|
||||||
method: 'POST',
|
method: "POST",
|
||||||
body: JSON.stringify(payload),
|
body: JSON.stringify(payload),
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json'
|
"Content-Type": "application/json",
|
||||||
}
|
},
|
||||||
}).then(handleError).then(props.onExit).catch( (e) => {
|
})
|
||||||
// do nothing.
|
.then(handleError)
|
||||||
});
|
.then(props.onExit)
|
||||||
|
.catch(() => {
|
||||||
|
// do nothing.
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
@ -45,7 +45,9 @@ export default function MemberInvitationModal(props) {
|
||||||
role="dialog"
|
role="dialog"
|
||||||
aria-modal="true">
|
aria-modal="true">
|
||||||
<div className="flex items-end justify-center min-h-screen pt-4 px-4 pb-20 text-center sm:block sm:p-0">
|
<div className="flex items-end justify-center min-h-screen pt-4 px-4 pb-20 text-center sm:block sm:p-0">
|
||||||
<div className="fixed inset-0 bg-gray-500 z-0 bg-opacity-75 transition-opacity" aria-hidden="true"></div>
|
<div
|
||||||
|
className="fixed inset-0 bg-gray-500 z-0 bg-opacity-75 transition-opacity"
|
||||||
|
aria-hidden="true"></div>
|
||||||
|
|
||||||
<span className="hidden sm:inline-block sm:align-middle sm:h-screen" aria-hidden="true">
|
<span className="hidden sm:inline-block sm:align-middle sm:h-screen" aria-hidden="true">
|
||||||
​
|
​
|
||||||
|
@ -127,4 +129,4 @@ export default function MemberInvitationModal(props) {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,42 +1,49 @@
|
||||||
import {useEffect, useState} from "react";
|
import { useState } from "react";
|
||||||
import TeamListItem from "./TeamListItem";
|
import TeamListItem from "./TeamListItem";
|
||||||
import EditTeamModal from "./EditTeamModal";
|
import EditTeamModal from "./EditTeamModal";
|
||||||
import MemberInvitationModal from "./MemberInvitationModal";
|
import MemberInvitationModal from "./MemberInvitationModal";
|
||||||
|
|
||||||
export default function TeamList(props) {
|
export default function TeamList(props) {
|
||||||
|
const [showMemberInvitationModal, setShowMemberInvitationModal] = useState(false);
|
||||||
const [ showMemberInvitationModal, setShowMemberInvitationModal ] = useState(false);
|
const [showEditTeamModal, setShowEditTeamModal] = useState(false);
|
||||||
const [ showEditTeamModal, setShowEditTeamModal ] = useState(false);
|
const [team, setTeam] = useState(null);
|
||||||
const [ team, setTeam ] = useState(null);
|
|
||||||
|
|
||||||
const selectAction = (action: string, team: any) => {
|
const selectAction = (action: string, team: any) => {
|
||||||
setTeam(team);
|
setTeam(team);
|
||||||
switch (action) {
|
switch (action) {
|
||||||
case 'edit':
|
case "edit":
|
||||||
setShowEditTeamModal(true);
|
setShowEditTeamModal(true);
|
||||||
break;
|
break;
|
||||||
case 'invite':
|
case "invite":
|
||||||
setShowMemberInvitationModal(true);
|
setShowMemberInvitationModal(true);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (<div>
|
return (
|
||||||
<ul className="bg-white border px-2 mb-2 rounded divide-y divide-gray-200">
|
<div>
|
||||||
{props.teams.map(
|
<ul className="bg-white border px-2 mb-2 rounded divide-y divide-gray-200">
|
||||||
(team: any) => <TeamListItem onChange={props.onChange} key={team.id} team={team} onActionSelect={
|
{props.teams.map((team: any) => (
|
||||||
(action: string) => selectAction(action, team)
|
<TeamListItem
|
||||||
}></TeamListItem>
|
onChange={props.onChange}
|
||||||
|
key={team.id}
|
||||||
|
team={team}
|
||||||
|
onActionSelect={(action: string) => selectAction(action, team)}></TeamListItem>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
{showEditTeamModal && (
|
||||||
|
<EditTeamModal
|
||||||
|
team={team}
|
||||||
|
onExit={() => {
|
||||||
|
props.onChange();
|
||||||
|
setShowEditTeamModal(false);
|
||||||
|
}}></EditTeamModal>
|
||||||
)}
|
)}
|
||||||
</ul>
|
{showMemberInvitationModal && (
|
||||||
{showEditTeamModal && <EditTeamModal team={team} onExit={() => {
|
<MemberInvitationModal
|
||||||
props.onChange();
|
team={team}
|
||||||
setShowEditTeamModal(false);
|
onExit={() => setShowMemberInvitationModal(false)}></MemberInvitationModal>
|
||||||
}}></EditTeamModal>}
|
)}
|
||||||
{showMemberInvitationModal &&
|
</div>
|
||||||
<MemberInvitationModal
|
);
|
||||||
team={team}
|
|
||||||
onExit={() => setShowMemberInvitationModal(false)}></MemberInvitationModal>
|
|
||||||
}
|
|
||||||
</div>);
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,61 +1,89 @@
|
||||||
import {CogIcon, TrashIcon, UserAddIcon, UsersIcon} from "@heroicons/react/outline";
|
import { CogIcon, TrashIcon, UsersIcon } from "@heroicons/react/outline";
|
||||||
import Dropdown from "../ui/Dropdown";
|
import Dropdown from "../ui/Dropdown";
|
||||||
import {useState} from "react";
|
import { useState } from "react";
|
||||||
|
|
||||||
export default function TeamListItem(props) {
|
export default function TeamListItem(props) {
|
||||||
|
const [team, setTeam] = useState(props.team);
|
||||||
const [ team, setTeam ] = useState(props.team);
|
|
||||||
|
|
||||||
const acceptInvite = () => invitationResponse(true);
|
const acceptInvite = () => invitationResponse(true);
|
||||||
const declineInvite = () => invitationResponse(false);
|
const declineInvite = () => invitationResponse(false);
|
||||||
|
|
||||||
const invitationResponse = (accept: boolean) => fetch('/api/user/membership', {
|
const invitationResponse = (accept: boolean) =>
|
||||||
method: accept ? 'PATCH' : 'DELETE',
|
fetch("/api/user/membership", {
|
||||||
body: JSON.stringify({ teamId: props.team.id }),
|
method: accept ? "PATCH" : "DELETE",
|
||||||
headers: {
|
body: JSON.stringify({ teamId: props.team.id }),
|
||||||
'Content-Type': 'application/json'
|
headers: {
|
||||||
}
|
"Content-Type": "application/json",
|
||||||
}).then( () => {
|
},
|
||||||
// success
|
}).then(() => {
|
||||||
setTeam(null);
|
// success
|
||||||
props.onChange();
|
setTeam(null);
|
||||||
});
|
props.onChange();
|
||||||
|
});
|
||||||
|
|
||||||
return (team && <li className="mb-2 mt-2 divide-y">
|
return (
|
||||||
<div className="flex justify-between mb-2 mt-2">
|
team && (
|
||||||
<div>
|
<li className="mb-2 mt-2 divide-y">
|
||||||
<UsersIcon className="text-gray-400 group-hover:text-gray-500 flex-shrink-0 -mt-4 mr-2 h-6 w-6 inline"/>
|
<div className="flex justify-between mb-2 mt-2">
|
||||||
<div className="inline-block -mt-1">
|
<div>
|
||||||
<span className="font-bold text-neutral-700 text-sm">{props.team.name}</span>
|
<UsersIcon className="text-gray-400 group-hover:text-gray-500 flex-shrink-0 -mt-4 mr-2 h-6 w-6 inline" />
|
||||||
<span className="text-xs text-gray-400 -mt-1 block capitalize">{props.team.role.toLowerCase()}</span>
|
<div className="inline-block -mt-1">
|
||||||
|
<span className="font-bold text-neutral-700 text-sm">{props.team.name}</span>
|
||||||
|
<span className="text-xs text-gray-400 -mt-1 block capitalize">
|
||||||
|
{props.team.role.toLowerCase()}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{props.team.role === "INVITEE" && (
|
||||||
|
<div>
|
||||||
|
<button
|
||||||
|
className="btn-sm bg-transparent text-green-500 border border-green-500 px-3 py-1 rounded-sm ml-2"
|
||||||
|
onClick={acceptInvite}>
|
||||||
|
Accept invitation
|
||||||
|
</button>
|
||||||
|
<button className="btn-sm bg-transparent px-2 py-1 ml-1">
|
||||||
|
<TrashIcon className="h-6 w-6 inline text-gray-400 -mt-1" onClick={declineInvite} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{props.team.role === "MEMBER" && (
|
||||||
|
<div>
|
||||||
|
<button
|
||||||
|
onClick={declineInvite}
|
||||||
|
className="btn-sm bg-transparent text-gray-400 border border-gray-400 px-3 py-1 rounded-sm ml-2">
|
||||||
|
Leave
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{props.team.role === "OWNER" && (
|
||||||
|
<div>
|
||||||
|
<Dropdown className="relative inline-block text-left">
|
||||||
|
<button className="btn-sm bg-transparent text-gray-400 px-3 py-1 rounded-sm ml-2">
|
||||||
|
<CogIcon className="h-6 w-6 inline text-gray-400" />
|
||||||
|
</button>
|
||||||
|
<ul
|
||||||
|
role="menu"
|
||||||
|
className="z-10 origin-top-right absolute right-0 w-36 rounded-sm shadow-lg bg-white ring-1 ring-black ring-opacity-5 focus:outline-none">
|
||||||
|
<li
|
||||||
|
className="text-sm text-gray-700 hover:bg-gray-100 hover:text-gray-900"
|
||||||
|
role="menuitem">
|
||||||
|
<button className="block px-4 py-2" onClick={() => props.onActionSelect("invite")}>
|
||||||
|
Invite members
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
<li
|
||||||
|
className="text-sm text-gray-700 hover:bg-gray-100 hover:text-gray-900"
|
||||||
|
role="menuitem">
|
||||||
|
<button className="block px-4 py-2" onClick={() => props.onActionSelect("edit")}>
|
||||||
|
Manage team
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</Dropdown>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
{/*{props.team.userRole === 'Owner' && expanded && <div className="pt-2">
|
||||||
{props.team.role === 'INVITEE' && <div>
|
|
||||||
<button className="btn-sm bg-transparent text-green-500 border border-green-500 px-3 py-1 rounded-sm ml-2" onClick={acceptInvite}>Accept invitation</button>
|
|
||||||
<button className="btn-sm bg-transparent px-2 py-1 ml-1">
|
|
||||||
<TrashIcon className="h-6 w-6 inline text-gray-400 -mt-1" onClick={declineInvite} />
|
|
||||||
</button>
|
|
||||||
</div>}
|
|
||||||
{props.team.role === 'MEMBER' && <div>
|
|
||||||
<button onClick={declineInvite} className="btn-sm bg-transparent text-gray-400 border border-gray-400 px-3 py-1 rounded-sm ml-2">Leave</button>
|
|
||||||
</div>}
|
|
||||||
{props.team.role === 'OWNER' && <div>
|
|
||||||
<Dropdown className="relative inline-block text-left">
|
|
||||||
<button className="btn-sm bg-transparent text-gray-400 px-3 py-1 rounded-sm ml-2">
|
|
||||||
<CogIcon className="h-6 w-6 inline text-gray-400" />
|
|
||||||
</button>
|
|
||||||
<ul role="menu" className="z-10 origin-top-right absolute right-0 w-36 rounded-sm shadow-lg bg-white ring-1 ring-black ring-opacity-5 focus:outline-none">
|
|
||||||
<li className="text-sm text-gray-700 hover:bg-gray-100 hover:text-gray-900" role="menuitem">
|
|
||||||
<button className="block px-4 py-2" onClick={() => props.onActionSelect('invite')}>Invite members</button>
|
|
||||||
</li>
|
|
||||||
<li className="text-sm text-gray-700 hover:bg-gray-100 hover:text-gray-900" role="menuitem">
|
|
||||||
<button className="block px-4 py-2" onClick={() => props.onActionSelect('edit')}>Manage team</button>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</Dropdown>
|
|
||||||
</div>}
|
|
||||||
</div>
|
|
||||||
{/*{props.team.userRole === 'Owner' && expanded && <div className="pt-2">
|
|
||||||
{props.team.members.length > 0 && <div>
|
{props.team.members.length > 0 && <div>
|
||||||
<h2 className="text-lg font-medium text-gray-900 mb-1">Members</h2>
|
<h2 className="text-lg font-medium text-gray-900 mb-1">Members</h2>
|
||||||
<table className="table-auto mb-2 w-full">
|
<table className="table-auto mb-2 w-full">
|
||||||
|
@ -73,5 +101,7 @@ export default function TeamListItem(props) {
|
||||||
<button className="btn-sm bg-transparent text-gray-400 border border-gray-400 px-3 py-1 rounded-sm"><UserAddIcon className="text-gray-400 group-hover:text-gray-500 flex-shrink-0 -mt-1 h-6 w-6 inline"/> Invite member</button>
|
<button className="btn-sm bg-transparent text-gray-400 border border-gray-400 px-3 py-1 rounded-sm"><UserAddIcon className="text-gray-400 group-hover:text-gray-500 flex-shrink-0 -mt-1 h-6 w-6 inline"/> Invite member</button>
|
||||||
<button className="btn-sm bg-transparent text-red-400 border border-red-400 px-3 py-1 rounded-sm ml-2">Disband</button>
|
<button className="btn-sm bg-transparent text-red-400 border border-red-400 px-3 py-1 rounded-sm ml-2">Disband</button>
|
||||||
</div>}*/}
|
</div>}*/}
|
||||||
</li>);
|
</li>
|
||||||
|
)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -33,7 +33,9 @@ export default function SetTimesModal(props) {
|
||||||
role="dialog"
|
role="dialog"
|
||||||
aria-modal="true">
|
aria-modal="true">
|
||||||
<div className="flex items-end justify-center min-h-screen pt-4 px-4 pb-20 text-center sm:block sm:p-0">
|
<div className="flex items-end justify-center min-h-screen pt-4 px-4 pb-20 text-center sm:block sm:p-0">
|
||||||
<div className="fixed inset-0 bg-gray-500 z-0 bg-opacity-75 transition-opacity" aria-hidden="true"></div>
|
<div
|
||||||
|
className="fixed inset-0 bg-gray-500 z-0 bg-opacity-75 transition-opacity"
|
||||||
|
aria-hidden="true"></div>
|
||||||
|
|
||||||
<span className="hidden sm:inline-block sm:align-middle sm:h-screen" aria-hidden="true">
|
<span className="hidden sm:inline-block sm:align-middle sm:h-screen" aria-hidden="true">
|
||||||
​
|
​
|
||||||
|
|
|
@ -6,10 +6,12 @@ import { stripHtml } from "./emails/helpers";
|
||||||
const translator = short();
|
const translator = short();
|
||||||
|
|
||||||
export default class CalEventParser {
|
export default class CalEventParser {
|
||||||
calEvent: CalendarEvent;
|
protected calEvent: CalendarEvent;
|
||||||
|
protected maybeUid: string;
|
||||||
|
|
||||||
constructor(calEvent: CalendarEvent) {
|
constructor(calEvent: CalendarEvent, maybeUid: string = null) {
|
||||||
this.calEvent = calEvent;
|
this.calEvent = calEvent;
|
||||||
|
this.maybeUid = maybeUid;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -30,7 +32,7 @@ export default class CalEventParser {
|
||||||
* Returns a unique identifier for the given calendar event.
|
* Returns a unique identifier for the given calendar event.
|
||||||
*/
|
*/
|
||||||
public getUid(): string {
|
public getUid(): string {
|
||||||
return translator.fromUUID(uuidv5(JSON.stringify(this.calEvent), uuidv5.URL));
|
return this.maybeUid ?? translator.fromUUID(uuidv5(JSON.stringify(this.calEvent), uuidv5.URL));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
@ -5,19 +5,17 @@ import EventAttendeeRescheduledMail from "./emails/EventAttendeeRescheduledMail"
|
||||||
import prisma from "./prisma";
|
import prisma from "./prisma";
|
||||||
import { Credential } from "@prisma/client";
|
import { Credential } from "@prisma/client";
|
||||||
import CalEventParser from "./CalEventParser";
|
import CalEventParser from "./CalEventParser";
|
||||||
|
import { EventResult } from "@lib/events/EventManager";
|
||||||
|
import logger from "@lib/logger";
|
||||||
|
|
||||||
|
const log = logger.getChildLogger({ prefix: ["[lib] calendarClient"] });
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||||
const { google } = require("googleapis");
|
const { google } = require("googleapis");
|
||||||
|
|
||||||
const googleAuth = (credential) => {
|
const googleAuth = (credential) => {
|
||||||
const { client_secret, client_id, redirect_uris } = JSON.parse(
|
const { client_secret, client_id, redirect_uris } = JSON.parse(process.env.GOOGLE_API_CREDENTIALS).web;
|
||||||
process.env.GOOGLE_API_CREDENTIALS
|
const myGoogleAuth = new google.auth.OAuth2(client_id, client_secret, redirect_uris[0]);
|
||||||
).web;
|
|
||||||
const myGoogleAuth = new google.auth.OAuth2(
|
|
||||||
client_id,
|
|
||||||
client_secret,
|
|
||||||
redirect_uris[0]
|
|
||||||
);
|
|
||||||
myGoogleAuth.setCredentials(credential.key);
|
myGoogleAuth.setCredentials(credential.key);
|
||||||
|
|
||||||
const isExpired = () => myGoogleAuth.isTokenExpiring();
|
const isExpired = () => myGoogleAuth.isTokenExpiring();
|
||||||
|
@ -49,8 +47,7 @@ const googleAuth = (credential) => {
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
getToken: () =>
|
getToken: () => (!isExpired() ? Promise.resolve(myGoogleAuth) : refreshAccessToken()),
|
||||||
!isExpired() ? Promise.resolve(myGoogleAuth) : refreshAccessToken(),
|
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -88,9 +85,7 @@ const o365Auth = (credential) => {
|
||||||
.then(handleErrorsJson)
|
.then(handleErrorsJson)
|
||||||
.then((responseBody) => {
|
.then((responseBody) => {
|
||||||
credential.key.access_token = responseBody.access_token;
|
credential.key.access_token = responseBody.access_token;
|
||||||
credential.key.expiry_date = Math.round(
|
credential.key.expiry_date = Math.round(+new Date() / 1000 + responseBody.expires_in);
|
||||||
+new Date() / 1000 + responseBody.expires_in
|
|
||||||
);
|
|
||||||
return prisma.credential
|
return prisma.credential
|
||||||
.update({
|
.update({
|
||||||
where: {
|
where: {
|
||||||
|
@ -148,11 +143,7 @@ export interface CalendarApiAdapter {
|
||||||
|
|
||||||
deleteEvent(uid: string);
|
deleteEvent(uid: string);
|
||||||
|
|
||||||
getAvailability(
|
getAvailability(dateFrom, dateTo, selectedCalendars: IntegrationCalendar[]): Promise<unknown>;
|
||||||
dateFrom,
|
|
||||||
dateTo,
|
|
||||||
selectedCalendars: IntegrationCalendar[]
|
|
||||||
): Promise<unknown>;
|
|
||||||
|
|
||||||
listCalendars(): Promise<IntegrationCalendar[]>;
|
listCalendars(): Promise<IntegrationCalendar[]>;
|
||||||
}
|
}
|
||||||
|
@ -336,9 +327,7 @@ const GoogleCalendar = (credential): CalendarApiAdapter => {
|
||||||
}
|
}
|
||||||
|
|
||||||
(selectedCalendarIds.length == 0
|
(selectedCalendarIds.length == 0
|
||||||
? calendar.calendarList
|
? calendar.calendarList.list().then((cals) => cals.data.items.map((cal) => cal.id))
|
||||||
.list()
|
|
||||||
.then((cals) => cals.data.items.map((cal) => cal.id))
|
|
||||||
: Promise.resolve(selectedCalendarIds)
|
: Promise.resolve(selectedCalendarIds)
|
||||||
)
|
)
|
||||||
.then((calsIds) => {
|
.then((calsIds) => {
|
||||||
|
@ -354,19 +343,12 @@ const GoogleCalendar = (credential): CalendarApiAdapter => {
|
||||||
if (err) {
|
if (err) {
|
||||||
reject(err);
|
reject(err);
|
||||||
}
|
}
|
||||||
resolve(
|
resolve(Object.values(apires.data.calendars).flatMap((item) => item["busy"]));
|
||||||
Object.values(apires.data.calendars).flatMap(
|
|
||||||
(item) => item["busy"]
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
})
|
})
|
||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
console.error(
|
console.error("There was an error contacting google calendar service: ", err);
|
||||||
"There was an error contacting google calendar service: ",
|
|
||||||
err
|
|
||||||
);
|
|
||||||
reject(err);
|
reject(err);
|
||||||
});
|
});
|
||||||
})
|
})
|
||||||
|
@ -413,10 +395,7 @@ const GoogleCalendar = (credential): CalendarApiAdapter => {
|
||||||
},
|
},
|
||||||
function (err, event) {
|
function (err, event) {
|
||||||
if (err) {
|
if (err) {
|
||||||
console.error(
|
console.error("There was an error contacting google calendar service: ", err);
|
||||||
"There was an error contacting google calendar service: ",
|
|
||||||
err
|
|
||||||
);
|
|
||||||
return reject(err);
|
return reject(err);
|
||||||
}
|
}
|
||||||
return resolve(event.data);
|
return resolve(event.data);
|
||||||
|
@ -464,10 +443,7 @@ const GoogleCalendar = (credential): CalendarApiAdapter => {
|
||||||
},
|
},
|
||||||
function (err, event) {
|
function (err, event) {
|
||||||
if (err) {
|
if (err) {
|
||||||
console.error(
|
console.error("There was an error contacting google calendar service: ", err);
|
||||||
"There was an error contacting google calendar service: ",
|
|
||||||
err
|
|
||||||
);
|
|
||||||
return reject(err);
|
return reject(err);
|
||||||
}
|
}
|
||||||
return resolve(event.data);
|
return resolve(event.data);
|
||||||
|
@ -492,10 +468,7 @@ const GoogleCalendar = (credential): CalendarApiAdapter => {
|
||||||
},
|
},
|
||||||
function (err, event) {
|
function (err, event) {
|
||||||
if (err) {
|
if (err) {
|
||||||
console.error(
|
console.error("There was an error contacting google calendar service: ", err);
|
||||||
"There was an error contacting google calendar service: ",
|
|
||||||
err
|
|
||||||
);
|
|
||||||
return reject(err);
|
return reject(err);
|
||||||
}
|
}
|
||||||
return resolve(event.data);
|
return resolve(event.data);
|
||||||
|
@ -526,10 +499,7 @@ const GoogleCalendar = (credential): CalendarApiAdapter => {
|
||||||
);
|
);
|
||||||
})
|
})
|
||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
console.error(
|
console.error("There was an error contacting google calendar service: ", err);
|
||||||
"There was an error contacting google calendar service: ",
|
|
||||||
err
|
|
||||||
);
|
|
||||||
reject(err);
|
reject(err);
|
||||||
});
|
});
|
||||||
})
|
})
|
||||||
|
@ -552,30 +522,25 @@ const calendars = (withCredentials): CalendarApiAdapter[] =>
|
||||||
})
|
})
|
||||||
.filter(Boolean);
|
.filter(Boolean);
|
||||||
|
|
||||||
const getBusyCalendarTimes = (
|
const getBusyCalendarTimes = (withCredentials, dateFrom, dateTo, selectedCalendars) =>
|
||||||
withCredentials,
|
|
||||||
dateFrom,
|
|
||||||
dateTo,
|
|
||||||
selectedCalendars
|
|
||||||
) =>
|
|
||||||
Promise.all(
|
Promise.all(
|
||||||
calendars(withCredentials).map((c) =>
|
calendars(withCredentials).map((c) => c.getAvailability(dateFrom, dateTo, selectedCalendars))
|
||||||
c.getAvailability(dateFrom, dateTo, selectedCalendars)
|
|
||||||
)
|
|
||||||
).then((results) => {
|
).then((results) => {
|
||||||
return results.reduce((acc, availability) => acc.concat(availability), []);
|
return results.reduce((acc, availability) => acc.concat(availability), []);
|
||||||
});
|
});
|
||||||
|
|
||||||
const listCalendars = (withCredentials) =>
|
const listCalendars = (withCredentials) =>
|
||||||
Promise.all(calendars(withCredentials).map((c) => c.listCalendars())).then(
|
Promise.all(calendars(withCredentials).map((c) => c.listCalendars())).then((results) =>
|
||||||
(results) => results.reduce((acc, calendars) => acc.concat(calendars), [])
|
results.reduce((acc, calendars) => acc.concat(calendars), [])
|
||||||
);
|
);
|
||||||
|
|
||||||
const createEvent = async (
|
const createEvent = async (
|
||||||
credential: Credential,
|
credential: Credential,
|
||||||
calEvent: CalendarEvent
|
calEvent: CalendarEvent,
|
||||||
): Promise<unknown> => {
|
noMail = false,
|
||||||
const parser: CalEventParser = new CalEventParser(calEvent);
|
maybeUid: string = null
|
||||||
|
): Promise<EventResult> => {
|
||||||
|
const parser: CalEventParser = new CalEventParser(calEvent, maybeUid);
|
||||||
const uid: string = parser.getUid();
|
const uid: string = parser.getUid();
|
||||||
/*
|
/*
|
||||||
* Matching the credential type is a workaround because the office calendar simply strips away newlines (\n and \r).
|
* Matching the credential type is a workaround because the office calendar simply strips away newlines (\n and \r).
|
||||||
|
@ -584,78 +549,103 @@ const createEvent = async (
|
||||||
*/
|
*/
|
||||||
const richEvent: CalendarEvent = parser.asRichEventPlain();
|
const richEvent: CalendarEvent = parser.asRichEventPlain();
|
||||||
|
|
||||||
|
let success = true;
|
||||||
|
|
||||||
const creationResult = credential
|
const creationResult = credential
|
||||||
? await calendars([credential])[0].createEvent(richEvent)
|
? await calendars([credential])[0]
|
||||||
|
.createEvent(richEvent)
|
||||||
|
.catch((e) => {
|
||||||
|
log.error("createEvent failed", e, calEvent);
|
||||||
|
success = false;
|
||||||
|
})
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
const maybeHangoutLink = creationResult?.hangoutLink;
|
const maybeHangoutLink = creationResult?.hangoutLink;
|
||||||
const maybeEntryPoints = creationResult?.entryPoints;
|
const maybeEntryPoints = creationResult?.entryPoints;
|
||||||
const maybeConferenceData = creationResult?.conferenceData;
|
const maybeConferenceData = creationResult?.conferenceData;
|
||||||
|
|
||||||
const organizerMail = new EventOrganizerMail(calEvent, uid, {
|
if (!noMail) {
|
||||||
hangoutLink: maybeHangoutLink,
|
const organizerMail = new EventOrganizerMail(calEvent, uid, {
|
||||||
conferenceData: maybeConferenceData,
|
hangoutLink: maybeHangoutLink,
|
||||||
entryPoints: maybeEntryPoints,
|
conferenceData: maybeConferenceData,
|
||||||
});
|
entryPoints: maybeEntryPoints,
|
||||||
|
});
|
||||||
|
|
||||||
const attendeeMail = new EventAttendeeMail(calEvent, uid, {
|
const attendeeMail = new EventAttendeeMail(calEvent, uid, {
|
||||||
hangoutLink: maybeHangoutLink,
|
hangoutLink: maybeHangoutLink,
|
||||||
conferenceData: maybeConferenceData,
|
conferenceData: maybeConferenceData,
|
||||||
entryPoints: maybeEntryPoints,
|
entryPoints: maybeEntryPoints,
|
||||||
});
|
});
|
||||||
|
|
||||||
try {
|
|
||||||
await organizerMail.sendEmail();
|
|
||||||
} catch (e) {
|
|
||||||
console.error("organizerMail.sendEmail failed", e);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!creationResult || !creationResult.disableConfirmationEmail) {
|
|
||||||
try {
|
try {
|
||||||
await attendeeMail.sendEmail();
|
await organizerMail.sendEmail();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("attendeeMail.sendEmail failed", e);
|
console.error("organizerMail.sendEmail failed", e);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!creationResult || !creationResult.disableConfirmationEmail) {
|
||||||
|
try {
|
||||||
|
await attendeeMail.sendEmail();
|
||||||
|
} catch (e) {
|
||||||
|
console.error("attendeeMail.sendEmail failed", e);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
type: credential.type,
|
||||||
|
success,
|
||||||
uid,
|
uid,
|
||||||
createdEvent: creationResult,
|
createdEvent: creationResult,
|
||||||
|
originalEvent: calEvent,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
const updateEvent = async (
|
const updateEvent = async (
|
||||||
credential: Credential,
|
credential: Credential,
|
||||||
uidToUpdate: string,
|
uidToUpdate: string,
|
||||||
calEvent: CalendarEvent
|
calEvent: CalendarEvent,
|
||||||
): Promise<unknown> => {
|
noMail = false
|
||||||
|
): Promise<EventResult> => {
|
||||||
const parser: CalEventParser = new CalEventParser(calEvent);
|
const parser: CalEventParser = new CalEventParser(calEvent);
|
||||||
const newUid: string = parser.getUid();
|
const newUid: string = parser.getUid();
|
||||||
const richEvent: CalendarEvent = parser.asRichEventPlain();
|
const richEvent: CalendarEvent = parser.asRichEventPlain();
|
||||||
|
|
||||||
|
let success = true;
|
||||||
|
|
||||||
const updateResult = credential
|
const updateResult = credential
|
||||||
? await calendars([credential])[0].updateEvent(uidToUpdate, richEvent)
|
? await calendars([credential])[0]
|
||||||
|
.updateEvent(uidToUpdate, richEvent)
|
||||||
|
.catch((e) => {
|
||||||
|
log.error("updateEvent failed", e, calEvent);
|
||||||
|
success = false;
|
||||||
|
})
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
const organizerMail = new EventOrganizerRescheduledMail(calEvent, newUid);
|
if (!noMail) {
|
||||||
const attendeeMail = new EventAttendeeRescheduledMail(calEvent, newUid);
|
const organizerMail = new EventOrganizerRescheduledMail(calEvent, newUid);
|
||||||
try {
|
const attendeeMail = new EventAttendeeRescheduledMail(calEvent, newUid);
|
||||||
await organizerMail.sendEmail();
|
|
||||||
} catch (e) {
|
|
||||||
console.error("organizerMail.sendEmail failed", e);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!updateResult || !updateResult.disableConfirmationEmail) {
|
|
||||||
try {
|
try {
|
||||||
await attendeeMail.sendEmail();
|
await organizerMail.sendEmail();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("attendeeMail.sendEmail failed", e);
|
console.error("organizerMail.sendEmail failed", e);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!updateResult || !updateResult.disableConfirmationEmail) {
|
||||||
|
try {
|
||||||
|
await attendeeMail.sendEmail();
|
||||||
|
} catch (e) {
|
||||||
|
console.error("attendeeMail.sendEmail failed", e);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
type: credential.type,
|
||||||
|
success,
|
||||||
uid: newUid,
|
uid: newUid,
|
||||||
updatedEvent: updateResult,
|
updatedEvent: updateResult,
|
||||||
|
originalEvent: calEvent,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -667,12 +657,4 @@ const deleteEvent = (credential: Credential, uid: string): Promise<unknown> => {
|
||||||
return Promise.resolve({});
|
return Promise.resolve({});
|
||||||
};
|
};
|
||||||
|
|
||||||
export {
|
export { getBusyCalendarTimes, createEvent, updateEvent, deleteEvent, listCalendars };
|
||||||
getBusyCalendarTimes,
|
|
||||||
createEvent,
|
|
||||||
updateEvent,
|
|
||||||
deleteEvent,
|
|
||||||
CalendarEvent,
|
|
||||||
listCalendars,
|
|
||||||
IntegrationCalendar,
|
|
||||||
};
|
|
||||||
|
|
|
@ -4,7 +4,7 @@ import { CalendarEvent, ConferenceData } from "../calendarClient";
|
||||||
import { serverConfig } from "../serverConfig";
|
import { serverConfig } from "../serverConfig";
|
||||||
import nodemailer from "nodemailer";
|
import nodemailer from "nodemailer";
|
||||||
|
|
||||||
interface EntryPoint {
|
export interface EntryPoint {
|
||||||
entryPointType?: string;
|
entryPointType?: string;
|
||||||
uri?: string;
|
uri?: string;
|
||||||
label?: string;
|
label?: string;
|
||||||
|
@ -15,7 +15,7 @@ interface EntryPoint {
|
||||||
password?: string;
|
password?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface AdditionInformation {
|
export interface AdditionInformation {
|
||||||
conferenceData?: ConferenceData;
|
conferenceData?: ConferenceData;
|
||||||
entryPoints?: EntryPoint[];
|
entryPoints?: EntryPoint[];
|
||||||
hangoutLink?: string;
|
hangoutLink?: string;
|
||||||
|
@ -34,11 +34,12 @@ export default abstract class EventMail {
|
||||||
*
|
*
|
||||||
* @param calEvent
|
* @param calEvent
|
||||||
* @param uid
|
* @param uid
|
||||||
|
* @param additionInformation
|
||||||
*/
|
*/
|
||||||
constructor(calEvent: CalendarEvent, uid: string, additionInformation: AdditionInformation = null) {
|
constructor(calEvent: CalendarEvent, uid: string, additionInformation: AdditionInformation = null) {
|
||||||
this.calEvent = calEvent;
|
this.calEvent = calEvent;
|
||||||
this.uid = uid;
|
this.uid = uid;
|
||||||
this.parser = new CalEventParser(calEvent);
|
this.parser = new CalEventParser(calEvent, uid);
|
||||||
this.additionInformation = additionInformation;
|
this.additionInformation = additionInformation;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -1,14 +1,21 @@
|
||||||
import {CalendarEvent} from "../calendarClient";
|
import { CalendarEvent } from "../calendarClient";
|
||||||
import EventAttendeeMail from "./EventAttendeeMail";
|
import EventAttendeeMail from "./EventAttendeeMail";
|
||||||
import {getFormattedMeetingId, getIntegrationName} from "./helpers";
|
import { getFormattedMeetingId, getIntegrationName } from "./helpers";
|
||||||
import {VideoCallData} from "../videoClient";
|
import { VideoCallData } from "../videoClient";
|
||||||
|
import { AdditionInformation } from "@lib/emails/EventMail";
|
||||||
|
|
||||||
export default class VideoEventAttendeeMail extends EventAttendeeMail {
|
export default class VideoEventAttendeeMail extends EventAttendeeMail {
|
||||||
videoCallData: VideoCallData;
|
videoCallData: VideoCallData;
|
||||||
|
|
||||||
constructor(calEvent: CalendarEvent, uid: string, videoCallData: VideoCallData) {
|
constructor(
|
||||||
|
calEvent: CalendarEvent,
|
||||||
|
uid: string,
|
||||||
|
videoCallData: VideoCallData,
|
||||||
|
additionInformation: AdditionInformation = null
|
||||||
|
) {
|
||||||
super(calEvent, uid);
|
super(calEvent, uid);
|
||||||
this.videoCallData = videoCallData;
|
this.videoCallData = videoCallData;
|
||||||
|
this.additionInformation = additionInformation;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -24,4 +31,4 @@ export default class VideoEventAttendeeMail extends EventAttendeeMail {
|
||||||
<strong>Meeting URL:</strong> <a href="${this.videoCallData.url}">${this.videoCallData.url}</a><br />
|
<strong>Meeting URL:</strong> <a href="${this.videoCallData.url}">${this.videoCallData.url}</a><br />
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -2,13 +2,20 @@ import { CalendarEvent } from "../calendarClient";
|
||||||
import EventOrganizerMail from "./EventOrganizerMail";
|
import EventOrganizerMail from "./EventOrganizerMail";
|
||||||
import { VideoCallData } from "../videoClient";
|
import { VideoCallData } from "../videoClient";
|
||||||
import { getFormattedMeetingId, getIntegrationName } from "./helpers";
|
import { getFormattedMeetingId, getIntegrationName } from "./helpers";
|
||||||
|
import { AdditionInformation } from "@lib/emails/EventMail";
|
||||||
|
|
||||||
export default class VideoEventOrganizerMail extends EventOrganizerMail {
|
export default class VideoEventOrganizerMail extends EventOrganizerMail {
|
||||||
videoCallData: VideoCallData;
|
videoCallData: VideoCallData;
|
||||||
|
|
||||||
constructor(calEvent: CalendarEvent, uid: string, videoCallData: VideoCallData) {
|
constructor(
|
||||||
|
calEvent: CalendarEvent,
|
||||||
|
uid: string,
|
||||||
|
videoCallData: VideoCallData,
|
||||||
|
additionInformation: AdditionInformation = null
|
||||||
|
) {
|
||||||
super(calEvent, uid);
|
super(calEvent, uid);
|
||||||
this.videoCallData = videoCallData;
|
this.videoCallData = videoCallData;
|
||||||
|
this.additionInformation = additionInformation;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
306
lib/events/EventManager.ts
Normal file
|
@ -0,0 +1,306 @@
|
||||||
|
import { CalendarEvent, createEvent, updateEvent } from "@lib/calendarClient";
|
||||||
|
import { Credential } from "@prisma/client";
|
||||||
|
import async from "async";
|
||||||
|
import { createMeeting, updateMeeting } from "@lib/videoClient";
|
||||||
|
import prisma from "@lib/prisma";
|
||||||
|
import { LocationType } from "@lib/location";
|
||||||
|
import { v5 as uuidv5 } from "uuid";
|
||||||
|
import merge from "lodash.merge";
|
||||||
|
|
||||||
|
export interface EventResult {
|
||||||
|
type: string;
|
||||||
|
success: boolean;
|
||||||
|
uid: string;
|
||||||
|
createdEvent?: unknown;
|
||||||
|
updatedEvent?: unknown;
|
||||||
|
originalEvent: CalendarEvent;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateUpdateResult {
|
||||||
|
results: Array<EventResult>;
|
||||||
|
referencesToCreate: Array<PartialReference>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PartialBooking {
|
||||||
|
id: number;
|
||||||
|
references: Array<PartialReference>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PartialReference {
|
||||||
|
id?: number;
|
||||||
|
type: string;
|
||||||
|
uid: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface GetLocationRequestFromIntegrationRequest {
|
||||||
|
location: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default class EventManager {
|
||||||
|
calendarCredentials: Array<Credential>;
|
||||||
|
videoCredentials: Array<Credential>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Takes an array of credentials and initializes a new instance of the EventManager.
|
||||||
|
*
|
||||||
|
* @param credentials
|
||||||
|
*/
|
||||||
|
constructor(credentials: Array<Credential>) {
|
||||||
|
this.calendarCredentials = credentials.filter((cred) => cred.type.endsWith("_calendar"));
|
||||||
|
this.videoCredentials = credentials.filter((cred) => cred.type.endsWith("_video"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Takes a CalendarEvent and creates all necessary integration entries for it.
|
||||||
|
* When a video integration is chosen as the event's location, a video integration
|
||||||
|
* event will be scheduled for it as well.
|
||||||
|
* An optional uid can be set to override the auto-generated uid.
|
||||||
|
*
|
||||||
|
* @param event
|
||||||
|
* @param maybeUid
|
||||||
|
*/
|
||||||
|
public async create(event: CalendarEvent, maybeUid: string = null): Promise<CreateUpdateResult> {
|
||||||
|
event = EventManager.processLocation(event);
|
||||||
|
const isDedicated = EventManager.isDedicatedIntegration(event.location);
|
||||||
|
|
||||||
|
// First, create all calendar events. If this is a dedicated integration event, don't send a mail right here.
|
||||||
|
const results: Array<EventResult> = await this.createAllCalendarEvents(event, isDedicated, maybeUid);
|
||||||
|
|
||||||
|
// If and only if event type is a dedicated meeting, create a dedicated video meeting as well.
|
||||||
|
if (isDedicated) {
|
||||||
|
results.push(await this.createVideoEvent(event, maybeUid));
|
||||||
|
}
|
||||||
|
|
||||||
|
const referencesToCreate: Array<PartialReference> = results.map((result) => {
|
||||||
|
return {
|
||||||
|
type: result.type,
|
||||||
|
uid: result.createdEvent.id.toString(),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
results,
|
||||||
|
referencesToCreate,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Takes a calendarEvent and a rescheduleUid and updates the event that has the
|
||||||
|
* given uid using the data delivered in the given CalendarEvent.
|
||||||
|
*
|
||||||
|
* @param event
|
||||||
|
* @param rescheduleUid
|
||||||
|
*/
|
||||||
|
public async update(event: CalendarEvent, rescheduleUid: string): Promise<CreateUpdateResult> {
|
||||||
|
event = EventManager.processLocation(event);
|
||||||
|
|
||||||
|
// Get details of existing booking.
|
||||||
|
const booking = await prisma.booking.findFirst({
|
||||||
|
where: {
|
||||||
|
uid: rescheduleUid,
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
references: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
type: true,
|
||||||
|
uid: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const isDedicated = EventManager.isDedicatedIntegration(event.location);
|
||||||
|
|
||||||
|
// First, update all calendar events. If this is a dedicated event, don't send a mail right here.
|
||||||
|
const results: Array<EventResult> = await this.updateAllCalendarEvents(event, booking, isDedicated);
|
||||||
|
|
||||||
|
// If and only if event type is a dedicated meeting, update the dedicated video meeting as well.
|
||||||
|
if (isDedicated) {
|
||||||
|
results.push(await this.updateVideoEvent(event, booking));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Now we can delete the old booking and its references.
|
||||||
|
const bookingReferenceDeletes = prisma.bookingReference.deleteMany({
|
||||||
|
where: {
|
||||||
|
bookingId: booking.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const attendeeDeletes = prisma.attendee.deleteMany({
|
||||||
|
where: {
|
||||||
|
bookingId: booking.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const bookingDeletes = prisma.booking.delete({
|
||||||
|
where: {
|
||||||
|
uid: rescheduleUid,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Wait for all deletions to be applied.
|
||||||
|
await Promise.all([bookingReferenceDeletes, attendeeDeletes, bookingDeletes]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
results,
|
||||||
|
referencesToCreate: [...booking.references],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates event entries for all calendar integrations given in the credentials.
|
||||||
|
* When noMail is true, no mails will be sent. This is used when the event is
|
||||||
|
* a video meeting because then the mail containing the video credentials will be
|
||||||
|
* more important than the mails created for these bare calendar events.
|
||||||
|
*
|
||||||
|
* When the optional uid is set, it will be used instead of the auto generated uid.
|
||||||
|
*
|
||||||
|
* @param event
|
||||||
|
* @param noMail
|
||||||
|
* @param maybeUid
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
private createAllCalendarEvents(
|
||||||
|
event: CalendarEvent,
|
||||||
|
noMail: boolean,
|
||||||
|
maybeUid: string = null
|
||||||
|
): Promise<Array<EventResult>> {
|
||||||
|
return async.mapLimit(this.calendarCredentials, 5, async (credential: Credential) => {
|
||||||
|
return createEvent(credential, event, noMail, maybeUid);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks which video integration is needed for the event's location and returns
|
||||||
|
* credentials for that - if existing.
|
||||||
|
* @param event
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
private getVideoCredential(event: CalendarEvent): Credential | undefined {
|
||||||
|
const integrationName = event.location.replace("integrations:", "");
|
||||||
|
return this.videoCredentials.find((credential: Credential) => credential.type.includes(integrationName));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a video event entry for the selected integration location.
|
||||||
|
*
|
||||||
|
* When optional uid is set, it will be used instead of the auto generated uid.
|
||||||
|
*
|
||||||
|
* @param event
|
||||||
|
* @param maybeUid
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
private createVideoEvent(event: CalendarEvent, maybeUid: string = null): Promise<EventResult> {
|
||||||
|
const credential = this.getVideoCredential(event);
|
||||||
|
|
||||||
|
if (credential) {
|
||||||
|
return createMeeting(credential, event, maybeUid);
|
||||||
|
} else {
|
||||||
|
return Promise.reject("No suitable credentials given for the requested integration name.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Updates the event entries for all calendar integrations given in the credentials.
|
||||||
|
* When noMail is true, no mails will be sent. This is used when the event is
|
||||||
|
* a video meeting because then the mail containing the video credentials will be
|
||||||
|
* more important than the mails created for these bare calendar events.
|
||||||
|
*
|
||||||
|
* @param event
|
||||||
|
* @param booking
|
||||||
|
* @param noMail
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
private updateAllCalendarEvents(
|
||||||
|
event: CalendarEvent,
|
||||||
|
booking: PartialBooking,
|
||||||
|
noMail: boolean
|
||||||
|
): Promise<Array<EventResult>> {
|
||||||
|
return async.mapLimit(this.calendarCredentials, 5, async (credential) => {
|
||||||
|
const bookingRefUid = booking.references.filter((ref) => ref.type === credential.type)[0]?.uid;
|
||||||
|
return updateEvent(credential, bookingRefUid, event, noMail);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Updates a single video event.
|
||||||
|
*
|
||||||
|
* @param event
|
||||||
|
* @param booking
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
private updateVideoEvent(event: CalendarEvent, booking: PartialBooking) {
|
||||||
|
const credential = this.getVideoCredential(event);
|
||||||
|
|
||||||
|
if (credential) {
|
||||||
|
const bookingRefUid = booking.references.filter((ref) => ref.type === credential.type)[0].uid;
|
||||||
|
return updateMeeting(credential, bookingRefUid, event);
|
||||||
|
} else {
|
||||||
|
return Promise.reject("No suitable credentials given for the requested integration name.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns true if the given location describes a dedicated integration that
|
||||||
|
* delivers meeting credentials. Zoom, for example, is dedicated, because it
|
||||||
|
* needs to be called independently from any calendar APIs to receive meeting
|
||||||
|
* credentials. Google Meetings, in contrast, are not dedicated, because they
|
||||||
|
* are created while scheduling a regular calendar event by simply adding some
|
||||||
|
* attributes to the payload JSON.
|
||||||
|
*
|
||||||
|
* @param location
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
private static isDedicatedIntegration(location: string): boolean {
|
||||||
|
// Hard-coded for now, because Zoom and Google Meet are both integrations, but one is dedicated, the other one isn't.
|
||||||
|
return location === "integrations:zoom";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper function for processLocation: Returns the conferenceData object to be merged
|
||||||
|
* with the CalendarEvent.
|
||||||
|
*
|
||||||
|
* @param locationObj
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
private static getLocationRequestFromIntegration(locationObj: GetLocationRequestFromIntegrationRequest) {
|
||||||
|
const location = locationObj.location;
|
||||||
|
|
||||||
|
if (location === LocationType.GoogleMeet.valueOf() || location === LocationType.Zoom.valueOf()) {
|
||||||
|
const requestId = uuidv5(location, uuidv5.URL);
|
||||||
|
|
||||||
|
return {
|
||||||
|
conferenceData: {
|
||||||
|
createRequest: {
|
||||||
|
requestId: requestId,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
location,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Takes a CalendarEvent and adds a ConferenceData object to the event
|
||||||
|
* if the event has an integration-related location.
|
||||||
|
*
|
||||||
|
* @param event
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
private static processLocation(event: CalendarEvent): CalendarEvent {
|
||||||
|
// If location is set to an integration location
|
||||||
|
// Build proper transforms for evt object
|
||||||
|
// Extend evt object with those transformations
|
||||||
|
if (event.location?.includes("integration")) {
|
||||||
|
const maybeLocationRequestObject = EventManager.getLocationRequestFromIntegration({
|
||||||
|
location: event.location,
|
||||||
|
});
|
||||||
|
|
||||||
|
event = merge(event, maybeLocationRequestObject);
|
||||||
|
}
|
||||||
|
|
||||||
|
return event;
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,11 +1,19 @@
|
||||||
import prisma from "./prisma";
|
import prisma from "./prisma";
|
||||||
import {CalendarEvent} from "./calendarClient";
|
import { CalendarEvent } from "./calendarClient";
|
||||||
import VideoEventOrganizerMail from "./emails/VideoEventOrganizerMail";
|
import VideoEventOrganizerMail from "./emails/VideoEventOrganizerMail";
|
||||||
import VideoEventAttendeeMail from "./emails/VideoEventAttendeeMail";
|
import VideoEventAttendeeMail from "./emails/VideoEventAttendeeMail";
|
||||||
import {v5 as uuidv5} from 'uuid';
|
import { v5 as uuidv5 } from "uuid";
|
||||||
import short from 'short-uuid';
|
import short from "short-uuid";
|
||||||
import EventAttendeeRescheduledMail from "./emails/EventAttendeeRescheduledMail";
|
import EventAttendeeRescheduledMail from "./emails/EventAttendeeRescheduledMail";
|
||||||
import EventOrganizerRescheduledMail from "./emails/EventOrganizerRescheduledMail";
|
import EventOrganizerRescheduledMail from "./emails/EventOrganizerRescheduledMail";
|
||||||
|
import { EventResult } from "@lib/events/EventManager";
|
||||||
|
import logger from "@lib/logger";
|
||||||
|
import { AdditionInformation, EntryPoint } from "@lib/emails/EventMail";
|
||||||
|
import { getIntegrationName } from "@lib/emails/helpers";
|
||||||
|
import CalEventParser from "@lib/CalEventParser";
|
||||||
|
import { Credential } from "@prisma/client";
|
||||||
|
|
||||||
|
const log = logger.getChildLogger({ prefix: ["[lib] videoClient"] });
|
||||||
|
|
||||||
const translator = short();
|
const translator = short();
|
||||||
|
|
||||||
|
@ -33,63 +41,67 @@ function handleErrorsRaw(response) {
|
||||||
}
|
}
|
||||||
|
|
||||||
const zoomAuth = (credential) => {
|
const zoomAuth = (credential) => {
|
||||||
|
const isExpired = (expiryDate) => expiryDate < +new Date();
|
||||||
|
const authHeader =
|
||||||
|
"Basic " +
|
||||||
|
Buffer.from(process.env.ZOOM_CLIENT_ID + ":" + process.env.ZOOM_CLIENT_SECRET).toString("base64");
|
||||||
|
|
||||||
const isExpired = (expiryDate) => expiryDate < +(new Date());
|
const refreshAccessToken = (refreshToken) =>
|
||||||
const authHeader = 'Basic ' + Buffer.from(process.env.ZOOM_CLIENT_ID + ':' + process.env.ZOOM_CLIENT_SECRET).toString('base64');
|
fetch("https://zoom.us/oauth/token", {
|
||||||
|
method: "POST",
|
||||||
const refreshAccessToken = (refreshToken) => fetch('https://zoom.us/oauth/token', {
|
headers: {
|
||||||
method: 'POST',
|
Authorization: authHeader,
|
||||||
headers: {
|
"Content-Type": "application/x-www-form-urlencoded",
|
||||||
'Authorization': authHeader,
|
},
|
||||||
'Content-Type': 'application/x-www-form-urlencoded'
|
body: new URLSearchParams({
|
||||||
},
|
refresh_token: refreshToken,
|
||||||
body: new URLSearchParams({
|
grant_type: "refresh_token",
|
||||||
'refresh_token': refreshToken,
|
}),
|
||||||
'grant_type': 'refresh_token',
|
|
||||||
})
|
})
|
||||||
})
|
.then(handleErrorsJson)
|
||||||
.then(handleErrorsJson)
|
.then(async (responseBody) => {
|
||||||
.then(async (responseBody) => {
|
// Store new tokens in database.
|
||||||
// Store new tokens in database.
|
await prisma.credential.update({
|
||||||
await prisma.credential.update({
|
where: {
|
||||||
where: {
|
id: credential.id,
|
||||||
id: credential.id
|
},
|
||||||
},
|
data: {
|
||||||
data: {
|
key: responseBody,
|
||||||
key: responseBody
|
},
|
||||||
}
|
});
|
||||||
|
credential.key.access_token = responseBody.access_token;
|
||||||
|
credential.key.expires_in = Math.round(+new Date() / 1000 + responseBody.expires_in);
|
||||||
|
return credential.key.access_token;
|
||||||
});
|
});
|
||||||
credential.key.access_token = responseBody.access_token;
|
|
||||||
credential.key.expires_in = Math.round((+(new Date()) / 1000) + responseBody.expires_in);
|
|
||||||
return credential.key.access_token;
|
|
||||||
})
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
getToken: () => !isExpired(credential.key.expires_in) ? Promise.resolve(credential.key.access_token) : refreshAccessToken(credential.key.refresh_token)
|
getToken: () =>
|
||||||
|
!isExpired(credential.key.expires_in)
|
||||||
|
? Promise.resolve(credential.key.access_token)
|
||||||
|
: refreshAccessToken(credential.key.refresh_token),
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
interface VideoApiAdapter {
|
interface VideoApiAdapter {
|
||||||
createMeeting(event: CalendarEvent): Promise<any>;
|
createMeeting(event: CalendarEvent): Promise<any>;
|
||||||
|
|
||||||
updateMeeting(uid: String, event: CalendarEvent);
|
updateMeeting(uid: string, event: CalendarEvent);
|
||||||
|
|
||||||
deleteMeeting(uid: String);
|
deleteMeeting(uid: string): Promise<unknown>;
|
||||||
|
|
||||||
getAvailability(dateFrom, dateTo): Promise<any>;
|
getAvailability(dateFrom, dateTo): Promise<any>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ZoomVideo = (credential): VideoApiAdapter => {
|
const ZoomVideo = (credential): VideoApiAdapter => {
|
||||||
|
|
||||||
const auth = zoomAuth(credential);
|
const auth = zoomAuth(credential);
|
||||||
|
|
||||||
const translateEvent = (event: CalendarEvent) => {
|
const translateEvent = (event: CalendarEvent) => {
|
||||||
// Documentation at: https://marketplace.zoom.us/docs/api-reference/zoom-api/meetings/meetingcreate
|
// Documentation at: https://marketplace.zoom.us/docs/api-reference/zoom-api/meetings/meetingcreate
|
||||||
return {
|
return {
|
||||||
topic: event.title,
|
topic: event.title,
|
||||||
type: 2, // Means that this is a scheduled meeting
|
type: 2, // Means that this is a scheduled meeting
|
||||||
start_time: event.startTime,
|
start_time: event.startTime,
|
||||||
duration: ((new Date(event.endTime)).getTime() - (new Date(event.startTime)).getTime()) / 60000,
|
duration: (new Date(event.endTime).getTime() - new Date(event.startTime).getTime()) / 60000,
|
||||||
//schedule_for: "string", TODO: Used when scheduling the meeting for someone else (needed?)
|
//schedule_for: "string", TODO: Used when scheduling the meeting for someone else (needed?)
|
||||||
timezone: event.attendees[0].timeZone,
|
timezone: event.attendees[0].timeZone,
|
||||||
//password: "string", TODO: Should we use a password? Maybe generate a random one?
|
//password: "string", TODO: Should we use a password? Maybe generate a random one?
|
||||||
|
@ -97,8 +109,8 @@ const ZoomVideo = (credential): VideoApiAdapter => {
|
||||||
settings: {
|
settings: {
|
||||||
host_video: true,
|
host_video: true,
|
||||||
participant_video: true,
|
participant_video: true,
|
||||||
cn_meeting: false, // TODO: true if host meeting in China
|
cn_meeting: false, // TODO: true if host meeting in China
|
||||||
in_meeting: false, // TODO: true if host meeting in India
|
in_meeting: false, // TODO: true if host meeting in India
|
||||||
join_before_host: true,
|
join_before_host: true,
|
||||||
mute_upon_entry: false,
|
mute_upon_entry: false,
|
||||||
watermark: false,
|
watermark: false,
|
||||||
|
@ -107,82 +119,112 @@ const ZoomVideo = (credential): VideoApiAdapter => {
|
||||||
audio: "both",
|
audio: "both",
|
||||||
auto_recording: "none",
|
auto_recording: "none",
|
||||||
enforce_login: false,
|
enforce_login: false,
|
||||||
registrants_email_notification: true
|
registrants_email_notification: true,
|
||||||
}
|
},
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
getAvailability: (dateFrom, dateTo) => {
|
getAvailability: () => {
|
||||||
return auth.getToken().then(
|
return auth
|
||||||
// TODO Possibly implement pagination for cases when there are more than 300 meetings already scheduled.
|
.getToken()
|
||||||
(accessToken) => fetch('https://api.zoom.us/v2/users/me/meetings?type=scheduled&page_size=300', {
|
.then(
|
||||||
method: 'get',
|
// TODO Possibly implement pagination for cases when there are more than 300 meetings already scheduled.
|
||||||
headers: {
|
(accessToken) =>
|
||||||
'Authorization': 'Bearer ' + accessToken
|
fetch("https://api.zoom.us/v2/users/me/meetings?type=scheduled&page_size=300", {
|
||||||
}
|
method: "get",
|
||||||
})
|
headers: {
|
||||||
.then(handleErrorsJson)
|
Authorization: "Bearer " + accessToken,
|
||||||
.then(responseBody => {
|
},
|
||||||
return responseBody.meetings.map((meeting) => ({
|
})
|
||||||
start: meeting.start_time,
|
.then(handleErrorsJson)
|
||||||
end: (new Date((new Date(meeting.start_time)).getTime() + meeting.duration * 60000)).toISOString()
|
.then((responseBody) => {
|
||||||
}))
|
return responseBody.meetings.map((meeting) => ({
|
||||||
})
|
start: meeting.start_time,
|
||||||
).catch((err) => {
|
end: new Date(
|
||||||
console.log(err);
|
new Date(meeting.start_time).getTime() + meeting.duration * 60000
|
||||||
});
|
).toISOString(),
|
||||||
|
}));
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.catch((err) => {
|
||||||
|
console.log(err);
|
||||||
|
});
|
||||||
},
|
},
|
||||||
createMeeting: (event: CalendarEvent) => auth.getToken().then(accessToken => fetch('https://api.zoom.us/v2/users/me/meetings', {
|
createMeeting: (event: CalendarEvent) =>
|
||||||
method: 'POST',
|
auth.getToken().then((accessToken) =>
|
||||||
headers: {
|
fetch("https://api.zoom.us/v2/users/me/meetings", {
|
||||||
'Authorization': 'Bearer ' + accessToken,
|
method: "POST",
|
||||||
'Content-Type': 'application/json',
|
headers: {
|
||||||
},
|
Authorization: "Bearer " + accessToken,
|
||||||
body: JSON.stringify(translateEvent(event))
|
"Content-Type": "application/json",
|
||||||
}).then(handleErrorsJson)),
|
},
|
||||||
deleteMeeting: (uid: String) => auth.getToken().then(accessToken => fetch('https://api.zoom.us/v2/meetings/' + uid, {
|
body: JSON.stringify(translateEvent(event)),
|
||||||
method: 'DELETE',
|
}).then(handleErrorsJson)
|
||||||
headers: {
|
),
|
||||||
'Authorization': 'Bearer ' + accessToken
|
deleteMeeting: (uid: string) =>
|
||||||
}
|
auth.getToken().then((accessToken) =>
|
||||||
}).then(handleErrorsRaw)),
|
fetch("https://api.zoom.us/v2/meetings/" + uid, {
|
||||||
updateMeeting: (uid: String, event: CalendarEvent) => auth.getToken().then(accessToken => fetch('https://api.zoom.us/v2/meetings/' + uid, {
|
method: "DELETE",
|
||||||
method: 'PATCH',
|
headers: {
|
||||||
headers: {
|
Authorization: "Bearer " + accessToken,
|
||||||
'Authorization': 'Bearer ' + accessToken,
|
},
|
||||||
'Content-Type': 'application/json'
|
}).then(handleErrorsRaw)
|
||||||
},
|
),
|
||||||
body: JSON.stringify(translateEvent(event))
|
updateMeeting: (uid: string, event: CalendarEvent) =>
|
||||||
}).then(handleErrorsRaw)),
|
auth.getToken().then((accessToken) =>
|
||||||
}
|
fetch("https://api.zoom.us/v2/meetings/" + uid, {
|
||||||
|
method: "PATCH",
|
||||||
|
headers: {
|
||||||
|
Authorization: "Bearer " + accessToken,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify(translateEvent(event)),
|
||||||
|
}).then(handleErrorsRaw)
|
||||||
|
),
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
// factory
|
// factory
|
||||||
const videoIntegrations = (withCredentials): VideoApiAdapter[] => withCredentials.map((cred) => {
|
const videoIntegrations = (withCredentials): VideoApiAdapter[] =>
|
||||||
switch (cred.type) {
|
withCredentials
|
||||||
case 'zoom_video':
|
.map((cred) => {
|
||||||
return ZoomVideo(cred);
|
switch (cred.type) {
|
||||||
default:
|
case "zoom_video":
|
||||||
return; // unknown credential, could be legacy? In any case, ignore
|
return ZoomVideo(cred);
|
||||||
}
|
default:
|
||||||
}).filter(Boolean);
|
return; // unknown credential, could be legacy? In any case, ignore
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.filter(Boolean);
|
||||||
|
|
||||||
|
const getBusyVideoTimes: (withCredentials) => Promise<unknown[]> = (withCredentials) =>
|
||||||
|
Promise.all(videoIntegrations(withCredentials).map((c) => c.getAvailability())).then((results) =>
|
||||||
|
results.reduce((acc, availability) => acc.concat(availability), [])
|
||||||
|
);
|
||||||
|
|
||||||
const getBusyVideoTimes = (withCredentials, dateFrom, dateTo) => Promise.all(
|
const createMeeting = async (
|
||||||
videoIntegrations(withCredentials).map(c => c.getAvailability(dateFrom, dateTo))
|
credential: Credential,
|
||||||
).then(
|
calEvent: CalendarEvent,
|
||||||
(results) => results.reduce((acc, availability) => acc.concat(availability), [])
|
maybeUid: string = null
|
||||||
);
|
): Promise<EventResult> => {
|
||||||
|
const parser: CalEventParser = new CalEventParser(calEvent, maybeUid);
|
||||||
const createMeeting = async (credential, calEvent: CalendarEvent): Promise<any> => {
|
const uid: string = parser.getUid();
|
||||||
const uid: string = translator.fromUUID(uuidv5(JSON.stringify(calEvent), uuidv5.URL));
|
|
||||||
|
|
||||||
if (!credential) {
|
if (!credential) {
|
||||||
throw new Error("Credentials must be set! Video platforms are optional, so this method shouldn't even be called when no video credentials are set.");
|
throw new Error(
|
||||||
|
"Credentials must be set! Video platforms are optional, so this method shouldn't even be called when no video credentials are set."
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const creationResult = await videoIntegrations([credential])[0].createMeeting(calEvent);
|
let success = true;
|
||||||
|
|
||||||
|
const creationResult = await videoIntegrations([credential])[0]
|
||||||
|
.createMeeting(calEvent)
|
||||||
|
.catch((e) => {
|
||||||
|
log.error("createMeeting failed", e, calEvent);
|
||||||
|
success = false;
|
||||||
|
});
|
||||||
|
|
||||||
const videoCallData: VideoCallData = {
|
const videoCallData: VideoCallData = {
|
||||||
type: credential.type,
|
type: credential.type,
|
||||||
|
@ -191,60 +233,92 @@ const createMeeting = async (credential, calEvent: CalendarEvent): Promise<any>
|
||||||
url: creationResult.join_url,
|
url: creationResult.join_url,
|
||||||
};
|
};
|
||||||
|
|
||||||
const organizerMail = new VideoEventOrganizerMail(calEvent, uid, videoCallData);
|
const entryPoint: EntryPoint = {
|
||||||
const attendeeMail = new VideoEventAttendeeMail(calEvent, uid, videoCallData);
|
entryPointType: getIntegrationName(videoCallData),
|
||||||
|
uri: videoCallData.url,
|
||||||
|
label: "Enter Meeting",
|
||||||
|
pin: videoCallData.password,
|
||||||
|
};
|
||||||
|
|
||||||
|
const additionInformation: AdditionInformation = {
|
||||||
|
entryPoints: [entryPoint],
|
||||||
|
};
|
||||||
|
|
||||||
|
const organizerMail = new VideoEventOrganizerMail(calEvent, uid, videoCallData, additionInformation);
|
||||||
|
const attendeeMail = new VideoEventAttendeeMail(calEvent, uid, videoCallData, additionInformation);
|
||||||
try {
|
try {
|
||||||
await organizerMail.sendEmail();
|
await organizerMail.sendEmail();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("organizerMail.sendEmail failed", e)
|
console.error("organizerMail.sendEmail failed", e);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!creationResult || !creationResult.disableConfirmationEmail) {
|
if (!creationResult || !creationResult.disableConfirmationEmail) {
|
||||||
try {
|
try {
|
||||||
await attendeeMail.sendEmail();
|
await attendeeMail.sendEmail();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("attendeeMail.sendEmail failed", e)
|
console.error("attendeeMail.sendEmail failed", e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
type: credential.type,
|
||||||
|
success,
|
||||||
uid,
|
uid,
|
||||||
createdEvent: creationResult
|
createdEvent: creationResult,
|
||||||
|
originalEvent: calEvent,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
const updateMeeting = async (credential, uidToUpdate: String, calEvent: CalendarEvent): Promise<any> => {
|
const updateMeeting = async (
|
||||||
|
credential: Credential,
|
||||||
|
uidToUpdate: string,
|
||||||
|
calEvent: CalendarEvent
|
||||||
|
): Promise<EventResult> => {
|
||||||
const newUid: string = translator.fromUUID(uuidv5(JSON.stringify(calEvent), uuidv5.URL));
|
const newUid: string = translator.fromUUID(uuidv5(JSON.stringify(calEvent), uuidv5.URL));
|
||||||
|
|
||||||
if (!credential) {
|
if (!credential) {
|
||||||
throw new Error("Credentials must be set! Video platforms are optional, so this method shouldn't even be called when no video credentials are set.");
|
throw new Error(
|
||||||
|
"Credentials must be set! Video platforms are optional, so this method shouldn't even be called when no video credentials are set."
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const updateResult = credential ? await videoIntegrations([credential])[0].updateMeeting(uidToUpdate, calEvent) : null;
|
let success = true;
|
||||||
|
|
||||||
|
const updateResult = credential
|
||||||
|
? await videoIntegrations([credential])[0]
|
||||||
|
.updateMeeting(uidToUpdate, calEvent)
|
||||||
|
.catch((e) => {
|
||||||
|
log.error("updateMeeting failed", e, calEvent);
|
||||||
|
success = false;
|
||||||
|
})
|
||||||
|
: null;
|
||||||
|
|
||||||
const organizerMail = new EventOrganizerRescheduledMail(calEvent, newUid);
|
const organizerMail = new EventOrganizerRescheduledMail(calEvent, newUid);
|
||||||
const attendeeMail = new EventAttendeeRescheduledMail(calEvent, newUid);
|
const attendeeMail = new EventAttendeeRescheduledMail(calEvent, newUid);
|
||||||
try {
|
try {
|
||||||
await organizerMail.sendEmail();
|
await organizerMail.sendEmail();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("organizerMail.sendEmail failed", e)
|
console.error("organizerMail.sendEmail failed", e);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!updateResult || !updateResult.disableConfirmationEmail) {
|
if (!updateResult || !updateResult.disableConfirmationEmail) {
|
||||||
try {
|
try {
|
||||||
await attendeeMail.sendEmail();
|
await attendeeMail.sendEmail();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("attendeeMail.sendEmail failed", e)
|
console.error("attendeeMail.sendEmail failed", e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
type: credential.type,
|
||||||
|
success,
|
||||||
uid: newUid,
|
uid: newUid,
|
||||||
updatedEvent: updateResult
|
updatedEvent: updateResult,
|
||||||
|
originalEvent: calEvent,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
const deleteMeeting = (credential, uid: String): Promise<any> => {
|
const deleteMeeting = (credential: Credential, uid: string): Promise<unknown> => {
|
||||||
if (credential) {
|
if (credential) {
|
||||||
return videoIntegrations([credential])[0].deleteMeeting(uid);
|
return videoIntegrations([credential])[0].deleteMeeting(uid);
|
||||||
}
|
}
|
||||||
|
@ -252,4 +326,4 @@ const deleteMeeting = (credential, uid: String): Promise<any> => {
|
||||||
return Promise.resolve({});
|
return Promise.resolve({});
|
||||||
};
|
};
|
||||||
|
|
||||||
export {getBusyVideoTimes, createMeeting, updateMeeting, deleteMeeting};
|
export { getBusyVideoTimes, createMeeting, updateMeeting, deleteMeeting };
|
||||||
|
|
|
@ -1,31 +1,40 @@
|
||||||
|
/* eslint-disable @typescript-eslint/no-var-requires */
|
||||||
const withTM = require('next-transpile-modules')(['react-timezone-select']);
|
const withTM = require("next-transpile-modules")(["react-timezone-select"]);
|
||||||
|
|
||||||
// TODO: Revisit this later with getStaticProps in App
|
// TODO: Revisit this later with getStaticProps in App
|
||||||
if (process.env.NEXTAUTH_URL) {
|
if (process.env.NEXTAUTH_URL) {
|
||||||
process.env.BASE_URL = process.env.NEXTAUTH_URL.replace('/api/auth', '');
|
process.env.BASE_URL = process.env.NEXTAUTH_URL.replace("/api/auth", "");
|
||||||
}
|
}
|
||||||
|
|
||||||
if ( ! process.env.EMAIL_FROM ) {
|
if (!process.env.EMAIL_FROM) {
|
||||||
console.warn('\x1b[33mwarn', '\x1b[0m', 'EMAIL_FROM environment variable is not set, this may indicate mailing is currently disabled. Please refer to the .env.example file.');
|
console.warn(
|
||||||
|
"\x1b[33mwarn",
|
||||||
|
"\x1b[0m",
|
||||||
|
"EMAIL_FROM environment variable is not set, this may indicate mailing is currently disabled. Please refer to the .env.example file."
|
||||||
|
);
|
||||||
}
|
}
|
||||||
if (process.env.BASE_URL) {
|
if (process.env.BASE_URL) {
|
||||||
process.env.NEXTAUTH_URL = process.env.BASE_URL + '/api/auth';
|
process.env.NEXTAUTH_URL = process.env.BASE_URL + "/api/auth";
|
||||||
}
|
}
|
||||||
|
|
||||||
const validJson = (jsonString) => {
|
const validJson = (jsonString) => {
|
||||||
try {
|
try {
|
||||||
const o = JSON.parse(jsonString);
|
const o = JSON.parse(jsonString);
|
||||||
if (o && typeof o === "object") {
|
if (o && typeof o === "object") {
|
||||||
return o;
|
return o;
|
||||||
}
|
|
||||||
}
|
}
|
||||||
catch (e) { console.error(e); }
|
} catch (e) {
|
||||||
return false;
|
console.error(e);
|
||||||
}
|
}
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
if (process.env.GOOGLE_API_CREDENTIALS && ! validJson(process.env.GOOGLE_API_CREDENTIALS)) {
|
if (process.env.GOOGLE_API_CREDENTIALS && !validJson(process.env.GOOGLE_API_CREDENTIALS)) {
|
||||||
console.warn('\x1b[33mwarn', '\x1b[0m', "- Disabled 'Google Calendar' integration. Reason: Invalid value for GOOGLE_API_CREDENTIALS environment variable. When set, this value needs to contain valid JSON like {\"web\":{\"client_id\":\"<clid>\",\"client_secret\":\"<secret>\",\"redirect_uris\":[\"<yourhost>/api/integrations/googlecalendar/callback>\"]}. You can download this JSON from your OAuth Client @ https://console.cloud.google.com/apis/credentials.");
|
console.warn(
|
||||||
|
"\x1b[33mwarn",
|
||||||
|
"\x1b[0m",
|
||||||
|
'- Disabled \'Google Calendar\' integration. Reason: Invalid value for GOOGLE_API_CREDENTIALS environment variable. When set, this value needs to contain valid JSON like {"web":{"client_id":"<clid>","client_secret":"<secret>","redirect_uris":["<yourhost>/api/integrations/googlecalendar/callback>"]}. You can download this JSON from your OAuth Client @ https://console.cloud.google.com/apis/credentials.'
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = withTM({
|
module.exports = withTM({
|
||||||
|
@ -42,10 +51,10 @@ module.exports = withTM({
|
||||||
async redirects() {
|
async redirects() {
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
source: '/settings',
|
source: "/settings",
|
||||||
destination: '/settings/profile',
|
destination: "/settings/profile",
|
||||||
permanent: true,
|
permanent: true,
|
||||||
}
|
},
|
||||||
]
|
];
|
||||||
}
|
},
|
||||||
});
|
});
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
import { ChevronRightIcon } from "@heroicons/react/solid";
|
import { ChevronRightIcon } from "@heroicons/react/solid";
|
||||||
import { DocumentTextIcon, BookOpenIcon, CodeIcon, CheckIcon } from "@heroicons/react/outline";
|
import { BookOpenIcon, CheckIcon, CodeIcon, DocumentTextIcon } from "@heroicons/react/outline";
|
||||||
import { useRouter } from "next/router";
|
import { useRouter } from "next/router";
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
|
|
@ -9,10 +9,7 @@ function MyApp({ Component, pageProps }: AppProps) {
|
||||||
<TelemetryProvider value={createTelemetryClient()}>
|
<TelemetryProvider value={createTelemetryClient()}>
|
||||||
<Provider session={pageProps.session}>
|
<Provider session={pageProps.session}>
|
||||||
<Head>
|
<Head>
|
||||||
<meta
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
name="viewport"
|
|
||||||
content="width=device-width, initial-scale=1.0"
|
|
||||||
/>
|
|
||||||
</Head>
|
</Head>
|
||||||
<Component {...pageProps} />
|
<Component {...pageProps} />
|
||||||
</Provider>
|
</Provider>
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
import Document, { Html, Head, Main, NextScript } from "next/document";
|
import Document, { Head, Html, Main, NextScript } from "next/document";
|
||||||
|
|
||||||
class MyDocument extends Document {
|
class MyDocument extends Document {
|
||||||
static async getInitialProps(ctx) {
|
static async getInitialProps(ctx) {
|
||||||
|
|
|
@ -1,16 +1,15 @@
|
||||||
import type { NextApiRequest, NextApiResponse } from "next";
|
import type { NextApiRequest, NextApiResponse } from "next";
|
||||||
import prisma from "../../../lib/prisma";
|
import prisma from "../../../lib/prisma";
|
||||||
import { CalendarEvent, createEvent, getBusyCalendarTimes, updateEvent } from "../../../lib/calendarClient";
|
import { CalendarEvent, getBusyCalendarTimes } from "@lib/calendarClient";
|
||||||
import async from "async";
|
|
||||||
import { v5 as uuidv5 } from "uuid";
|
import { v5 as uuidv5 } from "uuid";
|
||||||
import short from "short-uuid";
|
import short from "short-uuid";
|
||||||
import { createMeeting, getBusyVideoTimes, updateMeeting } from "../../../lib/videoClient";
|
import { getBusyVideoTimes } from "@lib/videoClient";
|
||||||
import EventAttendeeMail from "../../../lib/emails/EventAttendeeMail";
|
import EventAttendeeMail from "../../../lib/emails/EventAttendeeMail";
|
||||||
import { getEventName } from "../../../lib/event";
|
import { getEventName } from "@lib/event";
|
||||||
import { LocationType } from "../../../lib/location";
|
|
||||||
import merge from "lodash.merge";
|
|
||||||
import dayjs from "dayjs";
|
import dayjs from "dayjs";
|
||||||
import logger from "../../../lib/logger";
|
import logger from "../../../lib/logger";
|
||||||
|
import EventManager, { CreateUpdateResult, EventResult } from "@lib/events/EventManager";
|
||||||
|
import { User } from "@prisma/client";
|
||||||
|
|
||||||
import utc from "dayjs/plugin/utc";
|
import utc from "dayjs/plugin/utc";
|
||||||
import timezone from "dayjs/plugin/timezone";
|
import timezone from "dayjs/plugin/timezone";
|
||||||
|
@ -35,11 +34,6 @@ function isAvailable(busyTimes, time, length) {
|
||||||
const startTime = dayjs(busyTime.start);
|
const startTime = dayjs(busyTime.start);
|
||||||
const endTime = dayjs(busyTime.end);
|
const endTime = dayjs(busyTime.end);
|
||||||
|
|
||||||
// Check if start times are the same
|
|
||||||
if (dayjs(time).format("HH:mm") == startTime.format("HH:mm")) {
|
|
||||||
t = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if time is between start and end times
|
// Check if time is between start and end times
|
||||||
if (dayjs(time).isBetween(startTime, endTime)) {
|
if (dayjs(time).isBetween(startTime, endTime)) {
|
||||||
t = false;
|
t = false;
|
||||||
|
@ -86,167 +80,8 @@ function isOutOfBounds(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
interface GetLocationRequestFromIntegrationRequest {
|
|
||||||
location: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const getLocationRequestFromIntegration = ({ location }: GetLocationRequestFromIntegrationRequest) => {
|
|
||||||
if (location === LocationType.GoogleMeet.valueOf()) {
|
|
||||||
const requestId = uuidv5(location, uuidv5.URL);
|
|
||||||
|
|
||||||
return {
|
|
||||||
conferenceData: {
|
|
||||||
createRequest: {
|
|
||||||
requestId: requestId,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
};
|
|
||||||
|
|
||||||
async function rescheduleEvent(
|
|
||||||
rescheduleUid: string | string[],
|
|
||||||
results: unknown[],
|
|
||||||
calendarCredentials: unknown[],
|
|
||||||
evt: CalendarEvent,
|
|
||||||
videoCredentials: unknown[],
|
|
||||||
referencesToCreate: { type: string; uid: string }[]
|
|
||||||
): Promise<{
|
|
||||||
referencesToCreate: { type: string; uid: string }[];
|
|
||||||
results: unknown[];
|
|
||||||
error: { errorCode: string; message: string } | null;
|
|
||||||
}> {
|
|
||||||
// Reschedule event
|
|
||||||
const booking = await prisma.booking.findFirst({
|
|
||||||
where: {
|
|
||||||
uid: rescheduleUid,
|
|
||||||
},
|
|
||||||
select: {
|
|
||||||
id: true,
|
|
||||||
references: {
|
|
||||||
select: {
|
|
||||||
id: true,
|
|
||||||
type: true,
|
|
||||||
uid: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// Use all integrations
|
|
||||||
results = results.concat(
|
|
||||||
await async.mapLimit(calendarCredentials, 5, async (credential) => {
|
|
||||||
const bookingRefUid = booking.references.filter((ref) => ref.type === credential.type)[0].uid;
|
|
||||||
return updateEvent(credential, bookingRefUid, evt)
|
|
||||||
.then((response) => ({ type: credential.type, success: true, response }))
|
|
||||||
.catch((e) => {
|
|
||||||
log.error("updateEvent failed", e, evt);
|
|
||||||
return { type: credential.type, success: false };
|
|
||||||
});
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
results = results.concat(
|
|
||||||
await async.mapLimit(videoCredentials, 5, async (credential) => {
|
|
||||||
const bookingRefUid = booking.references.filter((ref) => ref.type === credential.type)[0].uid;
|
|
||||||
return updateMeeting(credential, bookingRefUid, evt)
|
|
||||||
.then((response) => ({ type: credential.type, success: true, response }))
|
|
||||||
.catch((e) => {
|
|
||||||
log.error("updateMeeting failed", e, evt);
|
|
||||||
return { type: credential.type, success: false };
|
|
||||||
});
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
if (results.length > 0 && results.every((res) => !res.success)) {
|
|
||||||
const error = {
|
|
||||||
errorCode: "BookingReschedulingMeetingFailed",
|
|
||||||
message: "Booking Rescheduling failed",
|
|
||||||
};
|
|
||||||
|
|
||||||
return { referencesToCreate: [], results: [], error: error };
|
|
||||||
}
|
|
||||||
|
|
||||||
// Clone elements
|
|
||||||
referencesToCreate = [...booking.references];
|
|
||||||
|
|
||||||
// Now we can delete the old booking and its references.
|
|
||||||
const bookingReferenceDeletes = prisma.bookingReference.deleteMany({
|
|
||||||
where: {
|
|
||||||
bookingId: booking.id,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
const attendeeDeletes = prisma.attendee.deleteMany({
|
|
||||||
where: {
|
|
||||||
bookingId: booking.id,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
const bookingDeletes = prisma.booking.delete({
|
|
||||||
where: {
|
|
||||||
uid: rescheduleUid,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
await Promise.all([bookingReferenceDeletes, attendeeDeletes, bookingDeletes]);
|
|
||||||
return { error: undefined, results, referencesToCreate };
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function scheduleEvent(
|
|
||||||
results: unknown[],
|
|
||||||
calendarCredentials: unknown[],
|
|
||||||
evt: CalendarEvent,
|
|
||||||
videoCredentials: unknown[],
|
|
||||||
referencesToCreate: { type: string; uid: string }[]
|
|
||||||
): Promise<{
|
|
||||||
referencesToCreate: { type: string; uid: string }[];
|
|
||||||
results: unknown[];
|
|
||||||
error: { errorCode: string; message: string } | null;
|
|
||||||
}> {
|
|
||||||
// Schedule event
|
|
||||||
results = results.concat(
|
|
||||||
await async.mapLimit(calendarCredentials, 5, async (credential) => {
|
|
||||||
return createEvent(credential, evt)
|
|
||||||
.then((response) => ({ type: credential.type, success: true, response }))
|
|
||||||
.catch((e) => {
|
|
||||||
log.error("createEvent failed", e, evt);
|
|
||||||
return { type: credential.type, success: false };
|
|
||||||
});
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
results = results.concat(
|
|
||||||
await async.mapLimit(videoCredentials, 5, async (credential) => {
|
|
||||||
return createMeeting(credential, evt)
|
|
||||||
.then((response) => ({ type: credential.type, success: true, response }))
|
|
||||||
.catch((e) => {
|
|
||||||
log.error("createMeeting failed", e, evt);
|
|
||||||
return { type: credential.type, success: false };
|
|
||||||
});
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
if (results.length > 0 && results.every((res) => !res.success)) {
|
|
||||||
const error = {
|
|
||||||
errorCode: "BookingCreatingMeetingFailed",
|
|
||||||
message: "Booking failed",
|
|
||||||
};
|
|
||||||
|
|
||||||
return { referencesToCreate: [], results: [], error: error };
|
|
||||||
}
|
|
||||||
|
|
||||||
referencesToCreate = results.map((result) => {
|
|
||||||
return {
|
|
||||||
type: result.type,
|
|
||||||
uid: result.response.createdEvent.id.toString(),
|
|
||||||
};
|
|
||||||
});
|
|
||||||
return { error: undefined, results, referencesToCreate };
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function handleLegacyConfirmationMail(
|
export async function handleLegacyConfirmationMail(
|
||||||
results: unknown[],
|
results: Array<EventResult>,
|
||||||
selectedEventType: { requiresConfirmation: boolean },
|
selectedEventType: { requiresConfirmation: boolean },
|
||||||
evt: CalendarEvent,
|
evt: CalendarEvent,
|
||||||
hashUID: string
|
hashUID: string
|
||||||
|
@ -283,7 +118,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
|
||||||
return res.status(400).json(error);
|
return res.status(400).json(error);
|
||||||
}
|
}
|
||||||
|
|
||||||
let currentUser = await prisma.user.findFirst({
|
let currentUser: User = await prisma.user.findFirst({
|
||||||
where: {
|
where: {
|
||||||
username: user,
|
username: user,
|
||||||
},
|
},
|
||||||
|
@ -302,10 +137,6 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// Split credentials up into calendar credentials and video credentials
|
|
||||||
let calendarCredentials = currentUser.credentials.filter((cred) => cred.type.endsWith("_calendar"));
|
|
||||||
let videoCredentials = currentUser.credentials.filter((cred) => cred.type.endsWith("_video"));
|
|
||||||
|
|
||||||
const hasCalendarIntegrations =
|
const hasCalendarIntegrations =
|
||||||
currentUser.credentials.filter((cred) => cred.type.endsWith("_calendar")).length > 0;
|
currentUser.credentials.filter((cred) => cred.type.endsWith("_calendar")).length > 0;
|
||||||
const hasVideoIntegrations =
|
const hasVideoIntegrations =
|
||||||
|
@ -317,11 +148,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
|
||||||
dayjs(req.body.end).endOf("day").utc().format("YYYY-MM-DDTHH:mm:ss[Z]"),
|
dayjs(req.body.end).endOf("day").utc().format("YYYY-MM-DDTHH:mm:ss[Z]"),
|
||||||
selectedCalendars
|
selectedCalendars
|
||||||
);
|
);
|
||||||
const videoAvailability = await getBusyVideoTimes(
|
const videoAvailability = await getBusyVideoTimes(currentUser.credentials);
|
||||||
currentUser.credentials,
|
|
||||||
dayjs(req.body.start).startOf("day").utc().format(),
|
|
||||||
dayjs(req.body.end).endOf("day").utc().format()
|
|
||||||
);
|
|
||||||
let commonAvailability = [];
|
let commonAvailability = [];
|
||||||
|
|
||||||
if (hasCalendarIntegrations && hasVideoIntegrations) {
|
if (hasCalendarIntegrations && hasVideoIntegrations) {
|
||||||
|
@ -347,9 +174,9 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
|
||||||
name: true,
|
name: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
calendarCredentials = currentUser.credentials.filter((cred) => cred.type.endsWith("_calendar"));
|
|
||||||
videoCredentials = currentUser.credentials.filter((cred) => cred.type.endsWith("_video"));
|
|
||||||
|
|
||||||
|
// Initialize EventManager with credentials
|
||||||
|
const eventManager = new EventManager(currentUser.credentials);
|
||||||
const rescheduleUid = req.body.rescheduleUid;
|
const rescheduleUid = req.body.rescheduleUid;
|
||||||
|
|
||||||
const selectedEventType = await prisma.eventType.findFirst({
|
const selectedEventType = await prisma.eventType.findFirst({
|
||||||
|
@ -370,20 +197,18 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const rawLocation = req.body.location;
|
|
||||||
|
|
||||||
const invitee = [{ email: req.body.email, name: req.body.name, timeZone: req.body.timeZone }];
|
const invitee = [{ email: req.body.email, name: req.body.name, timeZone: req.body.timeZone }];
|
||||||
const guests = req.body.guests.map(guest=>{
|
const guests = req.body.guests.map((guest) => {
|
||||||
const g = {
|
const g = {
|
||||||
'email': guest,
|
email: guest,
|
||||||
'name': '',
|
name: "",
|
||||||
'timeZone': req.body.timeZone
|
timeZone: req.body.timeZone,
|
||||||
}
|
};
|
||||||
return g;
|
return g;
|
||||||
});
|
});
|
||||||
const attendeesList = [...invitee,...guests];
|
const attendeesList = [...invitee, ...guests];
|
||||||
|
|
||||||
let evt: CalendarEvent = {
|
const evt: CalendarEvent = {
|
||||||
type: selectedEventType.title,
|
type: selectedEventType.title,
|
||||||
title: getEventName(req.body.name, selectedEventType.title, selectedEventType.eventName),
|
title: getEventName(req.body.name, selectedEventType.title, selectedEventType.eventName),
|
||||||
description: req.body.notes,
|
description: req.body.notes,
|
||||||
|
@ -391,25 +216,9 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
|
||||||
endTime: req.body.end,
|
endTime: req.body.end,
|
||||||
organizer: { email: currentUser.email, name: currentUser.name, timeZone: currentUser.timeZone },
|
organizer: { email: currentUser.email, name: currentUser.name, timeZone: currentUser.timeZone },
|
||||||
attendees: attendeesList,
|
attendees: attendeesList,
|
||||||
|
location: req.body.location, // Will be processed by the EventManager later.
|
||||||
};
|
};
|
||||||
|
|
||||||
// If phone or inPerson use raw location
|
|
||||||
// set evt.location to req.body.location
|
|
||||||
if (!rawLocation?.includes("integration")) {
|
|
||||||
evt.location = rawLocation;
|
|
||||||
}
|
|
||||||
|
|
||||||
// If location is set to an integration location
|
|
||||||
// Build proper transforms for evt object
|
|
||||||
// Extend evt object with those transformations
|
|
||||||
if (rawLocation?.includes("integration")) {
|
|
||||||
const maybeLocationRequestObject = getLocationRequestFromIntegration({
|
|
||||||
location: rawLocation,
|
|
||||||
});
|
|
||||||
|
|
||||||
evt = merge(evt, maybeLocationRequestObject);
|
|
||||||
}
|
|
||||||
|
|
||||||
const eventType = await prisma.eventType.findFirst({
|
const eventType = await prisma.eventType.findFirst({
|
||||||
where: {
|
where: {
|
||||||
userId: currentUser.id,
|
userId: currentUser.id,
|
||||||
|
@ -468,44 +277,47 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
|
||||||
return res.status(400).json(error);
|
return res.status(400).json(error);
|
||||||
}
|
}
|
||||||
|
|
||||||
let results = [];
|
let results: Array<EventResult> = [];
|
||||||
let referencesToCreate = [];
|
let referencesToCreate = [];
|
||||||
|
|
||||||
if (rescheduleUid) {
|
if (rescheduleUid) {
|
||||||
const __ret = await rescheduleEvent(
|
// Use EventManager to conditionally use all needed integrations.
|
||||||
rescheduleUid,
|
const updateResults: CreateUpdateResult = await eventManager.update(evt, rescheduleUid);
|
||||||
results,
|
|
||||||
calendarCredentials,
|
if (results.length > 0 && results.every((res) => !res.success)) {
|
||||||
evt,
|
const error = {
|
||||||
videoCredentials,
|
errorCode: "BookingReschedulingMeetingFailed",
|
||||||
referencesToCreate
|
message: "Booking Rescheduling failed",
|
||||||
);
|
};
|
||||||
if (__ret.error) {
|
|
||||||
log.error(`Booking ${user} failed`, __ret.error, results);
|
log.error(`Booking ${user} failed`, error, results);
|
||||||
return res.status(500).json(__ret.error);
|
return res.status(500).json(error);
|
||||||
}
|
}
|
||||||
results = __ret.results;
|
|
||||||
referencesToCreate = __ret.referencesToCreate;
|
// Forward results
|
||||||
|
results = updateResults.results;
|
||||||
|
referencesToCreate = updateResults.referencesToCreate;
|
||||||
} else if (!selectedEventType.requiresConfirmation) {
|
} else if (!selectedEventType.requiresConfirmation) {
|
||||||
const __ret = await scheduleEvent(
|
// Use EventManager to conditionally use all needed integrations.
|
||||||
results,
|
const createResults: CreateUpdateResult = await eventManager.create(evt);
|
||||||
calendarCredentials,
|
|
||||||
evt,
|
if (results.length > 0 && results.every((res) => !res.success)) {
|
||||||
videoCredentials,
|
const error = {
|
||||||
referencesToCreate
|
errorCode: "BookingCreatingMeetingFailed",
|
||||||
);
|
message: "Booking failed",
|
||||||
if (__ret.error) {
|
};
|
||||||
log.error(`Booking ${user} failed`, __ret.error, results);
|
|
||||||
return res.status(500).json(__ret.error);
|
log.error(`Booking ${user} failed`, error, results);
|
||||||
|
return res.status(500).json(error);
|
||||||
}
|
}
|
||||||
results = __ret.results;
|
|
||||||
referencesToCreate = __ret.referencesToCreate;
|
// Forward results
|
||||||
|
results = createResults.results;
|
||||||
|
referencesToCreate = createResults.referencesToCreate;
|
||||||
}
|
}
|
||||||
|
|
||||||
const hashUID =
|
const hashUID =
|
||||||
results.length > 0
|
results.length > 0 ? results[0].uid : translator.fromUUID(uuidv5(JSON.stringify(evt), uuidv5.URL));
|
||||||
? results[0].response.uid
|
|
||||||
: translator.fromUUID(uuidv5(JSON.stringify(evt), uuidv5.URL));
|
|
||||||
// TODO Should just be set to the true case as soon as we have a "bare email" integration class.
|
// TODO Should just be set to the true case as soon as we have a "bare email" integration class.
|
||||||
// UID generation should happen in the integration itself, not here.
|
// UID generation should happen in the integration itself, not here.
|
||||||
const legacyMailError = await handleLegacyConfirmationMail(results, selectedEventType, evt, hashUID);
|
const legacyMailError = await handleLegacyConfirmationMail(results, selectedEventType, evt, hashUID);
|
||||||
|
@ -532,6 +344,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
|
||||||
attendees: {
|
attendees: {
|
||||||
create: evt.attendees,
|
create: evt.attendees,
|
||||||
},
|
},
|
||||||
|
location: evt.location, // This is the raw location that can be processed by the EventManager.
|
||||||
confirmed: !selectedEventType.requiresConfirmation,
|
confirmed: !selectedEventType.requiresConfirmation,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
|
@ -1,9 +1,10 @@
|
||||||
import type { NextApiRequest, NextApiResponse } from "next";
|
import type { NextApiRequest, NextApiResponse } from "next";
|
||||||
import { getSession } from "next-auth/client";
|
import { getSession } from "next-auth/client";
|
||||||
import prisma from "../../../lib/prisma";
|
import prisma from "../../../lib/prisma";
|
||||||
import { handleLegacyConfirmationMail, scheduleEvent } from "./[user]";
|
import { handleLegacyConfirmationMail } from "./[user]";
|
||||||
import { CalendarEvent } from "@lib/calendarClient";
|
import { CalendarEvent } from "@lib/calendarClient";
|
||||||
import EventRejectionMail from "@lib/emails/EventRejectionMail";
|
import EventRejectionMail from "@lib/emails/EventRejectionMail";
|
||||||
|
import EventManager from "@lib/events/EventManager";
|
||||||
|
|
||||||
export default async function handler(req: NextApiRequest, res: NextApiResponse): Promise<void> {
|
export default async function handler(req: NextApiRequest, res: NextApiResponse): Promise<void> {
|
||||||
const session = await getSession({ req: req });
|
const session = await getSession({ req: req });
|
||||||
|
@ -41,6 +42,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
|
||||||
endTime: true,
|
endTime: true,
|
||||||
confirmed: true,
|
confirmed: true,
|
||||||
attendees: true,
|
attendees: true,
|
||||||
|
location: true,
|
||||||
userId: true,
|
userId: true,
|
||||||
id: true,
|
id: true,
|
||||||
uid: true,
|
uid: true,
|
||||||
|
@ -54,9 +56,6 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
|
||||||
return res.status(400).json({ message: "booking already confirmed" });
|
return res.status(400).json({ message: "booking already confirmed" });
|
||||||
}
|
}
|
||||||
|
|
||||||
const calendarCredentials = currentUser.credentials.filter((cred) => cred.type.endsWith("_calendar"));
|
|
||||||
const videoCredentials = currentUser.credentials.filter((cred) => cred.type.endsWith("_video"));
|
|
||||||
|
|
||||||
const evt: CalendarEvent = {
|
const evt: CalendarEvent = {
|
||||||
type: booking.title,
|
type: booking.title,
|
||||||
title: booking.title,
|
title: booking.title,
|
||||||
|
@ -65,10 +64,12 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
|
||||||
endTime: booking.endTime.toISOString(),
|
endTime: booking.endTime.toISOString(),
|
||||||
organizer: { email: currentUser.email, name: currentUser.name, timeZone: currentUser.timeZone },
|
organizer: { email: currentUser.email, name: currentUser.name, timeZone: currentUser.timeZone },
|
||||||
attendees: booking.attendees,
|
attendees: booking.attendees,
|
||||||
|
location: booking.location,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (req.body.confirmed) {
|
if (req.body.confirmed) {
|
||||||
const scheduleResult = await scheduleEvent([], calendarCredentials, evt, videoCredentials, []);
|
const eventManager = new EventManager(currentUser.credentials);
|
||||||
|
const scheduleResult = await eventManager.create(evt, booking.uid);
|
||||||
|
|
||||||
await handleLegacyConfirmationMail(
|
await handleLegacyConfirmationMail(
|
||||||
scheduleResult.results,
|
scheduleResult.results,
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
import prisma from '../../lib/prisma';
|
import prisma from "../../lib/prisma";
|
||||||
import {deleteEvent} from "../../lib/calendarClient";
|
import { deleteEvent } from "../../lib/calendarClient";
|
||||||
import async from 'async';
|
import async from "async";
|
||||||
import {deleteMeeting} from "../../lib/videoClient";
|
import { deleteMeeting } from "../../lib/videoClient";
|
||||||
|
|
||||||
export default async function handler(req, res) {
|
export default async function handler(req, res) {
|
||||||
if (req.method == "POST") {
|
if (req.method == "POST") {
|
||||||
|
@ -15,36 +15,38 @@ export default async function handler(req, res) {
|
||||||
id: true,
|
id: true,
|
||||||
user: {
|
user: {
|
||||||
select: {
|
select: {
|
||||||
credentials: true
|
credentials: true,
|
||||||
}
|
},
|
||||||
},
|
},
|
||||||
attendees: true,
|
attendees: true,
|
||||||
references: {
|
references: {
|
||||||
select: {
|
select: {
|
||||||
uid: true,
|
uid: true,
|
||||||
type: true
|
type: true,
|
||||||
}
|
},
|
||||||
}
|
},
|
||||||
}
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const apiDeletes = async.mapLimit(bookingToDelete.user.credentials, 5, async (credential) => {
|
const apiDeletes = async.mapLimit(bookingToDelete.user.credentials, 5, async (credential) => {
|
||||||
const bookingRefUid = bookingToDelete.references.filter((ref) => ref.type === credential.type)[0].uid;
|
const bookingRefUid = bookingToDelete.references.filter((ref) => ref.type === credential.type)[0]?.uid;
|
||||||
if(credential.type.endsWith("_calendar")) {
|
if (bookingRefUid) {
|
||||||
return await deleteEvent(credential, bookingRefUid);
|
if (credential.type.endsWith("_calendar")) {
|
||||||
} else if(credential.type.endsWith("_video")) {
|
return await deleteEvent(credential, bookingRefUid);
|
||||||
return await deleteMeeting(credential, bookingRefUid);
|
} else if (credential.type.endsWith("_video")) {
|
||||||
|
return await deleteMeeting(credential, bookingRefUid);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
const attendeeDeletes = prisma.attendee.deleteMany({
|
const attendeeDeletes = prisma.attendee.deleteMany({
|
||||||
where: {
|
where: {
|
||||||
bookingId: bookingToDelete.id
|
bookingId: bookingToDelete.id,
|
||||||
}
|
},
|
||||||
});
|
});
|
||||||
const bookingReferenceDeletes = prisma.bookingReference.deleteMany({
|
const bookingReferenceDeletes = prisma.bookingReference.deleteMany({
|
||||||
where: {
|
where: {
|
||||||
bookingId: bookingToDelete.id
|
bookingId: bookingToDelete.id,
|
||||||
}
|
},
|
||||||
});
|
});
|
||||||
const bookingDeletes = prisma.booking.delete({
|
const bookingDeletes = prisma.booking.delete({
|
||||||
where: {
|
where: {
|
||||||
|
@ -52,17 +54,12 @@ export default async function handler(req, res) {
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
await Promise.all([
|
await Promise.all([apiDeletes, attendeeDeletes, bookingReferenceDeletes, bookingDeletes]);
|
||||||
apiDeletes,
|
|
||||||
attendeeDeletes,
|
|
||||||
bookingReferenceDeletes,
|
|
||||||
bookingDeletes
|
|
||||||
]);
|
|
||||||
|
|
||||||
//TODO Perhaps send emails to user and client to tell about the cancellation
|
//TODO Perhaps send emails to user and client to tell about the cancellation
|
||||||
|
|
||||||
res.status(200).json({message: 'Booking successfully deleted.'});
|
res.status(200).json({ message: "Booking successfully deleted." });
|
||||||
} else {
|
} else {
|
||||||
res.status(405).json({message: 'This endpoint only accepts POST requests.'});
|
res.status(405).json({ message: "This endpoint only accepts POST requests." });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,45 +1,51 @@
|
||||||
import { useRouter } from 'next/router';
|
import { useRouter } from "next/router";
|
||||||
import { XIcon } from '@heroicons/react/outline';
|
import { XIcon } from "@heroicons/react/outline";
|
||||||
import Head from 'next/head';
|
import Head from "next/head";
|
||||||
import Link from 'next/link';
|
import Link from "next/link";
|
||||||
|
|
||||||
export default function Error() {
|
export default function Error() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { error } = router.query;
|
const { error } = router.query;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed z-50 inset-0 overflow-y-auto" aria-labelledby="modal-title" role="dialog" aria-modal="true">
|
<div
|
||||||
<Head>
|
className="fixed z-50 inset-0 overflow-y-auto"
|
||||||
<title>{error} - Calendso</title>
|
aria-labelledby="modal-title"
|
||||||
<link rel="icon" href="/favicon.ico" />
|
role="dialog"
|
||||||
</Head>
|
aria-modal="true">
|
||||||
<div className="flex items-end justify-center min-h-screen pt-4 px-4 pb-20 text-center sm:block sm:p-0">
|
<Head>
|
||||||
<span className="hidden sm:inline-block sm:align-middle sm:h-screen" aria-hidden="true">​</span>
|
<title>{error} - Calendso</title>
|
||||||
<div className="inline-block align-bottom bg-white rounded-lg px-4 pt-5 pb-4 text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-sm sm:w-full sm:p-6">
|
<link rel="icon" href="/favicon.ico" />
|
||||||
<div>
|
</Head>
|
||||||
<div className="mx-auto flex items-center justify-center h-12 w-12 rounded-full bg-red-100">
|
<div className="flex items-end justify-center min-h-screen pt-4 px-4 pb-20 text-center sm:block sm:p-0">
|
||||||
<XIcon className="h-6 w-6 text-red-600" />
|
<span className="hidden sm:inline-block sm:align-middle sm:h-screen" aria-hidden="true">
|
||||||
</div>
|
​
|
||||||
<div className="mt-3 text-center sm:mt-5">
|
</span>
|
||||||
<h3 className="text-lg leading-6 font-medium text-gray-900" id="modal-title">
|
<div className="inline-block align-bottom bg-white rounded-lg px-4 pt-5 pb-4 text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-sm sm:w-full sm:p-6">
|
||||||
{error}
|
<div>
|
||||||
</h3>
|
<div className="mx-auto flex items-center justify-center h-12 w-12 rounded-full bg-red-100">
|
||||||
<div className="mt-2">
|
<XIcon className="h-6 w-6 text-red-600" />
|
||||||
<p className="text-sm text-gray-500">
|
|
||||||
An error occurred when logging you in. Head back to the login screen and try again.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="mt-5 sm:mt-6">
|
|
||||||
<Link href="/auth/login">
|
|
||||||
<a className="inline-flex justify-center w-full rounded-sm border border-transparent shadow-sm px-4 py-2 bg-neutral-900 text-base font-medium text-white hover:bg-neutral-800 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-neutral-500 sm:text-sm">
|
|
||||||
Go back to the login page
|
|
||||||
</a>
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
<div className="mt-3 text-center sm:mt-5">
|
||||||
|
<h3 className="text-lg leading-6 font-medium text-gray-900" id="modal-title">
|
||||||
|
{error}
|
||||||
|
</h3>
|
||||||
|
<div className="mt-2">
|
||||||
|
<p className="text-sm text-gray-500">
|
||||||
|
An error occurred when logging you in. Head back to the login screen and try again.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-5 sm:mt-6">
|
||||||
|
<Link href="/auth/login">
|
||||||
|
<a className="inline-flex justify-center w-full rounded-sm border border-transparent shadow-sm px-4 py-2 bg-neutral-900 text-base font-medium text-white hover:bg-neutral-800 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-neutral-500 sm:text-sm">
|
||||||
|
Go back to the login page
|
||||||
|
</a>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -2,11 +2,10 @@ import { getCsrfToken } from "next-auth/client";
|
||||||
import prisma from "../../../lib/prisma";
|
import prisma from "../../../lib/prisma";
|
||||||
|
|
||||||
import Head from "next/head";
|
import Head from "next/head";
|
||||||
import React from "react";
|
import React, { useMemo } from "react";
|
||||||
import debounce from "lodash.debounce";
|
import debounce from "lodash.debounce";
|
||||||
import dayjs from "dayjs";
|
import dayjs from "dayjs";
|
||||||
import { ResetPasswordRequest } from "@prisma/client";
|
import { ResetPasswordRequest } from "@prisma/client";
|
||||||
import { useMemo } from "react";
|
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { GetServerSidePropsContext } from "next";
|
import { GetServerSidePropsContext } from "next";
|
||||||
|
|
||||||
|
|
|
@ -1,41 +1,45 @@
|
||||||
import Head from 'next/head';
|
import Head from "next/head";
|
||||||
import Link from 'next/link';
|
import Link from "next/link";
|
||||||
import { CheckIcon } from '@heroicons/react/outline';
|
import { CheckIcon } from "@heroicons/react/outline";
|
||||||
|
|
||||||
export default function Logout() {
|
export default function Logout() {
|
||||||
return (
|
return (
|
||||||
<div className="fixed z-50 inset-0 overflow-y-auto" aria-labelledby="modal-title" role="dialog" aria-modal="true">
|
<div
|
||||||
<Head>
|
className="fixed z-50 inset-0 overflow-y-auto"
|
||||||
<title>Logged out - Calendso</title>
|
aria-labelledby="modal-title"
|
||||||
<link rel="icon" href="/favicon.ico" />
|
role="dialog"
|
||||||
</Head>
|
aria-modal="true">
|
||||||
<div className="flex items-end justify-center min-h-screen pt-4 px-4 pb-20 text-center sm:block sm:p-0">
|
<Head>
|
||||||
<span className="hidden sm:inline-block sm:align-middle sm:h-screen" aria-hidden="true">​</span>
|
<title>Logged out - Calendso</title>
|
||||||
<div className="inline-block align-bottom bg-white rounded-lg px-4 pt-5 pb-4 text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-sm sm:w-full sm:p-6">
|
<link rel="icon" href="/favicon.ico" />
|
||||||
<div>
|
</Head>
|
||||||
<div className="mx-auto flex items-center justify-center h-12 w-12 rounded-full bg-green-100">
|
<div className="flex items-end justify-center min-h-screen pt-4 px-4 pb-20 text-center sm:block sm:p-0">
|
||||||
<CheckIcon className="h-6 w-6 text-green-600" />
|
<span className="hidden sm:inline-block sm:align-middle sm:h-screen" aria-hidden="true">
|
||||||
</div>
|
​
|
||||||
<div className="mt-3 text-center sm:mt-5">
|
</span>
|
||||||
<h3 className="text-lg leading-6 font-medium text-gray-900" id="modal-title">
|
<div className="inline-block align-bottom bg-white rounded-lg px-4 pt-5 pb-4 text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-sm sm:w-full sm:p-6">
|
||||||
You've been logged out
|
<div>
|
||||||
</h3>
|
<div className="mx-auto flex items-center justify-center h-12 w-12 rounded-full bg-green-100">
|
||||||
<div className="mt-2">
|
<CheckIcon className="h-6 w-6 text-green-600" />
|
||||||
<p className="text-sm text-gray-500">
|
|
||||||
We hope to see you again soon!
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="mt-5 sm:mt-6">
|
|
||||||
<Link href="/auth/login">
|
|
||||||
<a className="inline-flex justify-center w-full rounded-md border border-transparent shadow-sm px-4 py-2 bg-black text-base font-medium text-white focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-black sm:text-sm">
|
|
||||||
Go back to the login page
|
|
||||||
</a>
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
<div className="mt-3 text-center sm:mt-5">
|
||||||
|
<h3 className="text-lg leading-6 font-medium text-gray-900" id="modal-title">
|
||||||
|
You've been logged out
|
||||||
|
</h3>
|
||||||
|
<div className="mt-2">
|
||||||
|
<p className="text-sm text-gray-500">We hope to see you again soon!</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-5 sm:mt-6">
|
||||||
|
<Link href="/auth/login">
|
||||||
|
<a className="inline-flex justify-center w-full rounded-md border border-transparent shadow-sm px-4 py-2 bg-black text-base font-medium text-white focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-black sm:text-sm">
|
||||||
|
Go back to the login page
|
||||||
|
</a>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,27 +1,25 @@
|
||||||
import Head from 'next/head';
|
import Head from "next/head";
|
||||||
import {useRouter} from "next/router";
|
import { useRouter } from "next/router";
|
||||||
import {signIn} from 'next-auth/client'
|
import { signIn } from "next-auth/client";
|
||||||
import ErrorAlert from "../../components/ui/alerts/Error";
|
import ErrorAlert from "../../components/ui/alerts/Error";
|
||||||
import {useState} from "react";
|
import { useState } from "react";
|
||||||
import {UsernameInput} from "../../components/ui/UsernameInput";
|
import { UsernameInput } from "../../components/ui/UsernameInput";
|
||||||
import prisma from "../../lib/prisma";
|
import prisma from "../../lib/prisma";
|
||||||
|
|
||||||
export default function Signup(props) {
|
export default function Signup(props) {
|
||||||
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
const [ hasErrors, setHasErrors ] = useState(false);
|
const [hasErrors, setHasErrors] = useState(false);
|
||||||
const [ errorMessage, setErrorMessage ] = useState('');
|
const [errorMessage, setErrorMessage] = useState("");
|
||||||
|
|
||||||
const handleErrors = async (resp) => {
|
const handleErrors = async (resp) => {
|
||||||
if (!resp.ok) {
|
if (!resp.ok) {
|
||||||
const err = await resp.json();
|
const err = await resp.json();
|
||||||
throw new Error(err.message);
|
throw new Error(err.message);
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
const signUp = (e) => {
|
const signUp = (e) => {
|
||||||
|
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
||||||
if (e.target.password.value !== e.target.passwordcheck.value) {
|
if (e.target.password.value !== e.target.passwordcheck.value) {
|
||||||
|
@ -31,39 +29,37 @@ export default function Signup(props) {
|
||||||
const email: string = e.target.email.value;
|
const email: string = e.target.email.value;
|
||||||
const password: string = e.target.password.value;
|
const password: string = e.target.password.value;
|
||||||
|
|
||||||
fetch('/api/auth/signup',
|
fetch("/api/auth/signup", {
|
||||||
{
|
body: JSON.stringify({
|
||||||
body: JSON.stringify({
|
username: e.target.username.value,
|
||||||
username: e.target.username.value,
|
password,
|
||||||
password,
|
email,
|
||||||
email,
|
}),
|
||||||
}),
|
headers: {
|
||||||
headers: {
|
"Content-Type": "application/json",
|
||||||
'Content-Type': 'application/json',
|
},
|
||||||
},
|
method: "POST",
|
||||||
method: 'POST'
|
})
|
||||||
}
|
|
||||||
)
|
|
||||||
.then(handleErrors)
|
.then(handleErrors)
|
||||||
.then(
|
.then(() => signIn("Calendso", { callbackUrl: (router.query.callbackUrl || "") as string }))
|
||||||
() => signIn('Calendso', { callbackUrl: (router.query.callbackUrl || '') as string })
|
.catch((err) => {
|
||||||
)
|
|
||||||
.catch( (err) => {
|
|
||||||
setHasErrors(true);
|
setHasErrors(true);
|
||||||
setErrorMessage(err.message);
|
setErrorMessage(err.message);
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-gray-50 flex flex-col justify-center py-12 sm:px-6 lg:px-8" aria-labelledby="modal-title" role="dialog" aria-modal="true">
|
<div
|
||||||
|
className="min-h-screen bg-gray-50 flex flex-col justify-center py-12 sm:px-6 lg:px-8"
|
||||||
|
aria-labelledby="modal-title"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true">
|
||||||
<Head>
|
<Head>
|
||||||
<title>Sign up</title>
|
<title>Sign up</title>
|
||||||
<link rel="icon" href="/favicon.ico" />
|
<link rel="icon" href="/favicon.ico" />
|
||||||
</Head>
|
</Head>
|
||||||
<div className="sm:mx-auto sm:w-full sm:max-w-md">
|
<div className="sm:mx-auto sm:w-full sm:max-w-md">
|
||||||
<h2 className="text-center text-3xl font-extrabold text-gray-900">
|
<h2 className="text-center text-3xl font-extrabold text-gray-900">Create your account</h2>
|
||||||
Create your account
|
|
||||||
</h2>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-8 sm:mx-auto sm:w-full sm:max-w-md">
|
<div className="mt-8 sm:mx-auto sm:w-full sm:max-w-md">
|
||||||
<div className="bg-white py-8 px-4 shadow mx-2 sm:rounded-lg sm:px-10">
|
<div className="bg-white py-8 px-4 shadow mx-2 sm:rounded-lg sm:px-10">
|
||||||
|
@ -74,23 +70,60 @@ export default function Signup(props) {
|
||||||
<UsernameInput required />
|
<UsernameInput required />
|
||||||
</div>
|
</div>
|
||||||
<div className="mb-2">
|
<div className="mb-2">
|
||||||
<label htmlFor="email" className="block text-sm font-medium text-gray-700">Email</label>
|
<label htmlFor="email" className="block text-sm font-medium text-gray-700">
|
||||||
<input type="email" name="email" id="email" placeholder="jdoe@example.com" disabled={!!props.email} readOnly={!!props.email} value={props.email} className="bg-gray-100 mt-1 block w-full border border-gray-300 rounded-md shadow-sm py-2 px-3 focus:outline-none focus:ring-black focus:border-black sm:text-sm" />
|
Email
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
name="email"
|
||||||
|
id="email"
|
||||||
|
placeholder="jdoe@example.com"
|
||||||
|
disabled={!!props.email}
|
||||||
|
readOnly={!!props.email}
|
||||||
|
value={props.email}
|
||||||
|
className="bg-gray-100 mt-1 block w-full border border-gray-300 rounded-md shadow-sm py-2 px-3 focus:outline-none focus:ring-black focus:border-black sm:text-sm"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="mb-2">
|
<div className="mb-2">
|
||||||
<label htmlFor="password" className="block text-sm font-medium text-gray-700">Password</label>
|
<label htmlFor="password" className="block text-sm font-medium text-gray-700">
|
||||||
<input type="password" name="password" id="password" required placeholder="•••••••••••••" className="mt-1 block w-full border border-gray-300 rounded-md shadow-sm py-2 px-3 focus:outline-none focus:ring-black focus:border-black sm:text-sm" />
|
Password
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
name="password"
|
||||||
|
id="password"
|
||||||
|
required
|
||||||
|
placeholder="•••••••••••••"
|
||||||
|
className="mt-1 block w-full border border-gray-300 rounded-md shadow-sm py-2 px-3 focus:outline-none focus:ring-black focus:border-black sm:text-sm"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label htmlFor="passwordcheck" className="block text-sm font-medium text-gray-700">Confirm password</label>
|
<label htmlFor="passwordcheck" className="block text-sm font-medium text-gray-700">
|
||||||
<input type="password" name="passwordcheck" id="passwordcheck" required placeholder="•••••••••••••" className="mt-1 block w-full border border-gray-300 rounded-md shadow-sm py-2 px-3 focus:outline-none focus:ring-black focus:border-black sm:text-sm" />
|
Confirm password
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
name="passwordcheck"
|
||||||
|
id="passwordcheck"
|
||||||
|
required
|
||||||
|
placeholder="•••••••••••••"
|
||||||
|
className="mt-1 block w-full border border-gray-300 rounded-md shadow-sm py-2 px-3 focus:outline-none focus:ring-black focus:border-black sm:text-sm"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-3 sm:mt-4 flex">
|
<div className="mt-3 sm:mt-4 flex">
|
||||||
<input type="submit" value="Create Account"
|
<input
|
||||||
className="btn btn-primary w-7/12 mr-2 inline-flex justify-center rounded-md border border-transparent cursor-pointer shadow-sm px-4 py-2 bg-blue-600 text-base font-medium text-white hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-black sm:text-sm" />
|
type="submit"
|
||||||
<a onClick={() => signIn('Calendso', { callbackUrl: (router.query.callbackUrl || '') as string })}
|
value="Create Account"
|
||||||
className="w-5/12 inline-flex justify-center text-sm text-gray-500 font-medium border px-4 py-2 rounded btn cursor-pointer">Login instead</a>
|
className="btn btn-primary w-7/12 mr-2 inline-flex justify-center rounded-md border border-transparent cursor-pointer shadow-sm px-4 py-2 bg-blue-600 text-base font-medium text-white hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-black sm:text-sm"
|
||||||
|
/>
|
||||||
|
<a
|
||||||
|
onClick={() =>
|
||||||
|
signIn("Calendso", { callbackUrl: (router.query.callbackUrl || "") as string })
|
||||||
|
}
|
||||||
|
className="w-5/12 inline-flex justify-center text-sm text-gray-500 font-medium border px-4 py-2 rounded btn cursor-pointer">
|
||||||
|
Login instead
|
||||||
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
@ -103,39 +136,41 @@ export async function getServerSideProps(ctx) {
|
||||||
if (!ctx.query.token) {
|
if (!ctx.query.token) {
|
||||||
return {
|
return {
|
||||||
notFound: true,
|
notFound: true,
|
||||||
}
|
};
|
||||||
}
|
}
|
||||||
const verificationRequest = await prisma.verificationRequest.findUnique({
|
const verificationRequest = await prisma.verificationRequest.findUnique({
|
||||||
where: {
|
where: {
|
||||||
token: ctx.query.token,
|
token: ctx.query.token,
|
||||||
}
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// for now, disable if no verificationRequestToken given or token expired
|
// for now, disable if no verificationRequestToken given or token expired
|
||||||
if ( ! verificationRequest || verificationRequest.expires < new Date() ) {
|
if (!verificationRequest || verificationRequest.expires < new Date()) {
|
||||||
return {
|
return {
|
||||||
notFound: true,
|
notFound: true,
|
||||||
}
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const existingUser = await prisma.user.findFirst({
|
const existingUser = await prisma.user.findFirst({
|
||||||
where: {
|
where: {
|
||||||
AND: [
|
AND: [
|
||||||
{
|
{
|
||||||
email: verificationRequest.identifier
|
email: verificationRequest.identifier,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
emailVerified: {
|
emailVerified: {
|
||||||
not: null,
|
not: null,
|
||||||
},
|
},
|
||||||
}
|
},
|
||||||
]
|
],
|
||||||
}
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (existingUser) {
|
if (existingUser) {
|
||||||
return { redirect: { permanent: false, destination: '/auth/login?callbackUrl=' + ctx.query.callbackUrl } };
|
return {
|
||||||
|
redirect: { permanent: false, destination: "/auth/login?callbackUrl=" + ctx.query.callbackUrl },
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
return { props: { email: verificationRequest.identifier } };
|
return { props: { email: verificationRequest.identifier } };
|
||||||
}
|
}
|
||||||
|
|
|
@ -7,7 +7,7 @@ import { useRouter } from "next/router";
|
||||||
import { useRef, useState } from "react";
|
import { useRef, useState } from "react";
|
||||||
import { getSession, useSession } from "next-auth/client";
|
import { getSession, useSession } from "next-auth/client";
|
||||||
import { ClockIcon } from "@heroicons/react/outline";
|
import { ClockIcon } from "@heroicons/react/outline";
|
||||||
import Loader from '@components/Loader';
|
import Loader from "@components/Loader";
|
||||||
|
|
||||||
export default function Availability(props) {
|
export default function Availability(props) {
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||||
|
@ -53,7 +53,7 @@ export default function Availability(props) {
|
||||||
m = m < 10 ? "0" + m : m;
|
m = m < 10 ? "0" + m : m;
|
||||||
return `${h}:${m}`;
|
return `${h}:${m}`;
|
||||||
}
|
}
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||||
async function createEventTypeHandler(event) {
|
async function createEventTypeHandler(event) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
|
|
||||||
|
@ -64,7 +64,7 @@ export default function Availability(props) {
|
||||||
const enteredIsHidden = isHiddenRef.current.checked;
|
const enteredIsHidden = isHiddenRef.current.checked;
|
||||||
|
|
||||||
// TODO: Add validation
|
// TODO: Add validation
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||||
const response = await fetch("/api/availability/eventtype", {
|
const response = await fetch("/api/availability/eventtype", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
|
@ -100,7 +100,7 @@ export default function Availability(props) {
|
||||||
const bufferMins = enteredBufferHours * 60 + enteredBufferMins;
|
const bufferMins = enteredBufferHours * 60 + enteredBufferMins;
|
||||||
|
|
||||||
// TODO: Add validation
|
// TODO: Add validation
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||||
const response = await fetch("/api/availability/day", {
|
const response = await fetch("/api/availability/day", {
|
||||||
method: "PATCH",
|
method: "PATCH",
|
||||||
body: JSON.stringify({ start: startMins, end: endMins, buffer: bufferMins }),
|
body: JSON.stringify({ start: startMins, end: endMins, buffer: bufferMins }),
|
||||||
|
|
|
@ -6,13 +6,15 @@ import dayjs from "dayjs";
|
||||||
import utc from "dayjs/plugin/utc";
|
import utc from "dayjs/plugin/utc";
|
||||||
import { GetServerSideProps } from "next";
|
import { GetServerSideProps } from "next";
|
||||||
import prisma from "@lib/prisma";
|
import prisma from "@lib/prisma";
|
||||||
import Loader from '@components/Loader';
|
import Loader from "@components/Loader";
|
||||||
|
|
||||||
dayjs.extend(utc);
|
dayjs.extend(utc);
|
||||||
|
|
||||||
export default function Troubleshoot({ user }) {
|
export default function Troubleshoot({ user }) {
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||||
const [session, loading] = useSession();
|
const [session, loading] = useSession();
|
||||||
const [availability, setAvailability] = useState([]);
|
const [availability, setAvailability] = useState([]);
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||||
const [selectedDate, setSelectedDate] = useState(dayjs());
|
const [selectedDate, setSelectedDate] = useState(dayjs());
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
|
|
|
@ -1,14 +1,14 @@
|
||||||
import {useState} from 'react';
|
import { useState } from "react";
|
||||||
import Head from 'next/head';
|
import Head from "next/head";
|
||||||
import prisma from '../../lib/prisma';
|
import prisma from "../../lib/prisma";
|
||||||
import {useRouter} from 'next/router';
|
import { useRouter } from "next/router";
|
||||||
import dayjs from 'dayjs';
|
import dayjs from "dayjs";
|
||||||
import {CalendarIcon, ClockIcon, XIcon} from '@heroicons/react/solid';
|
import { CalendarIcon, ClockIcon, XIcon } from "@heroicons/react/solid";
|
||||||
import isSameOrBefore from 'dayjs/plugin/isSameOrBefore';
|
import isSameOrBefore from "dayjs/plugin/isSameOrBefore";
|
||||||
import isBetween from 'dayjs/plugin/isBetween';
|
import isBetween from "dayjs/plugin/isBetween";
|
||||||
import utc from 'dayjs/plugin/utc';
|
import utc from "dayjs/plugin/utc";
|
||||||
import timezone from 'dayjs/plugin/timezone';
|
import timezone from "dayjs/plugin/timezone";
|
||||||
import {collectPageParameters, telemetryEventTypes, useTelemetry} from "../../lib/telemetry";
|
import { collectPageParameters, telemetryEventTypes, useTelemetry } from "../../lib/telemetry";
|
||||||
|
|
||||||
dayjs.extend(isSameOrBefore);
|
dayjs.extend(isSameOrBefore);
|
||||||
dayjs.extend(isBetween);
|
dayjs.extend(isBetween);
|
||||||
|
@ -16,153 +16,164 @@ dayjs.extend(utc);
|
||||||
dayjs.extend(timezone);
|
dayjs.extend(timezone);
|
||||||
|
|
||||||
export default function Type(props) {
|
export default function Type(props) {
|
||||||
// Get router variables
|
// Get router variables
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { uid } = router.query;
|
const { uid } = router.query;
|
||||||
|
|
||||||
const [is24h, setIs24h] = useState(false);
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||||
const [loading, setLoading] = useState(false);
|
const [is24h, setIs24h] = useState(false);
|
||||||
const [error, setError] = useState(null);
|
const [loading, setLoading] = useState(false);
|
||||||
const telemetry = useTelemetry();
|
const [error, setError] = useState(null);
|
||||||
|
const telemetry = useTelemetry();
|
||||||
|
|
||||||
const cancellationHandler = async (event) => {
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||||
setLoading(true);
|
const cancellationHandler = async (event) => {
|
||||||
|
setLoading(true);
|
||||||
|
|
||||||
let payload = {
|
const payload = {
|
||||||
uid: uid
|
uid: uid,
|
||||||
};
|
};
|
||||||
|
|
||||||
telemetry.withJitsu(jitsu => jitsu.track(telemetryEventTypes.bookingCancelled, collectPageParameters()));
|
telemetry.withJitsu((jitsu) =>
|
||||||
const res = await fetch(
|
jitsu.track(telemetryEventTypes.bookingCancelled, collectPageParameters())
|
||||||
'/api/cancel',
|
|
||||||
{
|
|
||||||
body: JSON.stringify(payload),
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json'
|
|
||||||
},
|
|
||||||
method: 'POST'
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
if(res.status >= 200 && res.status < 300) {
|
|
||||||
router.push('/cancel/success?user=' + props.user.username + '&title=' + props.eventType.title);
|
|
||||||
} else {
|
|
||||||
setLoading(false);
|
|
||||||
setError("An error with status code " + res.status + " occurred. Please try again later.");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<Head>
|
|
||||||
<title>
|
|
||||||
Cancel {props.booking.title} | {props.user.name || props.user.username} |
|
|
||||||
Calendso
|
|
||||||
</title>
|
|
||||||
<link rel="icon" href="/favicon.ico"/>
|
|
||||||
</Head>
|
|
||||||
<main className="max-w-3xl mx-auto my-24">
|
|
||||||
<div className="fixed z-50 inset-0 overflow-y-auto">
|
|
||||||
<div
|
|
||||||
className="flex items-end justify-center min-h-screen pt-4 px-4 pb-20 text-center sm:block sm:p-0">
|
|
||||||
<div className="fixed inset-0 my-4 sm:my-0 transition-opacity" aria-hidden="true">
|
|
||||||
<span className="hidden sm:inline-block sm:align-middle sm:h-screen"
|
|
||||||
aria-hidden="true">​</span>
|
|
||||||
<div
|
|
||||||
className="inline-block align-bottom bg-white rounded-lg px-4 pt-5 pb-4 text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-sm sm:w-full sm:p-6"
|
|
||||||
role="dialog" aria-modal="true" aria-labelledby="modal-headline">
|
|
||||||
{error && <div>
|
|
||||||
<div className="mx-auto flex items-center justify-center h-12 w-12 rounded-full bg-red-100">
|
|
||||||
<XIcon className="h-6 w-6 text-red-600" />
|
|
||||||
</div>
|
|
||||||
<div className="mt-3 text-center sm:mt-5">
|
|
||||||
<h3 className="text-lg leading-6 font-medium text-gray-900" id="modal-title">
|
|
||||||
{error}
|
|
||||||
</h3>
|
|
||||||
</div>
|
|
||||||
</div>}
|
|
||||||
{!error && <div>
|
|
||||||
<div
|
|
||||||
className="mx-auto flex items-center justify-center h-12 w-12 rounded-full bg-red-100">
|
|
||||||
<XIcon className="h-6 w-6 text-red-600"/>
|
|
||||||
</div>
|
|
||||||
<div className="mt-3 text-center sm:mt-5">
|
|
||||||
<h3 className="text-lg leading-6 font-medium text-gray-900" id="modal-headline">
|
|
||||||
Really cancel your booking?
|
|
||||||
</h3>
|
|
||||||
<div className="mt-2">
|
|
||||||
<p className="text-sm text-gray-500">
|
|
||||||
Instead, you could also reschedule it.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="mt-4 border-t border-b py-4">
|
|
||||||
<h2 className="text-lg font-medium text-gray-600 mb-2">{props.booking.title}</h2>
|
|
||||||
<p className="text-gray-500 mb-1">
|
|
||||||
<ClockIcon className="inline-block w-4 h-4 mr-1 -mt-1"/>
|
|
||||||
{props.eventType.length} minutes
|
|
||||||
</p>
|
|
||||||
<p className="text-gray-500">
|
|
||||||
<CalendarIcon className="inline-block w-4 h-4 mr-1 -mt-1"/>
|
|
||||||
{dayjs.utc(props.booking.startTime).format((is24h ? 'H:mm' : 'h:mma') + ", dddd DD MMMM YYYY")}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>}
|
|
||||||
<div className="mt-5 sm:mt-6 text-center">
|
|
||||||
<div className="mt-5">
|
|
||||||
<button onClick={cancellationHandler} disabled={loading} type="button"
|
|
||||||
className="inline-flex items-center justify-center px-4 py-2 border border-transparent font-medium rounded-md text-red-700 bg-red-100 hover:bg-red-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500 sm:text-sm mx-2 btn-white">
|
|
||||||
Cancel
|
|
||||||
</button>
|
|
||||||
<button onClick={() => router.push('/reschedule/' + uid)} disabled={loading} type="button"
|
|
||||||
className="inline-flex items-center justify-center px-4 py-2 border border-transparent font-medium rounded-md text-gray-700 bg-gray-100 hover:bg-gray-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-gray-500 sm:text-sm mx-2 btn-white">
|
|
||||||
Reschedule
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
|
const res = await fetch("/api/cancel", {
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
method: "POST",
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.status >= 200 && res.status < 300) {
|
||||||
|
router.push("/cancel/success?user=" + props.user.username + "&title=" + props.eventType.title);
|
||||||
|
} else {
|
||||||
|
setLoading(false);
|
||||||
|
setError("An error with status code " + res.status + " occurred. Please try again later.");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<Head>
|
||||||
|
<title>
|
||||||
|
Cancel {props.booking.title} | {props.user.name || props.user.username} | Calendso
|
||||||
|
</title>
|
||||||
|
<link rel="icon" href="/favicon.ico" />
|
||||||
|
</Head>
|
||||||
|
<main className="max-w-3xl mx-auto my-24">
|
||||||
|
<div className="fixed z-50 inset-0 overflow-y-auto">
|
||||||
|
<div className="flex items-end justify-center min-h-screen pt-4 px-4 pb-20 text-center sm:block sm:p-0">
|
||||||
|
<div className="fixed inset-0 my-4 sm:my-0 transition-opacity" aria-hidden="true">
|
||||||
|
<span className="hidden sm:inline-block sm:align-middle sm:h-screen" aria-hidden="true">
|
||||||
|
​
|
||||||
|
</span>
|
||||||
|
<div
|
||||||
|
className="inline-block align-bottom bg-white rounded-lg px-4 pt-5 pb-4 text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-sm sm:w-full sm:p-6"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-labelledby="modal-headline">
|
||||||
|
{error && (
|
||||||
|
<div>
|
||||||
|
<div className="mx-auto flex items-center justify-center h-12 w-12 rounded-full bg-red-100">
|
||||||
|
<XIcon className="h-6 w-6 text-red-600" />
|
||||||
|
</div>
|
||||||
|
<div className="mt-3 text-center sm:mt-5">
|
||||||
|
<h3 className="text-lg leading-6 font-medium text-gray-900" id="modal-title">
|
||||||
|
{error}
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!error && (
|
||||||
|
<div>
|
||||||
|
<div className="mx-auto flex items-center justify-center h-12 w-12 rounded-full bg-red-100">
|
||||||
|
<XIcon className="h-6 w-6 text-red-600" />
|
||||||
|
</div>
|
||||||
|
<div className="mt-3 text-center sm:mt-5">
|
||||||
|
<h3 className="text-lg leading-6 font-medium text-gray-900" id="modal-headline">
|
||||||
|
Really cancel your booking?
|
||||||
|
</h3>
|
||||||
|
<div className="mt-2">
|
||||||
|
<p className="text-sm text-gray-500">Instead, you could also reschedule it.</p>
|
||||||
|
</div>
|
||||||
|
<div className="mt-4 border-t border-b py-4">
|
||||||
|
<h2 className="text-lg font-medium text-gray-600 mb-2">{props.booking.title}</h2>
|
||||||
|
<p className="text-gray-500 mb-1">
|
||||||
|
<ClockIcon className="inline-block w-4 h-4 mr-1 -mt-1" />
|
||||||
|
{props.eventType.length} minutes
|
||||||
|
</p>
|
||||||
|
<p className="text-gray-500">
|
||||||
|
<CalendarIcon className="inline-block w-4 h-4 mr-1 -mt-1" />
|
||||||
|
{dayjs
|
||||||
|
.utc(props.booking.startTime)
|
||||||
|
.format((is24h ? "H:mm" : "h:mma") + ", dddd DD MMMM YYYY")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="mt-5 sm:mt-6 text-center">
|
||||||
|
<div className="mt-5">
|
||||||
|
<button
|
||||||
|
onClick={cancellationHandler}
|
||||||
|
disabled={loading}
|
||||||
|
type="button"
|
||||||
|
className="inline-flex items-center justify-center px-4 py-2 border border-transparent font-medium rounded-md text-red-700 bg-red-100 hover:bg-red-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500 sm:text-sm mx-2 btn-white">
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => router.push("/reschedule/" + uid)}
|
||||||
|
disabled={loading}
|
||||||
|
type="button"
|
||||||
|
className="inline-flex items-center justify-center px-4 py-2 border border-transparent font-medium rounded-md text-gray-700 bg-gray-100 hover:bg-gray-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-gray-500 sm:text-sm mx-2 btn-white">
|
||||||
|
Reschedule
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getServerSideProps(context) {
|
export async function getServerSideProps(context) {
|
||||||
const booking = await prisma.booking.findFirst({
|
const booking = await prisma.booking.findFirst({
|
||||||
where: {
|
where: {
|
||||||
uid: context.query.uid,
|
uid: context.query.uid,
|
||||||
},
|
},
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
title: true,
|
||||||
|
description: true,
|
||||||
|
startTime: true,
|
||||||
|
endTime: true,
|
||||||
|
attendees: true,
|
||||||
|
eventType: true,
|
||||||
|
user: {
|
||||||
select: {
|
select: {
|
||||||
id: true,
|
id: true,
|
||||||
title: true,
|
username: true,
|
||||||
description: true,
|
name: true,
|
||||||
startTime: true,
|
|
||||||
endTime: true,
|
|
||||||
attendees: true,
|
|
||||||
eventType: true,
|
|
||||||
user: {
|
|
||||||
select: {
|
|
||||||
id: true,
|
|
||||||
username: true,
|
|
||||||
name: true,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Workaround since Next.js has problems serializing date objects (see https://github.com/vercel/next.js/issues/11993)
|
|
||||||
const bookingObj = Object.assign({}, booking, {
|
|
||||||
startTime: booking.startTime.toString(),
|
|
||||||
endTime: booking.endTime.toString()
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
props: {
|
|
||||||
user: booking.user,
|
|
||||||
eventType: booking.eventType,
|
|
||||||
booking: bookingObj
|
|
||||||
},
|
},
|
||||||
}
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Workaround since Next.js has problems serializing date objects (see https://github.com/vercel/next.js/issues/11993)
|
||||||
|
const bookingObj = Object.assign({}, booking, {
|
||||||
|
startTime: booking.startTime.toString(),
|
||||||
|
endTime: booking.endTime.toString(),
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
props: {
|
||||||
|
user: booking.user,
|
||||||
|
eventType: booking.eventType,
|
||||||
|
booking: bookingObj,
|
||||||
|
},
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,12 +1,12 @@
|
||||||
import Head from 'next/head';
|
import Head from "next/head";
|
||||||
import prisma from '../../lib/prisma';
|
import prisma from "../../lib/prisma";
|
||||||
import {useRouter} from 'next/router';
|
import { useRouter } from "next/router";
|
||||||
import dayjs from 'dayjs';
|
import dayjs from "dayjs";
|
||||||
import isSameOrBefore from 'dayjs/plugin/isSameOrBefore';
|
import isSameOrBefore from "dayjs/plugin/isSameOrBefore";
|
||||||
import isBetween from 'dayjs/plugin/isBetween';
|
import isBetween from "dayjs/plugin/isBetween";
|
||||||
import utc from 'dayjs/plugin/utc';
|
import utc from "dayjs/plugin/utc";
|
||||||
import timezone from 'dayjs/plugin/timezone';
|
import timezone from "dayjs/plugin/timezone";
|
||||||
import {CheckIcon} from "@heroicons/react/outline";
|
import { CheckIcon } from "@heroicons/react/outline";
|
||||||
|
|
||||||
dayjs.extend(isSameOrBefore);
|
dayjs.extend(isSameOrBefore);
|
||||||
dayjs.extend(isBetween);
|
dayjs.extend(isBetween);
|
||||||
|
@ -14,78 +14,79 @@ dayjs.extend(utc);
|
||||||
dayjs.extend(timezone);
|
dayjs.extend(timezone);
|
||||||
|
|
||||||
export default function Type(props) {
|
export default function Type(props) {
|
||||||
// Get router variables
|
// Get router variables
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<Head>
|
<Head>
|
||||||
<title>
|
<title>
|
||||||
Cancelled {props.title} | {props.user.name || props.user.username} |
|
Cancelled {props.title} | {props.user.name || props.user.username} | Calendso
|
||||||
Calendso
|
</title>
|
||||||
</title>
|
<link rel="icon" href="/favicon.ico" />
|
||||||
<link rel="icon" href="/favicon.ico"/>
|
</Head>
|
||||||
</Head>
|
<main className="max-w-3xl mx-auto my-24">
|
||||||
<main className="max-w-3xl mx-auto my-24">
|
<div className="fixed z-50 inset-0 overflow-y-auto">
|
||||||
<div className="fixed z-50 inset-0 overflow-y-auto">
|
<div className="flex items-end justify-center min-h-screen pt-4 px-4 pb-20 text-center sm:block sm:p-0">
|
||||||
<div
|
<div className="fixed inset-0 my-4 sm:my-0 transition-opacity" aria-hidden="true">
|
||||||
className="flex items-end justify-center min-h-screen pt-4 px-4 pb-20 text-center sm:block sm:p-0">
|
<span className="hidden sm:inline-block sm:align-middle sm:h-screen" aria-hidden="true">
|
||||||
<div className="fixed inset-0 my-4 sm:my-0 transition-opacity" aria-hidden="true">
|
​
|
||||||
<span className="hidden sm:inline-block sm:align-middle sm:h-screen"
|
</span>
|
||||||
aria-hidden="true">​</span>
|
<div
|
||||||
<div
|
className="inline-block align-bottom bg-white rounded-lg px-4 pt-5 pb-4 text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-sm sm:w-full sm:p-6"
|
||||||
className="inline-block align-bottom bg-white rounded-lg px-4 pt-5 pb-4 text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-sm sm:w-full sm:p-6"
|
role="dialog"
|
||||||
role="dialog" aria-modal="true" aria-labelledby="modal-headline">
|
aria-modal="true"
|
||||||
<div>
|
aria-labelledby="modal-headline">
|
||||||
<div className="mx-auto flex items-center justify-center h-12 w-12 rounded-full bg-green-100">
|
<div>
|
||||||
<CheckIcon className="h-6 w-6 text-green-600" />
|
<div className="mx-auto flex items-center justify-center h-12 w-12 rounded-full bg-green-100">
|
||||||
</div>
|
<CheckIcon className="h-6 w-6 text-green-600" />
|
||||||
<div className="mt-3 text-center sm:mt-5">
|
</div>
|
||||||
<h3 className="text-lg leading-6 font-medium text-gray-900" id="modal-headline">
|
<div className="mt-3 text-center sm:mt-5">
|
||||||
Cancellation successful
|
<h3 className="text-lg leading-6 font-medium text-gray-900" id="modal-headline">
|
||||||
</h3>
|
Cancellation successful
|
||||||
<div className="mt-2">
|
</h3>
|
||||||
<p className="text-sm text-gray-500">
|
<div className="mt-2">
|
||||||
Feel free to pick another event anytime.
|
<p className="text-sm text-gray-500">Feel free to pick another event anytime.</p>
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="mt-5 sm:mt-6 text-center">
|
|
||||||
<div className="mt-5">
|
|
||||||
<button onClick={() => router.push('/' + props.user.username)} type="button"
|
|
||||||
className="inline-flex items-center justify-center px-4 py-2 border border-transparent font-medium rounded-md text-gray-700 bg-gray-100 hover:bg-gray-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-gray-500 sm:text-sm mx-2 btn-white">
|
|
||||||
Pick another
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
<div className="mt-5 sm:mt-6 text-center">
|
||||||
|
<div className="mt-5">
|
||||||
|
<button
|
||||||
|
onClick={() => router.push("/" + props.user.username)}
|
||||||
|
type="button"
|
||||||
|
className="inline-flex items-center justify-center px-4 py-2 border border-transparent font-medium rounded-md text-gray-700 bg-gray-100 hover:bg-gray-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-gray-500 sm:text-sm mx-2 btn-white">
|
||||||
|
Pick another
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getServerSideProps(context) {
|
export async function getServerSideProps(context) {
|
||||||
const user = await prisma.user.findFirst({
|
const user = await prisma.user.findFirst({
|
||||||
where: {
|
where: {
|
||||||
username: context.query.user,
|
username: context.query.user,
|
||||||
},
|
},
|
||||||
select: {
|
select: {
|
||||||
username: true,
|
username: true,
|
||||||
name: true,
|
name: true,
|
||||||
bio: true,
|
bio: true,
|
||||||
avatar: true,
|
avatar: true,
|
||||||
eventTypes: true
|
eventTypes: true,
|
||||||
}
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
props: {
|
props: {
|
||||||
user,
|
user,
|
||||||
title: context.query.title
|
title: context.query.title,
|
||||||
},
|
},
|
||||||
}
|
};
|
||||||
}
|
}
|
||||||
|
|
|
@ -9,19 +9,19 @@ import { LocationType } from "@lib/location";
|
||||||
import Shell from "@components/Shell";
|
import Shell from "@components/Shell";
|
||||||
import { getSession } from "next-auth/client";
|
import { getSession } from "next-auth/client";
|
||||||
import { Scheduler } from "@components/ui/Scheduler";
|
import { Scheduler } from "@components/ui/Scheduler";
|
||||||
import { Disclosure } from "@headlessui/react";
|
import { Disclosure, RadioGroup } from "@headlessui/react";
|
||||||
|
|
||||||
import { PhoneIcon, XIcon } from "@heroicons/react/outline";
|
import { PhoneIcon, XIcon } from "@heroicons/react/outline";
|
||||||
import { EventTypeCustomInput, EventTypeCustomInputType } from "@lib/eventTypeInput";
|
import { EventTypeCustomInput, EventTypeCustomInputType } from "@lib/eventTypeInput";
|
||||||
import {
|
import {
|
||||||
LocationMarkerIcon,
|
|
||||||
LinkIcon,
|
|
||||||
PlusIcon,
|
|
||||||
DocumentIcon,
|
|
||||||
ChevronRightIcon,
|
ChevronRightIcon,
|
||||||
ClockIcon,
|
ClockIcon,
|
||||||
TrashIcon,
|
DocumentIcon,
|
||||||
ExternalLinkIcon,
|
ExternalLinkIcon,
|
||||||
|
LinkIcon,
|
||||||
|
LocationMarkerIcon,
|
||||||
|
PlusIcon,
|
||||||
|
TrashIcon,
|
||||||
} from "@heroicons/react/solid";
|
} from "@heroicons/react/solid";
|
||||||
|
|
||||||
import dayjs from "dayjs";
|
import dayjs from "dayjs";
|
||||||
|
@ -29,7 +29,6 @@ import utc from "dayjs/plugin/utc";
|
||||||
import timezone from "dayjs/plugin/timezone";
|
import timezone from "dayjs/plugin/timezone";
|
||||||
import { Availability, EventType, User } from "@prisma/client";
|
import { Availability, EventType, User } from "@prisma/client";
|
||||||
import { validJson } from "@lib/jsonUtils";
|
import { validJson } from "@lib/jsonUtils";
|
||||||
import { RadioGroup } from "@headlessui/react";
|
|
||||||
import classnames from "classnames";
|
import classnames from "classnames";
|
||||||
import throttle from "lodash.throttle";
|
import throttle from "lodash.throttle";
|
||||||
import "react-dates/initialize";
|
import "react-dates/initialize";
|
||||||
|
|
|
@ -7,9 +7,7 @@ function RedirectPage() {
|
||||||
router.push("/event-types");
|
router.push("/event-types");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
return (
|
return <Loader />;
|
||||||
<Loader/>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
RedirectPage.getInitialProps = (ctx) => {
|
RedirectPage.getInitialProps = (ctx) => {
|
||||||
|
|
|
@ -4,7 +4,7 @@ import { getIntegrationName, getIntegrationType } from "../../lib/integrations";
|
||||||
import Shell from "../../components/Shell";
|
import Shell from "../../components/Shell";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useRouter } from "next/router";
|
import { useRouter } from "next/router";
|
||||||
import { useSession, getSession } from "next-auth/client";
|
import { getSession, useSession } from "next-auth/client";
|
||||||
import Loader from "@components/Loader";
|
import Loader from "@components/Loader";
|
||||||
|
|
||||||
export default function Integration(props) {
|
export default function Integration(props) {
|
||||||
|
|
|
@ -9,7 +9,7 @@ import { InformationCircleIcon } from "@heroicons/react/outline";
|
||||||
import { Switch } from "@headlessui/react";
|
import { Switch } from "@headlessui/react";
|
||||||
import Loader from "@components/Loader";
|
import Loader from "@components/Loader";
|
||||||
import classNames from "@lib/classNames";
|
import classNames from "@lib/classNames";
|
||||||
import { Dialog, DialogClose, DialogContent, DialogTrigger, DialogHeader } from "@components/Dialog";
|
import { Dialog, DialogClose, DialogContent, DialogHeader, DialogTrigger } from "@components/Dialog";
|
||||||
|
|
||||||
export default function IntegrationHome({ integrations }) {
|
export default function IntegrationHome({ integrations }) {
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||||
|
|
|
@ -2,8 +2,8 @@ import Head from "next/head";
|
||||||
import prisma from "../../lib/prisma";
|
import prisma from "../../lib/prisma";
|
||||||
import Shell from "../../components/Shell";
|
import Shell from "../../components/Shell";
|
||||||
import SettingsShell from "../../components/Settings";
|
import SettingsShell from "../../components/Settings";
|
||||||
import { useSession, getSession } from "next-auth/client";
|
import { getSession, useSession } from "next-auth/client";
|
||||||
import Loader from '@components/Loader';
|
import Loader from "@components/Loader";
|
||||||
|
|
||||||
export default function Embed(props) {
|
export default function Embed(props) {
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||||
|
|
|
@ -4,7 +4,7 @@ import prisma from "../../lib/prisma";
|
||||||
import Modal from "../../components/Modal";
|
import Modal from "../../components/Modal";
|
||||||
import Shell from "../../components/Shell";
|
import Shell from "../../components/Shell";
|
||||||
import SettingsShell from "../../components/Settings";
|
import SettingsShell from "../../components/Settings";
|
||||||
import { useSession, getSession } from "next-auth/client";
|
import { getSession, useSession } from "next-auth/client";
|
||||||
import Loader from "@components/Loader";
|
import Loader from "@components/Loader";
|
||||||
|
|
||||||
export default function Settings() {
|
export default function Settings() {
|
||||||
|
|
|
@ -0,0 +1,2 @@
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "Booking" ADD COLUMN "location" TEXT;
|
|
@ -133,6 +133,7 @@ model Booking {
|
||||||
endTime DateTime
|
endTime DateTime
|
||||||
|
|
||||||
attendees Attendee[]
|
attendees Attendee[]
|
||||||
|
location String?
|
||||||
|
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime?
|
updatedAt DateTime?
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<!-- Generator: Adobe Illustrator 25.2.1, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
|
<!-- Generator: Adobe Illustrator 25.2.1, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
|
||||||
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
|
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" x="0px" y="0px"
|
||||||
viewBox="0 0 427 97.5" style="enable-background:new 0 0 427 97.5;" xml:space="preserve">
|
viewBox="0 0 427 97.5" style="enable-background:new 0 0 427 97.5;" xml:space="preserve">
|
||||||
<style type="text/css">
|
<style type="text/css">
|
||||||
.st0{fillRule:evenodd;clipRule:evenodd;fill:#26282C;}
|
.st0{fillRule:evenodd;clipRule:evenodd;fill:#26282C;}
|
||||||
</style>
|
</style>
|
||||||
|
|
Before Width: | Height: | Size: 4.3 KiB After Width: | Height: | Size: 4.2 KiB |
|
@ -1,7 +1,7 @@
|
||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<!-- Generator: Adobe Illustrator 25.2.1, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
|
<!-- Generator: Adobe Illustrator 25.2.1, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
|
||||||
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
|
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" x="0px" y="0px"
|
||||||
viewBox="0 0 427 97.5" style="enable-background:new 0 0 427 97.5;" xml:space="preserve">
|
viewBox="0 0 427 97.5" style="enable-background:new 0 0 427 97.5;" xml:space="preserve">
|
||||||
<style type="text/css">
|
<style type="text/css">
|
||||||
.st0{fillRule:evenodd;clipRule:evenodd;fill:#fff;}
|
.st0{fillRule:evenodd;clipRule:evenodd;fill:#fff;}
|
||||||
</style>
|
</style>
|
||||||
|
|
Before Width: | Height: | Size: 4.2 KiB After Width: | Height: | Size: 4.2 KiB |
|
@ -1,7 +1,7 @@
|
||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<!-- Generator: Adobe Illustrator 25.2.1, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
|
<!-- Generator: Adobe Illustrator 25.2.1, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
|
||||||
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
|
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" x="0px" y="0px"
|
||||||
viewBox="0 0 427 97.5" style="enable-background:new 0 0 427 97.5;" xml:space="preserve">
|
viewBox="0 0 427 97.5" style="enable-background:new 0 0 427 97.5;" xml:space="preserve">
|
||||||
<style type="text/css">
|
<style type="text/css">
|
||||||
.st0{fillRule:evenodd;clipRule:evenodd;fill:#104D86;}
|
.st0{fillRule:evenodd;clipRule:evenodd;fill:#104D86;}
|
||||||
</style>
|
</style>
|
||||||
|
|
Before Width: | Height: | Size: 4.2 KiB After Width: | Height: | Size: 4.2 KiB |
|
@ -1,7 +1,7 @@
|
||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<!-- Generator: Adobe Illustrator 25.2.1, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
|
<!-- Generator: Adobe Illustrator 25.2.1, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
|
||||||
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
|
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" x="0px" y="0px"
|
||||||
viewBox="0 0 29.2 33" style="enable-background:new 0 0 29.2 33;" xml:space="preserve">
|
viewBox="0 0 29.2 33" style="enable-background:new 0 0 29.2 33;" xml:space="preserve">
|
||||||
<style type="text/css">
|
<style type="text/css">
|
||||||
.st0{fill:#F68D2E;}
|
.st0{fill:#F68D2E;}
|
||||||
</style>
|
</style>
|
||||||
|
|
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 1.5 KiB |
|
@ -10,9 +10,9 @@
|
||||||
<!ENTITY ns_custom "http://ns.adobe.com/GenericCustomNamespace/1.0/">
|
<!ENTITY ns_custom "http://ns.adobe.com/GenericCustomNamespace/1.0/">
|
||||||
<!ENTITY ns_adobe_xpath "http://ns.adobe.com/XPath/1.0/">
|
<!ENTITY ns_adobe_xpath "http://ns.adobe.com/XPath/1.0/">
|
||||||
]>
|
]>
|
||||||
<svg version="1.1" id="Livello_1" xmlns:x="&ns_extend;" xmlns:i="&ns_ai;" xmlns:graph="&ns_graphs;"
|
<svg version="1.1" id="Livello_1"
|
||||||
xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" viewBox="0 0 2228.833 2073.333"
|
xmlns="http://www.w3.org/2000/svg" x="0px" y="0px" viewBox="0 0 2228.833 2073.333"
|
||||||
enable-background="new 0 0 2228.833 2073.333" xml:space="preserve">
|
enable-background="new 0 0 2228.833 2073.333" xml:space="preserve">
|
||||||
<metadata>
|
<metadata>
|
||||||
<sfw xmlns="&ns_sfw;">
|
<sfw xmlns="&ns_sfw;">
|
||||||
<slices></slices>
|
<slices></slices>
|
||||||
|
|
Before Width: | Height: | Size: 4.6 KiB After Width: | Height: | Size: 4.5 KiB |
|
@ -1 +1 @@
|
||||||
<svg height="64" viewBox="0 0 32 32" width="64" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"><clipPath id="a"><path d="m-200-175h1000v562h-1000z"/></clipPath><clipPath id="b"><circle cx="107" cy="106" r="102"/></clipPath><clipPath id="c"><circle cx="107" cy="106" r="100"/></clipPath><clipPath id="d"><circle cx="107" cy="106" r="92"/></clipPath><clipPath id="e"><path clipRule="evenodd" d="m135 94.06 26-19c2.27-1.85 4-1.42 4 2v57.94c0 3.84-2.16 3.4-4 2l-26-19zm-88-16.86v43.2a17.69 17.69 0 0 0 17.77 17.6h63a3.22 3.22 0 0 0 3.23-3.2v-43.2a17.69 17.69 0 0 0 -17.77-17.6h-63a3.22 3.22 0 0 0 -3.23 3.2z"/></clipPath><g clip-path="url(#a)" transform="translate(0 -178)"><path d="m232 61h366v90h-366z" fill="#4a8cff"/></g><g clip-path="url(#a)" transform="matrix(.156863 0 0 .156863 -.784314 -.627496)"><g clip-path="url(#b)"><path d="m0-1h214v214h-214z" fill="#e5e5e4"/></g><g clip-path="url(#c)"><path d="m2 1h210v210h-210z" fill="#fff"/></g><g clip-path="url(#d)"><path d="m10 9h194v194h-194z" fill="#4a8cff"/></g><g clip-path="url(#e)"><path d="m42 69h128v74h-128z" fill="#fff"/></g></g><g clip-path="url(#a)" transform="translate(0 -178)"><path d="m232 19.25h180v38.17h-180z" fill="#90908f"/></g></svg>
|
<svg height="64" viewBox="0 0 32 32" width="64" xmlns="http://www.w3.org/2000/svg"><clipPath id="a"><path d="m-200-175h1000v562h-1000z"/></clipPath><clipPath id="b"><circle cx="107" cy="106" r="102"/></clipPath><clipPath id="c"><circle cx="107" cy="106" r="100"/></clipPath><clipPath id="d"><circle cx="107" cy="106" r="92"/></clipPath><clipPath id="e"><path clipRule="evenodd" d="m135 94.06 26-19c2.27-1.85 4-1.42 4 2v57.94c0 3.84-2.16 3.4-4 2l-26-19zm-88-16.86v43.2a17.69 17.69 0 0 0 17.77 17.6h63a3.22 3.22 0 0 0 3.23-3.2v-43.2a17.69 17.69 0 0 0 -17.77-17.6h-63a3.22 3.22 0 0 0 -3.23 3.2z"/></clipPath><g clip-path="url(#a)" transform="translate(0 -178)"><path d="m232 61h366v90h-366z" fill="#4a8cff"/></g><g clip-path="url(#a)" transform="matrix(.156863 0 0 .156863 -.784314 -.627496)"><g clip-path="url(#b)"><path d="m0-1h214v214h-214z" fill="#e5e5e4"/></g><g clip-path="url(#c)"><path d="m2 1h210v210h-210z" fill="#fff"/></g><g clip-path="url(#d)"><path d="m10 9h194v194h-194z" fill="#4a8cff"/></g><g clip-path="url(#e)"><path d="m42 69h128v74h-128z" fill="#fff"/></g></g><g clip-path="url(#a)" transform="translate(0 -178)"><path d="m232 19.25h180v38.17h-180z" fill="#90908f"/></g></svg>
|
||||||
|
|
Before Width: | Height: | Size: 1.2 KiB After Width: | Height: | Size: 1.2 KiB |