Merge branch 'main' of github.com:calendso/calendso
|
@ -1,9 +1,8 @@
|
|||
import { GiftIcon } from "@heroicons/react/outline";
|
||||
export default function DonateBanner() {
|
||||
|
||||
if (location.hostname.endsWith(".calendso.com")) {
|
||||
return null;
|
||||
}
|
||||
if (location.hostname.endsWith(".calendso.com")) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
|
@ -17,21 +16,19 @@ return null;
|
|||
<GiftIcon className="h-6 w-6 text-white" aria-hidden="true" />
|
||||
</span>
|
||||
<p className="ml-3 font-medium text-white truncate">
|
||||
<span className="md:hidden">
|
||||
Support the ongoing development
|
||||
</span>
|
||||
<span className="md:hidden">Support the ongoing development</span>
|
||||
<span className="hidden md:inline">
|
||||
You're using the free self-hosted version. Support the
|
||||
ongoing development by making a donation.
|
||||
You're using the free self-hosted version. Support the ongoing development by making
|
||||
a donation.
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
<div className="order-3 mt-2 flex-shrink-0 w-full sm:order-2 sm:mt-0 sm:w-auto">
|
||||
<a
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
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
|
||||
</a>
|
||||
</div>
|
||||
|
|
|
@ -1,7 +1,14 @@
|
|||
export default function Logo({small} : {small?: boolean}){
|
||||
return <h1 className="brand-logo inline">
|
||||
<strong>
|
||||
<img className={small ? "h-4 w-auto" : "h-5 w-auto"} alt="Calendso" title="Calendso" src="/calendso-logo-white-word.svg" />
|
||||
</strong>
|
||||
</h1>;
|
||||
export default function Logo({ small }: { small?: boolean }) {
|
||||
return (
|
||||
<h1 className="brand-logo inline">
|
||||
<strong>
|
||||
<img
|
||||
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 { Dialog, Transition } from '@headlessui/react'
|
||||
import { CheckIcon } from '@heroicons/react/outline'
|
||||
import { Fragment } from "react";
|
||||
import { Dialog, Transition } from "@headlessui/react";
|
||||
import { CheckIcon } from "@heroicons/react/outline";
|
||||
|
||||
export default function Modal(props) {
|
||||
return (
|
||||
<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}>
|
||||
<div className="flex items-end justify-center min-h-screen pt-4 px-4 pb-20 text-center sm:block sm:p-0">
|
||||
<Transition.Child
|
||||
as={Fragment}
|
||||
enter="ease-out duration-300"
|
||||
enterFrom="opacity-0"
|
||||
enterTo="opacity-100"
|
||||
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>
|
||||
return (
|
||||
<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}>
|
||||
<div className="flex items-end justify-center min-h-screen pt-4 px-4 pb-20 text-center sm:block sm:p-0">
|
||||
<Transition.Child
|
||||
as={Fragment}
|
||||
enter="ease-out duration-300"
|
||||
enterFrom="opacity-0"
|
||||
enterTo="opacity-100"
|
||||
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. */}
|
||||
<span className="hidden sm:inline-block sm:align-middle sm:h-screen" aria-hidden="true">
|
||||
​
|
||||
</span>
|
||||
<Transition.Child
|
||||
as={Fragment}
|
||||
enter="ease-out duration-300"
|
||||
enterFrom="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
|
||||
enterTo="opacity-100 translate-y-0 sm:scale-100"
|
||||
leave="ease-in duration-200"
|
||||
leaveFrom="opacity-100 translate-y-0 sm:scale-100"
|
||||
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>
|
||||
<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" />
|
||||
</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>
|
||||
{/* 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>
|
||||
<Transition.Child
|
||||
as={Fragment}
|
||||
enter="ease-out duration-300"
|
||||
enterFrom="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
|
||||
enterTo="opacity-100 translate-y-0 sm:scale-100"
|
||||
leave="ease-in duration-200"
|
||||
leaveFrom="opacity-100 translate-y-0 sm:scale-100"
|
||||
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>
|
||||
<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" />
|
||||
</div>
|
||||
</Dialog>
|
||||
</Transition.Root>
|
||||
)
|
||||
<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>
|
||||
</Dialog>
|
||||
</Transition.Root>
|
||||
);
|
||||
}
|
|
@ -1,5 +1,5 @@
|
|||
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 classNames from "@lib/classNames";
|
||||
|
||||
|
|
|
@ -7,13 +7,13 @@ import { collectPageParameters, telemetryEventTypes, useTelemetry } from "../lib
|
|||
import { SelectorIcon } from "@heroicons/react/outline";
|
||||
import {
|
||||
CalendarIcon,
|
||||
ClockIcon,
|
||||
PuzzleIcon,
|
||||
CogIcon,
|
||||
ChatAltIcon,
|
||||
LogoutIcon,
|
||||
ClockIcon,
|
||||
CogIcon,
|
||||
ExternalLinkIcon,
|
||||
LinkIcon,
|
||||
LogoutIcon,
|
||||
PuzzleIcon,
|
||||
} from "@heroicons/react/solid";
|
||||
import Logo from "./Logo";
|
||||
import classNames from "@lib/classNames";
|
||||
|
|
|
@ -1,105 +1,131 @@
|
|||
import {useEffect, useState} from "react";
|
||||
import {UsersIcon,UserRemoveIcon} from "@heroicons/react/outline";
|
||||
import {useSession} from "next-auth/client";
|
||||
import { useEffect, useState } from "react";
|
||||
import { UserRemoveIcon, UsersIcon } from "@heroicons/react/outline";
|
||||
import { useSession } from "next-auth/client";
|
||||
|
||||
export default function EditTeamModal(props) {
|
||||
const [session] = useSession();
|
||||
const [members, setMembers] = useState([]);
|
||||
const [checkedDisbandTeam, setCheckedDisbandTeam] = useState(false);
|
||||
|
||||
const [ session, loading ] = useSession();
|
||||
const [ members, setMembers ] = useState([]);
|
||||
const [ checkedDisbandTeam, setCheckedDisbandTeam ] = useState(false);
|
||||
const loadMembers = () =>
|
||||
fetch("/api/teams/" + props.team.id + "/membership")
|
||||
.then((res: any) => res.json())
|
||||
.then((data) => setMembers(data.members));
|
||||
|
||||
const loadMembers = () => fetch('/api/teams/' + props.team.id + '/membership')
|
||||
.then( (res: any) => res.json() ).then( (data) => setMembers(data.members) );
|
||||
|
||||
useEffect( () => {
|
||||
useEffect(() => {
|
||||
loadMembers();
|
||||
}, []);
|
||||
|
||||
const deleteTeam = (e) => {
|
||||
e.preventDefault();
|
||||
return fetch('/api/teams/' + props.team.id, {
|
||||
method: 'DELETE',
|
||||
return fetch("/api/teams/" + props.team.id, {
|
||||
method: "DELETE",
|
||||
}).then(props.onExit);
|
||||
}
|
||||
};
|
||||
|
||||
const removeMember = (member) => {
|
||||
return fetch('/api/teams/' + props.team.id + '/membership', {
|
||||
method: 'DELETE',
|
||||
return fetch("/api/teams/" + props.team.id + "/membership", {
|
||||
method: "DELETE",
|
||||
body: JSON.stringify({ userId: member.id }),
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
}).then(loadMembers);
|
||||
}
|
||||
};
|
||||
|
||||
return (<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>
|
||||
return (
|
||||
<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="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">
|
||||
<UsersIcon className="h-6 w-6 text-black" />
|
||||
<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="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" />
|
||||
</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 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>
|
||||
<form>
|
||||
<div>
|
||||
<p className="text-sm text-gray-400">
|
||||
Manage and delete your team.
|
||||
</p>
|
||||
<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={() => 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>
|
||||
<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">
|
||||
<div className="mt-5 sm:mt-4 sm:flex sm:flex-row-reverse">
|
||||
{/*!checkedDisbandTeam && <button type="submit" className="btn btn-primary">
|
||||
Update
|
||||
</button>*/}
|
||||
{checkedDisbandTeam && <button onClick={deleteTeam} className="btn bg-red-700 rounded text-white px-2 font-medium text-sm">
|
||||
Disband Team
|
||||
</button>}
|
||||
<button onClick={props.onExit} type="button" className="btn btn-white mr-2">
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
{checkedDisbandTeam && (
|
||||
<button
|
||||
onClick={deleteTeam}
|
||||
className="btn bg-red-700 rounded text-white px-2 font-medium text-sm">
|
||||
Disband Team
|
||||
</button>
|
||||
)}
|
||||
<button onClick={props.onExit} type="button" className="btn btn-white mr-2">
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>);
|
||||
);
|
||||
}
|
|
@ -2,11 +2,9 @@ import { UsersIcon } from "@heroicons/react/outline";
|
|||
import { useState } from "react";
|
||||
|
||||
export default function MemberInvitationModal(props) {
|
||||
|
||||
const [ errorMessage, setErrorMessage ] = useState('');
|
||||
const [errorMessage, setErrorMessage] = useState("");
|
||||
|
||||
const handleError = async (res) => {
|
||||
|
||||
const responseData = await res.json();
|
||||
|
||||
if (res.ok === false) {
|
||||
|
@ -18,24 +16,26 @@ export default function MemberInvitationModal(props) {
|
|||
};
|
||||
|
||||
const inviteMember = (e) => {
|
||||
|
||||
e.preventDefault();
|
||||
|
||||
const payload = {
|
||||
role: e.target.elements['role'].value,
|
||||
usernameOrEmail: e.target.elements['inviteUser'].value,
|
||||
sendEmailInvitation: e.target.elements['sendInviteEmail'].checked,
|
||||
}
|
||||
role: e.target.elements["role"].value,
|
||||
usernameOrEmail: e.target.elements["inviteUser"].value,
|
||||
sendEmailInvitation: e.target.elements["sendInviteEmail"].checked,
|
||||
};
|
||||
|
||||
return fetch('/api/teams/' + props.team.id + '/invite', {
|
||||
method: 'POST',
|
||||
return fetch("/api/teams/" + props.team.id + "/invite", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload),
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
}).then(handleError).then(props.onExit).catch( (e) => {
|
||||
// do nothing.
|
||||
});
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
})
|
||||
.then(handleError)
|
||||
.then(props.onExit)
|
||||
.catch(() => {
|
||||
// do nothing.
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
|
@ -45,7 +45,9 @@ export default function MemberInvitationModal(props) {
|
|||
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>
|
||||
<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">
|
||||
​
|
||||
|
|
|
@ -1,42 +1,49 @@
|
|||
import {useEffect, useState} from "react";
|
||||
import { useState } from "react";
|
||||
import TeamListItem from "./TeamListItem";
|
||||
import EditTeamModal from "./EditTeamModal";
|
||||
import MemberInvitationModal from "./MemberInvitationModal";
|
||||
|
||||
export default function TeamList(props) {
|
||||
|
||||
const [ showMemberInvitationModal, setShowMemberInvitationModal ] = useState(false);
|
||||
const [ showEditTeamModal, setShowEditTeamModal ] = useState(false);
|
||||
const [ team, setTeam ] = useState(null);
|
||||
const [showMemberInvitationModal, setShowMemberInvitationModal] = useState(false);
|
||||
const [showEditTeamModal, setShowEditTeamModal] = useState(false);
|
||||
const [team, setTeam] = useState(null);
|
||||
|
||||
const selectAction = (action: string, team: any) => {
|
||||
setTeam(team);
|
||||
switch (action) {
|
||||
case 'edit':
|
||||
case "edit":
|
||||
setShowEditTeamModal(true);
|
||||
break;
|
||||
case 'invite':
|
||||
case "invite":
|
||||
setShowMemberInvitationModal(true);
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
return (<div>
|
||||
<ul className="bg-white border px-2 mb-2 rounded divide-y divide-gray-200">
|
||||
{props.teams.map(
|
||||
(team: any) => <TeamListItem onChange={props.onChange} key={team.id} team={team} onActionSelect={
|
||||
(action: string) => selectAction(action, team)
|
||||
}></TeamListItem>
|
||||
return (
|
||||
<div>
|
||||
<ul className="bg-white border px-2 mb-2 rounded divide-y divide-gray-200">
|
||||
{props.teams.map((team: any) => (
|
||||
<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>
|
||||
{showEditTeamModal && <EditTeamModal team={team} onExit={() => {
|
||||
props.onChange();
|
||||
setShowEditTeamModal(false);
|
||||
}}></EditTeamModal>}
|
||||
{showMemberInvitationModal &&
|
||||
<MemberInvitationModal
|
||||
team={team}
|
||||
onExit={() => setShowMemberInvitationModal(false)}></MemberInvitationModal>
|
||||
}
|
||||
</div>);
|
||||
{showMemberInvitationModal && (
|
||||
<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 {useState} from "react";
|
||||
import { useState } from "react";
|
||||
|
||||
export default function TeamListItem(props) {
|
||||
|
||||
const [ team, setTeam ] = useState(props.team);
|
||||
const [team, setTeam] = useState(props.team);
|
||||
|
||||
const acceptInvite = () => invitationResponse(true);
|
||||
const declineInvite = () => invitationResponse(false);
|
||||
|
||||
const invitationResponse = (accept: boolean) => fetch('/api/user/membership', {
|
||||
method: accept ? 'PATCH' : 'DELETE',
|
||||
body: JSON.stringify({ teamId: props.team.id }),
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
}).then( () => {
|
||||
// success
|
||||
setTeam(null);
|
||||
props.onChange();
|
||||
});
|
||||
const invitationResponse = (accept: boolean) =>
|
||||
fetch("/api/user/membership", {
|
||||
method: accept ? "PATCH" : "DELETE",
|
||||
body: JSON.stringify({ teamId: props.team.id }),
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
}).then(() => {
|
||||
// success
|
||||
setTeam(null);
|
||||
props.onChange();
|
||||
});
|
||||
|
||||
return (team && <li className="mb-2 mt-2 divide-y">
|
||||
<div className="flex justify-between mb-2 mt-2">
|
||||
<div>
|
||||
<UsersIcon className="text-gray-400 group-hover:text-gray-500 flex-shrink-0 -mt-4 mr-2 h-6 w-6 inline"/>
|
||||
<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>
|
||||
return (
|
||||
team && (
|
||||
<li className="mb-2 mt-2 divide-y">
|
||||
<div className="flex justify-between mb-2 mt-2">
|
||||
<div>
|
||||
<UsersIcon className="text-gray-400 group-hover:text-gray-500 flex-shrink-0 -mt-4 mr-2 h-6 w-6 inline" />
|
||||
<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>
|
||||
{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.userRole === 'Owner' && expanded && <div className="pt-2">
|
||||
{props.team.members.length > 0 && <div>
|
||||
<h2 className="text-lg font-medium text-gray-900 mb-1">Members</h2>
|
||||
<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-red-400 border border-red-400 px-3 py-1 rounded-sm ml-2">Disband</button>
|
||||
</div>}*/}
|
||||
</li>);
|
||||
</li>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
|
|
@ -33,7 +33,9 @@ export default function SetTimesModal(props) {
|
|||
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>
|
||||
<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">
|
||||
​
|
||||
|
|
|
@ -6,10 +6,12 @@ import { stripHtml } from "./emails/helpers";
|
|||
const translator = short();
|
||||
|
||||
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.maybeUid = maybeUid;
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -30,7 +32,7 @@ export default class CalEventParser {
|
|||
* Returns a unique identifier for the given calendar event.
|
||||
*/
|
||||
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 { Credential } from "@prisma/client";
|
||||
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
|
||||
const { google } = require("googleapis");
|
||||
|
||||
const googleAuth = (credential) => {
|
||||
const { client_secret, client_id, redirect_uris } = JSON.parse(
|
||||
process.env.GOOGLE_API_CREDENTIALS
|
||||
).web;
|
||||
const myGoogleAuth = new google.auth.OAuth2(
|
||||
client_id,
|
||||
client_secret,
|
||||
redirect_uris[0]
|
||||
);
|
||||
const { client_secret, client_id, redirect_uris } = JSON.parse(process.env.GOOGLE_API_CREDENTIALS).web;
|
||||
const myGoogleAuth = new google.auth.OAuth2(client_id, client_secret, redirect_uris[0]);
|
||||
myGoogleAuth.setCredentials(credential.key);
|
||||
|
||||
const isExpired = () => myGoogleAuth.isTokenExpiring();
|
||||
|
@ -49,8 +47,7 @@ const googleAuth = (credential) => {
|
|||
});
|
||||
|
||||
return {
|
||||
getToken: () =>
|
||||
!isExpired() ? Promise.resolve(myGoogleAuth) : refreshAccessToken(),
|
||||
getToken: () => (!isExpired() ? Promise.resolve(myGoogleAuth) : refreshAccessToken()),
|
||||
};
|
||||
};
|
||||
|
||||
|
@ -88,9 +85,7 @@ const o365Auth = (credential) => {
|
|||
.then(handleErrorsJson)
|
||||
.then((responseBody) => {
|
||||
credential.key.access_token = responseBody.access_token;
|
||||
credential.key.expiry_date = Math.round(
|
||||
+new Date() / 1000 + responseBody.expires_in
|
||||
);
|
||||
credential.key.expiry_date = Math.round(+new Date() / 1000 + responseBody.expires_in);
|
||||
return prisma.credential
|
||||
.update({
|
||||
where: {
|
||||
|
@ -148,11 +143,7 @@ export interface CalendarApiAdapter {
|
|||
|
||||
deleteEvent(uid: string);
|
||||
|
||||
getAvailability(
|
||||
dateFrom,
|
||||
dateTo,
|
||||
selectedCalendars: IntegrationCalendar[]
|
||||
): Promise<unknown>;
|
||||
getAvailability(dateFrom, dateTo, selectedCalendars: IntegrationCalendar[]): Promise<unknown>;
|
||||
|
||||
listCalendars(): Promise<IntegrationCalendar[]>;
|
||||
}
|
||||
|
@ -336,9 +327,7 @@ const GoogleCalendar = (credential): CalendarApiAdapter => {
|
|||
}
|
||||
|
||||
(selectedCalendarIds.length == 0
|
||||
? calendar.calendarList
|
||||
.list()
|
||||
.then((cals) => cals.data.items.map((cal) => cal.id))
|
||||
? calendar.calendarList.list().then((cals) => cals.data.items.map((cal) => cal.id))
|
||||
: Promise.resolve(selectedCalendarIds)
|
||||
)
|
||||
.then((calsIds) => {
|
||||
|
@ -354,19 +343,12 @@ const GoogleCalendar = (credential): CalendarApiAdapter => {
|
|||
if (err) {
|
||||
reject(err);
|
||||
}
|
||||
resolve(
|
||||
Object.values(apires.data.calendars).flatMap(
|
||||
(item) => item["busy"]
|
||||
)
|
||||
);
|
||||
resolve(Object.values(apires.data.calendars).flatMap((item) => item["busy"]));
|
||||
}
|
||||
);
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error(
|
||||
"There was an error contacting google calendar service: ",
|
||||
err
|
||||
);
|
||||
console.error("There was an error contacting google calendar service: ", err);
|
||||
reject(err);
|
||||
});
|
||||
})
|
||||
|
@ -413,10 +395,7 @@ const GoogleCalendar = (credential): CalendarApiAdapter => {
|
|||
},
|
||||
function (err, event) {
|
||||
if (err) {
|
||||
console.error(
|
||||
"There was an error contacting google calendar service: ",
|
||||
err
|
||||
);
|
||||
console.error("There was an error contacting google calendar service: ", err);
|
||||
return reject(err);
|
||||
}
|
||||
return resolve(event.data);
|
||||
|
@ -464,10 +443,7 @@ const GoogleCalendar = (credential): CalendarApiAdapter => {
|
|||
},
|
||||
function (err, event) {
|
||||
if (err) {
|
||||
console.error(
|
||||
"There was an error contacting google calendar service: ",
|
||||
err
|
||||
);
|
||||
console.error("There was an error contacting google calendar service: ", err);
|
||||
return reject(err);
|
||||
}
|
||||
return resolve(event.data);
|
||||
|
@ -492,10 +468,7 @@ const GoogleCalendar = (credential): CalendarApiAdapter => {
|
|||
},
|
||||
function (err, event) {
|
||||
if (err) {
|
||||
console.error(
|
||||
"There was an error contacting google calendar service: ",
|
||||
err
|
||||
);
|
||||
console.error("There was an error contacting google calendar service: ", err);
|
||||
return reject(err);
|
||||
}
|
||||
return resolve(event.data);
|
||||
|
@ -526,10 +499,7 @@ const GoogleCalendar = (credential): CalendarApiAdapter => {
|
|||
);
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error(
|
||||
"There was an error contacting google calendar service: ",
|
||||
err
|
||||
);
|
||||
console.error("There was an error contacting google calendar service: ", err);
|
||||
reject(err);
|
||||
});
|
||||
})
|
||||
|
@ -552,30 +522,25 @@ const calendars = (withCredentials): CalendarApiAdapter[] =>
|
|||
})
|
||||
.filter(Boolean);
|
||||
|
||||
const getBusyCalendarTimes = (
|
||||
withCredentials,
|
||||
dateFrom,
|
||||
dateTo,
|
||||
selectedCalendars
|
||||
) =>
|
||||
const getBusyCalendarTimes = (withCredentials, dateFrom, dateTo, selectedCalendars) =>
|
||||
Promise.all(
|
||||
calendars(withCredentials).map((c) =>
|
||||
c.getAvailability(dateFrom, dateTo, selectedCalendars)
|
||||
)
|
||||
calendars(withCredentials).map((c) => c.getAvailability(dateFrom, dateTo, selectedCalendars))
|
||||
).then((results) => {
|
||||
return results.reduce((acc, availability) => acc.concat(availability), []);
|
||||
});
|
||||
|
||||
const listCalendars = (withCredentials) =>
|
||||
Promise.all(calendars(withCredentials).map((c) => c.listCalendars())).then(
|
||||
(results) => results.reduce((acc, calendars) => acc.concat(calendars), [])
|
||||
Promise.all(calendars(withCredentials).map((c) => c.listCalendars())).then((results) =>
|
||||
results.reduce((acc, calendars) => acc.concat(calendars), [])
|
||||
);
|
||||
|
||||
const createEvent = async (
|
||||
credential: Credential,
|
||||
calEvent: CalendarEvent
|
||||
): Promise<unknown> => {
|
||||
const parser: CalEventParser = new CalEventParser(calEvent);
|
||||
calEvent: CalendarEvent,
|
||||
noMail = false,
|
||||
maybeUid: string = null
|
||||
): Promise<EventResult> => {
|
||||
const parser: CalEventParser = new CalEventParser(calEvent, maybeUid);
|
||||
const uid: string = parser.getUid();
|
||||
/*
|
||||
* 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();
|
||||
|
||||
let success = true;
|
||||
|
||||
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;
|
||||
|
||||
const maybeHangoutLink = creationResult?.hangoutLink;
|
||||
const maybeEntryPoints = creationResult?.entryPoints;
|
||||
const maybeConferenceData = creationResult?.conferenceData;
|
||||
|
||||
const organizerMail = new EventOrganizerMail(calEvent, uid, {
|
||||
hangoutLink: maybeHangoutLink,
|
||||
conferenceData: maybeConferenceData,
|
||||
entryPoints: maybeEntryPoints,
|
||||
});
|
||||
if (!noMail) {
|
||||
const organizerMail = new EventOrganizerMail(calEvent, uid, {
|
||||
hangoutLink: maybeHangoutLink,
|
||||
conferenceData: maybeConferenceData,
|
||||
entryPoints: maybeEntryPoints,
|
||||
});
|
||||
|
||||
const attendeeMail = new EventAttendeeMail(calEvent, uid, {
|
||||
hangoutLink: maybeHangoutLink,
|
||||
conferenceData: maybeConferenceData,
|
||||
entryPoints: maybeEntryPoints,
|
||||
});
|
||||
const attendeeMail = new EventAttendeeMail(calEvent, uid, {
|
||||
hangoutLink: maybeHangoutLink,
|
||||
conferenceData: maybeConferenceData,
|
||||
entryPoints: maybeEntryPoints,
|
||||
});
|
||||
|
||||
try {
|
||||
await organizerMail.sendEmail();
|
||||
} catch (e) {
|
||||
console.error("organizerMail.sendEmail failed", e);
|
||||
}
|
||||
|
||||
if (!creationResult || !creationResult.disableConfirmationEmail) {
|
||||
try {
|
||||
await attendeeMail.sendEmail();
|
||||
await organizerMail.sendEmail();
|
||||
} 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 {
|
||||
type: credential.type,
|
||||
success,
|
||||
uid,
|
||||
createdEvent: creationResult,
|
||||
originalEvent: calEvent,
|
||||
};
|
||||
};
|
||||
|
||||
const updateEvent = async (
|
||||
credential: Credential,
|
||||
uidToUpdate: string,
|
||||
calEvent: CalendarEvent
|
||||
): Promise<unknown> => {
|
||||
calEvent: CalendarEvent,
|
||||
noMail = false
|
||||
): Promise<EventResult> => {
|
||||
const parser: CalEventParser = new CalEventParser(calEvent);
|
||||
const newUid: string = parser.getUid();
|
||||
const richEvent: CalendarEvent = parser.asRichEventPlain();
|
||||
|
||||
let success = true;
|
||||
|
||||
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;
|
||||
|
||||
const organizerMail = new EventOrganizerRescheduledMail(calEvent, newUid);
|
||||
const attendeeMail = new EventAttendeeRescheduledMail(calEvent, newUid);
|
||||
try {
|
||||
await organizerMail.sendEmail();
|
||||
} catch (e) {
|
||||
console.error("organizerMail.sendEmail failed", e);
|
||||
}
|
||||
|
||||
if (!updateResult || !updateResult.disableConfirmationEmail) {
|
||||
if (!noMail) {
|
||||
const organizerMail = new EventOrganizerRescheduledMail(calEvent, newUid);
|
||||
const attendeeMail = new EventAttendeeRescheduledMail(calEvent, newUid);
|
||||
try {
|
||||
await attendeeMail.sendEmail();
|
||||
await organizerMail.sendEmail();
|
||||
} 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 {
|
||||
type: credential.type,
|
||||
success,
|
||||
uid: newUid,
|
||||
updatedEvent: updateResult,
|
||||
originalEvent: calEvent,
|
||||
};
|
||||
};
|
||||
|
||||
|
@ -667,12 +657,4 @@ const deleteEvent = (credential: Credential, uid: string): Promise<unknown> => {
|
|||
return Promise.resolve({});
|
||||
};
|
||||
|
||||
export {
|
||||
getBusyCalendarTimes,
|
||||
createEvent,
|
||||
updateEvent,
|
||||
deleteEvent,
|
||||
CalendarEvent,
|
||||
listCalendars,
|
||||
IntegrationCalendar,
|
||||
};
|
||||
export { getBusyCalendarTimes, createEvent, updateEvent, deleteEvent, listCalendars };
|
||||
|
|
|
@ -4,7 +4,7 @@ import { CalendarEvent, ConferenceData } from "../calendarClient";
|
|||
import { serverConfig } from "../serverConfig";
|
||||
import nodemailer from "nodemailer";
|
||||
|
||||
interface EntryPoint {
|
||||
export interface EntryPoint {
|
||||
entryPointType?: string;
|
||||
uri?: string;
|
||||
label?: string;
|
||||
|
@ -15,7 +15,7 @@ interface EntryPoint {
|
|||
password?: string;
|
||||
}
|
||||
|
||||
interface AdditionInformation {
|
||||
export interface AdditionInformation {
|
||||
conferenceData?: ConferenceData;
|
||||
entryPoints?: EntryPoint[];
|
||||
hangoutLink?: string;
|
||||
|
@ -34,11 +34,12 @@ export default abstract class EventMail {
|
|||
*
|
||||
* @param calEvent
|
||||
* @param uid
|
||||
* @param additionInformation
|
||||
*/
|
||||
constructor(calEvent: CalendarEvent, uid: string, additionInformation: AdditionInformation = null) {
|
||||
this.calEvent = calEvent;
|
||||
this.uid = uid;
|
||||
this.parser = new CalEventParser(calEvent);
|
||||
this.parser = new CalEventParser(calEvent, uid);
|
||||
this.additionInformation = additionInformation;
|
||||
}
|
||||
|
||||
|
|
|
@ -1,14 +1,21 @@
|
|||
import {CalendarEvent} from "../calendarClient";
|
||||
import { CalendarEvent } from "../calendarClient";
|
||||
import EventAttendeeMail from "./EventAttendeeMail";
|
||||
import {getFormattedMeetingId, getIntegrationName} from "./helpers";
|
||||
import {VideoCallData} from "../videoClient";
|
||||
import { getFormattedMeetingId, getIntegrationName } from "./helpers";
|
||||
import { VideoCallData } from "../videoClient";
|
||||
import { AdditionInformation } from "@lib/emails/EventMail";
|
||||
|
||||
export default class VideoEventAttendeeMail extends EventAttendeeMail {
|
||||
videoCallData: VideoCallData;
|
||||
|
||||
constructor(calEvent: CalendarEvent, uid: string, videoCallData: VideoCallData) {
|
||||
constructor(
|
||||
calEvent: CalendarEvent,
|
||||
uid: string,
|
||||
videoCallData: VideoCallData,
|
||||
additionInformation: AdditionInformation = null
|
||||
) {
|
||||
super(calEvent, uid);
|
||||
this.videoCallData = videoCallData;
|
||||
this.additionInformation = additionInformation;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -2,13 +2,20 @@ import { CalendarEvent } from "../calendarClient";
|
|||
import EventOrganizerMail from "./EventOrganizerMail";
|
||||
import { VideoCallData } from "../videoClient";
|
||||
import { getFormattedMeetingId, getIntegrationName } from "./helpers";
|
||||
import { AdditionInformation } from "@lib/emails/EventMail";
|
||||
|
||||
export default class VideoEventOrganizerMail extends EventOrganizerMail {
|
||||
videoCallData: VideoCallData;
|
||||
|
||||
constructor(calEvent: CalendarEvent, uid: string, videoCallData: VideoCallData) {
|
||||
constructor(
|
||||
calEvent: CalendarEvent,
|
||||
uid: string,
|
||||
videoCallData: VideoCallData,
|
||||
additionInformation: AdditionInformation = null
|
||||
) {
|
||||
super(calEvent, uid);
|
||||
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 {CalendarEvent} from "./calendarClient";
|
||||
import { CalendarEvent } from "./calendarClient";
|
||||
import VideoEventOrganizerMail from "./emails/VideoEventOrganizerMail";
|
||||
import VideoEventAttendeeMail from "./emails/VideoEventAttendeeMail";
|
||||
import {v5 as uuidv5} from 'uuid';
|
||||
import short from 'short-uuid';
|
||||
import { v5 as uuidv5 } from "uuid";
|
||||
import short from "short-uuid";
|
||||
import EventAttendeeRescheduledMail from "./emails/EventAttendeeRescheduledMail";
|
||||
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();
|
||||
|
||||
|
@ -33,63 +41,67 @@ function handleErrorsRaw(response) {
|
|||
}
|
||||
|
||||
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 authHeader = 'Basic ' + Buffer.from(process.env.ZOOM_CLIENT_ID + ':' + process.env.ZOOM_CLIENT_SECRET).toString('base64');
|
||||
|
||||
const refreshAccessToken = (refreshToken) => fetch('https://zoom.us/oauth/token', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': authHeader,
|
||||
'Content-Type': 'application/x-www-form-urlencoded'
|
||||
},
|
||||
body: new URLSearchParams({
|
||||
'refresh_token': refreshToken,
|
||||
'grant_type': 'refresh_token',
|
||||
const refreshAccessToken = (refreshToken) =>
|
||||
fetch("https://zoom.us/oauth/token", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: authHeader,
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
},
|
||||
body: new URLSearchParams({
|
||||
refresh_token: refreshToken,
|
||||
grant_type: "refresh_token",
|
||||
}),
|
||||
})
|
||||
})
|
||||
.then(handleErrorsJson)
|
||||
.then(async (responseBody) => {
|
||||
// Store new tokens in database.
|
||||
await prisma.credential.update({
|
||||
where: {
|
||||
id: credential.id
|
||||
},
|
||||
data: {
|
||||
key: responseBody
|
||||
}
|
||||
.then(handleErrorsJson)
|
||||
.then(async (responseBody) => {
|
||||
// Store new tokens in database.
|
||||
await prisma.credential.update({
|
||||
where: {
|
||||
id: credential.id,
|
||||
},
|
||||
data: {
|
||||
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 {
|
||||
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 {
|
||||
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>;
|
||||
}
|
||||
|
||||
const ZoomVideo = (credential): VideoApiAdapter => {
|
||||
|
||||
const auth = zoomAuth(credential);
|
||||
|
||||
const translateEvent = (event: CalendarEvent) => {
|
||||
// Documentation at: https://marketplace.zoom.us/docs/api-reference/zoom-api/meetings/meetingcreate
|
||||
return {
|
||||
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,
|
||||
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?)
|
||||
timezone: event.attendees[0].timeZone,
|
||||
//password: "string", TODO: Should we use a password? Maybe generate a random one?
|
||||
|
@ -97,8 +109,8 @@ const ZoomVideo = (credential): VideoApiAdapter => {
|
|||
settings: {
|
||||
host_video: true,
|
||||
participant_video: true,
|
||||
cn_meeting: false, // TODO: true if host meeting in China
|
||||
in_meeting: false, // TODO: true if host meeting in India
|
||||
cn_meeting: false, // TODO: true if host meeting in China
|
||||
in_meeting: false, // TODO: true if host meeting in India
|
||||
join_before_host: true,
|
||||
mute_upon_entry: false,
|
||||
watermark: false,
|
||||
|
@ -107,82 +119,112 @@ const ZoomVideo = (credential): VideoApiAdapter => {
|
|||
audio: "both",
|
||||
auto_recording: "none",
|
||||
enforce_login: false,
|
||||
registrants_email_notification: true
|
||||
}
|
||||
registrants_email_notification: true,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
return {
|
||||
getAvailability: (dateFrom, dateTo) => {
|
||||
return auth.getToken().then(
|
||||
// TODO Possibly implement pagination for cases when there are more than 300 meetings already scheduled.
|
||||
(accessToken) => fetch('https://api.zoom.us/v2/users/me/meetings?type=scheduled&page_size=300', {
|
||||
method: 'get',
|
||||
headers: {
|
||||
'Authorization': 'Bearer ' + accessToken
|
||||
}
|
||||
})
|
||||
.then(handleErrorsJson)
|
||||
.then(responseBody => {
|
||||
return responseBody.meetings.map((meeting) => ({
|
||||
start: meeting.start_time,
|
||||
end: (new Date((new Date(meeting.start_time)).getTime() + meeting.duration * 60000)).toISOString()
|
||||
}))
|
||||
})
|
||||
).catch((err) => {
|
||||
console.log(err);
|
||||
});
|
||||
getAvailability: () => {
|
||||
return auth
|
||||
.getToken()
|
||||
.then(
|
||||
// TODO Possibly implement pagination for cases when there are more than 300 meetings already scheduled.
|
||||
(accessToken) =>
|
||||
fetch("https://api.zoom.us/v2/users/me/meetings?type=scheduled&page_size=300", {
|
||||
method: "get",
|
||||
headers: {
|
||||
Authorization: "Bearer " + accessToken,
|
||||
},
|
||||
})
|
||||
.then(handleErrorsJson)
|
||||
.then((responseBody) => {
|
||||
return responseBody.meetings.map((meeting) => ({
|
||||
start: meeting.start_time,
|
||||
end: new Date(
|
||||
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', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': 'Bearer ' + accessToken,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(translateEvent(event))
|
||||
}).then(handleErrorsJson)),
|
||||
deleteMeeting: (uid: String) => auth.getToken().then(accessToken => fetch('https://api.zoom.us/v2/meetings/' + uid, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
'Authorization': 'Bearer ' + accessToken
|
||||
}
|
||||
}).then(handleErrorsRaw)),
|
||||
updateMeeting: (uid: String, event: CalendarEvent) => 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)),
|
||||
}
|
||||
createMeeting: (event: CalendarEvent) =>
|
||||
auth.getToken().then((accessToken) =>
|
||||
fetch("https://api.zoom.us/v2/users/me/meetings", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: "Bearer " + accessToken,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(translateEvent(event)),
|
||||
}).then(handleErrorsJson)
|
||||
),
|
||||
deleteMeeting: (uid: string) =>
|
||||
auth.getToken().then((accessToken) =>
|
||||
fetch("https://api.zoom.us/v2/meetings/" + uid, {
|
||||
method: "DELETE",
|
||||
headers: {
|
||||
Authorization: "Bearer " + accessToken,
|
||||
},
|
||||
}).then(handleErrorsRaw)
|
||||
),
|
||||
updateMeeting: (uid: string, event: CalendarEvent) =>
|
||||
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
|
||||
const videoIntegrations = (withCredentials): VideoApiAdapter[] => withCredentials.map((cred) => {
|
||||
switch (cred.type) {
|
||||
case 'zoom_video':
|
||||
return ZoomVideo(cred);
|
||||
default:
|
||||
return; // unknown credential, could be legacy? In any case, ignore
|
||||
}
|
||||
}).filter(Boolean);
|
||||
const videoIntegrations = (withCredentials): VideoApiAdapter[] =>
|
||||
withCredentials
|
||||
.map((cred) => {
|
||||
switch (cred.type) {
|
||||
case "zoom_video":
|
||||
return ZoomVideo(cred);
|
||||
default:
|
||||
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(
|
||||
videoIntegrations(withCredentials).map(c => c.getAvailability(dateFrom, dateTo))
|
||||
).then(
|
||||
(results) => results.reduce((acc, availability) => acc.concat(availability), [])
|
||||
);
|
||||
|
||||
const createMeeting = async (credential, calEvent: CalendarEvent): Promise<any> => {
|
||||
const uid: string = translator.fromUUID(uuidv5(JSON.stringify(calEvent), uuidv5.URL));
|
||||
const createMeeting = async (
|
||||
credential: Credential,
|
||||
calEvent: CalendarEvent,
|
||||
maybeUid: string = null
|
||||
): Promise<EventResult> => {
|
||||
const parser: CalEventParser = new CalEventParser(calEvent, maybeUid);
|
||||
const uid: string = parser.getUid();
|
||||
|
||||
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 = {
|
||||
type: credential.type,
|
||||
|
@ -191,60 +233,92 @@ const createMeeting = async (credential, calEvent: CalendarEvent): Promise<any>
|
|||
url: creationResult.join_url,
|
||||
};
|
||||
|
||||
const organizerMail = new VideoEventOrganizerMail(calEvent, uid, videoCallData);
|
||||
const attendeeMail = new VideoEventAttendeeMail(calEvent, uid, videoCallData);
|
||||
const entryPoint: EntryPoint = {
|
||||
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 {
|
||||
await organizerMail.sendEmail();
|
||||
} catch (e) {
|
||||
console.error("organizerMail.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)
|
||||
console.error("attendeeMail.sendEmail failed", e);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
type: credential.type,
|
||||
success,
|
||||
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));
|
||||
|
||||
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 attendeeMail = new EventAttendeeRescheduledMail(calEvent, newUid);
|
||||
try {
|
||||
await organizerMail.sendEmail();
|
||||
} catch (e) {
|
||||
console.error("organizerMail.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)
|
||||
console.error("attendeeMail.sendEmail failed", e);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
type: credential.type,
|
||||
success,
|
||||
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) {
|
||||
return videoIntegrations([credential])[0].deleteMeeting(uid);
|
||||
}
|
||||
|
@ -252,4 +326,4 @@ const deleteMeeting = (credential, uid: String): Promise<any> => {
|
|||
return Promise.resolve({});
|
||||
};
|
||||
|
||||
export {getBusyVideoTimes, createMeeting, updateMeeting, deleteMeeting};
|
||||
export { getBusyVideoTimes, createMeeting, updateMeeting, deleteMeeting };
|
||||
|
|
|
@ -1,31 +1,40 @@
|
|||
|
||||
const withTM = require('next-transpile-modules')(['react-timezone-select']);
|
||||
/* eslint-disable @typescript-eslint/no-var-requires */
|
||||
const withTM = require("next-transpile-modules")(["react-timezone-select"]);
|
||||
|
||||
// TODO: Revisit this later with getStaticProps in App
|
||||
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 ) {
|
||||
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.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."
|
||||
);
|
||||
}
|
||||
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) => {
|
||||
try {
|
||||
const o = JSON.parse(jsonString);
|
||||
if (o && typeof o === "object") {
|
||||
return o;
|
||||
}
|
||||
try {
|
||||
const o = JSON.parse(jsonString);
|
||||
if (o && typeof o === "object") {
|
||||
return o;
|
||||
}
|
||||
catch (e) { console.error(e); }
|
||||
return false;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
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.");
|
||||
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.'
|
||||
);
|
||||
}
|
||||
|
||||
module.exports = withTM({
|
||||
|
@ -42,10 +51,10 @@ module.exports = withTM({
|
|||
async redirects() {
|
||||
return [
|
||||
{
|
||||
source: '/settings',
|
||||
destination: '/settings/profile',
|
||||
source: "/settings",
|
||||
destination: "/settings/profile",
|
||||
permanent: true,
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
];
|
||||
},
|
||||
});
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
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 React from "react";
|
||||
import Link from "next/link";
|
||||
|
|
|
@ -9,10 +9,7 @@ function MyApp({ Component, pageProps }: AppProps) {
|
|||
<TelemetryProvider value={createTelemetryClient()}>
|
||||
<Provider session={pageProps.session}>
|
||||
<Head>
|
||||
<meta
|
||||
name="viewport"
|
||||
content="width=device-width, initial-scale=1.0"
|
||||
/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
</Head>
|
||||
<Component {...pageProps} />
|
||||
</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 {
|
||||
static async getInitialProps(ctx) {
|
||||
|
|
|
@ -1,16 +1,15 @@
|
|||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
import prisma from "../../../lib/prisma";
|
||||
import { CalendarEvent, createEvent, getBusyCalendarTimes, updateEvent } from "../../../lib/calendarClient";
|
||||
import async from "async";
|
||||
import { CalendarEvent, getBusyCalendarTimes } from "@lib/calendarClient";
|
||||
import { v5 as uuidv5 } from "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 { getEventName } from "../../../lib/event";
|
||||
import { LocationType } from "../../../lib/location";
|
||||
import merge from "lodash.merge";
|
||||
import { getEventName } from "@lib/event";
|
||||
import dayjs from "dayjs";
|
||||
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 timezone from "dayjs/plugin/timezone";
|
||||
|
@ -35,11 +34,6 @@ function isAvailable(busyTimes, time, length) {
|
|||
const startTime = dayjs(busyTime.start);
|
||||
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
|
||||
if (dayjs(time).isBetween(startTime, endTime)) {
|
||||
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(
|
||||
results: unknown[],
|
||||
results: Array<EventResult>,
|
||||
selectedEventType: { requiresConfirmation: boolean },
|
||||
evt: CalendarEvent,
|
||||
hashUID: string
|
||||
|
@ -283,7 +118,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
|
|||
return res.status(400).json(error);
|
||||
}
|
||||
|
||||
let currentUser = await prisma.user.findFirst({
|
||||
let currentUser: User = await prisma.user.findFirst({
|
||||
where: {
|
||||
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 =
|
||||
currentUser.credentials.filter((cred) => cred.type.endsWith("_calendar")).length > 0;
|
||||
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]"),
|
||||
selectedCalendars
|
||||
);
|
||||
const videoAvailability = await getBusyVideoTimes(
|
||||
currentUser.credentials,
|
||||
dayjs(req.body.start).startOf("day").utc().format(),
|
||||
dayjs(req.body.end).endOf("day").utc().format()
|
||||
);
|
||||
const videoAvailability = await getBusyVideoTimes(currentUser.credentials);
|
||||
let commonAvailability = [];
|
||||
|
||||
if (hasCalendarIntegrations && hasVideoIntegrations) {
|
||||
|
@ -347,9 +174,9 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
|
|||
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 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 guests = req.body.guests.map(guest=>{
|
||||
const guests = req.body.guests.map((guest) => {
|
||||
const g = {
|
||||
'email': guest,
|
||||
'name': '',
|
||||
'timeZone': req.body.timeZone
|
||||
}
|
||||
email: guest,
|
||||
name: "",
|
||||
timeZone: req.body.timeZone,
|
||||
};
|
||||
return g;
|
||||
});
|
||||
const attendeesList = [...invitee,...guests];
|
||||
const attendeesList = [...invitee, ...guests];
|
||||
|
||||
let evt: CalendarEvent = {
|
||||
const evt: CalendarEvent = {
|
||||
type: selectedEventType.title,
|
||||
title: getEventName(req.body.name, selectedEventType.title, selectedEventType.eventName),
|
||||
description: req.body.notes,
|
||||
|
@ -391,25 +216,9 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
|
|||
endTime: req.body.end,
|
||||
organizer: { email: currentUser.email, name: currentUser.name, timeZone: currentUser.timeZone },
|
||||
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({
|
||||
where: {
|
||||
userId: currentUser.id,
|
||||
|
@ -468,44 +277,47 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
|
|||
return res.status(400).json(error);
|
||||
}
|
||||
|
||||
let results = [];
|
||||
let results: Array<EventResult> = [];
|
||||
let referencesToCreate = [];
|
||||
|
||||
if (rescheduleUid) {
|
||||
const __ret = await rescheduleEvent(
|
||||
rescheduleUid,
|
||||
results,
|
||||
calendarCredentials,
|
||||
evt,
|
||||
videoCredentials,
|
||||
referencesToCreate
|
||||
);
|
||||
if (__ret.error) {
|
||||
log.error(`Booking ${user} failed`, __ret.error, results);
|
||||
return res.status(500).json(__ret.error);
|
||||
// Use EventManager to conditionally use all needed integrations.
|
||||
const updateResults: CreateUpdateResult = await eventManager.update(evt, rescheduleUid);
|
||||
|
||||
if (results.length > 0 && results.every((res) => !res.success)) {
|
||||
const error = {
|
||||
errorCode: "BookingReschedulingMeetingFailed",
|
||||
message: "Booking Rescheduling failed",
|
||||
};
|
||||
|
||||
log.error(`Booking ${user} failed`, error, results);
|
||||
return res.status(500).json(error);
|
||||
}
|
||||
results = __ret.results;
|
||||
referencesToCreate = __ret.referencesToCreate;
|
||||
|
||||
// Forward results
|
||||
results = updateResults.results;
|
||||
referencesToCreate = updateResults.referencesToCreate;
|
||||
} else if (!selectedEventType.requiresConfirmation) {
|
||||
const __ret = await scheduleEvent(
|
||||
results,
|
||||
calendarCredentials,
|
||||
evt,
|
||||
videoCredentials,
|
||||
referencesToCreate
|
||||
);
|
||||
if (__ret.error) {
|
||||
log.error(`Booking ${user} failed`, __ret.error, results);
|
||||
return res.status(500).json(__ret.error);
|
||||
// Use EventManager to conditionally use all needed integrations.
|
||||
const createResults: CreateUpdateResult = await eventManager.create(evt);
|
||||
|
||||
if (results.length > 0 && results.every((res) => !res.success)) {
|
||||
const error = {
|
||||
errorCode: "BookingCreatingMeetingFailed",
|
||||
message: "Booking failed",
|
||||
};
|
||||
|
||||
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 =
|
||||
results.length > 0
|
||||
? results[0].response.uid
|
||||
: translator.fromUUID(uuidv5(JSON.stringify(evt), uuidv5.URL));
|
||||
results.length > 0 ? results[0].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.
|
||||
// UID generation should happen in the integration itself, not here.
|
||||
const legacyMailError = await handleLegacyConfirmationMail(results, selectedEventType, evt, hashUID);
|
||||
|
@ -532,6 +344,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
|
|||
attendees: {
|
||||
create: evt.attendees,
|
||||
},
|
||||
location: evt.location, // This is the raw location that can be processed by the EventManager.
|
||||
confirmed: !selectedEventType.requiresConfirmation,
|
||||
},
|
||||
});
|
||||
|
|
|
@ -1,9 +1,10 @@
|
|||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
import { getSession } from "next-auth/client";
|
||||
import prisma from "../../../lib/prisma";
|
||||
import { handleLegacyConfirmationMail, scheduleEvent } from "./[user]";
|
||||
import { handleLegacyConfirmationMail } from "./[user]";
|
||||
import { CalendarEvent } from "@lib/calendarClient";
|
||||
import EventRejectionMail from "@lib/emails/EventRejectionMail";
|
||||
import EventManager from "@lib/events/EventManager";
|
||||
|
||||
export default async function handler(req: NextApiRequest, res: NextApiResponse): Promise<void> {
|
||||
const session = await getSession({ req: req });
|
||||
|
@ -41,6 +42,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
|
|||
endTime: true,
|
||||
confirmed: true,
|
||||
attendees: true,
|
||||
location: true,
|
||||
userId: true,
|
||||
id: 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" });
|
||||
}
|
||||
|
||||
const calendarCredentials = currentUser.credentials.filter((cred) => cred.type.endsWith("_calendar"));
|
||||
const videoCredentials = currentUser.credentials.filter((cred) => cred.type.endsWith("_video"));
|
||||
|
||||
const evt: CalendarEvent = {
|
||||
type: booking.title,
|
||||
title: booking.title,
|
||||
|
@ -65,10 +64,12 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
|
|||
endTime: booking.endTime.toISOString(),
|
||||
organizer: { email: currentUser.email, name: currentUser.name, timeZone: currentUser.timeZone },
|
||||
attendees: booking.attendees,
|
||||
location: booking.location,
|
||||
};
|
||||
|
||||
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(
|
||||
scheduleResult.results,
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
import prisma from '../../lib/prisma';
|
||||
import {deleteEvent} from "../../lib/calendarClient";
|
||||
import async from 'async';
|
||||
import {deleteMeeting} from "../../lib/videoClient";
|
||||
import prisma from "../../lib/prisma";
|
||||
import { deleteEvent } from "../../lib/calendarClient";
|
||||
import async from "async";
|
||||
import { deleteMeeting } from "../../lib/videoClient";
|
||||
|
||||
export default async function handler(req, res) {
|
||||
if (req.method == "POST") {
|
||||
|
@ -15,36 +15,38 @@ export default async function handler(req, res) {
|
|||
id: true,
|
||||
user: {
|
||||
select: {
|
||||
credentials: true
|
||||
}
|
||||
credentials: true,
|
||||
},
|
||||
},
|
||||
attendees: true,
|
||||
references: {
|
||||
select: {
|
||||
uid: true,
|
||||
type: true
|
||||
}
|
||||
}
|
||||
}
|
||||
type: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const apiDeletes = async.mapLimit(bookingToDelete.user.credentials, 5, async (credential) => {
|
||||
const bookingRefUid = bookingToDelete.references.filter((ref) => ref.type === credential.type)[0].uid;
|
||||
if(credential.type.endsWith("_calendar")) {
|
||||
return await deleteEvent(credential, bookingRefUid);
|
||||
} else if(credential.type.endsWith("_video")) {
|
||||
return await deleteMeeting(credential, bookingRefUid);
|
||||
const bookingRefUid = bookingToDelete.references.filter((ref) => ref.type === credential.type)[0]?.uid;
|
||||
if (bookingRefUid) {
|
||||
if (credential.type.endsWith("_calendar")) {
|
||||
return await deleteEvent(credential, bookingRefUid);
|
||||
} else if (credential.type.endsWith("_video")) {
|
||||
return await deleteMeeting(credential, bookingRefUid);
|
||||
}
|
||||
}
|
||||
});
|
||||
const attendeeDeletes = prisma.attendee.deleteMany({
|
||||
where: {
|
||||
bookingId: bookingToDelete.id
|
||||
}
|
||||
bookingId: bookingToDelete.id,
|
||||
},
|
||||
});
|
||||
const bookingReferenceDeletes = prisma.bookingReference.deleteMany({
|
||||
where: {
|
||||
bookingId: bookingToDelete.id
|
||||
}
|
||||
bookingId: bookingToDelete.id,
|
||||
},
|
||||
});
|
||||
const bookingDeletes = prisma.booking.delete({
|
||||
where: {
|
||||
|
@ -52,17 +54,12 @@ export default async function handler(req, res) {
|
|||
},
|
||||
});
|
||||
|
||||
await Promise.all([
|
||||
apiDeletes,
|
||||
attendeeDeletes,
|
||||
bookingReferenceDeletes,
|
||||
bookingDeletes
|
||||
]);
|
||||
await Promise.all([apiDeletes, attendeeDeletes, bookingReferenceDeletes, bookingDeletes]);
|
||||
|
||||
//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 {
|
||||
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 { XIcon } from '@heroicons/react/outline';
|
||||
import Head from 'next/head';
|
||||
import Link from 'next/link';
|
||||
import { useRouter } from "next/router";
|
||||
import { XIcon } from "@heroicons/react/outline";
|
||||
import Head from "next/head";
|
||||
import Link from "next/link";
|
||||
|
||||
export default function Error() {
|
||||
const router = useRouter();
|
||||
const { error } = router.query;
|
||||
const router = useRouter();
|
||||
const { error } = router.query;
|
||||
|
||||
return (
|
||||
<div className="fixed z-50 inset-0 overflow-y-auto" aria-labelledby="modal-title" role="dialog" aria-modal="true">
|
||||
<Head>
|
||||
<title>{error} - Calendso</title>
|
||||
<link rel="icon" href="/favicon.ico" />
|
||||
</Head>
|
||||
<div 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">​</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">
|
||||
<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 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>
|
||||
return (
|
||||
<div
|
||||
className="fixed z-50 inset-0 overflow-y-auto"
|
||||
aria-labelledby="modal-title"
|
||||
role="dialog"
|
||||
aria-modal="true">
|
||||
<Head>
|
||||
<title>{error} - Calendso</title>
|
||||
<link rel="icon" href="/favicon.ico" />
|
||||
</Head>
|
||||
<div 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">
|
||||
​
|
||||
</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">
|
||||
<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 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>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -2,11 +2,10 @@ import { getCsrfToken } from "next-auth/client";
|
|||
import prisma from "../../../lib/prisma";
|
||||
|
||||
import Head from "next/head";
|
||||
import React from "react";
|
||||
import React, { useMemo } from "react";
|
||||
import debounce from "lodash.debounce";
|
||||
import dayjs from "dayjs";
|
||||
import { ResetPasswordRequest } from "@prisma/client";
|
||||
import { useMemo } from "react";
|
||||
import Link from "next/link";
|
||||
import { GetServerSidePropsContext } from "next";
|
||||
|
||||
|
|
|
@ -1,41 +1,45 @@
|
|||
import Head from 'next/head';
|
||||
import Link from 'next/link';
|
||||
import { CheckIcon } from '@heroicons/react/outline';
|
||||
import Head from "next/head";
|
||||
import Link from "next/link";
|
||||
import { CheckIcon } from "@heroicons/react/outline";
|
||||
|
||||
export default function Logout() {
|
||||
return (
|
||||
<div className="fixed z-50 inset-0 overflow-y-auto" aria-labelledby="modal-title" role="dialog" aria-modal="true">
|
||||
<Head>
|
||||
<title>Logged out - Calendso</title>
|
||||
<link rel="icon" href="/favicon.ico" />
|
||||
</Head>
|
||||
<div 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">​</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">
|
||||
<div>
|
||||
<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" />
|
||||
</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>
|
||||
return (
|
||||
<div
|
||||
className="fixed z-50 inset-0 overflow-y-auto"
|
||||
aria-labelledby="modal-title"
|
||||
role="dialog"
|
||||
aria-modal="true">
|
||||
<Head>
|
||||
<title>Logged out - Calendso</title>
|
||||
<link rel="icon" href="/favicon.ico" />
|
||||
</Head>
|
||||
<div 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">
|
||||
​
|
||||
</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">
|
||||
<div>
|
||||
<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" />
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -1,27 +1,25 @@
|
|||
import Head from 'next/head';
|
||||
import {useRouter} from "next/router";
|
||||
import {signIn} from 'next-auth/client'
|
||||
import Head from "next/head";
|
||||
import { useRouter } from "next/router";
|
||||
import { signIn } from "next-auth/client";
|
||||
import ErrorAlert from "../../components/ui/alerts/Error";
|
||||
import {useState} from "react";
|
||||
import {UsernameInput} from "../../components/ui/UsernameInput";
|
||||
import { useState } from "react";
|
||||
import { UsernameInput } from "../../components/ui/UsernameInput";
|
||||
import prisma from "../../lib/prisma";
|
||||
|
||||
export default function Signup(props) {
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
const [ hasErrors, setHasErrors ] = useState(false);
|
||||
const [ errorMessage, setErrorMessage ] = useState('');
|
||||
const [hasErrors, setHasErrors] = useState(false);
|
||||
const [errorMessage, setErrorMessage] = useState("");
|
||||
|
||||
const handleErrors = async (resp) => {
|
||||
if (!resp.ok) {
|
||||
const err = await resp.json();
|
||||
throw new Error(err.message);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const signUp = (e) => {
|
||||
|
||||
e.preventDefault();
|
||||
|
||||
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 password: string = e.target.password.value;
|
||||
|
||||
fetch('/api/auth/signup',
|
||||
{
|
||||
body: JSON.stringify({
|
||||
username: e.target.username.value,
|
||||
password,
|
||||
email,
|
||||
}),
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
method: 'POST'
|
||||
}
|
||||
)
|
||||
fetch("/api/auth/signup", {
|
||||
body: JSON.stringify({
|
||||
username: e.target.username.value,
|
||||
password,
|
||||
email,
|
||||
}),
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
method: "POST",
|
||||
})
|
||||
.then(handleErrors)
|
||||
.then(
|
||||
() => signIn('Calendso', { callbackUrl: (router.query.callbackUrl || '') as string })
|
||||
)
|
||||
.catch( (err) => {
|
||||
.then(() => signIn("Calendso", { callbackUrl: (router.query.callbackUrl || "") as string }))
|
||||
.catch((err) => {
|
||||
setHasErrors(true);
|
||||
setErrorMessage(err.message);
|
||||
});
|
||||
};
|
||||
|
||||
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>
|
||||
<title>Sign up</title>
|
||||
<link rel="icon" href="/favicon.ico" />
|
||||
</Head>
|
||||
<div className="sm:mx-auto sm:w-full sm:max-w-md">
|
||||
<h2 className="text-center text-3xl font-extrabold text-gray-900">
|
||||
Create your account
|
||||
</h2>
|
||||
<h2 className="text-center text-3xl font-extrabold text-gray-900">Create your account</h2>
|
||||
</div>
|
||||
<div className="mt-8 sm:mx-auto sm:w-full sm:max-w-md">
|
||||
<div className="bg-white py-8 px-4 shadow mx-2 sm:rounded-lg sm:px-10">
|
||||
|
@ -74,23 +70,60 @@ export default function Signup(props) {
|
|||
<UsernameInput required />
|
||||
</div>
|
||||
<div className="mb-2">
|
||||
<label htmlFor="email" className="block text-sm font-medium text-gray-700">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" />
|
||||
<label htmlFor="email" className="block text-sm font-medium text-gray-700">
|
||||
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 className="mb-2">
|
||||
<label htmlFor="password" className="block text-sm font-medium text-gray-700">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" />
|
||||
<label htmlFor="password" className="block text-sm font-medium text-gray-700">
|
||||
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>
|
||||
<label htmlFor="passwordcheck" className="block text-sm font-medium text-gray-700">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" />
|
||||
<label htmlFor="passwordcheck" className="block text-sm font-medium text-gray-700">
|
||||
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 className="mt-3 sm:mt-4 flex">
|
||||
<input type="submit" value="Create Account"
|
||||
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>
|
||||
<input
|
||||
type="submit"
|
||||
value="Create Account"
|
||||
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>
|
||||
</form>
|
||||
</div>
|
||||
|
@ -103,38 +136,40 @@ export async function getServerSideProps(ctx) {
|
|||
if (!ctx.query.token) {
|
||||
return {
|
||||
notFound: true,
|
||||
}
|
||||
};
|
||||
}
|
||||
const verificationRequest = await prisma.verificationRequest.findUnique({
|
||||
where: {
|
||||
token: ctx.query.token,
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
// for now, disable if no verificationRequestToken given or token expired
|
||||
if ( ! verificationRequest || verificationRequest.expires < new Date() ) {
|
||||
if (!verificationRequest || verificationRequest.expires < new Date()) {
|
||||
return {
|
||||
notFound: true,
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const existingUser = await prisma.user.findFirst({
|
||||
where: {
|
||||
AND: [
|
||||
{
|
||||
email: verificationRequest.identifier
|
||||
email: verificationRequest.identifier,
|
||||
},
|
||||
{
|
||||
emailVerified: {
|
||||
not: null,
|
||||
},
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
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 } };
|
||||
|
|
|
@ -7,7 +7,7 @@ import { useRouter } from "next/router";
|
|||
import { useRef, useState } from "react";
|
||||
import { getSession, useSession } from "next-auth/client";
|
||||
import { ClockIcon } from "@heroicons/react/outline";
|
||||
import Loader from '@components/Loader';
|
||||
import Loader from "@components/Loader";
|
||||
|
||||
export default function Availability(props) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
|
@ -53,7 +53,7 @@ export default function Availability(props) {
|
|||
m = m < 10 ? "0" + m : m;
|
||||
return `${h}:${m}`;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
async function createEventTypeHandler(event) {
|
||||
event.preventDefault();
|
||||
|
||||
|
@ -64,7 +64,7 @@ export default function Availability(props) {
|
|||
const enteredIsHidden = isHiddenRef.current.checked;
|
||||
|
||||
// TODO: Add validation
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const response = await fetch("/api/availability/eventtype", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
|
@ -100,7 +100,7 @@ export default function Availability(props) {
|
|||
const bufferMins = enteredBufferHours * 60 + enteredBufferMins;
|
||||
|
||||
// TODO: Add validation
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const response = await fetch("/api/availability/day", {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify({ start: startMins, end: endMins, buffer: bufferMins }),
|
||||
|
|
|
@ -6,13 +6,15 @@ import dayjs from "dayjs";
|
|||
import utc from "dayjs/plugin/utc";
|
||||
import { GetServerSideProps } from "next";
|
||||
import prisma from "@lib/prisma";
|
||||
import Loader from '@components/Loader';
|
||||
import Loader from "@components/Loader";
|
||||
|
||||
dayjs.extend(utc);
|
||||
|
||||
export default function Troubleshoot({ user }) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const [session, loading] = useSession();
|
||||
const [availability, setAvailability] = useState([]);
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const [selectedDate, setSelectedDate] = useState(dayjs());
|
||||
|
||||
if (loading) {
|
||||
|
|
|
@ -1,14 +1,14 @@
|
|||
import {useState} from 'react';
|
||||
import Head from 'next/head';
|
||||
import prisma from '../../lib/prisma';
|
||||
import {useRouter} from 'next/router';
|
||||
import dayjs from 'dayjs';
|
||||
import {CalendarIcon, ClockIcon, XIcon} from '@heroicons/react/solid';
|
||||
import isSameOrBefore from 'dayjs/plugin/isSameOrBefore';
|
||||
import isBetween from 'dayjs/plugin/isBetween';
|
||||
import utc from 'dayjs/plugin/utc';
|
||||
import timezone from 'dayjs/plugin/timezone';
|
||||
import {collectPageParameters, telemetryEventTypes, useTelemetry} from "../../lib/telemetry";
|
||||
import { useState } from "react";
|
||||
import Head from "next/head";
|
||||
import prisma from "../../lib/prisma";
|
||||
import { useRouter } from "next/router";
|
||||
import dayjs from "dayjs";
|
||||
import { CalendarIcon, ClockIcon, XIcon } from "@heroicons/react/solid";
|
||||
import isSameOrBefore from "dayjs/plugin/isSameOrBefore";
|
||||
import isBetween from "dayjs/plugin/isBetween";
|
||||
import utc from "dayjs/plugin/utc";
|
||||
import timezone from "dayjs/plugin/timezone";
|
||||
import { collectPageParameters, telemetryEventTypes, useTelemetry } from "../../lib/telemetry";
|
||||
|
||||
dayjs.extend(isSameOrBefore);
|
||||
dayjs.extend(isBetween);
|
||||
|
@ -16,153 +16,164 @@ dayjs.extend(utc);
|
|||
dayjs.extend(timezone);
|
||||
|
||||
export default function Type(props) {
|
||||
// Get router variables
|
||||
const router = useRouter();
|
||||
const { uid } = router.query;
|
||||
// Get router variables
|
||||
const router = useRouter();
|
||||
const { uid } = router.query;
|
||||
|
||||
const [is24h, setIs24h] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState(null);
|
||||
const telemetry = useTelemetry();
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const [is24h, setIs24h] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState(null);
|
||||
const telemetry = useTelemetry();
|
||||
|
||||
const cancellationHandler = async (event) => {
|
||||
setLoading(true);
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const cancellationHandler = async (event) => {
|
||||
setLoading(true);
|
||||
|
||||
let payload = {
|
||||
uid: uid
|
||||
};
|
||||
const payload = {
|
||||
uid: uid,
|
||||
};
|
||||
|
||||
telemetry.withJitsu(jitsu => jitsu.track(telemetryEventTypes.bookingCancelled, collectPageParameters()));
|
||||
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>
|
||||
telemetry.withJitsu((jitsu) =>
|
||||
jitsu.track(telemetryEventTypes.bookingCancelled, collectPageParameters())
|
||||
);
|
||||
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) {
|
||||
const booking = await prisma.booking.findFirst({
|
||||
where: {
|
||||
uid: context.query.uid,
|
||||
},
|
||||
const booking = await prisma.booking.findFirst({
|
||||
where: {
|
||||
uid: context.query.uid,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
description: true,
|
||||
startTime: true,
|
||||
endTime: true,
|
||||
attendees: true,
|
||||
eventType: true,
|
||||
user: {
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
description: 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
|
||||
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,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
|
@ -1,12 +1,12 @@
|
|||
import Head from 'next/head';
|
||||
import prisma from '../../lib/prisma';
|
||||
import {useRouter} from 'next/router';
|
||||
import dayjs from 'dayjs';
|
||||
import isSameOrBefore from 'dayjs/plugin/isSameOrBefore';
|
||||
import isBetween from 'dayjs/plugin/isBetween';
|
||||
import utc from 'dayjs/plugin/utc';
|
||||
import timezone from 'dayjs/plugin/timezone';
|
||||
import {CheckIcon} from "@heroicons/react/outline";
|
||||
import Head from "next/head";
|
||||
import prisma from "../../lib/prisma";
|
||||
import { useRouter } from "next/router";
|
||||
import dayjs from "dayjs";
|
||||
import isSameOrBefore from "dayjs/plugin/isSameOrBefore";
|
||||
import isBetween from "dayjs/plugin/isBetween";
|
||||
import utc from "dayjs/plugin/utc";
|
||||
import timezone from "dayjs/plugin/timezone";
|
||||
import { CheckIcon } from "@heroicons/react/outline";
|
||||
|
||||
dayjs.extend(isSameOrBefore);
|
||||
dayjs.extend(isBetween);
|
||||
|
@ -14,78 +14,79 @@ dayjs.extend(utc);
|
|||
dayjs.extend(timezone);
|
||||
|
||||
export default function Type(props) {
|
||||
// Get router variables
|
||||
const router = useRouter();
|
||||
// Get router variables
|
||||
const router = useRouter();
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Head>
|
||||
<title>
|
||||
Cancelled {props.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">
|
||||
<div>
|
||||
<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" />
|
||||
</div>
|
||||
<div className="mt-3 text-center sm:mt-5">
|
||||
<h3 className="text-lg leading-6 font-medium text-gray-900" id="modal-headline">
|
||||
Cancellation successful
|
||||
</h3>
|
||||
<div className="mt-2">
|
||||
<p className="text-sm text-gray-500">
|
||||
Feel free to pick another event anytime.
|
||||
</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>
|
||||
return (
|
||||
<div>
|
||||
<Head>
|
||||
<title>
|
||||
Cancelled {props.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">
|
||||
<div>
|
||||
<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" />
|
||||
</div>
|
||||
<div className="mt-3 text-center sm:mt-5">
|
||||
<h3 className="text-lg leading-6 font-medium text-gray-900" id="modal-headline">
|
||||
Cancellation successful
|
||||
</h3>
|
||||
<div className="mt-2">
|
||||
<p className="text-sm text-gray-500">Feel free to pick another event anytime.</p>
|
||||
</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>
|
||||
);
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export async function getServerSideProps(context) {
|
||||
const user = await prisma.user.findFirst({
|
||||
where: {
|
||||
username: context.query.user,
|
||||
},
|
||||
select: {
|
||||
username: true,
|
||||
name: true,
|
||||
bio: true,
|
||||
avatar: true,
|
||||
eventTypes: true
|
||||
}
|
||||
});
|
||||
const user = await prisma.user.findFirst({
|
||||
where: {
|
||||
username: context.query.user,
|
||||
},
|
||||
select: {
|
||||
username: true,
|
||||
name: true,
|
||||
bio: true,
|
||||
avatar: true,
|
||||
eventTypes: true,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
props: {
|
||||
user,
|
||||
title: context.query.title
|
||||
},
|
||||
}
|
||||
return {
|
||||
props: {
|
||||
user,
|
||||
title: context.query.title,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
|
@ -9,19 +9,19 @@ import { LocationType } from "@lib/location";
|
|||
import Shell from "@components/Shell";
|
||||
import { getSession } from "next-auth/client";
|
||||
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 { EventTypeCustomInput, EventTypeCustomInputType } from "@lib/eventTypeInput";
|
||||
import {
|
||||
LocationMarkerIcon,
|
||||
LinkIcon,
|
||||
PlusIcon,
|
||||
DocumentIcon,
|
||||
ChevronRightIcon,
|
||||
ClockIcon,
|
||||
TrashIcon,
|
||||
DocumentIcon,
|
||||
ExternalLinkIcon,
|
||||
LinkIcon,
|
||||
LocationMarkerIcon,
|
||||
PlusIcon,
|
||||
TrashIcon,
|
||||
} from "@heroicons/react/solid";
|
||||
|
||||
import dayjs from "dayjs";
|
||||
|
@ -29,7 +29,6 @@ import utc from "dayjs/plugin/utc";
|
|||
import timezone from "dayjs/plugin/timezone";
|
||||
import { Availability, EventType, User } from "@prisma/client";
|
||||
import { validJson } from "@lib/jsonUtils";
|
||||
import { RadioGroup } from "@headlessui/react";
|
||||
import classnames from "classnames";
|
||||
import throttle from "lodash.throttle";
|
||||
import "react-dates/initialize";
|
||||
|
|
|
@ -7,9 +7,7 @@ function RedirectPage() {
|
|||
router.push("/event-types");
|
||||
return;
|
||||
}
|
||||
return (
|
||||
<Loader/>
|
||||
);
|
||||
return <Loader />;
|
||||
}
|
||||
|
||||
RedirectPage.getInitialProps = (ctx) => {
|
||||
|
|
|
@ -4,7 +4,7 @@ import { getIntegrationName, getIntegrationType } from "../../lib/integrations";
|
|||
import Shell from "../../components/Shell";
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/router";
|
||||
import { useSession, getSession } from "next-auth/client";
|
||||
import { getSession, useSession } from "next-auth/client";
|
||||
import Loader from "@components/Loader";
|
||||
|
||||
export default function Integration(props) {
|
||||
|
|
|
@ -9,7 +9,7 @@ import { InformationCircleIcon } from "@heroicons/react/outline";
|
|||
import { Switch } from "@headlessui/react";
|
||||
import Loader from "@components/Loader";
|
||||
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 }) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
|
|
|
@ -2,8 +2,8 @@ import Head from "next/head";
|
|||
import prisma from "../../lib/prisma";
|
||||
import Shell from "../../components/Shell";
|
||||
import SettingsShell from "../../components/Settings";
|
||||
import { useSession, getSession } from "next-auth/client";
|
||||
import Loader from '@components/Loader';
|
||||
import { getSession, useSession } from "next-auth/client";
|
||||
import Loader from "@components/Loader";
|
||||
|
||||
export default function Embed(props) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
|
|
|
@ -4,7 +4,7 @@ import prisma from "../../lib/prisma";
|
|||
import Modal from "../../components/Modal";
|
||||
import Shell from "../../components/Shell";
|
||||
import SettingsShell from "../../components/Settings";
|
||||
import { useSession, getSession } from "next-auth/client";
|
||||
import { getSession, useSession } from "next-auth/client";
|
||||
import Loader from "@components/Loader";
|
||||
|
||||
export default function Settings() {
|
||||
|
|
|
@ -0,0 +1,2 @@
|
|||
-- AlterTable
|
||||
ALTER TABLE "Booking" ADD COLUMN "location" TEXT;
|
|
@ -133,6 +133,7 @@ model Booking {
|
|||
endTime DateTime
|
||||
|
||||
attendees Attendee[]
|
||||
location String?
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime?
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- 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"
|
||||
viewBox="0 0 427 97.5" style="enable-background:new 0 0 427 97.5;" xml:space="preserve">
|
||||
<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">
|
||||
<style type="text/css">
|
||||
.st0{fillRule:evenodd;clipRule:evenodd;fill:#26282C;}
|
||||
</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"?>
|
||||
<!-- 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"
|
||||
viewBox="0 0 427 97.5" style="enable-background:new 0 0 427 97.5;" xml:space="preserve">
|
||||
<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">
|
||||
<style type="text/css">
|
||||
.st0{fillRule:evenodd;clipRule:evenodd;fill:#fff;}
|
||||
</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"?>
|
||||
<!-- 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"
|
||||
viewBox="0 0 427 97.5" style="enable-background:new 0 0 427 97.5;" xml:space="preserve">
|
||||
<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">
|
||||
<style type="text/css">
|
||||
.st0{fillRule:evenodd;clipRule:evenodd;fill:#104D86;}
|
||||
</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"?>
|
||||
<!-- 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"
|
||||
viewBox="0 0 29.2 33" style="enable-background:new 0 0 29.2 33;" xml:space="preserve">
|
||||
<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">
|
||||
<style type="text/css">
|
||||
.st0{fill:#F68D2E;}
|
||||
</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_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;"
|
||||
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"
|
||||
enable-background="new 0 0 2228.833 2073.333" xml:space="preserve">
|
||||
<svg version="1.1" id="Livello_1"
|
||||
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">
|
||||
<metadata>
|
||||
<sfw xmlns="&ns_sfw;">
|
||||
<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 |