Resolved conflicts; included reference creation into EventManager; use EventManager everywhere
This commit is contained in:
commit
86e2add30f
19 changed files with 843 additions and 308 deletions
|
@ -32,3 +32,5 @@ EMAIL_SERVER_PORT=587
|
||||||
EMAIL_SERVER_USER='<office365_emailAddress>'
|
EMAIL_SERVER_USER='<office365_emailAddress>'
|
||||||
# Keep in mind that if you have 2FA enabled, you will need to provision an App Password.
|
# Keep in mind that if you have 2FA enabled, you will need to provision an App Password.
|
||||||
EMAIL_SERVER_PASSWORD='<office365_password>'
|
EMAIL_SERVER_PASSWORD='<office365_password>'
|
||||||
|
# ApiKey for cronjobs
|
||||||
|
CRON_API_KEY='0cc0e6c35519bba620c9360cfe3e68d0'
|
||||||
|
|
|
@ -2,8 +2,8 @@ import Link from "next/link";
|
||||||
|
|
||||||
const PoweredByCalendso = () => (
|
const PoweredByCalendso = () => (
|
||||||
<div className="text-xs text-center sm:text-right pt-1">
|
<div className="text-xs text-center sm:text-right pt-1">
|
||||||
<Link href="https://calendso.com">
|
<Link href={`https://calendso.com?utm_source=embed&utm_medium=powered-by-button`}>
|
||||||
<a className="dark:text-white text-gray-500 opacity-50 hover:opacity-100">
|
<a target="_blank" className="dark:text-white text-gray-500 opacity-50 hover:opacity-100">
|
||||||
powered by{" "}
|
powered by{" "}
|
||||||
<img
|
<img
|
||||||
style={{ top: -2 }}
|
style={{ top: -2 }}
|
||||||
|
|
|
@ -574,7 +574,7 @@ const updateEvent = async (
|
||||||
credential: Credential,
|
credential: Credential,
|
||||||
uidToUpdate: string,
|
uidToUpdate: string,
|
||||||
calEvent: CalendarEvent,
|
calEvent: CalendarEvent,
|
||||||
noMail: false
|
noMail = false
|
||||||
): Promise<EventResult> => {
|
): Promise<EventResult> => {
|
||||||
const parser: CalEventParser = new CalEventParser(calEvent);
|
const parser: CalEventParser = new CalEventParser(calEvent);
|
||||||
const newUid: string = parser.getUid();
|
const newUid: string = parser.getUid();
|
||||||
|
@ -626,12 +626,4 @@ const deleteEvent = (credential: Credential, uid: string): Promise<unknown> => {
|
||||||
return Promise.resolve({});
|
return Promise.resolve({});
|
||||||
};
|
};
|
||||||
|
|
||||||
export {
|
export { getBusyCalendarTimes, createEvent, updateEvent, deleteEvent, listCalendars };
|
||||||
getBusyCalendarTimes,
|
|
||||||
createEvent,
|
|
||||||
updateEvent,
|
|
||||||
deleteEvent,
|
|
||||||
CalendarEvent,
|
|
||||||
listCalendars,
|
|
||||||
IntegrationCalendar,
|
|
||||||
};
|
|
||||||
|
|
|
@ -46,6 +46,31 @@ export default class EventOrganizerMail extends EventMail {
|
||||||
return icsEvent.value;
|
return icsEvent.value;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
protected getBodyHeader(): string {
|
||||||
|
return "A new event has been scheduled.";
|
||||||
|
}
|
||||||
|
|
||||||
|
protected getBodyText(): string {
|
||||||
|
return "You and any other attendees have been emailed with this information.";
|
||||||
|
}
|
||||||
|
|
||||||
|
protected getImage(): string {
|
||||||
|
return `<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
style="height: 60px; width: 60px; color: #31c48d"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||||
|
/>
|
||||||
|
</svg>`;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns the email text as HTML representation.
|
* Returns the email text as HTML representation.
|
||||||
*
|
*
|
||||||
|
@ -67,22 +92,9 @@ export default class EventOrganizerMail extends EventMail {
|
||||||
margin-top: 40px;
|
margin-top: 40px;
|
||||||
"
|
"
|
||||||
>
|
>
|
||||||
<svg
|
${this.getImage()}
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
<h1 style="font-weight: 500; color: #161e2e;">${this.getBodyHeader()}</h1>
|
||||||
style="height: 60px; width: 60px; color: #31c48d"
|
<p style="color: #4b5563; margin-bottom: 30px;">${this.getBodyText()}</p>
|
||||||
fill="none"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
stroke="currentColor"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
stroke-width="2"
|
|
||||||
d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
<h1 style="font-weight: 500; color: #161e2e;">A new event has been scheduled.</h1>
|
|
||||||
<p style="color: #4b5563; margin-bottom: 30px;">You and any other attendees have been emailed with this information.</p>
|
|
||||||
<hr />
|
<hr />
|
||||||
<table style="border-spacing: 20px; color: #161e2e; margin-bottom: 10px;">
|
<table style="border-spacing: 20px; color: #161e2e; margin-bottom: 10px;">
|
||||||
<colgroup>
|
<colgroup>
|
||||||
|
@ -165,8 +177,6 @@ export default class EventOrganizerMail extends EventMail {
|
||||||
* @protected
|
* @protected
|
||||||
*/
|
*/
|
||||||
protected getNodeMailerPayload(): Record<string, unknown> {
|
protected getNodeMailerPayload(): Record<string, unknown> {
|
||||||
const organizerStart: Dayjs = <Dayjs>dayjs(this.calEvent.startTime).tz(this.calEvent.organizer.timeZone);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
icalEvent: {
|
icalEvent: {
|
||||||
filename: "event.ics",
|
filename: "event.ics",
|
||||||
|
@ -174,14 +184,19 @@ export default class EventOrganizerMail extends EventMail {
|
||||||
},
|
},
|
||||||
from: `Calendso <${this.getMailerOptions().from}>`,
|
from: `Calendso <${this.getMailerOptions().from}>`,
|
||||||
to: this.calEvent.organizer.email,
|
to: this.calEvent.organizer.email,
|
||||||
subject: `New event: ${this.calEvent.attendees[0].name} - ${organizerStart.format("LT dddd, LL")} - ${
|
subject: this.getSubject(),
|
||||||
this.calEvent.type
|
|
||||||
}`,
|
|
||||||
html: this.getHtmlRepresentation(),
|
html: this.getHtmlRepresentation(),
|
||||||
text: this.getPlainTextRepresentation(),
|
text: this.getPlainTextRepresentation(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
protected getSubject(): string {
|
||||||
|
const organizerStart: Dayjs = <Dayjs>dayjs(this.calEvent.startTime).tz(this.calEvent.organizer.timeZone);
|
||||||
|
return `New event: ${this.calEvent.attendees[0].name} - ${organizerStart.format("LT dddd, LL")} - ${
|
||||||
|
this.calEvent.type
|
||||||
|
}`;
|
||||||
|
}
|
||||||
|
|
||||||
protected printNodeMailerError(error: string): void {
|
protected printNodeMailerError(error: string): void {
|
||||||
console.error("SEND_NEW_EVENT_NOTIFICATION_ERROR", this.calEvent.organizer.email, error);
|
console.error("SEND_NEW_EVENT_NOTIFICATION_ERROR", this.calEvent.organizer.email, error);
|
||||||
}
|
}
|
||||||
|
|
50
lib/emails/EventOrganizerRequestMail.ts
Normal file
50
lib/emails/EventOrganizerRequestMail.ts
Normal file
|
@ -0,0 +1,50 @@
|
||||||
|
import dayjs, { Dayjs } from "dayjs";
|
||||||
|
|
||||||
|
import utc from "dayjs/plugin/utc";
|
||||||
|
import timezone from "dayjs/plugin/timezone";
|
||||||
|
import toArray from "dayjs/plugin/toArray";
|
||||||
|
import localizedFormat from "dayjs/plugin/localizedFormat";
|
||||||
|
import EventOrganizerMail from "@lib/emails/EventOrganizerMail";
|
||||||
|
|
||||||
|
dayjs.extend(utc);
|
||||||
|
dayjs.extend(timezone);
|
||||||
|
dayjs.extend(toArray);
|
||||||
|
dayjs.extend(localizedFormat);
|
||||||
|
|
||||||
|
export default class EventOrganizerRequestMail extends EventOrganizerMail {
|
||||||
|
protected getBodyHeader(): string {
|
||||||
|
return "A new event is waiting for your approval.";
|
||||||
|
}
|
||||||
|
|
||||||
|
protected getBodyText(): string {
|
||||||
|
return "Check your bookings page to confirm or reject the booking.";
|
||||||
|
}
|
||||||
|
|
||||||
|
protected getAdditionalBody(): string {
|
||||||
|
return `<a href="${process.env.BASE_URL}/bookings">Confirm or reject the booking</a>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected getImage(): string {
|
||||||
|
return `<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
style="height: 60px; width: 60px; color: #01579b"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M8.228 9c.549-1.165 2.03-2 3.772-2 2.21 0 4 1.343 4 3 0 1.4-1.278 2.575-3.006 2.907-.542.104-.994.54-.994 1.093m0 3h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||||
|
/>
|
||||||
|
</svg>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected getSubject(): string {
|
||||||
|
const organizerStart: Dayjs = <Dayjs>dayjs(this.calEvent.startTime).tz(this.calEvent.organizer.timeZone);
|
||||||
|
return `New event request: ${this.calEvent.attendees[0].name} - ${organizerStart.format(
|
||||||
|
"LT dddd, LL"
|
||||||
|
)} - ${this.calEvent.type}`;
|
||||||
|
}
|
||||||
|
}
|
25
lib/emails/EventOrganizerRequestReminderMail.ts
Normal file
25
lib/emails/EventOrganizerRequestReminderMail.ts
Normal file
|
@ -0,0 +1,25 @@
|
||||||
|
import dayjs, { Dayjs } from "dayjs";
|
||||||
|
|
||||||
|
import utc from "dayjs/plugin/utc";
|
||||||
|
import timezone from "dayjs/plugin/timezone";
|
||||||
|
import toArray from "dayjs/plugin/toArray";
|
||||||
|
import localizedFormat from "dayjs/plugin/localizedFormat";
|
||||||
|
import EventOrganizerRequestMail from "@lib/emails/EventOrganizerRequestMail";
|
||||||
|
|
||||||
|
dayjs.extend(utc);
|
||||||
|
dayjs.extend(timezone);
|
||||||
|
dayjs.extend(toArray);
|
||||||
|
dayjs.extend(localizedFormat);
|
||||||
|
|
||||||
|
export default class EventOrganizerRequestReminderMail extends EventOrganizerRequestMail {
|
||||||
|
protected getBodyHeader(): string {
|
||||||
|
return "An event is still waiting for your approval.";
|
||||||
|
}
|
||||||
|
|
||||||
|
protected getSubject(): string {
|
||||||
|
const organizerStart: Dayjs = <Dayjs>dayjs(this.calEvent.startTime).tz(this.calEvent.organizer.timeZone);
|
||||||
|
return `Event request is still waiting: ${this.calEvent.attendees[0].name} - ${organizerStart.format(
|
||||||
|
"LT dddd, LL"
|
||||||
|
)} - ${this.calEvent.type}`;
|
||||||
|
}
|
||||||
|
}
|
91
lib/emails/EventRejectionMail.ts
Normal file
91
lib/emails/EventRejectionMail.ts
Normal file
|
@ -0,0 +1,91 @@
|
||||||
|
import dayjs, { Dayjs } from "dayjs";
|
||||||
|
import EventMail from "./EventMail";
|
||||||
|
|
||||||
|
import utc from "dayjs/plugin/utc";
|
||||||
|
import timezone from "dayjs/plugin/timezone";
|
||||||
|
import localizedFormat from "dayjs/plugin/localizedFormat";
|
||||||
|
|
||||||
|
dayjs.extend(utc);
|
||||||
|
dayjs.extend(timezone);
|
||||||
|
dayjs.extend(localizedFormat);
|
||||||
|
|
||||||
|
export default class EventRejectionMail extends EventMail {
|
||||||
|
/**
|
||||||
|
* Returns the email text as HTML representation.
|
||||||
|
*
|
||||||
|
* @protected
|
||||||
|
*/
|
||||||
|
protected getHtmlRepresentation(): string {
|
||||||
|
return (
|
||||||
|
`
|
||||||
|
<body style="background: #f4f5f7; font-family: Helvetica, sans-serif">
|
||||||
|
<div
|
||||||
|
style="
|
||||||
|
margin: 0 auto;
|
||||||
|
max-width: 450px;
|
||||||
|
background: white;
|
||||||
|
border-radius: 0.75rem;
|
||||||
|
border: 1px solid #e5e7eb;
|
||||||
|
padding: 2rem 2rem 2rem 2rem;
|
||||||
|
text-align: center;
|
||||||
|
margin-top: 40px;
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
style="height: 60px; width: 60px; color: #ba2525"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<h1 style="font-weight: 500; color: #161e2e;">Your meeting request has been rejected</h1>
|
||||||
|
<p style="color: #4b5563; margin-bottom: 30px;">You and any other attendees have been emailed with this information.</p>
|
||||||
|
<hr />
|
||||||
|
` +
|
||||||
|
`
|
||||||
|
</div>
|
||||||
|
<div style="text-align: center; margin-top: 20px; color: #ccc; font-size: 12px;">
|
||||||
|
<img style="opacity: 0.25; width: 120px;" src="https://app.calendso.com/calendso-logo-word.svg" alt="Calendso Logo"></div>
|
||||||
|
</body>
|
||||||
|
`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the payload object for the nodemailer.
|
||||||
|
*
|
||||||
|
* @protected
|
||||||
|
*/
|
||||||
|
protected getNodeMailerPayload(): Record<string, unknown> {
|
||||||
|
return {
|
||||||
|
to: `${this.calEvent.attendees[0].name} <${this.calEvent.attendees[0].email}>`,
|
||||||
|
from: `${this.calEvent.organizer.name} <${this.getMailerOptions().from}>`,
|
||||||
|
replyTo: this.calEvent.organizer.email,
|
||||||
|
subject: `Rejected: ${this.calEvent.type} with ${
|
||||||
|
this.calEvent.organizer.name
|
||||||
|
} on ${this.getInviteeStart().format("dddd, LL")}`,
|
||||||
|
html: this.getHtmlRepresentation(),
|
||||||
|
text: this.getPlainTextRepresentation(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
protected printNodeMailerError(error: string): void {
|
||||||
|
console.error("SEND_BOOKING_CONFIRMATION_ERROR", this.calEvent.attendees[0].email, error);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the inviteeStart value used at multiple points.
|
||||||
|
*
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
protected getInviteeStart(): Dayjs {
|
||||||
|
return <Dayjs>dayjs(this.calEvent.startTime).tz(this.calEvent.attendees[0].timeZone);
|
||||||
|
}
|
||||||
|
}
|
|
@ -2,6 +2,7 @@ import { CalendarEvent, createEvent, updateEvent } from "@lib/calendarClient";
|
||||||
import { Credential } from "@prisma/client";
|
import { Credential } from "@prisma/client";
|
||||||
import async from "async";
|
import async from "async";
|
||||||
import { createMeeting, updateMeeting } from "@lib/videoClient";
|
import { createMeeting, updateMeeting } from "@lib/videoClient";
|
||||||
|
import prisma from "@lib/prisma";
|
||||||
|
|
||||||
export interface EventResult {
|
export interface EventResult {
|
||||||
type: string;
|
type: string;
|
||||||
|
@ -12,13 +13,18 @@ export interface EventResult {
|
||||||
originalEvent: CalendarEvent;
|
originalEvent: CalendarEvent;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface CreateUpdateResult {
|
||||||
|
results: Array<EventResult>;
|
||||||
|
referencesToCreate: Array<PartialReference>;
|
||||||
|
}
|
||||||
|
|
||||||
export interface PartialBooking {
|
export interface PartialBooking {
|
||||||
id: number;
|
id: number;
|
||||||
references: Array<PartialReference>;
|
references: Array<PartialReference>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PartialReference {
|
export interface PartialReference {
|
||||||
id: number;
|
id?: number;
|
||||||
type: string;
|
type: string;
|
||||||
uid: string;
|
uid: string;
|
||||||
}
|
}
|
||||||
|
@ -32,7 +38,7 @@ export default class EventManager {
|
||||||
this.videoCredentials = credentials.filter((cred) => cred.type.endsWith("_video"));
|
this.videoCredentials = credentials.filter((cred) => cred.type.endsWith("_video"));
|
||||||
}
|
}
|
||||||
|
|
||||||
public async create(event: CalendarEvent): Promise<Array<EventResult>> {
|
public async create(event: CalendarEvent): Promise<CreateUpdateResult> {
|
||||||
const isVideo = EventManager.isIntegration(event.location);
|
const isVideo = EventManager.isIntegration(event.location);
|
||||||
|
|
||||||
// First, create all calendar events. If this is a video event, don't send a mail right here.
|
// First, create all calendar events. If this is a video event, don't send a mail right here.
|
||||||
|
@ -43,10 +49,37 @@ export default class EventManager {
|
||||||
results.push(await this.createVideoEvent(event));
|
results.push(await this.createVideoEvent(event));
|
||||||
}
|
}
|
||||||
|
|
||||||
return results;
|
const referencesToCreate: Array<PartialReference> = results.map((result) => {
|
||||||
|
return {
|
||||||
|
type: result.type,
|
||||||
|
uid: result.createdEvent.id.toString(),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
results,
|
||||||
|
referencesToCreate,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
public async update(event: CalendarEvent, booking: PartialBooking): Promise<Array<EventResult>> {
|
public async update(event: CalendarEvent, rescheduleUid: string): Promise<CreateUpdateResult> {
|
||||||
|
// 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 isVideo = EventManager.isIntegration(event.location);
|
const isVideo = EventManager.isIntegration(event.location);
|
||||||
|
|
||||||
// First, update all calendar events. If this is a video event, don't send a mail right here.
|
// First, update all calendar events. If this is a video event, don't send a mail right here.
|
||||||
|
@ -57,7 +90,30 @@ export default class EventManager {
|
||||||
results.push(await this.updateVideoEvent(event, booking));
|
results.push(await this.updateVideoEvent(event, booking));
|
||||||
}
|
}
|
||||||
|
|
||||||
return results;
|
// 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],
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
@ -16,6 +16,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
|
||||||
description: req.body.description,
|
description: req.body.description,
|
||||||
length: parseInt(req.body.length),
|
length: parseInt(req.body.length),
|
||||||
hidden: req.body.hidden,
|
hidden: req.body.hidden,
|
||||||
|
requiresConfirmation: req.body.requiresConfirmation,
|
||||||
locations: req.body.locations,
|
locations: req.body.locations,
|
||||||
eventName: req.body.eventName,
|
eventName: req.body.eventName,
|
||||||
customInputs: !req.body.customInputs
|
customInputs: !req.body.customInputs
|
||||||
|
|
|
@ -10,12 +10,14 @@ import { LocationType } from "@lib/location";
|
||||||
import merge from "lodash.merge";
|
import merge from "lodash.merge";
|
||||||
import dayjs from "dayjs";
|
import dayjs from "dayjs";
|
||||||
import logger from "../../../lib/logger";
|
import logger from "../../../lib/logger";
|
||||||
import EventManager, { EventResult } from "@lib/events/EventManager";
|
import EventManager, { CreateUpdateResult, EventResult } from "@lib/events/EventManager";
|
||||||
import { User } from "@prisma/client";
|
import { User } from "@prisma/client";
|
||||||
|
|
||||||
import utc from "dayjs/plugin/utc";
|
import utc from "dayjs/plugin/utc";
|
||||||
import timezone from "dayjs/plugin/timezone";
|
import timezone from "dayjs/plugin/timezone";
|
||||||
import dayjsBusinessDays from "dayjs-business-days";
|
import dayjsBusinessDays from "dayjs-business-days";
|
||||||
|
import { Exception } from "handlebars";
|
||||||
|
import EventOrganizerRequestMail from "@lib/emails/EventOrganizerRequestMail";
|
||||||
|
|
||||||
dayjs.extend(dayjsBusinessDays);
|
dayjs.extend(dayjsBusinessDays);
|
||||||
dayjs.extend(utc);
|
dayjs.extend(utc);
|
||||||
|
@ -116,6 +118,25 @@ const getLocationRequestFromIntegration = ({ location }: GetLocationRequestFromI
|
||||||
return null;
|
return null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export async function handleLegacyConfirmationMail(
|
||||||
|
results: Array<EventResult>,
|
||||||
|
selectedEventType: { requiresConfirmation: boolean },
|
||||||
|
evt: CalendarEvent,
|
||||||
|
hashUID: string
|
||||||
|
): Promise<{ error: Exception; message: string | null }> {
|
||||||
|
if (results.length === 0 && !selectedEventType.requiresConfirmation) {
|
||||||
|
// Legacy as well, as soon as we have a separate email integration class. Just used
|
||||||
|
// to send an email even if there is no integration at all.
|
||||||
|
try {
|
||||||
|
const mail = new EventAttendeeMail(evt, hashUID);
|
||||||
|
await mail.sendEmail();
|
||||||
|
} catch (e) {
|
||||||
|
return { error: e, message: "Booking failed" };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
export default async function handler(req: NextApiRequest, res: NextApiResponse): Promise<void> {
|
export default async function handler(req: NextApiRequest, res: NextApiResponse): Promise<void> {
|
||||||
const { user } = req.query;
|
const { user } = req.query;
|
||||||
log.debug(`Booking ${user} started`);
|
log.debug(`Booking ${user} started`);
|
||||||
|
@ -210,6 +231,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
|
||||||
periodStartDate: true,
|
periodStartDate: true,
|
||||||
periodEndDate: true,
|
periodEndDate: true,
|
||||||
periodCountCalendarDays: true,
|
periodCountCalendarDays: true,
|
||||||
|
requiresConfirmation: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -303,25 +325,8 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
|
||||||
let referencesToCreate = [];
|
let referencesToCreate = [];
|
||||||
|
|
||||||
if (rescheduleUid) {
|
if (rescheduleUid) {
|
||||||
// Reschedule event
|
|
||||||
const booking = await prisma.booking.findFirst({
|
|
||||||
where: {
|
|
||||||
uid: rescheduleUid,
|
|
||||||
},
|
|
||||||
select: {
|
|
||||||
id: true,
|
|
||||||
references: {
|
|
||||||
select: {
|
|
||||||
id: true,
|
|
||||||
type: true,
|
|
||||||
uid: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// Use EventManager to conditionally use all needed integrations.
|
// Use EventManager to conditionally use all needed integrations.
|
||||||
results = await eventManager.update(evt, booking);
|
const updateResults: CreateUpdateResult = await eventManager.update(evt, rescheduleUid);
|
||||||
|
|
||||||
if (results.length > 0 && results.every((res) => !res.success)) {
|
if (results.length > 0 && results.every((res) => !res.success)) {
|
||||||
const error = {
|
const error = {
|
||||||
|
@ -333,30 +338,12 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
|
||||||
return res.status(500).json(error);
|
return res.status(500).json(error);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clone elements
|
// Forward results
|
||||||
referencesToCreate = [...booking.references];
|
results = updateResults.results;
|
||||||
|
referencesToCreate = updateResults.referencesToCreate;
|
||||||
// Now we can delete the old booking and its references.
|
} else if (!selectedEventType.requiresConfirmation) {
|
||||||
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]);
|
|
||||||
} else {
|
|
||||||
// Use EventManager to conditionally use all needed integrations.
|
// Use EventManager to conditionally use all needed integrations.
|
||||||
results = await eventManager.create(evt);
|
const createResults: CreateUpdateResult = await eventManager.create(evt);
|
||||||
|
|
||||||
if (results.length > 0 && results.every((res) => !res.success)) {
|
if (results.length > 0 && results.every((res) => !res.success)) {
|
||||||
const error = {
|
const error = {
|
||||||
|
@ -368,31 +355,22 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
|
||||||
return res.status(500).json(error);
|
return res.status(500).json(error);
|
||||||
}
|
}
|
||||||
|
|
||||||
referencesToCreate = results.map((result) => {
|
// Forward results
|
||||||
return {
|
results = createResults.results;
|
||||||
type: result.type,
|
referencesToCreate = createResults.referencesToCreate;
|
||||||
uid: result.createdEvent.id.toString(),
|
|
||||||
};
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const hashUID =
|
const hashUID =
|
||||||
results.length > 0 ? results[0].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.
|
// TODO Should just be set to the true case as soon as we have a "bare email" integration class.
|
||||||
// UID generation should happen in the integration itself, not here.
|
// UID generation should happen in the integration itself, not here.
|
||||||
if (results.length === 0) {
|
const legacyMailError = await handleLegacyConfirmationMail(results, selectedEventType, evt, hashUID);
|
||||||
// Legacy as well, as soon as we have a separate email integration class. Just used
|
if (legacyMailError) {
|
||||||
// to send an email even if there is no integration at all.
|
log.error("Sending legacy event mail failed", legacyMailError.error);
|
||||||
try {
|
|
||||||
const mail = new EventAttendeeMail(evt, hashUID);
|
|
||||||
await mail.sendEmail();
|
|
||||||
} catch (e) {
|
|
||||||
log.error("Sending legacy event mail failed", e);
|
|
||||||
log.error(`Booking ${user} failed`);
|
log.error(`Booking ${user} failed`);
|
||||||
res.status(500).json({ message: "Booking failed" });
|
res.status(500).json({ message: legacyMailError.message });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await prisma.booking.create({
|
await prisma.booking.create({
|
||||||
|
@ -410,6 +388,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
|
||||||
attendees: {
|
attendees: {
|
||||||
create: evt.attendees,
|
create: evt.attendees,
|
||||||
},
|
},
|
||||||
|
confirmed: !selectedEventType.requiresConfirmation,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
@ -418,6 +397,10 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (selectedEventType.requiresConfirmation) {
|
||||||
|
await new EventOrganizerRequestMail(evt, hashUID).sendEmail();
|
||||||
|
}
|
||||||
|
|
||||||
log.debug(`Booking ${user} completed`);
|
log.debug(`Booking ${user} completed`);
|
||||||
return res.status(204).json({ message: "Booking completed" });
|
return res.status(204).json({ message: "Booking completed" });
|
||||||
} catch (reason) {
|
} catch (reason) {
|
||||||
|
|
106
pages/api/book/confirm.ts
Normal file
106
pages/api/book/confirm.ts
Normal file
|
@ -0,0 +1,106 @@
|
||||||
|
import type { NextApiRequest, NextApiResponse } from "next";
|
||||||
|
import { getSession } from "next-auth/client";
|
||||||
|
import prisma from "../../../lib/prisma";
|
||||||
|
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 });
|
||||||
|
if (!session) {
|
||||||
|
return res.status(401).json({ message: "Not authenticated" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const bookingId = req.body.id;
|
||||||
|
if (!bookingId) {
|
||||||
|
return res.status(400).json({ message: "bookingId missing" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentUser = await prisma.user.findFirst({
|
||||||
|
where: {
|
||||||
|
id: session.user.id,
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
credentials: true,
|
||||||
|
timeZone: true,
|
||||||
|
email: true,
|
||||||
|
name: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (req.method == "PATCH") {
|
||||||
|
const booking = await prisma.booking.findFirst({
|
||||||
|
where: {
|
||||||
|
id: bookingId,
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
title: true,
|
||||||
|
description: true,
|
||||||
|
startTime: true,
|
||||||
|
endTime: true,
|
||||||
|
confirmed: true,
|
||||||
|
attendees: true,
|
||||||
|
userId: true,
|
||||||
|
id: true,
|
||||||
|
uid: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!booking || booking.userId != currentUser.id) {
|
||||||
|
return res.status(404).json({ message: "booking not found" });
|
||||||
|
}
|
||||||
|
if (booking.confirmed) {
|
||||||
|
return res.status(400).json({ message: "booking already confirmed" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const evt: CalendarEvent = {
|
||||||
|
type: booking.title,
|
||||||
|
title: booking.title,
|
||||||
|
description: booking.description,
|
||||||
|
startTime: booking.startTime.toISOString(),
|
||||||
|
endTime: booking.endTime.toISOString(),
|
||||||
|
organizer: { email: currentUser.email, name: currentUser.name, timeZone: currentUser.timeZone },
|
||||||
|
attendees: booking.attendees,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (req.body.confirmed) {
|
||||||
|
const eventManager = new EventManager(currentUser.credentials);
|
||||||
|
const scheduleResult = await eventManager.create(evt);
|
||||||
|
|
||||||
|
await handleLegacyConfirmationMail(
|
||||||
|
scheduleResult.results,
|
||||||
|
{ requiresConfirmation: false },
|
||||||
|
evt,
|
||||||
|
booking.uid
|
||||||
|
);
|
||||||
|
|
||||||
|
await prisma.booking.update({
|
||||||
|
where: {
|
||||||
|
id: bookingId,
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
confirmed: true,
|
||||||
|
references: {
|
||||||
|
create: scheduleResult.referencesToCreate,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
res.status(204).json({ message: "ok" });
|
||||||
|
} else {
|
||||||
|
await prisma.booking.update({
|
||||||
|
where: {
|
||||||
|
id: bookingId,
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
rejected: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const attendeeMail = new EventRejectionMail(evt, booking.uid);
|
||||||
|
await attendeeMail.sendEmail();
|
||||||
|
res.status(204).json({ message: "ok" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
74
pages/api/cron/bookingReminder.ts
Normal file
74
pages/api/cron/bookingReminder.ts
Normal file
|
@ -0,0 +1,74 @@
|
||||||
|
import type { NextApiRequest, NextApiResponse } from "next";
|
||||||
|
import prisma from "@lib/prisma";
|
||||||
|
import dayjs from "dayjs";
|
||||||
|
import { ReminderType } from "@prisma/client";
|
||||||
|
import EventOrganizerRequestReminderMail from "@lib/emails/EventOrganizerRequestReminderMail";
|
||||||
|
import { CalendarEvent } from "@lib/calendarClient";
|
||||||
|
|
||||||
|
export default async function handler(req: NextApiRequest, res: NextApiResponse): Promise<void> {
|
||||||
|
const apiKey = req.query.apiKey;
|
||||||
|
if (process.env.CRON_API_KEY != apiKey) {
|
||||||
|
return res.status(401).json({ message: "Not authenticated" });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (req.method == "POST") {
|
||||||
|
const reminderIntervalMinutes = [48 * 60, 24 * 60, 3 * 60];
|
||||||
|
let notificationsSent = 0;
|
||||||
|
for (const interval of reminderIntervalMinutes) {
|
||||||
|
const bookings = await prisma.booking.findMany({
|
||||||
|
where: {
|
||||||
|
confirmed: false,
|
||||||
|
rejected: false,
|
||||||
|
createdAt: {
|
||||||
|
lte: dayjs().add(-interval, "minutes").toDate(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
title: true,
|
||||||
|
description: true,
|
||||||
|
startTime: true,
|
||||||
|
endTime: true,
|
||||||
|
attendees: true,
|
||||||
|
user: true,
|
||||||
|
id: true,
|
||||||
|
uid: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const reminders = await prisma.reminderMail.findMany({
|
||||||
|
where: {
|
||||||
|
reminderType: ReminderType.PENDING_BOOKING_CONFIRMATION,
|
||||||
|
referenceId: {
|
||||||
|
in: bookings.map((b) => b.id),
|
||||||
|
},
|
||||||
|
elapsedMinutes: {
|
||||||
|
gte: interval,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const booking of bookings.filter((b) => !reminders.some((r) => r.referenceId == b.id))) {
|
||||||
|
const evt: CalendarEvent = {
|
||||||
|
type: booking.title,
|
||||||
|
title: booking.title,
|
||||||
|
description: booking.description,
|
||||||
|
startTime: booking.startTime.toISOString(),
|
||||||
|
endTime: booking.endTime.toISOString(),
|
||||||
|
organizer: { email: booking.user.email, name: booking.user.name, timeZone: booking.user.timeZone },
|
||||||
|
attendees: booking.attendees,
|
||||||
|
};
|
||||||
|
|
||||||
|
await new EventOrganizerRequestReminderMail(evt, booking.uid).sendEmail();
|
||||||
|
await prisma.reminderMail.create({
|
||||||
|
data: {
|
||||||
|
referenceId: booking.id,
|
||||||
|
reminderType: ReminderType.PENDING_BOOKING_CONFIRMATION,
|
||||||
|
elapsedMinutes: interval,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
notificationsSent++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
res.status(200).json({ notificationsSent });
|
||||||
|
}
|
||||||
|
}
|
|
@ -66,6 +66,7 @@ type EventTypeInput = {
|
||||||
periodStartDate?: Date | string;
|
periodStartDate?: Date | string;
|
||||||
periodEndDate?: Date | string;
|
periodEndDate?: Date | string;
|
||||||
periodCountCalendarDays?: boolean;
|
periodCountCalendarDays?: boolean;
|
||||||
|
enteredRequiresConfirmation: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
const PERIOD_TYPES = [
|
const PERIOD_TYPES = [
|
||||||
|
@ -172,6 +173,7 @@ export default function EventTypePage({
|
||||||
const descriptionRef = useRef<HTMLTextAreaElement>();
|
const descriptionRef = useRef<HTMLTextAreaElement>();
|
||||||
const lengthRef = useRef<HTMLInputElement>();
|
const lengthRef = useRef<HTMLInputElement>();
|
||||||
const isHiddenRef = useRef<HTMLInputElement>();
|
const isHiddenRef = useRef<HTMLInputElement>();
|
||||||
|
const requiresConfirmationRef = useRef<HTMLInputElement>();
|
||||||
const eventNameRef = useRef<HTMLInputElement>();
|
const eventNameRef = useRef<HTMLInputElement>();
|
||||||
const periodDaysRef = useRef<HTMLInputElement>();
|
const periodDaysRef = useRef<HTMLInputElement>();
|
||||||
const periodDaysTypeRef = useRef<HTMLSelectElement>();
|
const periodDaysTypeRef = useRef<HTMLSelectElement>();
|
||||||
|
@ -188,6 +190,7 @@ export default function EventTypePage({
|
||||||
const enteredDescription: string = descriptionRef.current.value;
|
const enteredDescription: string = descriptionRef.current.value;
|
||||||
const enteredLength: number = parseInt(lengthRef.current.value);
|
const enteredLength: number = parseInt(lengthRef.current.value);
|
||||||
const enteredIsHidden: boolean = isHiddenRef.current.checked;
|
const enteredIsHidden: boolean = isHiddenRef.current.checked;
|
||||||
|
const enteredRequiresConfirmation: boolean = requiresConfirmationRef.current.checked;
|
||||||
const enteredEventName: string = eventNameRef.current.value;
|
const enteredEventName: string = eventNameRef.current.value;
|
||||||
|
|
||||||
const type = periodType.type;
|
const type = periodType.type;
|
||||||
|
@ -223,6 +226,7 @@ export default function EventTypePage({
|
||||||
periodStartDate: enteredPeriodStartDate,
|
periodStartDate: enteredPeriodStartDate,
|
||||||
periodEndDate: enteredPeriodEndDate,
|
periodEndDate: enteredPeriodEndDate,
|
||||||
periodCountCalendarDays: enteredPeriodDaysType,
|
periodCountCalendarDays: enteredPeriodDaysType,
|
||||||
|
requiresConfirmation: enteredRequiresConfirmation,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (enteredAvailability) {
|
if (enteredAvailability) {
|
||||||
|
@ -641,6 +645,29 @@ export default function EventTypePage({
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="my-8">
|
||||||
|
<div className="relative flex items-start">
|
||||||
|
<div className="flex items-center h-5">
|
||||||
|
<input
|
||||||
|
ref={requiresConfirmationRef}
|
||||||
|
id="requiresConfirmation"
|
||||||
|
name="requiresConfirmation"
|
||||||
|
type="checkbox"
|
||||||
|
className="focus:ring-blue-500 h-4 w-4 text-blue-600 border-gray-300 rounded"
|
||||||
|
defaultChecked={eventType.requiresConfirmation}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="ml-3 text-sm">
|
||||||
|
<label htmlFor="requiresConfirmation" className="font-medium text-gray-700">
|
||||||
|
Booking requires manual confirmation
|
||||||
|
</label>
|
||||||
|
<p className="text-gray-500">
|
||||||
|
The booking needs to be confirmed, before it is pushed to the integrations and a
|
||||||
|
confirmation mail is sent.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<fieldset className="my-8">
|
<fieldset className="my-8">
|
||||||
<Text variant="largetitle">When can people book this event?</Text>
|
<Text variant="largetitle">When can people book this event?</Text>
|
||||||
|
@ -991,6 +1018,7 @@ export const getServerSideProps: GetServerSideProps<Props> = async ({ req, query
|
||||||
periodStartDate: true,
|
periodStartDate: true,
|
||||||
periodEndDate: true,
|
periodEndDate: true,
|
||||||
periodCountCalendarDays: true,
|
periodCountCalendarDays: true,
|
||||||
|
requiresConfirmation: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
@ -2,14 +2,29 @@ import Head from "next/head";
|
||||||
import prisma from "../../lib/prisma";
|
import prisma from "../../lib/prisma";
|
||||||
import { getSession, useSession } from "next-auth/client";
|
import { getSession, useSession } from "next-auth/client";
|
||||||
import Shell from "../../components/Shell";
|
import Shell from "../../components/Shell";
|
||||||
|
import { useRouter } from "next/router";
|
||||||
|
|
||||||
export default function Bookings({ bookings }) {
|
export default function Bookings({ bookings }) {
|
||||||
const [, loading] = useSession();
|
const [, loading] = useSession();
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return <p className="text-gray-400">Loading...</p>;
|
return <p className="text-gray-400">Loading...</p>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function confirmBookingHandler(booking, confirm: boolean) {
|
||||||
|
const res = await fetch("/api/book/confirm", {
|
||||||
|
method: "PATCH",
|
||||||
|
body: JSON.stringify({ id: booking.id, confirmed: confirm }),
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (res.ok) {
|
||||||
|
await router.replace(router.asPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<Head>
|
<Head>
|
||||||
|
@ -45,13 +60,29 @@ export default function Bookings({ bookings }) {
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="bg-white divide-y divide-gray-200">
|
<tbody className="bg-white divide-y divide-gray-200">
|
||||||
{bookings.map((booking) => (
|
{bookings
|
||||||
|
.filter((booking) => !booking.confirmed && !booking.rejected)
|
||||||
|
.concat(bookings.filter((booking) => booking.confirmed || booking.rejected))
|
||||||
|
.map((booking) => (
|
||||||
<tr key={booking.id}>
|
<tr key={booking.id}>
|
||||||
<td className="px-6 py-4 whitespace-nowrap">
|
<td
|
||||||
<div className="text-sm font-medium text-gray-900">{booking.attendees[0].name}</div>
|
className={
|
||||||
|
"px-6 py-4 whitespace-nowrap" + (booking.rejected ? " line-through" : "")
|
||||||
|
}>
|
||||||
|
{!booking.confirmed && !booking.rejected && (
|
||||||
|
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-blue-600 text-gray-100">
|
||||||
|
Unconfirmed
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<div className="text-sm font-medium text-gray-900">
|
||||||
|
{booking.attendees[0].name}
|
||||||
|
</div>
|
||||||
<div className="text-sm text-gray-500">{booking.attendees[0].email}</div>
|
<div className="text-sm text-gray-500">{booking.attendees[0].email}</div>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-4 whitespace-nowrap">
|
<td
|
||||||
|
className={
|
||||||
|
"px-6 py-4 whitespace-nowrap" + (booking.rejected ? " line-through" : "")
|
||||||
|
}>
|
||||||
<div className="text-sm text-gray-900">{booking.title}</div>
|
<div className="text-sm text-gray-900">{booking.title}</div>
|
||||||
<div className="text-sm text-gray-500">{booking.description}</div>
|
<div className="text-sm text-gray-500">{booking.description}</div>
|
||||||
</td>
|
</td>
|
||||||
|
@ -61,6 +92,22 @@ export default function Bookings({ bookings }) {
|
||||||
</div>
|
</div>
|
||||||
</td> */}
|
</td> */}
|
||||||
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
|
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
|
||||||
|
{!booking.confirmed && !booking.rejected && (
|
||||||
|
<>
|
||||||
|
<a
|
||||||
|
onClick={() => confirmBookingHandler(booking, true)}
|
||||||
|
className="cursor-pointer text-blue-600 hover:text-blue-900">
|
||||||
|
Confirm
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
onClick={() => confirmBookingHandler(booking, false)}
|
||||||
|
className="cursor-pointer ml-4 text-blue-600 hover:text-blue-900">
|
||||||
|
Reject
|
||||||
|
</a>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{booking.confirmed && !booking.rejected && (
|
||||||
|
<>
|
||||||
<a
|
<a
|
||||||
href={window.location.href + "/../reschedule/" + booking.uid}
|
href={window.location.href + "/../reschedule/" + booking.uid}
|
||||||
className="text-blue-600 hover:text-blue-900">
|
className="text-blue-600 hover:text-blue-900">
|
||||||
|
@ -71,6 +118,11 @@ export default function Bookings({ bookings }) {
|
||||||
className="ml-4 text-blue-600 hover:text-blue-900">
|
className="ml-4 text-blue-600 hover:text-blue-900">
|
||||||
Cancel
|
Cancel
|
||||||
</a>
|
</a>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{!booking.confirmed && booking.rejected && (
|
||||||
|
<div className="text-sm text-gray-500">Rejected</div>
|
||||||
|
)}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
|
@ -110,6 +162,9 @@ export async function getServerSideProps(context) {
|
||||||
title: true,
|
title: true,
|
||||||
description: true,
|
description: true,
|
||||||
attendees: true,
|
attendees: true,
|
||||||
|
confirmed: true,
|
||||||
|
rejected: true,
|
||||||
|
id: true,
|
||||||
},
|
},
|
||||||
orderBy: {
|
orderBy: {
|
||||||
startTime: "desc",
|
startTime: "desc",
|
||||||
|
|
|
@ -1,24 +1,17 @@
|
||||||
import Head from 'next/head';
|
import Head from "next/head";
|
||||||
import Link from 'next/link';
|
import prisma from "../../lib/prisma";
|
||||||
import { useState } from 'react';
|
import Shell from "../../components/Shell";
|
||||||
import { useRouter } from 'next/router';
|
import SettingsShell from "../../components/Settings";
|
||||||
import prisma from '../../lib/prisma';
|
import { getSession, useSession } from "next-auth/client";
|
||||||
import Modal from '../../components/Modal';
|
|
||||||
import Shell from '../../components/Shell';
|
|
||||||
import SettingsShell from '../../components/Settings';
|
|
||||||
import Avatar from '../../components/Avatar';
|
|
||||||
import { signIn, useSession, getSession } from 'next-auth/client';
|
|
||||||
import TimezoneSelect from 'react-timezone-select';
|
|
||||||
|
|
||||||
export default function Embed(props) {
|
export default function Embed(props) {
|
||||||
const [ session, loading ] = useSession();
|
const [session, loading] = useSession();
|
||||||
const router = useRouter();
|
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return <div className="loader"></div>;
|
return <div className="loader"></div>;
|
||||||
}
|
}
|
||||||
|
|
||||||
return(
|
return (
|
||||||
<Shell heading="Embed">
|
<Shell heading="Embed">
|
||||||
<Head>
|
<Head>
|
||||||
<title>Embed | Calendso</title>
|
<title>Embed | Calendso</title>
|
||||||
|
@ -28,9 +21,7 @@ export default function Embed(props) {
|
||||||
<div className="py-6 px-4 sm:p-6 lg:pb-8 lg:col-span-9">
|
<div className="py-6 px-4 sm:p-6 lg:pb-8 lg:col-span-9">
|
||||||
<div className="mb-6">
|
<div className="mb-6">
|
||||||
<h2 className="text-lg leading-6 font-medium text-gray-900">Iframe Embed</h2>
|
<h2 className="text-lg leading-6 font-medium text-gray-900">Iframe Embed</h2>
|
||||||
<p className="mt-1 text-sm text-gray-500">
|
<p className="mt-1 text-sm text-gray-500">The easiest way to embed Calendso on your website.</p>
|
||||||
The easiest way to embed Calendso on your website.
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-2 space-x-4">
|
<div className="grid grid-cols-2 space-x-4">
|
||||||
<div>
|
<div>
|
||||||
|
@ -42,7 +33,13 @@ export default function Embed(props) {
|
||||||
id="iframe"
|
id="iframe"
|
||||||
className="h-32 shadow-sm focus:ring-blue-500 focus:border-blue-500 block w-full sm:text-sm border-gray-300 rounded-md"
|
className="h-32 shadow-sm focus:ring-blue-500 focus:border-blue-500 block w-full sm:text-sm border-gray-300 rounded-md"
|
||||||
placeholder="Loading..."
|
placeholder="Loading..."
|
||||||
defaultValue={'<iframe src="https://calendso.com/' + session.user.username + '" frameborder="0" allowfullscreen></iframe>'}
|
defaultValue={
|
||||||
|
'<iframe src="' +
|
||||||
|
props.BASE_URL +
|
||||||
|
"/" +
|
||||||
|
session.user.username +
|
||||||
|
'" frameborder="0" allowfullscreen></iframe>'
|
||||||
|
}
|
||||||
readOnly
|
readOnly
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
@ -56,7 +53,13 @@ export default function Embed(props) {
|
||||||
id="fullscreen"
|
id="fullscreen"
|
||||||
className="h-32 shadow-sm focus:ring-blue-500 focus:border-blue-500 block w-full sm:text-sm border-gray-300 rounded-md"
|
className="h-32 shadow-sm focus:ring-blue-500 focus:border-blue-500 block w-full sm:text-sm border-gray-300 rounded-md"
|
||||||
placeholder="Loading..."
|
placeholder="Loading..."
|
||||||
defaultValue={'<!DOCTYPE html><html lang="en"><head><meta charset="UTF-8"><meta http-equiv="X-UA-Compatible" content="IE=edge"><meta name="viewport" content="width=device-width, initial-scale=1.0"><title>Schedule a meeting</title><style>body {margin: 0;}iframe {height: calc(100vh - 4px);width: calc(100vw - 4px);box-sizing: border-box;}</style></head><body><iframe src="https://calendso.com/' + session.user.username + '" frameborder="0" allowfullscreen></iframe></body></html>'}
|
defaultValue={
|
||||||
|
'<!DOCTYPE html><html lang="en"><head><meta charset="UTF-8"><meta http-equiv="X-UA-Compatible" content="IE=edge"><meta name="viewport" content="width=device-width, initial-scale=1.0"><title>Schedule a meeting</title><style>body {margin: 0;}iframe {height: calc(100vh - 4px);width: calc(100vw - 4px);box-sizing: border-box;}</style></head><body><iframe src="' +
|
||||||
|
props.BASE_URL +
|
||||||
|
"/" +
|
||||||
|
session.user.username +
|
||||||
|
'" frameborder="0" allowfullscreen></iframe></body></html>'
|
||||||
|
}
|
||||||
readOnly
|
readOnly
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
@ -68,7 +71,9 @@ export default function Embed(props) {
|
||||||
Leverage our API for full control and customizability.
|
Leverage our API for full control and customizability.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<a href="https://api.docs.calendso.com" className="btn btn-primary">Browse our API documentation</a>
|
<a href="https://api.docs.calendso.com" className="btn btn-primary">
|
||||||
|
Browse our API documentation
|
||||||
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</SettingsShell>
|
</SettingsShell>
|
||||||
</Shell>
|
</Shell>
|
||||||
|
@ -78,7 +83,7 @@ export default function Embed(props) {
|
||||||
export async function getServerSideProps(context) {
|
export async function getServerSideProps(context) {
|
||||||
const session = await getSession(context);
|
const session = await getSession(context);
|
||||||
if (!session) {
|
if (!session) {
|
||||||
return { redirect: { permanent: false, destination: '/auth/login' } };
|
return { redirect: { permanent: false, destination: "/auth/login" } };
|
||||||
}
|
}
|
||||||
|
|
||||||
const user = await prisma.user.findFirst({
|
const user = await prisma.user.findFirst({
|
||||||
|
@ -94,10 +99,12 @@ export async function getServerSideProps(context) {
|
||||||
avatar: true,
|
avatar: true,
|
||||||
timeZone: true,
|
timeZone: true,
|
||||||
weekStart: true,
|
weekStart: true,
|
||||||
}
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const BASE_URL = process.env.BASE_URL;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
props: {user}, // will be passed to the page component as props
|
props: { user, BASE_URL }, // will be passed to the page component as props
|
||||||
}
|
};
|
||||||
}
|
}
|
|
@ -62,7 +62,10 @@ export default function Success(props) {
|
||||||
isReady && (
|
isReady && (
|
||||||
<div>
|
<div>
|
||||||
<Head>
|
<Head>
|
||||||
<title>Booking Confirmed | {eventName} | Calendso</title>
|
<title>
|
||||||
|
Booking {props.eventType.requiresConfirmation ? "Submitted" : "Confirmed"} | {eventName} |
|
||||||
|
Calendso
|
||||||
|
</title>
|
||||||
<link rel="icon" href="/favicon.ico" />
|
<link rel="icon" href="/favicon.ico" />
|
||||||
</Head>
|
</Head>
|
||||||
<main className="max-w-3xl mx-auto my-24">
|
<main className="max-w-3xl mx-auto my-24">
|
||||||
|
@ -79,17 +82,26 @@ export default function Success(props) {
|
||||||
aria-labelledby="modal-headline">
|
aria-labelledby="modal-headline">
|
||||||
<div>
|
<div>
|
||||||
<div className="mx-auto flex items-center justify-center h-12 w-12 rounded-full bg-green-100">
|
<div className="mx-auto flex items-center justify-center h-12 w-12 rounded-full bg-green-100">
|
||||||
|
{!props.eventType.requiresConfirmation && (
|
||||||
<CheckIcon className="h-6 w-6 text-green-600" />
|
<CheckIcon className="h-6 w-6 text-green-600" />
|
||||||
|
)}
|
||||||
|
{props.eventType.requiresConfirmation && (
|
||||||
|
<ClockIcon className="h-6 w-6 text-green-600" />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-3 text-center sm:mt-5">
|
<div className="mt-3 text-center sm:mt-5">
|
||||||
<h3
|
<h3
|
||||||
className="text-lg leading-6 font-medium dark:text-white text-gray-900"
|
className="text-lg leading-6 font-medium dark:text-white text-gray-900"
|
||||||
id="modal-headline">
|
id="modal-headline">
|
||||||
Booking confirmed
|
Booking {props.eventType.requiresConfirmation ? "Submitted" : "Confirmed"}
|
||||||
</h3>
|
</h3>
|
||||||
<div className="mt-2">
|
<div className="mt-2">
|
||||||
<p className="text-sm text-gray-500 dark:text-gray-300">
|
<p className="text-sm text-gray-500 dark:text-gray-300">
|
||||||
You are scheduled in with {props.user.name || props.user.username}.
|
{props.eventType.requiresConfirmation
|
||||||
|
? `${
|
||||||
|
props.user.name || props.user.username
|
||||||
|
} still needs to confirm or reject the booking.`
|
||||||
|
: `You are scheduled in with ${props.user.name || props.user.username}.`}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-4 border-t border-b dark:border-gray-900 py-4">
|
<div className="mt-4 border-t border-b dark:border-gray-900 py-4">
|
||||||
|
@ -113,8 +125,11 @@ export default function Success(props) {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{!props.eventType.requiresConfirmation && (
|
||||||
<div className="mt-5 sm:mt-0 pt-2 text-center">
|
<div className="mt-5 sm:mt-0 pt-2 text-center">
|
||||||
<span className="font-medium text-gray-500 dark:text-gray-50">Add to your calendar</span>
|
<span className="font-medium text-gray-500 dark:text-gray-50">
|
||||||
|
Add to your calendar
|
||||||
|
</span>
|
||||||
<div className="flex mt-2">
|
<div className="flex mt-2">
|
||||||
<Link
|
<Link
|
||||||
href={
|
href={
|
||||||
|
@ -200,6 +215,7 @@ export default function Success(props) {
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
{!props.user.hideBranding && (
|
{!props.user.hideBranding && (
|
||||||
<div className="mt-4 pt-4 border-t dark:border-gray-900 text-gray-400 text-center text-xs dark:text-white">
|
<div className="mt-4 pt-4 border-t dark:border-gray-900 text-gray-400 text-center text-xs dark:text-white">
|
||||||
<a href="https://checkout.calendso.com">Create your own booking link with Calendso</a>
|
<a href="https://checkout.calendso.com">Create your own booking link with Calendso</a>
|
||||||
|
@ -237,7 +253,7 @@ export async function getServerSideProps(context) {
|
||||||
{
|
{
|
||||||
id: parseInt(context.query.type),
|
id: parseInt(context.query.type),
|
||||||
},
|
},
|
||||||
["id", "title", "description", "length", "eventName"]
|
["id", "title", "description", "length", "eventName", "requiresConfirmation"]
|
||||||
);
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|
|
@ -0,0 +1,6 @@
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "Booking" ADD COLUMN "confirmed" BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
ADD COLUMN "rejected" BOOLEAN NOT NULL DEFAULT false;
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "EventType" ADD COLUMN "requiresConfirmation" BOOLEAN NOT NULL DEFAULT false;
|
|
@ -0,0 +1,13 @@
|
||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "ReminderType" AS ENUM ('PENDING_BOOKING_CONFIRMATION');
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "ReminderMail" (
|
||||||
|
"id" SERIAL NOT NULL,
|
||||||
|
"referenceId" INTEGER NOT NULL,
|
||||||
|
"reminderType" "ReminderType" NOT NULL,
|
||||||
|
"elapsedMinutes" INTEGER NOT NULL,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
PRIMARY KEY ("id")
|
||||||
|
);
|
|
@ -30,6 +30,7 @@ model EventType {
|
||||||
periodEndDate DateTime?
|
periodEndDate DateTime?
|
||||||
periodDays Int?
|
periodDays Int?
|
||||||
periodCountCalendarDays Boolean?
|
periodCountCalendarDays Boolean?
|
||||||
|
requiresConfirmation Boolean @default(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
model Credential {
|
model Credential {
|
||||||
|
@ -134,6 +135,8 @@ model Booking {
|
||||||
|
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime?
|
updatedAt DateTime?
|
||||||
|
confirmed Boolean @default(true)
|
||||||
|
rejected Boolean @default(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
model Availability {
|
model Availability {
|
||||||
|
@ -173,3 +176,15 @@ model ResetPasswordRequest {
|
||||||
email String
|
email String
|
||||||
expires DateTime
|
expires DateTime
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enum ReminderType {
|
||||||
|
PENDING_BOOKING_CONFIRMATION
|
||||||
|
}
|
||||||
|
|
||||||
|
model ReminderMail {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
referenceId Int
|
||||||
|
reminderType ReminderType
|
||||||
|
elapsedMinutes Int
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
}
|
||||||
|
|
Loading…
Reference in a new issue