diff --git a/.env.example b/.env.example index 6a114db29a..cae788c4bc 100644 --- a/.env.example +++ b/.env.example @@ -8,7 +8,7 @@ NEXT_PUBLIC_LICENSE_CONSENT='' # DATABASE_URL='postgresql://:@:/' -DATABASE_URL="postgresql://postgres:@localhost:5432/calendso?schema=public" +DATABASE_URL="postgresql://postgres:@localhost:5450/calendso" GOOGLE_API_CREDENTIALS='secret' @@ -22,12 +22,12 @@ GOOGLE_CLIENT_ID= GOOGLE_CLIENT_SECRET= # Enable SAML login using https://github.com/boxyhq/jackson -SAML_LOGIN_URL= -SAML_API_URL= -SAML_TENANT_ID= -SAML_PRODUCT_ID= -JACKSON_API_KEYS= -SAML_ADMINS= +SAML_LOGIN_URL='http://localhost:5000' +SAML_API_URL='http://localhost:6000' +SAML_TENANT_ID='Cal.com' +SAML_PRODUCT_ID='Cal.com' +JACKSON_API_KEYS='secret' +SAML_ADMINS='onboarding@example.com' # @see: https://github.com/calendso/calendso/issues/263 # Required for Vercel hosting - set NEXTAUTH_URL to equal your BASE_URL diff --git a/@types/ical.d.ts b/@types/ical.d.ts new file mode 100644 index 0000000000..b699d05405 --- /dev/null +++ b/@types/ical.d.ts @@ -0,0 +1,257 @@ +// SPDX-FileCopyrightText: © 2019 EteSync Authors +// SPDX-License-Identifier: GPL-3.0-only +// https://github.com/mozilla-comm/ical.js/issues/367#issuecomment-568493517 +declare module "ical.js" { + function parse(input: string): any[]; + + export class helpers { + public updateTimezones(vcal: Component): Component; + } + + class Component { + public fromString(str: string): Component; + + public name: string; + + constructor(jCal: any[] | string, parent?: Component); + + public toJSON(): any[]; + + public getFirstSubcomponent(name?: string): Component | null; + public getAllSubcomponents(name?: string): Component[]; + + public getFirstPropertyValue(name?: string): T; + + public getFirstProperty(name?: string): Property; + public getAllProperties(name?: string): Property[]; + + public addProperty(property: Property): Property; + public addPropertyWithValue(name: string, value: string | number | Record): Property; + + public hasProperty(name?: string): boolean; + + public updatePropertyWithValue(name: string, value: string | number | Record): Property; + + public removeAllProperties(name?: string): boolean; + + public addSubcomponent(component: Component): Component; + } + + export class Event { + public uid: string; + public summary: string; + public startDate: Time; + public endDate: Time; + public description: string; + public location: string; + public attendees: Property[]; + /** + * The sequence value for this event. Used for scheduling. + * + * @type {number} + * @memberof Event + */ + public sequence: number; + /** + * The duration. This can be the result directly from the property, or the + * duration calculated from start date and end date. Setting the property + * will remove any `dtend` properties. + * + * @type {Duration} + * @memberof Event + */ + public duration: Duration; + /** + * The organizer value as an uri. In most cases this is a mailto: uri, + * but it can also be something else, like urn:uuid:... + */ + public organizer: string; + /** The sequence value for this event. Used for scheduling */ + public sequence: number; + /** The recurrence id for this event */ + public recurrenceId: Time; + + public component: Component; + + public constructor( + component?: Component | null, + options?: { strictExceptions: boolean; exepctions: Array } + ); + + public isRecurring(): boolean; + public iterator(startTime?: Time): RecurExpansion; + } + + export class Property { + public name: string; + public type: string; + + constructor(jCal: any[] | string, parent?: Component); + + public getFirstValue(): T; + public getValues(): T[]; + + public setParameter(name: string, value: string | string[]): void; + public setValue(value: string | Record): void; + public setValues(values: (string | Record)[]): void; + public toJSON(): any; + } + + interface TimeJsonData { + year?: number; + month?: number; + day?: number; + hour?: number; + minute?: number; + second?: number; + isDate?: boolean; + } + + export class Time { + public fromString(str: string): Time; + public fromJSDate(aDate: Date | null, useUTC: boolean): Time; + public fromData(aData: TimeJsonData): Time; + + public now(): Time; + + public isDate: boolean; + public timezone: string; + public zone: Timezone; + + public year: number; + public month: number; + public day: number; + public hour: number; + public minute: number; + public second: number; + + constructor(data?: TimeJsonData); + public compare(aOther: Time): number; + + public clone(): Time; + public convertToZone(zone: Timezone): Time; + + public adjust( + aExtraDays: number, + aExtraHours: number, + aExtraMinutes: number, + aExtraSeconds: number, + aTimeopt?: Time + ): void; + + public addDuration(aDuration: Duration): void; + public subtractDateTz(aDate: Time): Duration; + + public toUnixTime(): number; + public toJSDate(): Date; + public toJSON(): TimeJsonData; + public get icaltype(): "date" | "date-time"; + } + + export class Duration { + public weeks: number; + public days: number; + public hours: number; + public minutes: number; + public seconds: number; + public isNegative: boolean; + public icalclass: string; + public icaltype: string; + } + + export class RecurExpansion { + public complete: boolean; + public dtstart: Time; + public last: Time; + public next(): Time; + public fromData(options); + public toJSON(); + constructor(options: { + /** Start time of the event */ + dtstart: Time; + /** Component for expansion, required if not resuming. */ + component?: Component; + }); + } + + export class Timezone { + public utcTimezone: Timezone; + public localTimezone: Timezone; + public convert_time(tt: Time, fromZone: Timezone, toZone: Timezone): Time; + + public tzid: string; + public component: Component; + + constructor( + data: + | Component + | { + component: string | Component; + tzid?: string; + location?: string; + tznames?: string; + latitude?: number; + longitude?: number; + } + ); + } + + export class TimezoneService { + public get(tzid: string): Timezone | null; + public has(tzid: string): boolean; + public register(tzid: string, zone: Timezone | Component); + public remove(tzid: string): Timezone | null; + } + + export type FrequencyValues = + | "YEARLY" + | "MONTHLY" + | "WEEKLY" + | "DAILY" + | "HOURLY" + | "MINUTELY" + | "SECONDLY"; + + export enum WeekDay { + SU = 1, + MO, + TU, + WE, + TH, + FR, + SA, + } + + export class RecurData { + public freq?: FrequencyValues; + public interval?: number; + public wkst?: WeekDay; + public until?: Time; + public count?: number; + public bysecond?: number[] | number; + public byminute?: number[] | number; + public byhour?: number[] | number; + public byday?: string[] | string; + public bymonthday?: number[] | number; + public byyearday?: number[] | number; + public byweekno?: number[] | number; + public bymonth?: number[] | number; + public bysetpos?: number[] | number; + } + + export class RecurIterator { + public next(): Time; + } + + export class Recur { + constructor(data?: RecurData); + public until: Time | null; + public freq: FrequencyValues; + public count: number | null; + + public clone(): Recur; + public toJSON(): Omit & { until?: string }; + public iterator(startTime?: Time): RecurIterator; + public isByCount(): boolean; + } +} diff --git a/components/ImageUploader.tsx b/components/ImageUploader.tsx index aa6d89ad5f..6ed211e6fb 100644 --- a/components/ImageUploader.tsx +++ b/components/ImageUploader.tsx @@ -125,9 +125,9 @@ export default function ImageUploader({
-
+
{!result && ( -
+
{!imageSrc && (

{t("no_target", { target })} diff --git a/lib/CalEventParser.ts b/lib/CalEventParser.ts index ded6fb12b4..92ba977c92 100644 --- a/lib/CalEventParser.ts +++ b/lib/CalEventParser.ts @@ -1,32 +1,26 @@ -import dayjs from "dayjs"; import short from "short-uuid"; import { v5 as uuidv5 } from "uuid"; import { getIntegrationName } from "@lib/integrations"; -import { CalendarEvent } from "./calendarClient"; +import { CalendarEvent, Person } from "./calendarClient"; import { BASE_URL } from "./config/constants"; const translator = short(); +// The odd indentation in this file is necessary because otherwise the leading tabs will be applied into the event description. + export const getWhat = (calEvent: CalendarEvent) => { return ` -${calEvent.language("what")} +${calEvent.language("what")}: ${calEvent.type} `; }; export const getWhen = (calEvent: CalendarEvent) => { - const inviteeStart = dayjs(calEvent.startTime).tz(calEvent.attendees[0].timeZone); - const inviteeEnd = dayjs(calEvent.endTime).tz(calEvent.attendees[0].timeZone); - return ` -${calEvent.language("when")} -${calEvent.language(inviteeStart.format("dddd").toLowerCase())}, ${calEvent.language( - inviteeStart.format("MMMM").toLowerCase() - )} ${inviteeStart.format("D")}, ${inviteeStart.format("YYYY")} | ${inviteeStart.format( - "h:mma" - )} - ${inviteeEnd.format("h:mma")} (${calEvent.attendees[0].timeZone}) +${calEvent.language("invitee_timezone")}: +${calEvent.attendees[0].timeZone} `; }; @@ -46,14 +40,14 @@ ${calEvent.organizer.email} `; return ` -${calEvent.language("who")} +${calEvent.language("who")}: ${organizer + attendees} `; }; export const getAdditionalNotes = (calEvent: CalendarEvent) => { return ` -${calEvent.language("additional_notes")} +${calEvent.language("additional_notes")}: ${calEvent.description} `; }; @@ -92,13 +86,28 @@ export const getCancelLink = (calEvent: CalendarEvent): string => { return BASE_URL + "/cancel/" + getUid(calEvent); }; -export const getRichDescription = (calEvent: CalendarEvent) => { +export const getRichDescription = (calEvent: CalendarEvent, attendee?: Person) => { + // Only the original attendee can make changes to the event + // Guests cannot + + if (attendee && attendee === calEvent.attendees[0]) { + return ` +${getWhat(calEvent)} +${getWhen(calEvent)} +${getWho(calEvent)} +${calEvent.language("where")}: +${getLocation(calEvent)} +${getAdditionalNotes(calEvent)} + `.trim(); + } + return ` - ${getWhat(calEvent)} - ${getWhen(calEvent)} - ${getWho(calEvent)} - ${getLocation(calEvent)} - ${getAdditionalNotes(calEvent)} - ${getManageLink(calEvent)} - `; +${getWhat(calEvent)} +${getWhen(calEvent)} +${getWho(calEvent)} +${calEvent.language("where")}: +${getLocation(calEvent)} +${getAdditionalNotes(calEvent)} +${getManageLink(calEvent)} + `.trim(); }; diff --git a/lib/calendarClient.ts b/lib/calendarClient.ts index 999b208784..ee6841f624 100644 --- a/lib/calendarClient.ts +++ b/lib/calendarClient.ts @@ -19,6 +19,8 @@ import { import logger from "@lib/logger"; import { VideoCallData } from "@lib/videoClient"; +import { Ensure } from "./types/utils"; + const log = logger.getChildLogger({ prefix: ["[lib] calendarClient"] }); export type Person = { name: string; email: string; timeZone: string }; @@ -61,7 +63,7 @@ export interface CalendarEvent { paymentInfo?: PaymentInfo | null; } -export interface IntegrationCalendar extends Partial { +export interface IntegrationCalendar extends Ensure, "externalId"> { primary?: boolean; name?: string; } @@ -89,11 +91,9 @@ function getCalendarAdapterOrNull(credential: Credential): CalendarApiAdapter | case "office365_calendar": return Office365CalendarApiAdapter(credential); case "caldav_calendar": - // FIXME types wrong & type casting should not be needed - return new CalDavCalendar(credential) as never as CalendarApiAdapter; + return new CalDavCalendar(credential); case "apple_calendar": - // FIXME types wrong & type casting should not be needed - return new AppleCalendar(credential) as never as CalendarApiAdapter; + return new AppleCalendar(credential); } return null; } diff --git a/lib/cropImage.ts b/lib/cropImage.ts index 1efedc0e5a..45a39ea487 100644 --- a/lib/cropImage.ts +++ b/lib/cropImage.ts @@ -45,7 +45,7 @@ export async function getCroppedImg(imageSrc: string, pixelCrop: Area): Promise< // on very low ratios, the quality of the resize becomes awful. For this reason the resizeRatio is limited to 0.75 if (resizeRatio <= 0.75) { // With a smaller image, thus improved ratio. Keep doing this until the resizeRatio > 0.75. - return getCroppedImg(canvas.toDataURL("image/jpeg"), { + return getCroppedImg(canvas.toDataURL("image/png"), { width: canvas.width, height: canvas.height, x: 0, @@ -53,5 +53,5 @@ export async function getCroppedImg(imageSrc: string, pixelCrop: Area): Promise< }); } - return canvas.toDataURL("image/jpeg"); + return canvas.toDataURL("image/png"); } diff --git a/lib/emails/templates/attendee-scheduled-email.ts b/lib/emails/templates/attendee-scheduled-email.ts index 0e4fea8b6d..7a656291b2 100644 --- a/lib/emails/templates/attendee-scheduled-email.ts +++ b/lib/emails/templates/attendee-scheduled-email.ts @@ -6,7 +6,7 @@ import utc from "dayjs/plugin/utc"; import { createEvent, DateArray } from "ics"; import nodemailer from "nodemailer"; -import { getCancelLink } from "@lib/CalEventParser"; +import { getCancelLink, getRichDescription } from "@lib/CalEventParser"; import { CalendarEvent, Person } from "@lib/calendarClient"; import { getErrorFromUnknown } from "@lib/errors"; import { getIntegrationName } from "@lib/integrations"; @@ -113,30 +113,12 @@ export default class AttendeeScheduledEmail { } protected getTextBody(): string { - // Only the original attendee can make changes to the event - // Guests cannot - if (this.attendee === this.calEvent.attendees[0]) { - return ` - ${this.calEvent.language("your_event_has_been_scheduled")} - ${this.calEvent.language("emailed_you_and_any_other_attendees")} - ${this.getWhat()} - ${this.getWhen()} - ${this.getWho()} - ${this.getLocation()} - ${this.getAdditionalNotes()} - ${this.calEvent.language("need_to_reschedule_or_cancel")} - ${getCancelLink(this.calEvent)} - `.replace(/(<([^>]+)>)/gi, ""); - } - return ` ${this.calEvent.language("your_event_has_been_scheduled")} ${this.calEvent.language("emailed_you_and_any_other_attendees")} -${this.getWhat()} -${this.getWhen()} -${this.getLocation()} -${this.getAdditionalNotes()} -`.replace(/(<([^>]+)>)/gi, ""); + +${getRichDescription(this.calEvent)} +`.trim(); } protected printNodeMailerError(error: Error): void { @@ -364,12 +346,12 @@ ${this.getAdditionalNotes()}

${this.calEvent.language("where")}

-

${ - hangoutLink && - `` - }

+

${providerName} ${ + hangoutLink && + `` + }

diff --git a/lib/emails/templates/organizer-scheduled-email.ts b/lib/emails/templates/organizer-scheduled-email.ts index 3dd206352c..6b28180917 100644 --- a/lib/emails/templates/organizer-scheduled-email.ts +++ b/lib/emails/templates/organizer-scheduled-email.ts @@ -6,7 +6,7 @@ import utc from "dayjs/plugin/utc"; import { createEvent, DateArray } from "ics"; import nodemailer from "nodemailer"; -import { getCancelLink } from "@lib/CalEventParser"; +import { getCancelLink, getRichDescription } from "@lib/CalEventParser"; import { CalendarEvent, Person } from "@lib/calendarClient"; import { getErrorFromUnknown } from "@lib/errors"; import { getIntegrationName } from "@lib/integrations"; @@ -123,14 +123,9 @@ export default class OrganizerScheduledEmail { return ` ${this.calEvent.language("new_event_scheduled")} ${this.calEvent.language("emailed_you_and_any_other_attendees")} -${this.getWhat()} -${this.getWhen()} -${this.getWho()} -${this.getLocation()} -${this.getAdditionalNotes()} -${this.calEvent.language("need_to_reschedule_or_cancel")} -${getCancelLink(this.calEvent)} -`.replace(/(<([^>]+)>)/gi, ""); + +${getRichDescription(this.calEvent)} +`.trim(); } protected printNodeMailerError(error: Error): void { @@ -352,12 +347,12 @@ ${getCancelLink(this.calEvent)}

${this.calEvent.language("where")}

-

${ - hangoutLink && - `` - }

+

${providerName} ${ + hangoutLink && + `` + }

diff --git a/lib/integrations/Apple/AppleCalendarAdapter.ts b/lib/integrations/Apple/AppleCalendarAdapter.ts index a2c3244755..f23187f634 100644 --- a/lib/integrations/Apple/AppleCalendarAdapter.ts +++ b/lib/integrations/Apple/AppleCalendarAdapter.ts @@ -24,8 +24,6 @@ dayjs.extend(utc); const log = logger.getChildLogger({ prefix: ["[[lib] apple calendar"] }); -type EventBusyDate = Record<"start" | "end", Date>; - export class AppleCalendar implements CalendarApiAdapter { private url: string; private credentials: Record; @@ -34,7 +32,7 @@ export class AppleCalendar implements CalendarApiAdapter { constructor(credential: Credential) { const decryptedCredential = JSON.parse( - symmetricDecrypt(credential.key, process.env.CALENDSO_ENCRYPTION_KEY) + symmetricDecrypt(credential.key as string, process.env.CALENDSO_ENCRYPTION_KEY!) ); const username = decryptedCredential.username; const password = decryptedCredential.password; @@ -52,12 +50,12 @@ export class AppleCalendar implements CalendarApiAdapter { }); } - convertDate(date: string): number[] { + convertDate(date: string): [number, number, number] { return dayjs(date) .utc() .toArray() .slice(0, 6) - .map((v, i) => (i === 1 ? v + 1 : v)); + .map((v, i) => (i === 1 ? v + 1 : v)) as [number, number, number]; } getDuration(start: string, end: string): DurationObject { @@ -70,11 +68,11 @@ export class AppleCalendar implements CalendarApiAdapter { return attendees.map(({ email, name }) => ({ name, email, partstat: "NEEDS-ACTION" })); } - async createEvent(event: CalendarEvent): Promise> { + async createEvent(event: CalendarEvent) { try { const calendars = await this.listCalendars(); const uid = uuidv4(); - const { error, value: iCalString } = await createEvent({ + const { error, value: iCalString } = createEvent({ uid, startInputType: "utc", start: this.convertDate(event.startTime), @@ -86,15 +84,9 @@ export class AppleCalendar implements CalendarApiAdapter { attendees: this.getAttendees(event.attendees), }); - if (error) { - log.debug("Error creating iCalString"); - return {}; - } + if (error) throw new Error("Error creating iCalString"); - if (!iCalString) { - log.debug("Error creating iCalString"); - return {}; - } + if (!iCalString) throw new Error("Error creating iCalString"); await Promise.all( calendars.map((calendar) => { @@ -112,6 +104,9 @@ export class AppleCalendar implements CalendarApiAdapter { return { uid, id: uid, + type: "apple_calendar", + password: "", + url: "", }; } catch (reason) { console.error(reason); @@ -132,7 +127,7 @@ export class AppleCalendar implements CalendarApiAdapter { } } - const { error, value: iCalString } = await createEvent({ + const { error, value: iCalString } = createEvent({ uid, startInputType: "utc", start: this.convertDate(event.startTime), @@ -201,11 +196,7 @@ export class AppleCalendar implements CalendarApiAdapter { } } - async getAvailability( - dateFrom: string, - dateTo: string, - selectedCalendars: IntegrationCalendar[] - ): Promise { + async getAvailability(dateFrom: string, dateTo: string, selectedCalendars: IntegrationCalendar[]) { try { const selectedCalendarIds = selectedCalendars .filter((e) => e.integration === this.integrationName) @@ -229,8 +220,8 @@ export class AppleCalendar implements CalendarApiAdapter { ids.map(async (calId) => { return (await this.getEvents(calId, dateFrom, dateTo)).map((event) => { return { - start: event.startDate, - end: event.endDate, + start: event.startDate.toISOString(), + end: event.endDate.toISOString(), }; }); }) @@ -272,8 +263,8 @@ export class AppleCalendar implements CalendarApiAdapter { calId: string, dateFrom: string | null, dateTo: string | null, - objectUrls: string[] | null - ): Promise { + objectUrls?: string[] | null + ) { try { const objects = await fetchCalendarObjects({ calendar: { @@ -290,54 +281,48 @@ export class AppleCalendar implements CalendarApiAdapter { headers: this.headers, }); - const events = - objects && - objects?.length > 0 && - objects - .map((object) => { - if (object?.data) { - const jcalData = ICAL.parse(object.data); - const vcalendar = new ICAL.Component(jcalData); - const vevent = vcalendar.getFirstSubcomponent("vevent"); - const event = new ICAL.Event(vevent); + const events = objects + .filter((e) => !!e.data) + .map((object) => { + const jcalData = ICAL.parse(object.data); + const vcalendar = new ICAL.Component(jcalData); + const vevent = vcalendar.getFirstSubcomponent("vevent"); + const event = new ICAL.Event(vevent); - const calendarTimezone = vcalendar.getFirstSubcomponent("vtimezone") - ? vcalendar.getFirstSubcomponent("vtimezone").getFirstPropertyValue("tzid") - : ""; + const calendarTimezone = + vcalendar.getFirstSubcomponent("vtimezone")?.getFirstPropertyValue("tzid") || ""; - const startDate = calendarTimezone - ? dayjs(event.startDate).tz(calendarTimezone) - : new Date(event.startDate.toUnixTime() * 1000); - const endDate = calendarTimezone - ? dayjs(event.endDate).tz(calendarTimezone) - : new Date(event.endDate.toUnixTime() * 1000); + const startDate = calendarTimezone + ? dayjs(event.startDate.toJSDate()).tz(calendarTimezone) + : new Date(event.startDate.toUnixTime() * 1000); + const endDate = calendarTimezone + ? dayjs(event.endDate.toJSDate()).tz(calendarTimezone) + : new Date(event.endDate.toUnixTime() * 1000); - return { - uid: event.uid, - etag: object.etag, - url: object.url, - summary: event.summary, - description: event.description, - location: event.location, - sequence: event.sequence, - startDate, - endDate, - duration: { - weeks: event.duration.weeks, - days: event.duration.days, - hours: event.duration.hours, - minutes: event.duration.minutes, - seconds: event.duration.seconds, - isNegative: event.duration.isNegative, - }, - organizer: event.organizer, - attendees: event.attendees.map((a) => a.getValues()), - recurrenceId: event.recurrenceId, - timezone: calendarTimezone, - }; - } - }) - .filter((e) => e != null); + return { + uid: event.uid, + etag: object.etag, + url: object.url, + summary: event.summary, + description: event.description, + location: event.location, + sequence: event.sequence, + startDate, + endDate, + duration: { + weeks: event.duration.weeks, + days: event.duration.days, + hours: event.duration.hours, + minutes: event.duration.minutes, + seconds: event.duration.seconds, + isNegative: event.duration.isNegative, + }, + organizer: event.organizer, + attendees: event.attendees.map((a) => a.getValues()), + recurrenceId: event.recurrenceId, + timezone: calendarTimezone, + }; + }); return events; } catch (reason) { diff --git a/lib/integrations/CalDav/CalDavCalendarAdapter.ts b/lib/integrations/CalDav/CalDavCalendarAdapter.ts index 511c9cb0dc..dc3e02ac93 100644 --- a/lib/integrations/CalDav/CalDavCalendarAdapter.ts +++ b/lib/integrations/CalDav/CalDavCalendarAdapter.ts @@ -24,8 +24,6 @@ dayjs.extend(utc); const log = logger.getChildLogger({ prefix: ["[lib] caldav"] }); -type EventBusyDate = Record<"start" | "end", Date>; - export class CalDavCalendar implements CalendarApiAdapter { private url: string; private credentials: Record; @@ -34,7 +32,7 @@ export class CalDavCalendar implements CalendarApiAdapter { constructor(credential: Credential) { const decryptedCredential = JSON.parse( - symmetricDecrypt(credential.key, process.env.CALENDSO_ENCRYPTION_KEY) + symmetricDecrypt(credential.key as string, process.env.CALENDSO_ENCRYPTION_KEY!) ); const username = decryptedCredential.username; const url = decryptedCredential.url; @@ -53,12 +51,12 @@ export class CalDavCalendar implements CalendarApiAdapter { }); } - convertDate(date: string): number[] { + convertDate(date: string): [number, number, number] { return dayjs(date) .utc() .toArray() .slice(0, 6) - .map((v, i) => (i === 1 ? v + 1 : v)); + .map((v, i) => (i === 1 ? v + 1 : v)) as [number, number, number]; } getDuration(start: string, end: string): DurationObject { @@ -71,15 +69,14 @@ export class CalDavCalendar implements CalendarApiAdapter { return attendees.map(({ email, name }) => ({ name, email, partstat: "NEEDS-ACTION" })); } - async createEvent(event: CalendarEvent): Promise> { + async createEvent(event: CalendarEvent) { try { const calendars = await this.listCalendars(); const uid = uuidv4(); - const { error, value: iCalString } = await createEvent({ + const { error, value: iCalString } = createEvent({ uid, startInputType: "utc", - // FIXME types start: this.convertDate(event.startTime), duration: this.getDuration(event.startTime, event.endTime), title: event.title, @@ -89,15 +86,9 @@ export class CalDavCalendar implements CalendarApiAdapter { attendees: this.getAttendees(event.attendees), }); - if (error) { - log.debug("Error creating iCalString"); - return {}; - } + if (error) throw new Error("Error creating iCalString"); - if (!iCalString) { - log.debug("Error creating iCalString"); - return {}; - } + if (!iCalString) throw new Error("Error creating iCalString"); await Promise.all( calendars.map((calendar) => { @@ -115,6 +106,9 @@ export class CalDavCalendar implements CalendarApiAdapter { return { uid, id: uid, + type: "caldav_calendar", + password: "", + url: "", }; } catch (reason) { log.error(reason); @@ -138,7 +132,6 @@ export class CalDavCalendar implements CalendarApiAdapter { const { error, value: iCalString } = await createEvent({ uid, startInputType: "utc", - // FIXME - types wrong start: this.convertDate(event.startTime), duration: this.getDuration(event.startTime, event.endTime), title: event.title, @@ -205,12 +198,7 @@ export class CalDavCalendar implements CalendarApiAdapter { } } - // FIXME - types wrong - async getAvailability( - dateFrom: string, - dateTo: string, - selectedCalendars: IntegrationCalendar[] - ): Promise { + async getAvailability(dateFrom: string, dateTo: string, selectedCalendars: IntegrationCalendar[]) { try { const selectedCalendarIds = selectedCalendars .filter((e) => e.integration === this.integrationName) @@ -235,8 +223,8 @@ export class CalDavCalendar implements CalendarApiAdapter { ids.map(async (calId) => { return (await this.getEvents(calId, dateFrom, dateTo)).map((event) => { return { - start: event.startDate, - end: event.endDate, + start: event.startDate.toISOString(), + end: event.endDate.toISOString(), }; }); }) @@ -274,7 +262,7 @@ export class CalDavCalendar implements CalendarApiAdapter { } } - async getEvents(calId: string, dateFrom: string | null, dateTo: string | null): Promise { + async getEvents(calId: string, dateFrom: string | null, dateTo: string | null) { try { const objects = await fetchCalendarObjects({ calendar: { @@ -295,50 +283,47 @@ export class CalDavCalendar implements CalendarApiAdapter { } const events = objects + .filter((e) => !!e.data) .map((object) => { - if (object?.data) { - const jcalData = ICAL.parse(object.data); - const vcalendar = new ICAL.Component(jcalData); - const vevent = vcalendar.getFirstSubcomponent("vevent"); - const event = new ICAL.Event(vevent); + const jcalData = ICAL.parse(object.data); + const vcalendar = new ICAL.Component(jcalData); + const vevent = vcalendar.getFirstSubcomponent("vevent"); + const event = new ICAL.Event(vevent); - const calendarTimezone = vcalendar.getFirstSubcomponent("vtimezone") - ? vcalendar.getFirstSubcomponent("vtimezone").getFirstPropertyValue("tzid") - : ""; + const calendarTimezone = + vcalendar.getFirstSubcomponent("vtimezone")?.getFirstPropertyValue("tzid") || ""; - const startDate = calendarTimezone - ? dayjs(event.startDate).tz(calendarTimezone) - : new Date(event.startDate.toUnixTime() * 1000); - const endDate = calendarTimezone - ? dayjs(event.endDate).tz(calendarTimezone) - : new Date(event.endDate.toUnixTime() * 1000); + const startDate = calendarTimezone + ? dayjs(event.startDate.toJSDate()).tz(calendarTimezone) + : new Date(event.startDate.toUnixTime() * 1000); + const endDate = calendarTimezone + ? dayjs(event.endDate.toJSDate()).tz(calendarTimezone) + : new Date(event.endDate.toUnixTime() * 1000); - return { - uid: event.uid, - etag: object.etag, - url: object.url, - summary: event.summary, - description: event.description, - location: event.location, - sequence: event.sequence, - startDate, - endDate, - duration: { - weeks: event.duration.weeks, - days: event.duration.days, - hours: event.duration.hours, - minutes: event.duration.minutes, - seconds: event.duration.seconds, - isNegative: event.duration.isNegative, - }, - organizer: event.organizer, - attendees: event.attendees.map((a) => a.getValues()), - recurrenceId: event.recurrenceId, - timezone: calendarTimezone, - }; - } - }) - .filter((e) => e != null); + return { + uid: event.uid, + etag: object.etag, + url: object.url, + summary: event.summary, + description: event.description, + location: event.location, + sequence: event.sequence, + startDate, + endDate, + duration: { + weeks: event.duration.weeks, + days: event.duration.days, + hours: event.duration.hours, + minutes: event.duration.minutes, + seconds: event.duration.seconds, + isNegative: event.duration.isNegative, + }, + organizer: event.organizer, + attendees: event.attendees.map((a) => a.getValues()), + recurrenceId: event.recurrenceId, + timezone: calendarTimezone, + }; + }); return events; } catch (reason) { diff --git a/lib/integrations/GoogleCalendar/GoogleCalendarApiAdapter.ts b/lib/integrations/GoogleCalendar/GoogleCalendarApiAdapter.ts index 306b595a7f..b9395bd803 100644 --- a/lib/integrations/GoogleCalendar/GoogleCalendarApiAdapter.ts +++ b/lib/integrations/GoogleCalendar/GoogleCalendarApiAdapter.ts @@ -7,7 +7,7 @@ import { CalendarApiAdapter, CalendarEvent, IntegrationCalendar } from "@lib/cal import prisma from "@lib/prisma"; export interface ConferenceData { - createRequest: calendar_v3.Schema$CreateConferenceRequest; + createRequest?: calendar_v3.Schema$CreateConferenceRequest; } const googleAuth = (credential: Credential) => { @@ -91,7 +91,19 @@ export const GoogleCalendarApiAdapter = (credential: Credential): CalendarApiAda if (err) { reject(err); } - resolve(Object.values(apires.data.calendars).flatMap((item) => item["busy"])); + let result: Prisma.PromiseReturnType = []; + if (apires?.data.calendars) { + result = Object.values(apires.data.calendars).reduce((c, i) => { + i.busy?.forEach((busyTime) => { + c.push({ + start: busyTime.start || "", + end: busyTime.end || "", + }); + }); + return c; + }, [] as typeof result); + } + resolve(result); } ); }) @@ -146,7 +158,14 @@ export const GoogleCalendarApiAdapter = (credential: Credential): CalendarApiAda console.error("There was an error contacting google calendar service: ", err); return reject(err); } - return resolve(event.data); + return resolve({ + ...event.data, + id: event.data.id || "", + hangoutLink: event.data.hangoutLink || "", + type: "google_calendar", + password: "", + url: "", + }); } ); }) diff --git a/package.json b/package.json index 348b18fae4..ea0536916d 100644 --- a/package.json +++ b/package.json @@ -13,8 +13,12 @@ "db-seed": "yarn ts-node scripts/seed.ts", "db-nuke": "docker-compose down --volumes --remove-orphans", "db-saml-nuke": "docker-compose -p cal-saml -f docker-compose-saml.yml down --volumes --remove-orphans", - "dx": "cross-env BASE_URL=http://localhost:3000 JWT_SECRET=secret DATABASE_URL=postgresql://postgres:@localhost:5450/calendso run-s db-up db-migrate db-seed dev", - "dx-saml": "cross-env BASE_URL=http://localhost:3000 SAML_LOGIN_URL=http://localhost:5000 SAML_API_URL=http://localhost:6000 SAML_ADMINS=onboarding@example.com JWT_SECRET=secret DATABASE_URL=postgresql://postgres:@localhost:5450/calendso run-s db-up db-migrate db-seed dev", + "db-setup": "run-s db-up db-migrate db-seed", + "db-reset": "run-s db-nuke db-setup", + "db-saml-setup": "run-s db-saml-up db-migrate db-seed", + "db-saml-reset": "run-s db-nuke db-saml-setup", + "dx": "env-cmd run-s db-setup dev", + "dx-saml": "env-cmd run-s db-saml-setup dev", "test": "jest", "test-playwright": "jest --config jest.playwright.config.js", "test-codegen": "yarn playwright codegen http://localhost:3000", @@ -95,8 +99,8 @@ "react-use-intercom": "1.4.0", "short-uuid": "^4.2.0", "stripe": "^8.191.0", - "tsdav": "^1.1.5", "superjson": "1.8.0", + "tsdav": "^1.1.5", "tslog": "^3.2.1", "uuid": "^8.3.2", "zod": "^3.8.2" @@ -121,7 +125,7 @@ "@typescript-eslint/parser": "^4.33.0", "autoprefixer": "^10.3.1", "babel-jest": "^27.3.1", - "cross-env": "^7.0.3", + "env-cmd": "10.1.0", "eslint": "^7.32.0", "eslint-config-prettier": "^8.3.0", "eslint-plugin-prettier": "^3.4.0", diff --git a/pages/api/teams.ts b/pages/api/teams.ts index bca5901fa9..aa708fc9ae 100644 --- a/pages/api/teams.ts +++ b/pages/api/teams.ts @@ -23,7 +23,9 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) }); if (nameCollisions > 0) { - return res.status(409).json({ errorCode: "TeamNameCollision", message: "Team name already take." }); + return res + .status(409) + .json({ errorCode: "TeamNameCollision", message: "Team username already taken." }); } const createTeam = await prisma.team.create({ diff --git a/pages/settings/teams.tsx b/pages/settings/teams.tsx index 6396340a75..a5d45513fd 100644 --- a/pages/settings/teams.tsx +++ b/pages/settings/teams.tsx @@ -13,6 +13,7 @@ import Shell from "@components/Shell"; import EditTeam from "@components/team/EditTeam"; import TeamList from "@components/team/TeamList"; import TeamListItem from "@components/team/TeamListItem"; +import { Alert } from "@components/ui/Alert"; import Button from "@components/ui/Button"; export default function Teams() { @@ -24,6 +25,8 @@ export default function Teams() { const [showCreateTeamModal, setShowCreateTeamModal] = useState(false); const [editTeamEnabled, setEditTeamEnabled] = useState(false); const [teamToEdit, setTeamToEdit] = useState(); + const [hasErrors, setHasErrors] = useState(false); + const [errorMessage, setErrorMessage] = useState(""); const nameRef = useRef() as React.MutableRefObject; const handleErrors = async (resp: Response) => { @@ -48,6 +51,11 @@ export default function Teams() { loadData(); }, []); + useEffect(() => { + setHasErrors(false); + setErrorMessage(""); + }, [showCreateTeamModal]); + if (loading) { return ; } @@ -60,10 +68,16 @@ export default function Teams() { headers: { "Content-Type": "application/json", }, - }).then(() => { - loadData(); - setShowCreateTeamModal(false); - }); + }) + .then(handleErrors) + .then(() => { + loadData(); + setShowCreateTeamModal(false); + }) + .catch((err) => { + setHasErrors(true); + setErrorMessage(err.message); + }); }; const editTeam = (team: Team) => { @@ -162,6 +176,7 @@ export default function Teams() { + {hasErrors && } ) { const { isReady } = useTheme(); @@ -61,38 +60,43 @@ function TeamPage({ team }: inferSSRProps) { isReady && (
-
-
- - {teamName} -
- {(showMembers.isOn || !team.eventTypes.length) && } - {!showMembers.isOn && team.eventTypes.length > 0 && ( -
- {eventTypes} - -
- - - +
+
+
+ +

+ {teamName} +

+

{team.bio}

- )} + {(showMembers.isOn || !team.eventTypes.length) && } + {!showMembers.isOn && team.eventTypes.length > 0 && ( +
+ {eventTypes} + +
+ + + +
+ )} +
) @@ -116,6 +120,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) => name: true, slug: true, logo: true, + bio: true, members: { select: { user: { diff --git a/playwright/integrations.test.ts b/playwright/integrations.test.ts index 8ea3ffec33..2bca930d58 100644 --- a/playwright/integrations.test.ts +++ b/playwright/integrations.test.ts @@ -1,4 +1,3 @@ -import dayjs from "dayjs"; import { kont } from "kont"; import { loginProvider } from "./lib/loginProvider"; @@ -35,14 +34,11 @@ describe("webhooks", () => { // page contains the url await expect(page).toHaveSelector(`text='${webhookReceiver.url}'`); - // --- go to tomorrow in the pro user's "30min"-event - const tomorrow = dayjs().add(1, "day"); - const tomorrowFormatted = tomorrow.format("YYYY-MM-DDZZ"); - - await page.goto(`http://localhost:3000/pro/30min?date=${encodeURIComponent(tomorrowFormatted)}`); - - // click first time available - await page.click("[data-testid=time]"); + // --- Book the first available day next month in the pro user's "30min"-event + await page.goto(`http://localhost:3000/pro/30min`); + await page.click('[data-testid="incrementMonth"]'); + await page.click('[data-testid="day"]'); + await page.click('[data-testid="time"]'); // --- fill form await page.fill('[name="name"]', "Test Testson"); diff --git a/tsconfig.json b/tsconfig.json index 84a7cd0884..4a5418130e 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -43,6 +43,7 @@ }, "include": [ "next-env.d.ts", + "@types/*.d.ts", "**/*.ts", "**/*.tsx" ], diff --git a/yarn.lock b/yarn.lock index 4637c72bf5..2052c9cb4e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3173,6 +3173,11 @@ commander@^3.0.2: resolved "https://registry.yarnpkg.com/commander/-/commander-3.0.2.tgz#6837c3fb677ad9933d1cfba42dd14d5117d6b39e" integrity sha512-Gar0ASD4BDyKC4hl4DwHqDrmvjoxWKZigVnAbn5H1owvm4CxCPdb0HQDehwNYMJpla5+M2tPmPARzhtYuwpHow== +commander@^4.0.0: + version "4.1.1" + resolved "https://registry.yarnpkg.com/commander/-/commander-4.1.1.tgz#9fd602bd936294e9e9ef46a3f4d6964044b18068" + integrity sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA== + commander@^5.1.0: version "5.1.0" resolved "https://registry.yarnpkg.com/commander/-/commander-5.1.0.tgz#46abbd1652f8e059bddaef99bbdcb2ad9cf179ae" @@ -3294,13 +3299,6 @@ create-require@^1.1.0: resolved "https://registry.yarnpkg.com/create-require/-/create-require-1.1.1.tgz#c1d7e8f1e5f6cfc9ff65f9cd352d37348756c333" integrity sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ== -cross-env@^7.0.3: - version "7.0.3" - resolved "https://registry.yarnpkg.com/cross-env/-/cross-env-7.0.3.tgz#865264b29677dc015ba8418918965dd232fc54cf" - integrity sha512-+/HKd6EgcQCJGh2PSjZuUitQBQynKor4wrFbRg4DtAgS1aWO+gU52xpH7M9ScGgXSYmAVS9bIJ8EzuaGw0oNAw== - dependencies: - cross-spawn "^7.0.1" - cross-fetch@3.1.4: version "3.1.4" resolved "https://registry.yarnpkg.com/cross-fetch/-/cross-fetch-3.1.4.tgz#9723f3a3a247bf8b89039f3a380a9244e8fa2f39" @@ -3319,7 +3317,7 @@ cross-spawn@^6.0.5: shebang-command "^1.2.0" which "^1.2.9" -cross-spawn@^7.0.0, cross-spawn@^7.0.1, cross-spawn@^7.0.2, cross-spawn@^7.0.3: +cross-spawn@^7.0.0, cross-spawn@^7.0.2, cross-spawn@^7.0.3: version "7.0.3" resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-7.0.3.tgz#f73a85b9d5d41d045551c177e2882d4ac85728a6" integrity sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w== @@ -3728,6 +3726,14 @@ enquirer@^2.3.5, enquirer@^2.3.6: dependencies: ansi-colors "^4.1.1" +env-cmd@10.1.0: + version "10.1.0" + resolved "https://registry.yarnpkg.com/env-cmd/-/env-cmd-10.1.0.tgz#c7f5d3b550c9519f137fdac4dd8fb6866a8c8c4b" + integrity sha512-mMdWTT9XKN7yNth/6N6g2GuKuJTsKMDHlQFUDacb/heQRRWOTIZ42t1rMHnQu4jYxU1ajdTeJM+9eEETlqToMA== + dependencies: + commander "^4.0.0" + cross-spawn "^7.0.0" + errno@^0.1.1, errno@~0.1.1: version "0.1.8" resolved "https://registry.yarnpkg.com/errno/-/errno-0.1.8.tgz#8bb3e9c7d463be4976ff888f76b4809ebc2e811f"