Compare commits

...
45 Commits
Author SHA1 Message Date
Keith Williams 5e3c0cdea1 v3.4.2
Auto Comment Merge Conflicts / auto-comment-merge-conflicts (push) Canceled after 0s
release.yml / release (push) Canceled after 0s
2023-10-19 12:31:00 -03:00
sean-brydon 6b6d3d90e4 feat/use-primary-by-default-overlay (#11935) 2023-10-19 19:39:06 +05:30
9b348adb6a fix: added loading state to button (#11624)
Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com>
Co-authored-by: Peer Richelsen <peeroke@gmail.com>
Co-authored-by: Udit Takkar <udit222001@gmail.com>
2023-10-19 13:52:10 +00:00
Nafees NazikandPeer Richelsen feda420f0c fix: org team page not found with uppercase letters (#11737)
Co-authored-by: Peer Richelsen <peeroke@gmail.com>
2023-10-19 14:44:43 +01:00
Syed Ali Shahbaz 4b818de0c8 feat: Allow hideBranding via public API (#11978) 2023-10-19 10:31:13 -03:00
614741d207 test: Create E2E tests for bookings with custom/required Phone + other questions (#11502)
Co-authored-by: gitstart-calcom <gitstart-calcom@users.noreply.github.com>
Co-authored-by: GitStart-Cal.com <121884634+gitstart-calcom@users.noreply.github.com>
Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com>
Co-authored-by: Shivam Kalra <shivamkalra98@gmail.com>
Co-authored-by: gitstart-calcom <gitstart@users.noreply.github.com>
Co-authored-by: Morgan Vernay <morgan@cal.com>
2023-10-19 09:27:32 -03:00
2550485c49 feat: Shows link location and respective icon in /bookings (#11866)
Co-authored-by: Peer Richelsen <peer@cal.com>
Co-authored-by: Peer Richelsen <peeroke@gmail.com>
2023-10-19 11:06:48 +02:00
Hariom BalharaandPeer Richelsen efc3e864bb fix: Missing avatar for non-migrated users on team booking page (#11977)
Co-authored-by: Peer Richelsen <peeroke@gmail.com>
2023-10-19 08:32:56 +05:30
Joe Au-Yeung 1bf56fbe93 fix: When GCal OAuth Canceled, Do Not Create A Credential (#11987) 2023-10-18 18:35:24 -07:00
Hariom BalharaandSyed Ali Shahbaz 629629cb9e Add /embed route for booking/[uid] (#11976)
Co-authored-by: Syed Ali Shahbaz <52925846+alishaz-polymath@users.noreply.github.com>
2023-10-18 18:43:12 +00:00
Peer Richelsen 8c0751b186 fix: location dropdown overflow (#11967) 2023-10-18 11:36:34 +00:00
sean-brydon 0b46f61a23 feat: (Overlay) Persist toggle option (#11961) 2023-10-18 11:16:02 +01:00
Carina WollendorferandCarinaWolli 0c92fbe11d fix: failing scheduleEmailReminder cron job (#11960)
Co-authored-by: CarinaWolli <wollencarina@gmail.com>
2023-10-18 15:17:39 +05:30
Alex van Andel 59fa713549 v3.4.1
Auto Comment Merge Conflicts / auto-comment-merge-conflicts (push) Canceled after 0s
release.yml / release (push) Canceled after 0s
2023-10-17 20:56:46 +01:00
Benny Jooandzomars 63416d4f33 chore: update tslog from 3.2.1 to 4.9.2 (#11717)
Co-authored-by: zomars <zomars@me.com>
2023-10-17 19:00:48 +00:00
DexterStoreyandtedspare 9e927af813 feat: add rate limiting and more error handling to Cal.ai (#11898)
Co-authored-by: tedspare <ted.spare@gmail.com>
2023-10-17 18:26:49 +00:00
Carina WollendorferandCarinaWolli 4b8bdeba74 fix: scheduleEmailReminders cron job (#11929)
Co-authored-by: CarinaWolli <wollencarina@gmail.com>
2023-10-17 11:19:39 -07:00
Peer Richelsen 4e4d67c8c0 chore: updated AI readme (#11926) 2023-10-17 16:08:20 +01:00
Udit Takkar ffda234b3c chore: updat ai port number in readme (#11946) 2023-10-17 16:07:58 +01:00
Peer Richelsen 0bc44c36db feat: added example messages to cal ai post-install (#11944)
* added example messages to cal ai post-install

* Revert yarn.lock changes

* added search plceholder to app store
2023-10-17 16:03:12 +01:00
Crowdin Bot f18bee5c3d New Crowdin translations by Github Action 2023-10-17 14:40:42 +00:00
Alex van Andel 73e16215bd chore: Decouple API from tRPC handlers (#11947) 2023-10-17 15:37:04 +01:00
Peer Richelsen 75d7d2f172 chore: workflow for unapproved issues (#11937)
* chore: workflow for unapproved issues

* Update .github/workflows/comment-unapproved-issues
2023-10-17 12:34:40 +01:00
Crowdin Bot 269dca5b6d New Crowdin translations by Github Action 2023-10-17 11:32:49 +00:00
Alex van Andel 0fd6bed813 chore: Remove users from getSchedule return (#11940) 2023-10-17 13:29:21 +02:00
Joe Au-Yeung 33bef6acd0 Add conditional (#11862) 2023-10-17 11:27:10 +00:00
Crowdin Bot a6c4b31845 New Crowdin translations by Github Action 2023-10-17 11:25:34 +00:00
Crowdin Bot ea8ba8defc New Crowdin translations by Github Action 2023-10-17 11:22:37 +00:00
Crowdin Bot 94cb78491a New Crowdin translations by Github Action 2023-10-17 11:19:55 +00:00
Hariom Balhara d12a5c5883 fix: videoCallUrl not updating when rescheduling with a broken Calendar integration (#11923) 2023-10-17 16:46:24 +05:30
7a014761dc unlock all fields in limits tab (#11936)
Co-authored-by: CarinaWolli <wollencarina@gmail.com>
Co-authored-by: Peer Richelsen <peeroke@gmail.com>
2023-10-17 09:37:37 +00:00
Manpreet SinghandCarina Wollendorfer 91ac952a72 feat: Adds missing bookingId to Cancel Webhook payload (#11906)
Co-authored-by: Carina Wollendorfer <30310907+CarinaWolli@users.noreply.github.com>
2023-10-17 09:02:35 +00:00
Hariom Balhara 225055fb0c feat: Support moving a user and it's teams to an org as temporary approach (#11892) 2023-10-17 08:36:46 +05:30
Omar López d46e80c2ac fix: inconsistent timezones in e2e tests (#11841)
* fix: inconsistent timezones in e2e tests

* Update users.ts

* Update playwright.config.ts

* Update playwright.config.ts

* Update users.ts

* fix: timezone sensitive tests
2023-10-17 08:23:54 +05:30
Crowdin Bot fe364bd2da New Crowdin translations by Github Action 2023-10-17 00:12:06 +00:00
Syed Ali Shahbaz 2756dff735 fix: Adds mandatory credentiaId in Destination Calendar API endpoint POST (#11880) 2023-10-16 17:09:22 -07:00
Peer Richelsen 401f64b986 Create README.md 2023-10-16 18:57:06 +01:00
Alex van Andel a02dcf485f fix: Add username idx (#11918)
* Add index on 'users.username'

* Bring back fields that are in main
2023-10-16 17:21:01 +00:00
Greg Pabian f2ecd9818a fix: build locale based on validated codes and regions (#11912)
* fix: build locale based on validated codes and regions

* keep html lang stable

* fix type error
2023-10-16 17:29:35 +01:00
sean-brydonandKeith Williams bc81f659aa test: Tests the order of middleware in API (#11852)
Co-authored-by: Keith Williams <keithwillcode@gmail.com>
2023-10-16 10:00:34 -03:00
sean-brydonandUdit Takkar f8f038c5e9 stack-troubleshooter-border-color-support (#11907)
Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com>
2023-10-16 11:45:16 +00:00
alannncandCarinaWolli fefb6acc57 feat: download insights raw data as csv (#11645)
Co-authored-by: CarinaWolli <wollencarina@gmail.com>
2023-10-16 13:27:25 +02:00
Chiranjeev Vishnoi 461120ad84 fix: [CAL-2593] Uploading a CSV File does not comma separate the values (#11756)
* csv email validation added

* most common csv delimeters added
2023-10-16 10:39:05 +01:00
Udit TakkarandPeer Richelsen bab72a5d2e test: Non admin members cannot create team in org (#11525)
Co-authored-by: Peer Richelsen <peeroke@gmail.com>
2023-10-16 11:06:08 +02:00
Meenu Yadav e4011b4a23 fix: Webhook settings list not having enough padding on the top (#11878) 2023-10-16 10:00:15 +01:00
145 changed files with 3379 additions and 753 deletions
@@ -0,0 +1,18 @@
name: Add comment
on:
issues:
types:
- labeled
jobs:
add-comment:
if: github.event.label.name == '🚨 needs approval'
runs-on: ubuntu-latest
permissions:
issues: write
steps:
- name: Add comment
uses: peter-evans/create-or-update-comment@5f728c3dae25f329afbe34ee4d08eef25569d79f
with:
issue-number: ${{ github.event.issue.number }}
body: |
This feature request has not been reviewed yet by the Product Team and needs approval beforehand. Once approved, this issue is available for anyone to work on. **Make sure to reference this issue in your pull request.** :sparkles: Thank you for your contribution! :sparkles:
+5 -1
View File
@@ -16,6 +16,10 @@ Emails are cleaned and routed in [receive/route.ts](/apps/ai/src/app/api/receive
Incoming emails are routed by email address. Addresses are verified by [DKIM record](https://support.google.com/a/answer/174124?hl=en), making them hard to spoof.
## Recognition
<a href="https://www.producthunt.com/posts/cal-ai?utm_source=badge-top-post-badge&utm_medium=badge&utm_souce=badge-cal&#0045;ai" target="_blank"><img src="https://api.producthunt.com/widgets/embed-image/v1/top-post-badge.svg?post_id=419860&theme=light&period=daily" alt="Cal&#0046;ai - World&#0039;s&#0032;first&#0032;open&#0032;source&#0032;AI&#0032;scheduling&#0032;assistant | Product Hunt" style="width: 250px; height: 54px;" width="250" height="54" /></a> <a href="https://www.producthunt.com/posts/cal-ai?utm_source=badge-featured&utm_medium=badge&utm_souce=badge-cal&#0045;ai" target="_blank"><img src="https://api.producthunt.com/widgets/embed-image/v1/featured.svg?post_id=419860&theme=light" alt="Cal&#0046;ai - World&#0039;s&#0032;first&#0032;open&#0032;source&#0032;AI&#0032;scheduling&#0032;assistant | Product Hunt" style="width: 250px; height: 54px;" width="250" height="54" /></a>
## Getting Started
### Development
@@ -44,7 +48,7 @@ Here is the full architecture:
### Email Router
To expose the AI app, run `ngrok http 3000` (or the AI app's port number) in a new terminal. You may need to install [nGrok](https://ngrok.com/).
To expose the AI app, run `ngrok http 3005` (or the AI app's port number) in a new terminal. You may need to install [nGrok](https://ngrok.com/).
To forward incoming emails to the serverless function at `/agent`, we use [SendGrid's Inbound Parse](https://docs.sendgrid.com/for-developers/parsing-email/setting-up-the-inbound-parse-webhook).
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@calcom/ai",
"version": "1.2.0",
"version": "1.2.1",
"private": true,
"author": "Cal.com Inc.",
"dependencies": {
+7
View File
@@ -40,6 +40,13 @@ export const POST = async (request: NextRequest) => {
return new NextResponse("ok");
} catch (error) {
await sendEmail({
subject: `Re: ${subject}`,
text: "Thanks for using Cal.ai! We're experiencing high demand and can't currently process your request. Please try again later.",
to: user.email,
from: agentEmail,
});
return new NextResponse(
(error as Error).message || "Something went wrong. Please try again or reach out for help.",
{ status: 500 }
+33 -10
View File
@@ -3,6 +3,7 @@ import { simpleParser } from "mailparser";
import type { NextRequest } from "next/server";
import { NextResponse } from "next/server";
import { checkRateLimitAndThrowError } from "@calcom/lib/checkRateLimitAndThrowError";
import prisma from "@calcom/prisma";
import { env } from "../../../env.mjs";
@@ -31,18 +32,37 @@ export const POST = async (request: NextRequest) => {
const formData = await request.formData();
const body = Object.fromEntries(formData);
// body.dkim looks like {@domain-com.22222222.gappssmtp.com : pass}
const signature = (body.dkim as string).includes(" : pass");
const envelope = JSON.parse(body.envelope as string);
const aiEmail = envelope.to[0];
const subject = body.subject || "";
try {
await checkRateLimitAndThrowError({
identifier: `ai:email:${envelope.from}`,
rateLimitingType: "ai",
});
} catch (error) {
await sendEmail({
subject: `Re: ${subject}`,
text: "Thanks for using Cal.ai! You've reached your daily limit. Please try again tomorrow.",
to: envelope.from,
from: aiEmail,
});
return new NextResponse("Exceeded rate limit", { status: 200 }); // Don't return 429 to avoid triggering retry logic in SendGrid
}
// Parse email from mixed MIME type
const parsed: ParsedMail = await simpleParser(body.email as Source);
if (!parsed.text && !parsed.subject) {
await sendEmail({
subject: `Re: ${subject}`,
text: "Thanks for using Cal.ai! It looks like you forgot to include a message. Please try again.",
to: envelope.from,
from: aiEmail,
});
return new NextResponse("Email missing text and subject", { status: 400 });
}
@@ -62,11 +82,14 @@ export const POST = async (request: NextRequest) => {
where: { email: envelope.from },
});
// body.dkim looks like {@domain-com.22222222.gappssmtp.com : pass}
const signature = (body.dkim as string).includes(" : pass");
// User is not a cal.com user or is using an unverified email.
if (!signature || !user) {
await sendEmail({
html: `Thanks for your interest in Cal.ai! To get started, Make sure you have a <a href="https://cal.com/signup" target="_blank">cal.com</a> account with this email address.`,
subject: `Re: ${body.subject}`,
subject: `Re: ${subject}`,
text: `Thanks for your interest in Cal.ai! To get started, Make sure you have a cal.com account with this email address. You can sign up for an account at: https://cal.com/signup`,
to: envelope.from,
from: aiEmail,
@@ -83,7 +106,7 @@ export const POST = async (request: NextRequest) => {
await sendEmail({
html: `Thanks for using Cal.ai! To get started, the app must be installed. <a href=${url} target="_blank">Click this link</a> to install it.`,
subject: `Re: ${body.subject}`,
subject: `Re: ${subject}`,
text: `Thanks for using Cal.ai! To get started, the app must be installed. Click this link to install the Cal.ai app: ${url}`,
to: envelope.from,
from: aiEmail,
@@ -110,7 +133,7 @@ export const POST = async (request: NextRequest) => {
if ("error" in availability) {
await sendEmail({
subject: `Re: ${body.subject}`,
subject: `Re: ${subject}`,
text: "Sorry, there was an error fetching your availability. Please try again.",
to: user.email,
from: aiEmail,
@@ -121,7 +144,7 @@ export const POST = async (request: NextRequest) => {
if ("error" in eventTypes) {
await sendEmail({
subject: `Re: ${body.subject}`,
subject: `Re: ${subject}`,
text: "Sorry, there was an error fetching your event types. Please try again.",
to: user.email,
from: aiEmail,
@@ -139,8 +162,8 @@ export const POST = async (request: NextRequest) => {
body: JSON.stringify({
apiKey,
userId: user.id,
message: parsed.text,
subject: parsed.subject,
message: parsed.text || "",
subject: parsed.subject || "",
replyTo: aiEmail,
user: {
email: user.email,
+1 -1
View File
@@ -4,7 +4,7 @@ import { hashAPIKey } from "@calcom/features/ee/api-keys/lib/apiKeys";
import checkLicense from "@calcom/features/ee/common/server/checkLicense";
import { IS_PRODUCTION } from "@calcom/lib/constants";
import { isAdminGuard } from "~/lib/utils/isAdmin";
import { isAdminGuard } from "../utils/isAdmin";
// Used to check if the apiKey is not expired, could be extracted if reused. but not for now.
export const dateNotInPast = function (date: Date) {
+23 -19
View File
@@ -16,22 +16,25 @@ import { rateLimitApiKey } from "./rateLimitApiKey";
import { verifyApiKey } from "./verifyApiKey";
import { withPagination } from "./withPagination";
const withMiddleware = label(
{
HTTP_GET_OR_POST,
HTTP_GET_DELETE_PATCH,
HTTP_GET,
HTTP_PATCH,
HTTP_POST,
HTTP_DELETE,
addRequestId,
verifyApiKey,
rateLimitApiKey,
customPrismaClient,
extendRequest,
pagination: withPagination,
captureErrors,
},
const middleware = {
HTTP_GET_OR_POST,
HTTP_GET_DELETE_PATCH,
HTTP_GET,
HTTP_PATCH,
HTTP_POST,
HTTP_DELETE,
addRequestId,
verifyApiKey,
rateLimitApiKey,
customPrismaClient,
extendRequest,
pagination: withPagination,
captureErrors,
};
type Middleware = keyof typeof middleware;
const middlewareOrder =
// The order here, determines the order of execution
[
"extendRequest",
@@ -41,7 +44,8 @@ const withMiddleware = label(
"verifyApiKey",
"rateLimitApiKey",
"addRequestId",
] // <-- Provide a list of middleware to call automatically
);
] as Middleware[]; // <-- Provide a list of middleware to call automatically
export { withMiddleware };
const withMiddleware = label(middleware, middlewareOrder);
export { withMiddleware, middleware, middlewareOrder };
@@ -3,6 +3,7 @@ import { z } from "zod";
import { _DestinationCalendarModel as DestinationCalendar } from "@calcom/prisma/zod";
export const schemaDestinationCalendarBaseBodyParams = DestinationCalendar.pick({
credentialId: true,
integration: true,
externalId: true,
eventTypeId: true,
@@ -14,6 +15,7 @@ const schemaDestinationCalendarCreateParams = z
.object({
integration: z.string(),
externalId: z.string(),
credentialId: z.number(),
eventTypeId: z.number().optional(),
bookingId: z.number().optional(),
userId: z.number().optional(),
@@ -45,4 +47,5 @@ export const schemaDestinationCalendarReadPublic = DestinationCalendar.pick({
eventTypeId: true,
bookingId: true,
userId: true,
credentialId: true,
});
+4
View File
@@ -75,6 +75,7 @@ export const schemaUserBaseBodyParams = User.pick({
theme: true,
defaultScheduleId: true,
locale: true,
hideBranding: true,
timeFormat: true,
brandColor: true,
darkBrandColor: true,
@@ -95,6 +96,7 @@ const schemaUserEditParams = z.object({
weekStart: z.nativeEnum(weekdays).optional(),
brandColor: z.string().min(4).max(9).regex(/^#/).optional(),
darkBrandColor: z.string().min(4).max(9).regex(/^#/).optional(),
hideBranding: z.boolean().optional(),
timeZone: timeZone.optional(),
theme: z.nativeEnum(theme).optional().nullable(),
timeFormat: z.nativeEnum(timeFormat).optional(),
@@ -115,6 +117,7 @@ const schemaUserCreateParams = z.object({
weekStart: z.nativeEnum(weekdays).optional(),
brandColor: z.string().min(4).max(9).regex(/^#/).optional(),
darkBrandColor: z.string().min(4).max(9).regex(/^#/).optional(),
hideBranding: z.boolean().optional(),
timeZone: timeZone.optional(),
theme: z.nativeEnum(theme).optional().nullable(),
timeFormat: z.nativeEnum(timeFormat).optional(),
@@ -157,6 +160,7 @@ export const schemaUserReadPublic = User.pick({
defaultScheduleId: true,
locale: true,
timeFormat: true,
hideBranding: true,
brandColor: true,
darkBrandColor: true,
allowDynamicBooking: true,
@@ -30,6 +30,7 @@ import {
* required:
* - integration
* - externalId
* - credentialId
* properties:
* integration:
* type: string
@@ -37,12 +38,18 @@ import {
* externalId:
* type: string
* description: 'The external ID of the integration'
* credentialId:
* type: integer
* description: 'The credential ID it is associated with'
* eventTypeId:
* type: integer
* description: 'The ID of the eventType it is associated with'
* bookingId:
* type: integer
* description: 'The booking ID it is associated with'
* userId:
* type: integer
* description: 'The user it is associated with'
* tags:
* - destination-calendars
* responses:
@@ -55,17 +62,33 @@ import {
*/
async function postHandler(req: NextApiRequest) {
const { userId, isAdmin, prisma, body } = req;
const parsedBody = schemaDestinationCalendarCreateBodyParams.parse(body);
await checkPermissions(req, userId);
if (!parsedBody.eventTypeId) {
parsedBody.userId = userId;
}
const assignedUserId = isAdmin ? parsedBody.userId || userId : userId;
if (isAdmin) {
parsedBody.userId = parsedBody.userId || userId;
/* Check if credentialId data matches the ownership and integration passed in */
const credential = await prisma.credential.findFirst({
where: { type: parsedBody.integration, userId: assignedUserId },
select: { id: true, type: true, userId: true },
});
if (!credential)
throw new HttpError({
statusCode: 400,
message: "Bad request, credential id invalid",
});
if (parsedBody.eventTypeId) {
const eventType = await prisma.eventType.findFirst({
where: { id: parsedBody.eventTypeId, userId: parsedBody.userId },
});
if (!eventType)
throw new HttpError({
statusCode: 400,
message: "Bad request, eventTypeId invalid",
});
parsedBody.userId = undefined;
}
const destination_calendar = await prisma.destinationCalendar.create({ data: { ...parsedBody } });
+4 -5
View File
@@ -3,18 +3,17 @@ import type { NextApiRequest, NextApiResponse } from "next";
import { HttpError } from "@calcom/lib/http-error";
import { defaultResponder } from "@calcom/lib/server";
import { createContext } from "@calcom/trpc/server/createContext";
import { slotsRouter } from "@calcom/trpc/server/routers/viewer/slots/_router";
import { getScheduleSchema } from "@calcom/trpc/server/routers/viewer/slots/types";
import { getAvailableSlots } from "@calcom/trpc/server/routers/viewer/slots/util";
import { TRPCError } from "@trpc/server";
import { getHTTPStatusCodeFromError } from "@trpc/server/http";
async function handler(req: NextApiRequest, res: NextApiResponse) {
/** @see https://trpc.io/docs/server-side-calls */
const ctx = await createContext({ req, res });
const caller = slotsRouter.createCaller(ctx);
try {
const input = getScheduleSchema.parse(req.query);
return await getAvailableSlots({ ctx: await createContext({ req, res }), input });
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return await caller.getSchedule(req.query as any /* Let tRPC handle this */);
} catch (cause) {
if (cause instanceof TRPCError) {
const statusCode = getHTTPStatusCodeFromError(cause);
+8 -4
View File
@@ -53,6 +53,9 @@ import { schemaUserEditBodyParams, schemaUserReadPublic } from "~/lib/validation
* timeZone:
* description: The user's time zone
* type: string
* hideBranding:
* description: Remove branding from the user's calendar page
* type: boolean
* theme:
* description: Default theme for the user. Acceptable values are one of [DARK, LIGHT]
* type: string
@@ -79,7 +82,7 @@ import { schemaUserEditBodyParams, schemaUserReadPublic } from "~/lib/validation
* - users
* responses:
* 200:
* description: OK, user edited successfuly
* description: OK, user edited successfully
* 400:
* description: Bad request. User body is invalid.
* 401:
@@ -94,9 +97,10 @@ export async function patchHandler(req: NextApiRequest) {
if (!isAdmin && query.userId !== req.userId) throw new HttpError({ statusCode: 403, message: "Forbidden" });
const body = await schemaUserEditBodyParams.parseAsync(req.body);
// disable role changes unless admin.
if (!isAdmin && body.role) {
body.role = undefined;
// disable role or branding changes unless admin.
if (!isAdmin) {
if (body.role) body.role = undefined;
if (body.hideBranding) body.hideBranding = undefined;
}
const userSchedules = await prisma.schedule.findMany({
+3
View File
@@ -42,6 +42,9 @@ import { schemaUserCreateBodyParams } from "~/lib/validations/user";
* darkBrandColor:
* description: The new user's brand color for dark mode
* type: string
* hideBranding:
* description: Remove branding from the user's calendar page
* type: boolean
* weekStart:
* description: Start of the week. Acceptable values are one of [SUNDAY, MONDAY, TUESDAY, WEDNESDAY, THURSDAY, FRIDAY, SATURDAY]
* type: string
@@ -0,0 +1,17 @@
import { describe, vi, it, expect, afterEach } from "vitest";
import { middlewareOrder } from "../../../lib/helpers/withMiddleware";
afterEach(() => {
vi.resetAllMocks();
});
// Not sure if there is much point testing this order is actually applied via an integration test:
// It is tested internally https://github.com/htunnicliff/next-api-middleware/blob/368b12aa30e79f4bd7cfe7aacc18da263cc3de2f/lib/label.spec.ts#L62
describe("API - withMiddleware test", () => {
it("Custom prisma should be before verifyApiKey", async () => {
const customPrismaClientIndex = middlewareOrder.indexOf("customPrismaClient");
const verifyApiKeyIndex = middlewareOrder.indexOf("verifyApiKey");
expect(customPrismaClientIndex).toBeLessThan(verifyApiKeyIndex);
});
});
+1
View File
@@ -0,0 +1 @@
Hello World
@@ -88,6 +88,10 @@ function BookingListItem(booking: BookingItemProps) {
const isRecurring = booking.recurringEventId !== null;
const isTabRecurring = booking.listingStatus === "recurring";
const isTabUnconfirmed = booking.listingStatus === "unconfirmed";
const eventLocationType = getEventLocationType(booking.location);
const meetingLink = booking.references[0]?.meetingUrl
? booking.references[0]?.meetingUrl
: booking.location;
const paymentAppData = getPaymentAppData(booking.eventType);
@@ -353,6 +357,27 @@ function BookingListItem(booking: BookingItemProps) {
attendees={booking.attendees}
/>
</div>
{!isPending && (eventLocationType || booking.location?.startsWith("https://")) && (
<Link
href={meetingLink ? meetingLink.toString() : ""}
className="text-sm leading-6 text-blue-400 hover:underline">
<div className="flex items-center gap-2">
{eventLocationType ? (
<>
<img
src={eventLocationType.iconUrl}
className="h-4 w-4 rounded-sm"
alt={`${eventLocationType.label} logo`}
/>
{t("join_event_location", { eventLocationType: eventLocationType.label })}
</>
) : (
t("join_meeting")
)}
</div>
</Link>
)}
{isPending && (
<Badge className="ltr:mr-2 rtl:ml-2" variant="orange">
{t("unconfirmed")}
@@ -7,7 +7,6 @@ import type { UseFormRegisterReturn } from "react-hook-form";
import { Controller, useFormContext, useWatch } from "react-hook-form";
import type { SingleValue } from "react-select";
import useLockedFieldsManager from "@calcom/features/ee/managed-event-types/hooks/useLockedFieldsManager";
import { classNames } from "@calcom/lib";
import type { DurationType } from "@calcom/lib/convertToNewDurationType";
import convertToNewDurationType from "@calcom/lib/convertToNewDurationType";
@@ -141,17 +140,6 @@ export const EventLimitsTab = ({ eventType }: Pick<EventTypeSetupProps, "eventTy
defaultValue: periodType?.type,
});
const { shouldLockIndicator, shouldLockDisableProps } = useLockedFieldsManager(
eventType,
t("locked_fields_admin_description"),
t("locked_fields_member_description")
);
const bookingLimitsLocked = shouldLockDisableProps("bookingLimits");
const durationLimitsLocked = shouldLockDisableProps("durationLimits");
const periodTypeLocked = shouldLockDisableProps("periodType");
const offsetStartLockedProps = shouldLockDisableProps("offsetStart");
const optionsPeriod = [
{ value: 1, label: t("calendar_days") },
{ value: 0, label: t("business_days") },
@@ -174,10 +162,7 @@ export const EventLimitsTab = ({ eventType }: Pick<EventTypeSetupProps, "eventTy
<div className="border-subtle space-y-6 rounded-lg border p-6">
<div className="flex flex-col space-y-4 lg:flex-row lg:space-x-4 lg:space-y-0">
<div className="w-full">
<Label htmlFor="beforeBufferTime">
{t("before_event")}
{shouldLockIndicator("bookingLimits")}
</Label>
<Label htmlFor="beforeBufferTime">{t("before_event")}</Label>
<Controller
name="beforeBufferTime"
control={formMethods.control}
@@ -196,7 +181,6 @@ export const EventLimitsTab = ({ eventType }: Pick<EventTypeSetupProps, "eventTy
return (
<Select
isSearchable={false}
isDisabled={shouldLockDisableProps("bookingLimits").disabled}
onChange={(val) => {
if (val) onChange(val.value);
}}
@@ -210,10 +194,7 @@ export const EventLimitsTab = ({ eventType }: Pick<EventTypeSetupProps, "eventTy
/>
</div>
<div className="w-full">
<Label htmlFor="afterBufferTime">
{t("after_event")}
{shouldLockIndicator("bookingLimits")}
</Label>
<Label htmlFor="afterBufferTime">{t("after_event")}</Label>
<Controller
name="afterBufferTime"
control={formMethods.control}
@@ -232,7 +213,6 @@ export const EventLimitsTab = ({ eventType }: Pick<EventTypeSetupProps, "eventTy
return (
<Select
isSearchable={false}
isDisabled={shouldLockDisableProps("bookingLimits").disabled}
onChange={(val) => {
if (val) onChange(val.value);
}}
@@ -248,20 +228,11 @@ export const EventLimitsTab = ({ eventType }: Pick<EventTypeSetupProps, "eventTy
</div>
<div className="flex flex-col space-y-4 lg:flex-row lg:space-x-4 lg:space-y-0">
<div className="w-full">
<Label htmlFor="minimumBookingNotice">
{t("minimum_booking_notice")}
{shouldLockIndicator("minimumBookingNotice")}
</Label>
<MinimumBookingNoticeInput
disabled={shouldLockDisableProps("minimumBookingNotice").disabled}
{...formMethods.register("minimumBookingNotice")}
/>
<Label htmlFor="minimumBookingNotice">{t("minimum_booking_notice")}</Label>
<MinimumBookingNoticeInput {...formMethods.register("minimumBookingNotice")} />
</div>
<div className="w-full">
<Label htmlFor="slotInterval">
{t("slot_interval")}
{shouldLockIndicator("slotInterval")}
</Label>
<Label htmlFor="slotInterval">{t("slot_interval")}</Label>
<Controller
name="slotInterval"
control={formMethods.control}
@@ -279,7 +250,6 @@ export const EventLimitsTab = ({ eventType }: Pick<EventTypeSetupProps, "eventTy
return (
<Select
isSearchable={false}
isDisabled={shouldLockDisableProps("slotInterval").disabled}
onChange={(val) => {
formMethods.setValue("slotInterval", val && (val.value || 0) > 0 ? val.value : null);
}}
@@ -305,7 +275,6 @@ export const EventLimitsTab = ({ eventType }: Pick<EventTypeSetupProps, "eventTy
toggleSwitchAtTheEnd={true}
labelClassName="text-sm"
title={t("limit_booking_frequency")}
{...bookingLimitsLocked}
description={t("limit_booking_frequency_description")}
checked={isChecked}
onCheckedChange={(active) => {
@@ -323,12 +292,7 @@ export const EventLimitsTab = ({ eventType }: Pick<EventTypeSetupProps, "eventTy
)}
childrenClassName="lg:ml-0">
<div className="border-subtle rounded-b-lg border border-t-0 p-6">
<IntervalLimitsManager
disabled={bookingLimitsLocked.disabled}
propertyName="bookingLimits"
defaultLimit={1}
step={1}
/>
<IntervalLimitsManager propertyName="bookingLimits" defaultLimit={1} step={1} />
</div>
</SettingsToggle>
);
@@ -350,7 +314,6 @@ export const EventLimitsTab = ({ eventType }: Pick<EventTypeSetupProps, "eventTy
childrenClassName="lg:ml-0"
title={t("limit_total_booking_duration")}
description={t("limit_total_booking_duration_description")}
{...durationLimitsLocked}
checked={isChecked}
onCheckedChange={(active) => {
if (active) {
@@ -365,7 +328,6 @@ export const EventLimitsTab = ({ eventType }: Pick<EventTypeSetupProps, "eventTy
<IntervalLimitsManager
propertyName="durationLimits"
defaultLimit={60}
disabled={durationLimitsLocked.disabled}
step={15}
textFieldSuffix={t("minutes")}
/>
@@ -391,7 +353,6 @@ export const EventLimitsTab = ({ eventType }: Pick<EventTypeSetupProps, "eventTy
childrenClassName="lg:ml-0"
title={t("limit_future_bookings")}
description={t("limit_future_bookings_description")}
{...periodTypeLocked}
checked={isChecked}
onCheckedChange={(bool) => formMethods.setValue("periodType", bool ? "ROLLING" : "UNLIMITED")}>
<div className="border-subtle rounded-b-lg border border-t-0 p-6">
@@ -399,9 +360,7 @@ export const EventLimitsTab = ({ eventType }: Pick<EventTypeSetupProps, "eventTy
defaultValue={watchPeriodType}
value={watchPeriodType}
onValueChange={(val) => formMethods.setValue("periodType", val as PeriodType)}>
{PERIOD_TYPES.filter((opt) =>
periodTypeLocked.disabled ? watchPeriodType === opt.type : true
).map((period) => {
{PERIOD_TYPES.map((period) => {
if (period.type === "UNLIMITED") return null;
return (
<div
@@ -410,14 +369,13 @@ export const EventLimitsTab = ({ eventType }: Pick<EventTypeSetupProps, "eventTy
watchPeriodType === "UNLIMITED" && "pointer-events-none opacity-30"
)}
key={period.type}>
{!periodTypeLocked.disabled && (
<RadioGroup.Item
id={period.type}
value={period.type}
className="min-w-4 bg-default border-default flex h-4 w-4 cursor-pointer items-center rounded-full border focus:border-2 focus:outline-none ltr:mr-2 rtl:ml-2">
<RadioGroup.Indicator className="after:bg-inverted relative flex h-4 w-4 items-center justify-center after:block after:h-2 after:w-2 after:rounded-full" />
</RadioGroup.Item>
)}
<RadioGroup.Item
id={period.type}
value={period.type}
className="min-w-4 bg-default border-default flex h-4 w-4 cursor-pointer items-center rounded-full border focus:border-2 focus:outline-none ltr:mr-2 rtl:ml-2">
<RadioGroup.Indicator className="after:bg-inverted relative flex h-4 w-4 items-center justify-center after:block after:h-2 after:w-2 after:rounded-full" />
</RadioGroup.Item>
{period.prefix ? <span>{period.prefix}&nbsp;</span> : null}
{period.type === "ROLLING" && (
<div className="flex items-center">
@@ -426,14 +384,12 @@ export const EventLimitsTab = ({ eventType }: Pick<EventTypeSetupProps, "eventTy
type="number"
className="border-default my-0 block w-16 text-sm [appearance:textfield] ltr:mr-2 rtl:ml-2"
placeholder="30"
disabled={periodTypeLocked.disabled}
{...formMethods.register("periodDays", { valueAsNumber: true })}
defaultValue={eventType.periodDays || 30}
/>
<Select
options={optionsPeriod}
isSearchable={false}
isDisabled={periodTypeLocked.disabled}
onChange={(opt) => {
formMethods.setValue(
"periodCountCalendarDays",
@@ -458,7 +414,6 @@ export const EventLimitsTab = ({ eventType }: Pick<EventTypeSetupProps, "eventTy
<DateRangePicker
startDate={formMethods.getValues("periodDates").startDate}
endDate={formMethods.getValues("periodDates").endDate}
disabled={periodTypeLocked.disabled}
onDatesChange={({ startDate, endDate }) => {
formMethods.setValue("periodDates", {
startDate,
@@ -490,7 +445,6 @@ export const EventLimitsTab = ({ eventType }: Pick<EventTypeSetupProps, "eventTy
childrenClassName="lg:ml-0"
title={t("offset_toggle")}
description={t("offset_toggle_description")}
{...offsetStartLockedProps}
checked={offsetToggle}
onCheckedChange={(active) => {
setOffsetToggle(active);
@@ -503,7 +457,6 @@ export const EventLimitsTab = ({ eventType }: Pick<EventTypeSetupProps, "eventTy
required
type="number"
containerClassName="max-w-80"
{...offsetStartLockedProps}
label={t("offset_start")}
{...formMethods.register("offsetStart")}
addOnSuffix={<>{t("minutes")}</>}
@@ -425,7 +425,7 @@ function EventTypeSingleLayout({
<div className="p-2 md:mx-0 md:p-0 xl:hidden">
<HorizontalTabs tabs={EventTypeTabs} linkShallow />
</div>
<div className="w-full overflow-hidden ltr:mr-2 rtl:ml-2">
<div className="w-full ltr:mr-2 rtl:ml-2">
<div
className={classNames(
"bg-default border-subtle mt-4 rounded-md sm:mx-0 xl:mt-0",
+23 -8
View File
@@ -1,4 +1,5 @@
import { TooltipProvider } from "@radix-ui/react-tooltip";
import { dir } from "i18next";
import type { Session } from "next-auth";
import { SessionProvider, useSession } from "next-auth/react";
import { EventCollectionProvider } from "next-collect/client";
@@ -77,19 +78,33 @@ const CustomI18nextProvider = (props: AppPropsWithoutNonce) => {
const locale = session?.data?.user.locale ?? props.pageProps.newLocale;
useEffect(() => {
window.document.documentElement.lang = locale;
let direction = window.document.dir || "ltr";
try {
const intlLocale = new Intl.Locale(locale);
// @ts-expect-error INFO: Typescript does not know about the Intl.Locale textInfo attribute
direction = intlLocale.textInfo?.direction;
// @ts-expect-error TS2790: The operand of a 'delete' operator must be optional.
delete window.document.documentElement["lang"];
window.document.documentElement.lang = locale;
// Next.js writes the locale to the same attribute
// https://github.com/vercel/next.js/blob/1609da2d9552fed48ab45969bdc5631230c6d356/packages/next/src/shared/lib/router/router.ts#L1786
// which can result in a race condition
// this property descriptor ensures this never happens
Object.defineProperty(window.document.documentElement, "lang", {
configurable: true,
// value: locale,
set: function (this) {
// empty setter on purpose
},
get: function () {
return locale;
},
});
} catch (error) {
console.error(error);
window.document.documentElement.lang = locale;
}
window.document.dir = direction;
window.document.dir = dir(locale);
}, [locale]);
const clientViewerI18n = useViewerI18n(locale);
+44
View File
@@ -0,0 +1,44 @@
import logger from "@calcom/lib/logger";
import { safeStringify } from "@calcom/lib/safeStringify";
import type { RedirectType } from "@calcom/prisma/client";
const log = logger.getSubLogger({ prefix: ["lib", "getTemporaryOrgRedirect"] });
export const getTemporaryOrgRedirect = async ({
slug,
redirectType,
eventTypeSlug,
}: {
slug: string;
redirectType: RedirectType;
eventTypeSlug: string | null;
}) => {
const prisma = (await import("@calcom/prisma")).default;
log.debug(
`Looking for redirect for`,
safeStringify({
slug,
redirectType,
eventTypeSlug,
})
);
const redirect = await prisma.tempOrgRedirect.findUnique({
where: {
from_type_fromOrgId: {
type: redirectType,
from: slug,
fromOrgId: 0,
},
},
});
if (redirect) {
log.debug(`Redirecting ${slug} to ${redirect.toUrl}`);
return {
redirect: {
permanent: false,
destination: eventTypeSlug ? `${redirect.toUrl}/${eventTypeSlug}` : redirect.toUrl,
},
} as const;
}
return null;
};
+2 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@calcom/web",
"version": "3.4.0",
"version": "3.4.2",
"private": true,
"scripts": {
"analyze": "ANALYZE=true next build",
@@ -166,6 +166,7 @@
"env-cmd": "^10.1.0",
"module-alias": "^2.2.2",
"msw": "^0.42.3",
"node-html-parser": "^6.1.10",
"postcss": "^8.4.18",
"tailwindcss": "^3.3.1",
"tailwindcss-animate": "^1.0.6",
+18 -2
View File
@@ -23,7 +23,7 @@ import useTheme from "@calcom/lib/hooks/useTheme";
import { markdownToSafeHTML } from "@calcom/lib/markdownToSafeHTML";
import { stripMarkdown } from "@calcom/lib/stripMarkdown";
import prisma from "@calcom/prisma";
import type { EventType, User } from "@calcom/prisma/client";
import { RedirectType, type EventType, type User } from "@calcom/prisma/client";
import { baseEventTypeSelect } from "@calcom/prisma/selects";
import { EventTypeMetaDataSchema } from "@calcom/prisma/zod-utils";
import { HeadSeo, UnpublishedEntity } from "@calcom/ui";
@@ -35,6 +35,8 @@ import PageWrapper from "@components/PageWrapper";
import { ssrInit } from "@server/lib/ssr";
import { getTemporaryOrgRedirect } from "../lib/getTemporaryOrgRedirect";
export function UserPage(props: InferGetServerSidePropsType<typeof getServerSideProps>) {
const { users, profile, eventTypes, markdownStrippedBio, entity } = props;
@@ -261,13 +263,14 @@ export const getServerSideProps: GetServerSideProps<UserPageProps> = async (cont
context.params?.orgSlug
);
const usernameList = getUsernameList(context.query.user as string);
const isOrgContext = isValidOrgDomain && currentOrgDomain;
const dataFetchStart = Date.now();
const usersWithoutAvatar = await prisma.user.findMany({
where: {
username: {
in: usernameList,
},
organization: isValidOrgDomain && currentOrgDomain ? getSlugOrRequestedSlug(currentOrgDomain) : null,
organization: isOrgContext ? getSlugOrRequestedSlug(currentOrgDomain) : null,
},
select: {
id: true,
@@ -275,6 +278,7 @@ export const getServerSideProps: GetServerSideProps<UserPageProps> = async (cont
email: true,
name: true,
bio: true,
metadata: true,
brandColor: true,
darkBrandColor: true,
organizationId: true,
@@ -312,6 +316,18 @@ export const getServerSideProps: GetServerSideProps<UserPageProps> = async (cont
avatar: `/${user.username}/avatar.png`,
}));
if (!isOrgContext) {
const redirect = await getTemporaryOrgRedirect({
slug: usernameList[0],
redirectType: RedirectType.User,
eventTypeSlug: null,
});
if (redirect) {
return redirect;
}
}
if (!users.length || (!isValidOrgDomain && !users.some((user) => user.organizationId === null))) {
return {
notFound: true,
+21 -4
View File
@@ -15,12 +15,15 @@ import { orgDomainConfig, userOrgQuery } from "@calcom/features/ee/organizations
import { getUsernameList } from "@calcom/lib/defaultEvents";
import slugify from "@calcom/lib/slugify";
import prisma from "@calcom/prisma";
import { RedirectType } from "@calcom/prisma/client";
import type { inferSSRProps } from "@lib/types/inferSSRProps";
import type { EmbedProps } from "@lib/withEmbedSsr";
import PageWrapper from "@components/PageWrapper";
import { getTemporaryOrgRedirect } from "../../lib/getTemporaryOrgRedirect";
export type PageProps = inferSSRProps<typeof getServerSideProps> & EmbedProps;
export default function Type({
@@ -93,7 +96,7 @@ async function getDynamicGroupPageProps(context: GetServerSidePropsContext) {
if (!users.length) {
return {
notFound: true,
};
} as const;
}
const org = isValidOrgDomain ? currentOrgDomain : null;
@@ -115,7 +118,7 @@ async function getDynamicGroupPageProps(context: GetServerSidePropsContext) {
if (!eventData) {
return {
notFound: true,
};
} as const;
}
return {
@@ -150,6 +153,20 @@ async function getUserPageProps(context: GetServerSidePropsContext) {
context.params?.orgSlug
);
const isOrgContext = currentOrgDomain && isValidOrgDomain;
if (!isOrgContext) {
const redirect = await getTemporaryOrgRedirect({
slug: usernames[0],
redirectType: RedirectType.User,
eventTypeSlug: slug,
});
if (redirect) {
return redirect;
}
}
const { ssrInit } = await import("@server/lib/ssr");
const ssr = await ssrInit(context);
const user = await prisma.user.findFirst({
@@ -167,7 +184,7 @@ async function getUserPageProps(context: GetServerSidePropsContext) {
if (!user) {
return {
notFound: true,
};
} as const;
}
let booking: GetBookingType | null = null;
@@ -189,7 +206,7 @@ async function getUserPageProps(context: GetServerSidePropsContext) {
if (!eventData) {
return {
notFound: true,
};
} as const;
}
return {
+3 -8
View File
@@ -1,4 +1,5 @@
import type { IncomingMessage } from "http";
import { dir } from "i18next";
import type { NextPageContext } from "next";
import type { DocumentContext, DocumentProps } from "next/document";
import Document, { Head, Html, Main, NextScript } from "next/document";
@@ -50,21 +51,15 @@ class MyDocument extends Document<Props> {
render() {
const { isEmbed, embedColorScheme } = this.props;
const newLocale = this.props.newLocale || "en";
const newDir = dir(newLocale);
const nonceParsed = z.string().safeParse(this.props.nonce);
const nonce = nonceParsed.success ? nonceParsed.data : "";
const intlLocale = new Intl.Locale(newLocale);
// @ts-expect-error INFO: Typescript does not know about the Intl.Locale textInfo attribute
const direction = intlLocale.textInfo?.direction;
if (!direction) {
throw new Error("NodeJS major breaking change detected, use getTextInfo() instead.");
}
return (
<Html
lang={newLocale}
dir={direction}
dir={newDir}
style={embedColorScheme ? { colorScheme: embedColorScheme as string } : undefined}>
<Head nonce={nonce}>
<script
+1 -1
View File
@@ -26,7 +26,7 @@ type AugmentedNextPageContext = Omit<NextPageContext, "err"> & {
err: AugmentedError;
};
const log = logger.getChildLogger({ prefix: ["[error]"] });
const log = logger.getSubLogger({ prefix: ["[error]"] });
const CustomError: NextPage<CustomErrorProps> = (props) => {
const { statusCode, err, message, hasGetInitialPropsRun } = props;
+1 -1
View File
@@ -6,7 +6,7 @@ import { prisma } from "@calcom/prisma";
import type { AppCategories, Prisma } from "@calcom/prisma/client";
const isDryRun = process.env.CRON_ENABLE_APP_SYNC !== "true";
const log = logger.getChildLogger({
const log = logger.getSubLogger({
prefix: ["[api/cron/syncAppMeta]", ...(isDryRun ? ["(dry-run)"] : [])],
});
+1 -1
View File
@@ -16,7 +16,7 @@ import {
} from "@calcom/lib/constants";
import logger from "@calcom/lib/logger";
const log = logger.getChildLogger({ prefix: ["[api/logo]"] });
const log = logger.getSubLogger({ prefix: ["[api/logo]"] });
function removePort(url: string) {
return url.replace(/:\d+$/, "");
+24 -1
View File
@@ -34,13 +34,35 @@ async function getIdentityData(req: NextApiRequest) {
: null;
if (username) {
const user = await prisma.user.findFirst({
let user = await prisma.user.findFirst({
where: {
username,
organization: orgQuery,
},
select: { avatar: true, email: true },
});
/**
* TEMPORARY CODE STARTS - TO BE REMOVED after mono-user schema is implemented
* Try the non-org user temporarily to support users part of a team but not part of the organization
* This is needed because of a situation where we migrate a user and the team to ORG but not all the users in the team to the ORG.
* Eventually, all users will be migrated to the ORG but this is when user by user migration happens initially.
*/
// No user found in the org, try the non-org user that might be part of the team that's part of an org
if (!user && orgQuery) {
// The only side effect this code could have is that it could serve the avatar of a non-org member from the org domain but as long as the username isn't taken by an org member.
user = await prisma.user.findFirst({
where: {
username,
organization: null,
},
select: { avatar: true, email: true },
});
}
/**
* TEMPORARY CODE ENDS
*/
return {
name: username,
email: user?.email,
@@ -48,6 +70,7 @@ async function getIdentityData(req: NextApiRequest) {
org,
};
}
if (teamname) {
const team = await prisma.team.findFirst({
where: {
+2
View File
@@ -45,6 +45,7 @@ function AppsSearch({
onChange: ChangeEventHandler<HTMLInputElement>;
className?: string;
}) {
const { t } = useLocale();
return (
<TextField
className="bg-subtle !border-muted !pl-0 focus:!ring-offset-0"
@@ -54,6 +55,7 @@ function AppsSearch({
type="search"
autoComplete="false"
onChange={onChange}
placeholder={t("search")}
/>
);
}
+12 -2
View File
@@ -1,7 +1,7 @@
import type { GetServerSidePropsContext } from "next";
import { signOut, useSession } from "next-auth/react";
import { useRouter } from "next/navigation";
import { useEffect } from "react";
import { useEffect, useState } from "react";
import { WEBSITE_URL } from "@calcom/lib/constants";
import { useLocale } from "@calcom/lib/hooks/useLocale";
@@ -18,6 +18,7 @@ import { ssrInit } from "@server/lib/ssr";
type Props = inferSSRProps<typeof getServerSideProps>;
export function Logout(props: Props) {
const [btnLoading, setBtnLoading] = useState<boolean>(false);
const { status } = useSession();
if (status === "authenticated") signOut({ redirect: false });
const router = useRouter();
@@ -35,6 +36,11 @@ export function Logout(props: Props) {
return "hope_to_see_you_soon";
};
const navigateToLogin = () => {
setBtnLoading(true);
router.push("/auth/login");
};
return (
<AuthContainer title={t("logged_out")} description={t("youve_been_logged_out")} showLogo>
<div className="mb-4">
@@ -50,7 +56,11 @@ export function Logout(props: Props) {
</div>
</div>
</div>
<Button href="/auth/login" className="flex w-full justify-center">
<Button
data-testid="logout-btn"
onClick={navigateToLogin}
className="flex w-full justify-center"
loading={btnLoading}>
{t("go_back_login")}
</Button>
</AuthContainer>
+4 -4
View File
@@ -1042,7 +1042,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
const parsedQuery = querySchema.safeParse(context.query);
if (!parsedQuery.success) return { notFound: true };
if (!parsedQuery.success) return { notFound: true } as const;
const { uid, eventTypeSlug, seatReferenceUid } = parsedQuery.data;
const { uid: maybeUid } = await maybeGetBookingUidFromSeat(prisma, uid);
@@ -1100,7 +1100,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
if (!bookingInfoRaw) {
return {
notFound: true,
};
} as const;
}
const eventTypeRaw = !bookingInfoRaw.eventTypeId
@@ -1109,7 +1109,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
if (!eventTypeRaw) {
return {
notFound: true,
};
} as const;
}
if (eventTypeRaw.seatsPerTimeSlot && !seatReferenceUid && !session) {
@@ -1130,7 +1130,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
if (!eventTypeRaw.owner)
return {
notFound: true,
};
} as const;
eventTypeRaw.users.push({
...eventTypeRaw.owner,
});
+7
View File
@@ -0,0 +1,7 @@
import withEmbedSsr from "@lib/withEmbedSsr";
import { getServerSideProps as _getServerSideProps } from "../[uid]";
export { default } from "../[uid]";
export const getServerSideProps = withEmbedSsr(_getServerSideProps);
@@ -1,19 +1,7 @@
import type { GetServerSidePropsContext } from "next";
import withEmbedSsr from "@lib/withEmbedSsr";
import { getServerSideProps as _getServerSideProps } from "../[type]";
export { default } from "../[type]";
export const getServerSideProps = async (context: GetServerSidePropsContext) => {
const ssrResponse = await _getServerSideProps(context);
if (ssrResponse.notFound) {
return ssrResponse;
}
return {
...ssrResponse,
props: {
...ssrResponse.props,
isEmbed: true,
},
};
};
export const getServerSideProps = withEmbedSsr(_getServerSideProps);
+18
View File
@@ -18,6 +18,7 @@ import slugify from "@calcom/lib/slugify";
import { stripMarkdown } from "@calcom/lib/stripMarkdown";
import { collectPageParameters, telemetryEventTypes, useTelemetry } from "@calcom/lib/telemetry";
import prisma from "@calcom/prisma";
import { RedirectType } from "@calcom/prisma/client";
import { teamMetadataSchema } from "@calcom/prisma/zod-utils";
import { Avatar, AvatarGroup, Button, HeadSeo, UnpublishedEntity } from "@calcom/ui";
import { ArrowRight } from "@calcom/ui/components/icon";
@@ -30,6 +31,8 @@ import Team from "@components/team/screens/Team";
import { ssrInit } from "@server/lib/ssr";
import { getTemporaryOrgRedirect } from "../../lib/getTemporaryOrgRedirect";
export type PageProps = inferSSRProps<typeof getServerSideProps>;
function TeamPage({
@@ -272,6 +275,8 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
context.req.headers.host ?? "",
context.params?.orgSlug
);
const isOrgContext = isValidOrgDomain && currentOrgDomain;
const flags = await getFeatureFlagMap(prisma);
const team = await getTeamWithMembers({
slug: slugify(slug ?? ""),
@@ -279,6 +284,19 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
isTeamView: true,
isOrgView: isValidOrgDomain && context.resolvedUrl === "/",
});
if (!isOrgContext && slug) {
const redirect = await getTemporaryOrgRedirect({
slug: slug,
redirectType: RedirectType.Team,
eventTypeSlug: null,
});
if (redirect) {
return redirect;
}
}
const ssr = await ssrInit(context);
const metadata = teamMetadataSchema.parse(team?.metadata ?? {});
console.info("gSSP, team/[slug] - ", {
+16
View File
@@ -11,12 +11,15 @@ import { getSlugOrRequestedSlug } from "@calcom/features/ee/organizations/lib/or
import { orgDomainConfig } from "@calcom/features/ee/organizations/lib/orgDomains";
import slugify from "@calcom/lib/slugify";
import prisma from "@calcom/prisma";
import { RedirectType } from "@calcom/prisma/client";
import type { inferSSRProps } from "@lib/types/inferSSRProps";
import type { EmbedProps } from "@lib/withEmbedSsr";
import PageWrapper from "@components/PageWrapper";
import { getTemporaryOrgRedirect } from "../../../lib/getTemporaryOrgRedirect";
export type PageProps = inferSSRProps<typeof getServerSideProps> & EmbedProps;
export default function Type({
@@ -75,6 +78,19 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
context.req.headers.host ?? "",
context.params?.orgSlug
);
const isOrgContext = currentOrgDomain && isValidOrgDomain;
if (!isOrgContext) {
const redirect = await getTemporaryOrgRedirect({
slug: teamSlug,
redirectType: RedirectType.Team,
eventTypeSlug: meetingSlug,
});
if (redirect) {
return redirect;
}
}
const team = await prisma.team.findFirst({
where: {
+1
View File
@@ -26,6 +26,7 @@ function Teams() {
CTA={
(!user.organizationId || user.organization.isOrgAdmin) && (
<Button
data-testid="new-team-btn"
variant="fab"
StartIcon={Plus}
type="button"
@@ -0,0 +1,387 @@
import { loginUser } from "../fixtures/regularBookings";
import { test } from "../lib/fixtures";
test.describe("Booking With Phone Question and Each Other Question", () => {
const bookingOptions = { hasPlaceholder: true, isRequired: true };
test.beforeEach(async ({ page, users, bookingPage }) => {
await loginUser(users);
await page.goto("/event-types");
await bookingPage.goToEventType("30 min");
await bookingPage.goToTab("event_advanced_tab_title");
});
test.describe("Booking With Phone Question and Address Question", () => {
test("Phone and Address required", async ({ bookingPage }) => {
await bookingPage.addQuestion("phone", "phone-test", "phone test", true, "phone test");
await bookingPage.addQuestion("address", "address-test", "address test", true, "address test");
await bookingPage.updateEventType();
const eventTypePage = await bookingPage.previewEventType();
await bookingPage.selectTimeSlot(eventTypePage);
await bookingPage.fillAndConfirmBooking({
eventTypePage,
placeholderText: "Please share anything that will help prepare for our meeting.",
question: "phone",
fillText: "Test Phone question and Address question (both required)",
secondQuestion: "address",
options: bookingOptions,
});
await bookingPage.cancelAndRescheduleBooking(eventTypePage);
});
test("Phone and Address not required", async ({ bookingPage }) => {
await bookingPage.addQuestion("phone", "phone-test", "phone test", true, "phone test");
await bookingPage.addQuestion("address", "address-test", "address test", false, "address test");
await bookingPage.updateEventType();
const eventTypePage = await bookingPage.previewEventType();
await bookingPage.selectTimeSlot(eventTypePage);
await bookingPage.fillAndConfirmBooking({
eventTypePage,
placeholderText: "Please share anything that will help prepare for our meeting.",
question: "phone",
fillText: "Test Phone question and Address question (only phone required)",
secondQuestion: "address",
options: { ...bookingOptions, isRequired: false },
});
await bookingPage.cancelAndRescheduleBooking(eventTypePage);
});
test.describe("Booking With Phone Question and checkbox group Question", () => {
const bookingOptions = { hasPlaceholder: false, isRequired: true };
test("Phone and checkbox group required", async ({ bookingPage }) => {
await bookingPage.addQuestion("phone", "phone-test", "phone test", true, "phone test");
await bookingPage.addQuestion("checkbox", "checkbox-test", "checkbox test", true);
await bookingPage.updateEventType();
const eventTypePage = await bookingPage.previewEventType();
await bookingPage.selectTimeSlot(eventTypePage);
await bookingPage.fillAndConfirmBooking({
eventTypePage,
placeholderText: "Please share anything that will help prepare for our meeting.",
question: "phone",
fillText: "Test Phone question and checkbox group question (both required)",
secondQuestion: "checkbox",
options: bookingOptions,
});
await bookingPage.cancelAndRescheduleBooking(eventTypePage);
});
test("Phone and checkbox group not required", async ({ bookingPage }) => {
await bookingPage.addQuestion("phone", "phone-test", "phone test", true, "phone test");
await bookingPage.addQuestion("checkbox", "checkbox-test", "checkbox test", false);
await bookingPage.updateEventType();
const eventTypePage = await bookingPage.previewEventType();
await bookingPage.selectTimeSlot(eventTypePage);
await bookingPage.fillAndConfirmBooking({
eventTypePage,
placeholderText: "Please share anything that will help prepare for our meeting.",
question: "phone",
fillText: "Test Phone question and checkbox group question (only phone required)",
secondQuestion: "checkbox",
options: { ...bookingOptions, isRequired: false },
});
await bookingPage.cancelAndRescheduleBooking(eventTypePage);
});
});
test.describe("Booking With Phone Question and checkbox Question", () => {
test("Phone and checkbox required", async ({ bookingPage }) => {
await bookingPage.addQuestion("phone", "phone-test", "phone test", true, "phone test");
await bookingPage.addQuestion("boolean", "boolean-test", "boolean test", true);
await bookingPage.updateEventType();
const eventTypePage = await bookingPage.previewEventType();
await bookingPage.selectTimeSlot(eventTypePage);
await bookingPage.fillAndConfirmBooking({
eventTypePage,
placeholderText: "Please share anything that will help prepare for our meeting.",
question: "phone",
fillText: "Test Phone question and checkbox question (both required)",
secondQuestion: "boolean",
options: bookingOptions,
});
await bookingPage.cancelAndRescheduleBooking(eventTypePage);
});
test("Phone and checkbox not required", async ({ bookingPage }) => {
await bookingPage.addQuestion("phone", "phone-test", "phone test", true, "phone test");
await bookingPage.addQuestion("boolean", "boolean-test", "boolean test", false);
await bookingPage.updateEventType();
const eventTypePage = await bookingPage.previewEventType();
await bookingPage.selectTimeSlot(eventTypePage);
await bookingPage.fillAndConfirmBooking({
eventTypePage,
placeholderText: "Please share anything that will help prepare for our meeting.",
question: "phone",
fillText: "Test Phone question and checkbox (only phone required)",
secondQuestion: "boolean",
options: { ...bookingOptions, isRequired: false },
});
await bookingPage.cancelAndRescheduleBooking(eventTypePage);
});
});
test.describe("Booking With Phone Question and Long text Question", () => {
test("Phone and Long text required", async ({ bookingPage }) => {
await bookingPage.addQuestion("phone", "phone-test", "phone test", true, "phone test");
await bookingPage.addQuestion("textarea", "textarea-test", "textarea test", true, "textarea test");
await bookingPage.updateEventType();
const eventTypePage = await bookingPage.previewEventType();
await bookingPage.selectTimeSlot(eventTypePage);
await bookingPage.fillAndConfirmBooking({
eventTypePage,
placeholderText: "Please share anything that will help prepare for our meeting.",
question: "phone",
fillText: "Test Phone question and Long Text question (both required)",
secondQuestion: "textarea",
options: bookingOptions,
});
await bookingPage.cancelAndRescheduleBooking(eventTypePage);
});
test("Phone and Long text not required", async ({ bookingPage }) => {
await bookingPage.addQuestion("phone", "phone-test", "phone test", true, "phone test");
await bookingPage.addQuestion("textarea", "textarea-test", "textarea test", false, "textarea test");
await bookingPage.updateEventType();
const eventTypePage = await bookingPage.previewEventType();
await bookingPage.selectTimeSlot(eventTypePage);
await bookingPage.fillAndConfirmBooking({
eventTypePage,
placeholderText: "Please share anything that will help prepare for our meeting.",
question: "phone",
fillText: "Test Phone question and Long Text question (only phone required)",
secondQuestion: "textarea",
options: { ...bookingOptions, isRequired: false },
});
await bookingPage.cancelAndRescheduleBooking(eventTypePage);
});
});
test.describe("Booking With Phone Question and Multi email Question", () => {
const bookingOptions = { hasPlaceholder: true, isRequired: true };
test("Phone and Multi email required", async ({ bookingPage }) => {
await bookingPage.addQuestion("phone", "phone-test", "phone test", true, "phone test");
await bookingPage.addQuestion(
"multiemail",
"multiemail-test",
"multiemail test",
true,
"multiemail test"
);
await bookingPage.updateEventType();
const eventTypePage = await bookingPage.previewEventType();
await bookingPage.selectTimeSlot(eventTypePage);
await bookingPage.fillAndConfirmBooking({
eventTypePage,
placeholderText: "Please share anything that will help prepare for our meeting.",
question: "phone",
fillText: "Test Phone question and Multi Email question (both required)",
secondQuestion: "multiemail",
options: bookingOptions,
});
await bookingPage.cancelAndRescheduleBooking(eventTypePage);
});
test("Phone and Multi email not required", async ({ bookingPage }) => {
await bookingPage.addQuestion("phone", "phone-test", "phone test", true, "phone test");
await bookingPage.addQuestion(
"multiemail",
"multiemail-test",
"multiemail test",
false,
"multiemail test"
);
await bookingPage.updateEventType();
const eventTypePage = await bookingPage.previewEventType();
await bookingPage.selectTimeSlot(eventTypePage);
await bookingPage.fillAndConfirmBooking({
eventTypePage,
placeholderText: "Please share anything that will help prepare for our meeting.",
question: "phone",
fillText: "Test Phone question and Multi Email question (only phone required)",
secondQuestion: "multiemail",
options: { ...bookingOptions, isRequired: false },
});
await bookingPage.cancelAndRescheduleBooking(eventTypePage);
});
});
test.describe("Booking With Phone Question and multiselect Question", () => {
test("Phone and multiselect text required", async ({ bookingPage }) => {
await bookingPage.addQuestion("phone", "phone-test", "phone test", true, "phone test");
await bookingPage.addQuestion("multiselect", "multiselect-test", "multiselect test", true);
await bookingPage.updateEventType();
const eventTypePage = await bookingPage.previewEventType();
await bookingPage.selectTimeSlot(eventTypePage);
await bookingPage.fillAndConfirmBooking({
eventTypePage,
placeholderText: "Please share anything that will help prepare for our meeting.",
question: "phone",
fillText: "Test Phone question and Multi Select question (both required)",
secondQuestion: "multiselect",
options: bookingOptions,
});
await bookingPage.cancelAndRescheduleBooking(eventTypePage);
});
test("Phone and multiselect text not required", async ({ bookingPage }) => {
await bookingPage.addQuestion("phone", "phone-test", "phone test", true, "phone test");
await bookingPage.addQuestion("multiselect", "multiselect-test", "multiselect test", false);
await bookingPage.updateEventType();
const eventTypePage = await bookingPage.previewEventType();
await bookingPage.selectTimeSlot(eventTypePage);
await bookingPage.fillAndConfirmBooking({
eventTypePage,
placeholderText: "Please share anything that will help prepare for our meeting.",
question: "phone",
fillText: "Test Phone question and Multi Select question (only phone required)",
secondQuestion: "multiselect",
options: { ...bookingOptions, isRequired: false },
});
await bookingPage.cancelAndRescheduleBooking(eventTypePage);
});
});
test.describe("Booking With Phone Question and Number Question", () => {
test("Phone and Number required", async ({ bookingPage }) => {
await bookingPage.addQuestion("phone", "phone-test", "phone test", true, "phone test");
await bookingPage.addQuestion("number", "number-test", "number test", true, "number test");
await bookingPage.updateEventType();
const eventTypePage = await bookingPage.previewEventType();
await bookingPage.selectTimeSlot(eventTypePage);
await bookingPage.fillAndConfirmBooking({
eventTypePage,
placeholderText: "Please share anything that will help prepare for our meeting.",
question: "phone",
fillText: "Test Phone question and Number question (both required)",
secondQuestion: "number",
options: bookingOptions,
});
await bookingPage.cancelAndRescheduleBooking(eventTypePage);
});
test("Phone and Number not required", async ({ bookingPage }) => {
await bookingPage.addQuestion("phone", "phone-test", "phone test", true, "phone test");
await bookingPage.addQuestion("number", "number-test", "number test", false, "number test");
await bookingPage.updateEventType();
const eventTypePage = await bookingPage.previewEventType();
await bookingPage.selectTimeSlot(eventTypePage);
await bookingPage.fillAndConfirmBooking({
eventTypePage,
placeholderText: "Please share anything that will help prepare for our meeting.",
question: "phone",
fillText: "Test Phone question and Number question (only phone required)",
secondQuestion: "number",
options: { ...bookingOptions, isRequired: false },
});
await bookingPage.cancelAndRescheduleBooking(eventTypePage);
});
});
test.describe("Booking With Phone Question and Radio group Question", () => {
test("Phone and Radio group required", async ({ bookingPage }) => {
await bookingPage.addQuestion("phone", "phone-test", "phone test", true, "phone test");
await bookingPage.addQuestion("radio", "radio-test", "radio test", true);
await bookingPage.updateEventType();
const eventTypePage = await bookingPage.previewEventType();
await bookingPage.selectTimeSlot(eventTypePage);
await bookingPage.fillAndConfirmBooking({
eventTypePage,
placeholderText: "Please share anything that will help prepare for our meeting.",
question: "phone",
fillText: "Test Phone question and Radio question (both required)",
secondQuestion: "radio",
options: bookingOptions,
});
await bookingPage.cancelAndRescheduleBooking(eventTypePage);
});
test("Phone and Radio group not required", async ({ bookingPage }) => {
await bookingPage.addQuestion("phone", "phone-test", "phone test", true, "phone test");
await bookingPage.addQuestion("radio", "radio-test", "radio test", false);
await bookingPage.updateEventType();
const eventTypePage = await bookingPage.previewEventType();
await bookingPage.selectTimeSlot(eventTypePage);
await bookingPage.fillAndConfirmBooking({
eventTypePage,
placeholderText: "Please share anything that will help prepare for our meeting.",
question: "phone",
fillText: "Test Phone question and Radio question (only phone required)",
secondQuestion: "radio",
options: { ...bookingOptions, isRequired: false },
});
await bookingPage.cancelAndRescheduleBooking(eventTypePage);
});
});
test.describe("Booking With Phone Question and select Question", () => {
test("Phone and select required", async ({ bookingPage }) => {
await bookingPage.addQuestion("phone", "phone-test", "phone test", true, "phone test");
await bookingPage.addQuestion("select", "select-test", "select test", true, "select test");
await bookingPage.updateEventType();
const eventTypePage = await bookingPage.previewEventType();
await bookingPage.selectTimeSlot(eventTypePage);
await bookingPage.fillAndConfirmBooking({
eventTypePage,
placeholderText: "Please share anything that will help prepare for our meeting.",
question: "phone",
fillText: "Test Phone question and Select question (both required)",
secondQuestion: "select",
options: bookingOptions,
});
await bookingPage.cancelAndRescheduleBooking(eventTypePage);
});
test("Phone and select not required", async ({ bookingPage }) => {
await bookingPage.addQuestion("phone", "phone-test", "phone test", true, "phone test");
await bookingPage.addQuestion("select", "select-test", "select test", false, "select test");
await bookingPage.updateEventType();
const eventTypePage = await bookingPage.previewEventType();
await bookingPage.selectTimeSlot(eventTypePage);
await bookingPage.fillAndConfirmBooking({
eventTypePage,
placeholderText: "Please share anything that will help prepare for our meeting.",
question: "phone",
fillText: "Test Phone question and Select question (only phone required)",
secondQuestion: "select",
options: { ...bookingOptions, isRequired: false },
});
await bookingPage.cancelAndRescheduleBooking(eventTypePage);
});
});
test.describe("Booking With Phone Question and Short text question", () => {
const bookingOptions = { hasPlaceholder: true, isRequired: true };
test("Phone and Short text required", async ({ bookingPage }) => {
await bookingPage.addQuestion("phone", "phone-test", "phone test", true, "phone test");
await bookingPage.addQuestion("text", "text-test", "text test", true, "text test");
await bookingPage.updateEventType();
const eventTypePage = await bookingPage.previewEventType();
await bookingPage.selectTimeSlot(eventTypePage);
await bookingPage.fillAndConfirmBooking({
eventTypePage,
placeholderText: "Please share anything that will help prepare for our meeting.",
question: "phone",
fillText: "Test Phone question and Text question (both required)",
secondQuestion: "text",
options: bookingOptions,
});
await bookingPage.cancelAndRescheduleBooking(eventTypePage);
});
test("Phone and Short text not required", async ({ bookingPage }) => {
await bookingPage.addQuestion("phone", "phone-test", "phone test", true, "phone test");
await bookingPage.addQuestion("text", "text-test", "text test", false, "text test");
await bookingPage.updateEventType();
const eventTypePage = await bookingPage.previewEventType();
await bookingPage.selectTimeSlot(eventTypePage);
await bookingPage.fillAndConfirmBooking({
eventTypePage,
placeholderText: "Please share anything that will help prepare for our meeting.",
question: "phone",
fillText: "Test Phone question and Text question (only phone required)",
secondQuestion: "text",
options: { ...bookingOptions, isRequired: false },
});
await bookingPage.cancelAndRescheduleBooking(eventTypePage);
});
});
});
});
+4 -1
View File
@@ -3,13 +3,16 @@ import type { Booking, Prisma } from "@prisma/client";
import short from "short-uuid";
import { v5 as uuidv5 } from "uuid";
import dayjs from "@calcom/dayjs";
import _dayjs from "@calcom/dayjs";
import { prisma } from "@calcom/prisma";
const translator = short();
type BookingFixture = ReturnType<typeof createBookingFixture>;
// We default all dayjs calls to use Europe/London timezone
const dayjs = (...args: Parameters<typeof _dayjs>) => _dayjs(...args).tz("Europe/London");
// creates a user fixture instance and stores the collection
export const createBookingsFixture = (page: Page) => {
const store = { bookings: [], page } as { bookings: BookingFixture[]; page: typeof page };
@@ -0,0 +1,250 @@
import { expect, type Page } from "@playwright/test";
import type { createUsersFixture } from "./users";
const reschedulePlaceholderText = "Let others know why you need to reschedule";
export const scheduleSuccessfullyText = "This meeting is scheduled";
const EMAIL = "test@test.com";
const EMAIL2 = "test2@test.com";
const PHONE = "+55 (32) 983289947";
type BookingOptions = {
hasPlaceholder?: boolean;
isReschedule?: boolean;
isRequired?: boolean;
};
interface QuestionActions {
[key: string]: () => Promise<void>;
}
type customLocators = {
shouldChangeSelectLocator: boolean;
shouldUseLastRadioGroupLocator: boolean;
shouldUseFirstRadioGroupLocator: boolean;
shouldChangeMultiSelectLocator: boolean;
};
type fillAndConfirmBookingParams = {
eventTypePage: Page;
placeholderText: string;
question: string;
fillText: string;
secondQuestion: string;
options: BookingOptions;
};
type UserFixture = ReturnType<typeof createUsersFixture>;
const fillQuestion = async (eventTypePage: Page, questionType: string, customLocators: customLocators) => {
const questionActions: QuestionActions = {
phone: async () => {
await eventTypePage.locator('input[name="phone-test"]').clear();
await eventTypePage.locator('input[name="phone-test"]').fill(PHONE);
},
multiemail: async () => {
await eventTypePage.getByRole("button", { name: `${questionType} test` }).click();
await eventTypePage.getByPlaceholder(`${questionType} test`).fill(EMAIL);
await eventTypePage.getByTestId("add-another-guest").last().click();
await eventTypePage.getByPlaceholder(`${questionType} test`).last().fill(EMAIL2);
},
checkbox: async () => {
if (customLocators.shouldUseLastRadioGroupLocator || customLocators.shouldChangeMultiSelectLocator) {
await eventTypePage.getByLabel("Option 1").last().click();
await eventTypePage.getByLabel("Option 2").last().click();
} else if (customLocators.shouldUseFirstRadioGroupLocator) {
await eventTypePage.getByLabel("Option 1").first().click();
await eventTypePage.getByLabel("Option 2").first().click();
} else {
await eventTypePage.getByLabel("Option 1").click();
await eventTypePage.getByLabel("Option 2").click();
}
},
multiselect: async () => {
if (customLocators.shouldChangeMultiSelectLocator) {
await eventTypePage.locator("form svg").nth(1).click();
await eventTypePage.getByTestId("select-option-Option 1").click();
} else {
await eventTypePage.locator("form svg").last().click();
await eventTypePage.getByTestId("select-option-Option 1").click();
}
},
boolean: async () => {
await eventTypePage.getByLabel(`${questionType} test`).check();
},
radio: async () => {
await eventTypePage.locator('[id="radio-test\\.option\\.0\\.radio"]').click();
},
select: async () => {
if (customLocators.shouldChangeSelectLocator) {
await eventTypePage.locator("form svg").nth(1).click();
await eventTypePage.getByTestId("select-option-Option 1").click();
} else {
await eventTypePage.locator("form svg").last().click();
await eventTypePage.getByTestId("select-option-Option 1").click();
}
},
number: async () => {
await eventTypePage.getByPlaceholder(`${questionType} test`).click();
await eventTypePage.getByPlaceholder(`${questionType} test`).fill("123");
},
address: async () => {
await eventTypePage.getByPlaceholder(`${questionType} test`).click();
await eventTypePage.getByPlaceholder(`${questionType} test`).fill("address test");
},
textarea: async () => {
await eventTypePage.getByPlaceholder(`${questionType} test`).click();
await eventTypePage.getByPlaceholder(`${questionType} test`).fill("textarea test");
},
text: async () => {
await eventTypePage.getByPlaceholder(`${questionType} test`).click();
await eventTypePage.getByPlaceholder(`${questionType} test`).fill("text test");
},
};
if (questionActions[questionType]) {
await questionActions[questionType]();
}
};
export async function loginUser(users: UserFixture) {
const pro = await users.create({ name: "testuser" });
await pro.apiLogin();
}
export function createBookingPageFixture(page: Page) {
return {
goToEventType: async (eventType: string) => {
await page.getByRole("link", { name: eventType }).click();
},
goToTab: async (tabName: string) => {
await page.getByTestId(`vertical-tab-${tabName}`).click();
},
addQuestion: async (
questionType: string,
identifier: string,
label: string,
isRequired: boolean,
placeholder?: string
) => {
await page.getByTestId("add-field").click();
await page.locator("#test-field-type > .bg-default > div > div:nth-child(2)").first().click();
await page.getByTestId(`select-option-${questionType}`).click();
await page.getByLabel("Identifier").dblclick();
await page.getByLabel("Identifier").fill(identifier);
await page.getByLabel("Label").click();
await page.getByLabel("Label").fill(label);
if (placeholder) {
await page.getByLabel("Placeholder").click();
await page.getByLabel("Placeholder").fill(placeholder);
}
if (!isRequired) {
await page.getByRole("radio", { name: "No" }).click();
}
await page.getByTestId("field-add-save").click();
},
updateEventType: async () => {
await page.getByTestId("update-eventtype").click();
},
previewEventType: async () => {
const eventtypePromise = page.waitForEvent("popup");
await page.getByTestId("preview-button").click();
return eventtypePromise;
},
selectTimeSlot: async (eventTypePage: Page) => {
while (await eventTypePage.getByRole("button", { name: "View next" }).isVisible()) {
await eventTypePage.getByRole("button", { name: "View next" }).click();
}
await eventTypePage.getByTestId("time").first().click();
},
clickReschedule: async () => {
await page.getByText("Reschedule").click();
},
navigateToAvailableTimeSlot: async () => {
while (await page.getByRole("button", { name: "View next" }).isVisible()) {
await page.getByRole("button", { name: "View next" }).click();
}
},
selectFirstAvailableTime: async () => {
await page.getByTestId("time").first().click();
},
fillRescheduleReasonAndConfirm: async () => {
await page.getByPlaceholder(reschedulePlaceholderText).click();
await page.getByPlaceholder(reschedulePlaceholderText).fill("Test reschedule");
await page.getByTestId("confirm-reschedule-button").click();
},
verifyReschedulingSuccess: async () => {
await expect(page.getByText(scheduleSuccessfullyText)).toBeVisible();
},
cancelBookingWithReason: async () => {
await page.getByTestId("cancel").click();
await page.getByTestId("cancel_reason").fill("Test cancel");
await page.getByTestId("confirm_cancel").click();
},
verifyBookingCancellation: async () => {
await expect(page.getByTestId("cancelled-headline")).toBeVisible();
},
cancelAndRescheduleBooking: async (eventTypePage: Page) => {
await eventTypePage.getByText("Reschedule").click();
while (await eventTypePage.getByRole("button", { name: "View next" }).isVisible()) {
await eventTypePage.getByRole("button", { name: "View next" }).click();
}
await eventTypePage.getByTestId("time").first().click();
await eventTypePage.getByPlaceholder(reschedulePlaceholderText).click();
await eventTypePage.getByPlaceholder(reschedulePlaceholderText).fill("Test reschedule");
await eventTypePage.getByTestId("confirm-reschedule-button").click();
await expect(eventTypePage.getByText(scheduleSuccessfullyText)).toBeVisible();
await eventTypePage.getByTestId("cancel").click();
await eventTypePage.getByTestId("cancel_reason").fill("Test cancel");
await eventTypePage.getByTestId("confirm_cancel").click();
await expect(eventTypePage.getByTestId("cancelled-headline")).toBeVisible();
},
fillAndConfirmBooking: async ({
eventTypePage,
placeholderText,
question,
fillText,
secondQuestion,
options,
}: fillAndConfirmBookingParams) => {
const confirmButton = options.isReschedule ? "confirm-reschedule-button" : "confirm-book-button";
await expect(eventTypePage.getByText(`${secondQuestion} test`).first()).toBeVisible();
await eventTypePage.getByPlaceholder(placeholderText).fill(fillText);
// Change the selector for specifics cases related to select question
const shouldChangeSelectLocator = (question: string, secondQuestion: string): boolean =>
question === "select" && ["multiemail", "multiselect"].includes(secondQuestion);
const shouldUseLastRadioGroupLocator = (question: string, secondQuestion: string): boolean =>
question === "radio" && secondQuestion === "checkbox";
const shouldUseFirstRadioGroupLocator = (question: string, secondQuestion: string): boolean =>
question === "checkbox" && secondQuestion === "radio";
const shouldChangeMultiSelectLocator = (question: string, secondQuestion: string): boolean =>
question === "multiselect" &&
["address", "checkbox", "multiemail", "select"].includes(secondQuestion);
const customLocators = {
shouldChangeSelectLocator: shouldChangeSelectLocator(question, secondQuestion),
shouldUseLastRadioGroupLocator: shouldUseLastRadioGroupLocator(question, secondQuestion),
shouldUseFirstRadioGroupLocator: shouldUseFirstRadioGroupLocator(question, secondQuestion),
shouldChangeMultiSelectLocator: shouldChangeMultiSelectLocator(question, secondQuestion),
};
// Fill the first question
await fillQuestion(eventTypePage, question, customLocators);
// Fill the second question if is required
options.isRequired && (await fillQuestion(eventTypePage, secondQuestion, customLocators));
await eventTypePage.getByTestId(confirmButton).click();
const scheduleSuccessfullyPage = eventTypePage.getByText(scheduleSuccessfullyText);
await scheduleSuccessfullyPage.waitFor({ state: "visible" });
await expect(scheduleSuccessfullyPage).toBeVisible();
},
};
}
+20 -4
View File
@@ -4,14 +4,14 @@ import { Prisma as PrismaType } from "@prisma/client";
import { hashSync as hash } from "bcryptjs";
import type { API } from "mailhog";
import dayjs from "@calcom/dayjs";
import stripe from "@calcom/features/ee/payments/server/stripe";
import { DEFAULT_SCHEDULE, getAvailabilityFromSchedule } from "@calcom/lib/availability";
import { WEBAPP_URL } from "@calcom/lib/constants";
import { prisma } from "@calcom/prisma";
import { MembershipRole, SchedulingType } from "@calcom/prisma/enums";
import { selectFirstAvailableTimeSlotNextMonth, teamEventSlug, teamEventTitle } from "../lib/testUtils";
import type { TimeZoneEnum } from "./types";
import { TimeZoneEnum } from "./types";
// Don't import hashPassword from app as that ends up importing next-auth and initializing it before NEXTAUTH_URL can be updated during tests.
export function hashPassword(password: string) {
@@ -288,6 +288,7 @@ export const createUsersFixture = (page: Page, emails: API | undefined, workerIn
const teamEvent = await createTeamEventType(user, team, scenario);
if (scenario.teammates) {
// Create Teammate users
const teamMatesIds = [];
for (const teammateObj of scenario.teammates) {
const teamUser = await prisma.user.create({
data: createUser(workerInfo, teammateObj),
@@ -319,8 +320,22 @@ export const createUsersFixture = (page: Page, emails: API | undefined, workerIn
}),
store.page
);
teamMatesIds.push(teamUser.id);
store.users.push(teammateFixture);
}
// Add Teammates to OrgUsers
if (scenario.isOrg) {
await prisma.team.update({
where: {
id: team.id,
},
data: {
orgUsers: {
connect: teamMatesIds.map((userId) => ({ id: userId })).concat([{ id: user.id }]),
},
},
});
}
}
}
const userFixture = createUserFixture(user, store.page);
@@ -461,13 +476,14 @@ const createUser = (workerInfo: WorkerInfo, opts?: CustomUserOpts | null): Prism
password: hashPassword(uname),
emailVerified: new Date(),
completedOnboarding: opts?.completedOnboarding ?? true,
timeZone: opts?.timeZone ?? dayjs.tz.guess(),
timeZone: opts?.timeZone ?? TimeZoneEnum.UK,
locale: opts?.locale ?? "en",
schedules:
opts?.completedOnboarding ?? true
? {
create: {
name: "Working Hours",
timeZone: opts?.timeZone ?? TimeZoneEnum.UK,
availability: {
createMany: {
data: getAvailabilityFromSchedule(DEFAULT_SCHEDULE),
@@ -543,7 +559,7 @@ export async function apiLogin(
const data = {
email: user.email ?? `${user.username}@example.com`,
password: user.password ?? user.username,
callbackURL: "http://localhost:3000/",
callbackURL: WEBAPP_URL,
redirect: "false",
json: "true",
csrfToken,
+30
View File
@@ -236,4 +236,34 @@ test.describe("Insights", async () => {
// expect for "Team: test-insight" text in page
expect(await page.locator("text=Team: test-insights").isVisible()).toBeTruthy();
});
test("should test download button", async ({ page, users }) => {
const owner = await users.create();
const member = await users.create();
await createTeamsAndMembership(owner.id, member.id);
await owner.apiLogin();
await page.goto("/insights");
await page.waitForLoadState("networkidle");
const downloadPromise = page.waitForEvent("download");
// Expect download button to be visible
expect(await page.locator("text=Download").isVisible()).toBeTruthy();
// Click on Download button
await page.getByText("Download").click();
// Expect as csv option to be visible
expect(await page.locator("text=as CSV").isVisible()).toBeTruthy();
// Start waiting for download before clicking. Note no await.
await page.getByText("as CSV").click();
const download = await downloadPromise;
// Wait for the download process to complete and save the downloaded file somewhere.
await download.saveAs("./" + "test-insights.csv");
});
});
+6
View File
@@ -10,6 +10,7 @@ import type { ExpectedUrlDetails } from "../../../../playwright.config";
import { createBookingsFixture } from "../fixtures/bookings";
import { createEmbedsFixture } from "../fixtures/embeds";
import { createPaymentsFixture } from "../fixtures/payments";
import { createBookingPageFixture } from "../fixtures/regularBookings";
import { createRoutingFormsFixture } from "../fixtures/routingForms";
import { createServersFixture } from "../fixtures/servers";
import { createUsersFixture } from "../fixtures/users";
@@ -24,6 +25,7 @@ export interface Fixtures {
prisma: typeof prisma;
emails?: API;
routingForms: ReturnType<typeof createRoutingFormsFixture>;
bookingPage: ReturnType<typeof createBookingPageFixture>;
}
declare global {
@@ -80,4 +82,8 @@ export const test = base.extend<Fixtures>({
await use(undefined);
}
},
bookingPage: async ({ page }, use) => {
const bookingPage = createBookingPageFixture(page);
await use(bookingPage);
},
});
+282 -4
View File
@@ -52,7 +52,151 @@ test.describe("unauthorized user sees correct translations (ar)", async () => {
});
});
test.describe("authorized user sees correct translations (de) [locale1]", async () => {
test.describe("unauthorized user sees correct translations (zh)", async () => {
test.use({
locale: "zh",
});
test("should use correct translations and html attributes", async ({ page }) => {
await page.goto("/");
await page.waitForLoadState("load");
await page.locator("html[lang=zh]").waitFor({ state: "attached" });
await page.locator("html[dir=ltr]").waitFor({ state: "attached" });
{
const locator = page.getByText("欢迎回来", { exact: true });
expect(await locator.count()).toEqual(1);
}
{
const locator = page.getByText("Welcome back", { exact: true });
expect(await locator.count()).toEqual(0);
}
});
});
test.describe("unauthorized user sees correct translations (zh-CN)", async () => {
test.use({
locale: "zh-CN",
});
test("should use correct translations and html attributes", async ({ page }) => {
await page.goto("/");
await page.waitForLoadState("load");
await page.locator("html[lang=zh-CN]").waitFor({ state: "attached" });
await page.locator("html[dir=ltr]").waitFor({ state: "attached" });
{
const locator = page.getByText("欢迎回来", { exact: true });
expect(await locator.count()).toEqual(1);
}
{
const locator = page.getByText("Welcome back", { exact: true });
expect(await locator.count()).toEqual(0);
}
});
});
test.describe("unauthorized user sees correct translations (zh-TW)", async () => {
test.use({
locale: "zh-TW",
});
test("should use correct translations and html attributes", async ({ page }) => {
await page.goto("/");
await page.waitForLoadState("load");
await page.locator("html[lang=zh-TW]").waitFor({ state: "attached" });
await page.locator("html[dir=ltr]").waitFor({ state: "attached" });
{
const locator = page.getByText("歡迎回來", { exact: true });
expect(await locator.count()).toEqual(1);
}
{
const locator = page.getByText("Welcome back", { exact: true });
expect(await locator.count()).toEqual(0);
}
});
});
test.describe("unauthorized user sees correct translations (pt)", async () => {
test.use({
locale: "pt",
});
test("should use correct translations and html attributes", async ({ page }) => {
await page.goto("/");
await page.waitForLoadState("load");
await page.locator("html[lang=pt]").waitFor({ state: "attached" });
await page.locator("html[dir=ltr]").waitFor({ state: "attached" });
{
const locator = page.getByText("Olá novamente", { exact: true });
expect(await locator.count()).toEqual(1);
}
{
const locator = page.getByText("Welcome back", { exact: true });
expect(await locator.count()).toEqual(0);
}
});
});
test.describe("unauthorized user sees correct translations (pt-br)", async () => {
test.use({
locale: "pt-br",
});
test("should use correct translations and html attributes", async ({ page }) => {
await page.goto("/");
await page.waitForLoadState("load");
await page.locator("html[lang=pt-br]").waitFor({ state: "attached" });
await page.locator("html[dir=ltr]").waitFor({ state: "attached" });
{
const locator = page.getByText("Bem-vindo(a) novamente", { exact: true });
expect(await locator.count()).toEqual(1);
}
{
const locator = page.getByText("Welcome back", { exact: true });
expect(await locator.count()).toEqual(0);
}
});
});
test.describe("unauthorized user sees correct translations (es-419)", async () => {
test.use({
locale: "es-419",
});
test("should use correct translations and html attributes", async ({ page }) => {
await page.goto("/");
await page.waitForLoadState("load");
await page.locator("html[lang=es-419]").waitFor({ state: "attached" });
await page.locator("html[dir=ltr]").waitFor({ state: "attached" });
{
const locator = page.getByText("Bienvenido de nuevo", { exact: true });
expect(await locator.count()).toEqual(1);
}
{
const locator = page.getByText("Welcome back", { exact: true });
expect(await locator.count()).toEqual(0);
}
});
});
test.describe("authorized user sees correct translations (de)", async () => {
test.use({
locale: "en",
});
@@ -124,6 +268,78 @@ test.describe("authorized user sees correct translations (de) [locale1]", async
});
});
test.describe("authorized user sees correct translations (pt-br)", async () => {
test.use({
locale: "en",
});
test("should return correct translations and html attributes", async ({ page, users }) => {
await test.step("should create a pt-br user", async () => {
const user = await users.create({
locale: "pt-br",
});
await user.apiLogin();
});
await test.step("should navigate to /event-types and show Brazil-Portuguese translations", async () => {
await page.goto("/event-types");
await page.waitForLoadState("networkidle");
await page.locator("html[lang=pt-br]").waitFor({ state: "attached" });
await page.locator("html[dir=ltr]").waitFor({ state: "attached" });
{
const locator = page.getByText("Tipos de Eventos", { exact: true });
expect(await locator.count()).toBeGreaterThanOrEqual(1);
}
{
const locator = page.getByText("Event Types", { exact: true });
expect(await locator.count()).toEqual(0);
}
});
await test.step("should navigate to /bookings and show Brazil-Portuguese translations", async () => {
await page.goto("/bookings");
await page.waitForLoadState("networkidle");
await page.locator("html[lang=pt-br]").waitFor({ state: "attached" });
await page.locator("html[dir=ltr]").waitFor({ state: "attached" });
{
const locator = page.getByText("Reservas", { exact: true });
expect(await locator.count()).toBeGreaterThanOrEqual(1);
}
{
const locator = page.getByText("Bookings", { exact: true });
expect(await locator.count()).toEqual(0);
}
});
await test.step("should reload the /bookings and show Brazil-Portuguese translations", async () => {
await page.reload();
await page.waitForLoadState("networkidle");
await page.locator("html[lang=pt-br]").waitFor({ state: "attached" });
await page.locator("html[dir=ltr]").waitFor({ state: "attached" });
{
const locator = page.getByText("Reservas", { exact: true });
expect(await locator.count()).toBeGreaterThanOrEqual(1);
}
{
const locator = page.getByText("Bookings", { exact: true });
expect(await locator.count()).toEqual(0);
}
});
});
});
test.describe("authorized user sees correct translations (ar)", async () => {
test.use({
locale: "en",
@@ -137,7 +353,7 @@ test.describe("authorized user sees correct translations (ar)", async () => {
await user.apiLogin();
});
await test.step("should navigate to /event-types and show German translations", async () => {
await test.step("should navigate to /event-types and show Arabic translations", async () => {
await page.goto("/event-types");
await page.waitForLoadState("networkidle");
@@ -156,7 +372,7 @@ test.describe("authorized user sees correct translations (ar)", async () => {
}
});
await test.step("should navigate to /bookings and show German translations", async () => {
await test.step("should navigate to /bookings and show Arabic translations", async () => {
await page.goto("/bookings");
await page.waitForLoadState("networkidle");
@@ -175,7 +391,7 @@ test.describe("authorized user sees correct translations (ar)", async () => {
}
});
await test.step("should reload the /bookings and show German translations", async () => {
await test.step("should reload the /bookings and show Arabic translations", async () => {
await page.reload();
await page.waitForLoadState("networkidle");
@@ -257,3 +473,65 @@ test.describe("authorized user sees changed translations (de->ar)", async () =>
});
});
});
test.describe("authorized user sees changed translations (de->pt-BR) [locale1]", async () => {
test.use({
locale: "en",
});
test("should return correct translations and html attributes", async ({ page, users }) => {
await test.step("should create a de user", async () => {
const user = await users.create({
locale: "de",
});
await user.apiLogin();
});
await test.step("should change the language and show Brazil-Portuguese translations", async () => {
await page.goto("/settings/my-account/general");
await page.waitForLoadState("networkidle");
await page.locator(".bg-default > div > div:nth-child(2)").first().click();
await page.locator("#react-select-2-option-14").click();
await page.getByRole("button", { name: "Aktualisieren" }).click();
await page
.getByRole("button", { name: "Einstellungen erfolgreich aktualisiert" })
.waitFor({ state: "visible" });
await page.locator("html[lang=pt-BR]").waitFor({ state: "attached" });
await page.locator("html[dir=ltr]").waitFor({ state: "attached" });
{
const locator = page.getByText("Geral", { exact: true }); // "general"
expect(await locator.count()).toBeGreaterThanOrEqual(1);
}
{
const locator = page.getByText("Allgemein", { exact: true }); // "general"
expect(await locator.count()).toEqual(0);
}
});
await test.step("should reload and show Brazil-Portuguese translations", async () => {
await page.reload();
await page.waitForLoadState("networkidle");
await page.locator("html[lang=pt-BR]").waitFor({ state: "attached" });
await page.locator("html[dir=ltr]").waitFor({ state: "attached" });
{
const locator = page.getByText("Geral", { exact: true }); // "general"
expect(await locator.count()).toBeGreaterThanOrEqual(1);
}
{
const locator = page.getByText("Allgemein", { exact: true }); // "general"
expect(await locator.count()).toEqual(0);
}
});
});
});
+1 -1
View File
@@ -35,7 +35,7 @@ test.describe("user can login & logout succesfully", async () => {
const signOutBtn = await page.locator(`text=${signOutLabel}`);
await signOutBtn.click();
await page.locator('a[href="/auth/login"]').click();
await page.locator("[data-testid=logout-btn]").click();
// Reroute to the home page to check if the login form shows up
await expect(page.locator(`[data-testid=login-form]`)).toBeVisible();
+34
View File
@@ -135,6 +135,40 @@ test.describe("Teams", () => {
expect(teamMatesObj.some(({ name }) => name === chosenUser)).toBe(true);
// TODO: Assert whether the user received an email
});
test("Non admin team members cannot create team in org", async ({ page, users }) => {
const teamMateName = "teammate-1";
const owner = await users.create(undefined, {
hasTeam: true,
isOrg: true,
teammates: [{ name: teamMateName }],
});
const allUsers = await users.get();
const memberUser = allUsers.find((user) => user.name === teamMateName);
// eslint-disable-next-line playwright/no-conditional-in-test
if (memberUser) {
await memberUser.apiLogin();
await page.goto("/teams");
await expect(page.locator("[data-testid=new-team-btn]")).toBeHidden();
await expect(page.locator("[data-testid=create-team-btn]")).toHaveAttribute("disabled", "");
const uniqueName = "test-unique-team-name";
// Go directly to the create team page
await page.goto("/settings/teams/new");
// Fill input[name="name"]
await page.locator('input[name="name"]').fill(uniqueName);
await page.locator("text=Continue").click();
await expect(page.locator("[data-testid=alert]")).toBeVisible();
// cleanup
const org = await owner.getOrg();
await prisma.team.delete({ where: { id: org.teamId } });
}
});
test("Can create team with same name as user", async ({ page, users }) => {
// Name to be used for both user and team
const uniqueName = "test-unique-name";
+4 -1
View File
@@ -1,6 +1,6 @@
import { expect } from "@playwright/test";
import dayjs from "@calcom/dayjs";
import _dayjs from "@calcom/dayjs";
import prisma from "@calcom/prisma";
import { test } from "./lib/fixtures";
@@ -9,6 +9,9 @@ test.describe.configure({ mode: "parallel" });
test.afterEach(({ users }) => users.deleteAll());
// We default all dayjs calls to use Europe/London timezone
const dayjs = (...args: Parameters<typeof _dayjs>) => _dayjs(...args).tz("Europe/London");
test.describe("Wipe my Cal App Test", () => {
test("Browse upcoming bookings and validate button shows and triggering wipe my cal button", async ({
page,
@@ -2091,8 +2091,11 @@
"copy_client_secret_info": "After copying the secret you won't be able to view it anymore",
"add_new_client": "Add new Client",
"this_app_is_not_setup_already": "This app has not been setup yet",
"as_csv": "as CSV",
"overlay_my_calendar":"Overlay my calendar",
"overlay_my_calendar_toc":"By connecting to your calendar, you accept our privacy policy and terms of use. You may revoke access at any time.",
"view_overlay_calendar_events":"View your calendar events to prevent clashed booking.",
"join_event_location": "Join {{eventLocationType}}",
"join_meeting": "Join Meeting",
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Add your new strings above here ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
}
+128 -1
View File
@@ -1 +1,128 @@
{}
{
"identity_provider": "Identitātes nodrošinātājs",
"trial_days_left": "Jūsu PRO izmēģinājuma ir atlikušas $t(day, {\"count\": {{days}} })",
"day_one": "{{count}} diena",
"day_other": "{{count}} dienas",
"second_one": "{{count}} sekunde",
"second_other": "{{count}} sekundes",
"upgrade_now": "Atjaunināt tagad",
"accept_invitation": "Apstiprināt Uzaicinājumu",
"calcom_explained": "{{appName}} nodrošina plānošanas infrastruktūru pilnīgi visiem.",
"calcom_explained_new_user": "Pabeidziet sava {{appName}} konta iestatīšanu! Jūs esat tikai dažu soļu attālumā no visu plānošanas problēmu atrisināšanas.",
"have_any_questions": "Jums ir jautājumi? Mēs esam šeit, lai palīdzētu.",
"reset_password_subject": "{{appName}}: Paroles atiestatīšanas instrukcijas",
"verify_email_subject": "{{appName}}: Verificējiet savu kontu",
"check_your_email": "Pārbaudiet savu e-pastu",
"verify_email_page_body": "Mēs nosūtījām e-pastu uz {{email}}. Ir svarīgi verificēt jūsu e-pastu, lai garantētu labāko e-pastu un kalendāru saņemšanu no {{appName}}.",
"verify_email_banner_body": "Verificējiet savu e-pasta adresi, lai garantētu labāko e-pastu un kalendāru saņemšanu",
"verify_email_email_header": "Verificējiet savu e-pasta adresi",
"verify_email_email_button": "Verificēt e-pastu",
"copy_somewhere_safe": "Saglabājiet šo API atslēgu drošā vietā. Jūs to nevarēsiet skatīt vēlreiz.",
"verify_email_email_body": "Lūdzu, apstipriniet savu e-pasta adresi, noklikšķinot uz zemāk esošās pogas.",
"verify_email_by_code_email_body": "Lūdzu, apstipriniet savu e-pasta adresi, izmantojot zemāk esošo kodu.",
"verify_email_email_link_text": "Šeit ir saite gadījumam, ja jums nepatīk klikšķināt uz pogām:",
"email_verification_code": "Ievadiet verifikācijas kodu",
"email_verification_code_placeholder": "Ievadiet verifikācijas kodu, kas tika nosūtīts uz jūsu e-pastu",
"incorrect_email_verification_code": "Verifikācijas kods ir nepareizs.",
"email_sent": "E-Pasts veiksmīgi nosūtīts",
"email_not_sent": "Notika kļūme, sūtot e-pastu",
"event_declined_subject": "Noraidīts: {{title}} ({{date}})",
"event_cancelled_subject": "Atcelts: {{title}} ({{date}})",
"event_request_declined": "Jūsu notikuma pieprasījums tika noraidīts",
"event_request_declined_recurring": "Jūsu aktārtotā notikuma pieprasījums tika noraidīts",
"event_request_cancelled": "Jūsu ieplānotais notikums tika atcelts",
"organizer": "Organizētājs",
"need_to_reschedule_or_cancel": "Nepieciešams pārlikt vai atcelt?",
"no_options_available": "Nav pieejamu opciju",
"cancellation_reason": "Iemesls atcelšanai (neobligāts)",
"cancellation_reason_placeholder": "Kāpēc jūs atceļat?",
"rejection_reason": "Iemesls noraidīšanai",
"rejection_reason_title": "Vai noraidīt rezervācijas pieprasījumu?",
"rejection_reason_description": "Vai tiešām vēlaties noraidīt rezervāciju? Mēs informēsim personu, kas mēģināja rezervēt. Zemāk varat norādīt iemeslu.",
"rejection_confirmation": "Noraidīt rezervāciju",
"manage_this_event": "Pārvaldīt šo notikumu",
"invite_team_member": "Uzaicināt komandas biedru",
"invite_team_individual_segment": "Uzaicināt personu",
"invite_team_bulk_segment": "Lielapjoma importēšana",
"invite_team_notifcation_badge": "Uza.",
"your_event_has_been_scheduled": "Jūsu notikums ir ieplānots",
"your_event_has_been_scheduled_recurring": "Jūsu atkārtotais notikums ir ieplānots",
"accept_our_license": "Akceptējiet mūsu licensi, izmainot .env mainīgo <1>NEXT_PUBLIC_LICENSE_CONSENT</1> uz '{{agree}}'.",
"remove_banner_instructions": "Lai noņemtu šo baneri, lūdzu atveriet .env failu un nomainat <1>NEXT_PUBLIC_LICENSE_CONSENT</1> mainīgo uz '{{agree}}'.",
"error_message": "Kļūmes paziņojums bija: '{{errorMessage}}'",
"refund_failed_subject": "Naudas atgriešana neizdevās: {{name}} - {{date}} - {{eventType}}",
"refund_failed": "Naudas atgriešana notikumam {{eventType}} ar {{userName}}, šādā datumā: {{date}} neizdevās.",
"check_with_provider_and_user": "Lūdzu, sazinieties ar savu maksājumu pakalpojumu sniedzēju un {{user}}, kā ar šo rīkoties.",
"a_refund_failed": "Naudas atgriešana neizdevās",
"awaiting_payment_subject": "Gaida maksājumu: {{title}} datumā {{date}}",
"meeting_awaiting_payment": "Jūsu tikšanās gaida maksājumu",
"help": "Palīdzība",
"price": "Cena",
"paid": "Apmaksāts",
"refunded": "Nauda atgriezta",
"payment": "Maksājums",
"missing_card_fields": "Trūkst karšu lauku",
"enter_meeting": "Pievienoties Tikšanās",
"video_call_provider": "Video zvanu nodrošinātājs",
"meeting_id": "Tikšanās ID",
"meeting_password": "Tikšanās Parole",
"meeting_url": "Tikšanās URL",
"meeting_request_rejected": "Jūsu tikšanās pieprasījums tika noraidīts",
"rejected_event_type_with_organizer": "Noraidīts: {{eventType}} ar {{organizer}} datumā {{date}}",
"hi": "Sveiki",
"join_team": "Pievienoties komandai",
"manage_this_team": "Pārvaldīt šo komandu",
"team_info": "Komandas Info",
"request_another_invitation_email": "Ja nevēlaties izmantot {{toEmail}} kā savu {{appName}} e-pasta adresi vai jums jau ir {{appName}} konts, lūdzu, pieprasiet citu ielūgumu uz to e-pastu.",
"you_have_been_invited": "Jūs esat uzaicināts pievienoties komandai {{teamName}}",
"user_invited_you": "{{user}} uzaicināja jūs pievienoties {{entity}} {{team}} iekš {{appName}}",
"hidden_team_member_title": "Jūs esat paslēpts šajā komandā",
"hidden_team_member_message": "Jūsu vieta nav apmaksāta. Vai nu Jauniniet savu plānu uz PRO, vai paziņojat komandas īpašniekam, lai apmaksā jūsu vietu.",
"hidden_team_owner_message": "Jums ir nepieciešams Pro konts, lai izmantotu komandas. Līdz jaunināšanai, jūs esat paslēpts.",
"link_expires": "p.s. Tas beidzas pēc {{expiresIn}} stundām.",
"upgrade_to_per_seat": "Jauniniet uz Par-Vietu",
"seat_options_doesnt_support_confirmation": "Vietu opcija neatbalsta apstiprinājuma prasību",
"team_upgrade_seats_details": "No {{memberCount}} dalībniekiem jūsu komandā, {{unpaidCount}} vieta(-as) nav apmaksāta(-as). Ar cenu ${{seatPrice}}/mēnesī par vietu, jūsu kopējās izmaksas ir aptuveni ${{totalCost}}/mēnesī.",
"team_upgrade_banner_description": "Jūs neesat pabeidzis savas komandas iestatīšanu. Jūsu komanda “{{teamName}}” ir jājaunina.",
"upgrade_banner_action": "Jauniniet šeit",
"team_upgraded_successfully": "Jūsu komanda tika veiksmīgi jaunināta!",
"org_upgrade_banner_description": "Paldies, ka izmēģinājāt mūsu Organization plānu. Mēs pamanījām, ka jūsu Organization “{{teamName}}” ir jājaunina.",
"org_upgraded_successfully": "Jūsu Organization tika veiksmīgi jaunināta!",
"use_link_to_reset_password": "Izmantojiet saiti zemāk, lai atiestatītu savu paroli",
"hey_there": "Sveiks,",
"forgot_your_password_calcom": "Aizmirsi savu paroli? - {{appName}}",
"delete_webhook_confirmation_message": "Vai tiešām vēlaties dzēst šo webhook? Ja notikums tiks pārlikts vai atcelts, jūs vairs nesaņemsiet {{appName}} reāllaika tikšanās datus norādītajā URL.",
"confirm_delete_webhook": "Jā, dzēst webhook",
"edit_webhook": "Rediģēt Webhook",
"delete_webhook": "Dzēst Webhook",
"webhook_status": "Webhook Statuss",
"webhook_enabled": "Webhook Iespējots",
"webhook_disabled": "Webhook Atspējots",
"webhook_response": "Webhook atbilde",
"webhook_test": "Webhooket tests",
"manage_your_webhook": "Pārvaldīt savu webhook",
"webhook_created_successfully": "Webhook veiksmīgi izveidots!",
"webhook_updated_successfully": "Webhook veiksmīgi atjaunots!",
"webhook_removed_successfully": "Webhook veiksmīgi dzēsts!",
"payload_template": "Payload šablons",
"dismiss": "Noraidīt",
"no_data_yet": "Datu vēl nav",
"ping_test": "Ping tests",
"add_to_homescreen": "Pievienojiet šo lietotni savam sākuma ekrānam, lai iegūtu ātrāku piekļuvi un uzlabotu pieredzi.",
"upcoming": "Gaidāms",
"recurring": "Atkārtots",
"past": "Pagātne",
"booker_event_not_found": "Mēs nevarējām atrast notikumu, kuru mēģināt rezervēt.",
"getting_started": "Darba sākšana",
"15min_meeting": "15 Min Tikšanās",
"30min_meeting": "30 Min Tikšanās",
"secret": "Noslēpums",
"leave_blank_to_remove_secret": "Atstājiet tukšu, lai noņemtu noslēpumu",
"webhook_secret_key_description": "Pārliecinieties, ka jūsu serveris saņem tikai paredzētos {{appName}} pieprasījumus, drošības apsvērumu dēļ",
"secret_meeting": "Slepenā Tikšanās",
"login_instead": "Tā vietā Autorizēties",
"already_have_an_account": "Vai jums jau ir konts?",
"create_account": "Izveidot Kontu",
"confirm_password": "Apstiprināt paroli",
"confirm_auth_change": "Šis mainīs veidu, kā jūs autorizējaties"
}
@@ -268,6 +268,7 @@
"set_availability": "Đặt lịch khả dụng của bạn",
"availability_settings": "Thiết lập tình trạng trống lịch",
"continue_without_calendar": "Tiếp tục mà không có lịch",
"continue_with": "Tiếp tục với {{appName}}",
"connect_your_calendar": "Kết nối lịch của bạn",
"connect_your_video_app": "Kết nối các ứng dụng video của bạn",
"connect_your_video_app_instructions": "Kết nối những ứng dụng video để dùng chúng ở những loại sự kiện của bạn.",
@@ -288,6 +289,8 @@
"when": "Khi nào",
"where": "Ở đâu",
"add_to_calendar": "Thêm vào lịch",
"add_to_calendar_description": "Chọn chỗ cần thêm sự kiện mới khi bạn được đặt lịch.",
"add_events_to": "Thêm các sự kiện vào",
"add_another_calendar": "Thêm lịch khác",
"other": "Khác",
"email_sign_in_subject": "Liên kết đăng nhập cho {{appName}}",
@@ -422,6 +425,7 @@
"booking_created": "Lịch hẹn đã được tạo",
"booking_rejected": "Lịch hẹn đã bị từ chối",
"booking_requested": "Lịch hẹn đã được yêu cầu",
"booking_payment_initiated": "Đã bắt đầu Thanh Toán Đặt Chỗ",
"meeting_ended": "Cuộc họp đã kết thúc",
"form_submitted": "Biểu mẫu đã được gửi",
"booking_paid": "Lịch hẹn đã thanh toán",
@@ -456,6 +460,7 @@
"no_event_types_have_been_setup": "Người dùng này chưa thiết lập bất kỳ loại sự kiện nào.",
"edit_logo": "Chỉnh sửa logo",
"upload_a_logo": "Tải lên logo",
"upload_logo": "Tải lên logo",
"remove_logo": "Xóa logo",
"enable": "Bật",
"code": "Mã",
@@ -568,6 +573,7 @@
"your_team_name": "Tên nhóm của bạn",
"team_updated_successfully": "Đã cập nhật nhóm thành công",
"your_team_updated_successfully": "Nhóm của bạn đã được cập nhật thành công.",
"your_org_updated_successfully": "Tổ Chức của bạn đã được cập nhật thành công.",
"about": "Về",
"team_description": "Một vài câu về nhóm của bạn. Thông tin này sẽ xuất hiện trên trang URL của nhóm của bạn.",
"org_description": "Một vài câu về tổ chức của bạn. Thông tin này sẽ xuất hiện trên trang URL của tổ chức của bạn.",
@@ -599,6 +605,7 @@
"hide_book_a_team_member": "Giấu nút Đặt lịch hẹn thành viên nhóm",
"hide_book_a_team_member_description": "Giấu nút Đặt lịch hẹn thành viên nhóm trên trang công khai của bạn.",
"danger_zone": "Khu vực nguy hiểm",
"account_deletion_cannot_be_undone": "Cẩn thận. Tài khoản đã xóa không thể phục hồi.",
"back": "Trở lại",
"cancel": "Hủy bỏ",
"cancel_all_remaining": "Hủy tất cả những gì còn lại",
@@ -688,6 +695,7 @@
"people": "Mọi người",
"your_email": "Email của bạn",
"change_avatar": "Thay đổi hình đại diện",
"upload_avatar": "Tải lên Avatar",
"language": "Ngôn ngữ",
"timezone": "Múi giờ",
"first_day_of_week": "Ngày đầu tuần",
@@ -778,6 +786,7 @@
"disable_guests": "Vô hiệu hóa Khách",
"disable_guests_description": "Vô hiệu hóa việc thêm khách bổ sung trong khi đặt lịch.",
"private_link": "Tạo liên kết riêng tư",
"enable_private_url": "Bật URL Riêng Tư",
"private_link_label": "Liên kết riêng",
"private_link_hint": "Liên kết riêng của bạn sẽ tạo lại sau mỗi lần sử dụng",
"copy_private_link": "Sao chép liên kết riêng",
@@ -1276,6 +1285,7 @@
"personal_cal_url": "URL {{appName}} cá nhân của tôi",
"bio_hint": "Nói mấy lời về bản thân. Thông tin này sẽ xuất hiện trên trang url cá nhân của bạn.",
"user_has_no_bio": "Người dùng này chưa thêm vào tiểu sử.",
"bio": "Bio",
"delete_account_modal_title": "Xoá tài khoản",
"confirm_delete_account_modal": "Bạn có chắc chắn muốn xoá tài khoản {{appName}}?",
"delete_my_account": "Xoá tài khoản của tôi",
@@ -1530,6 +1540,7 @@
"problem_registering_domain": "Có vấn đề với việc đăng ký tên miền phụ, vui lòng thử lại hoặc liên lạc với quản trị viên",
"team_publish": "Đăng nhóm",
"number_text_notifications": "Số điện thoại (thông báo tin nhắn văn bản)",
"number_sms_notifications": "Số điện thoại (thông báo SMS)",
"attendee_email_variable": "Email người tham dự",
"attendee_email_info": "Email người tham gia lịch hẹn",
"kbar_search_placeholder": "Nhập một lệnh hoặc tìm kiếm...",
@@ -1639,6 +1650,7 @@
"minimum_round_robin_hosts_count": "Số chủ tọa cần thiết để tham dự",
"hosts": "Chủ tọa",
"upgrade_to_enable_feature": "Bạn cần tạo một nhóm để bật tính năng này. Nhấp để tạo một nhóm.",
"orgs_upgrade_to_enable_feature": "Bạn cần nâng cấp gói Enterprise để bật tính năng này.",
"new_attendee": "Người tham dự mới",
"awaiting_approval": "Đang chờ duyệt",
"requires_google_calendar": "Ứng dụng này yêu cầu bạn liên kết với Google Calendar",
@@ -1743,6 +1755,7 @@
"show_on_booking_page": "Hiện trên trang lịch hẹn",
"get_started_zapier_templates": "Bắt đầu với các mẫu Zapier",
"team_is_unpublished": "{{team}} chưa được công bố",
"org_is_unpublished_description": "Liên kết tổ chức này hiện không khả dụng. Hãy liên lạc với chủ tổ chức hoặc yêu cầu họ công bố liên kết đó.",
"team_is_unpublished_description": "Liên kết {{entity}} này hiện không khả dụng. Hãy liên lạc với chủ {{entity}} hoặc yêu cầu họ công bố liên kết đó.",
"team_member": "Thành viên nhóm",
"a_routing_form": "Một biểu mẫu định hướng",
@@ -1877,6 +1890,7 @@
"edit_invite_link": "Sửa cài đặt liên kết",
"invite_link_copied": "Liên kết lời mời đã được sao chép",
"invite_link_deleted": "Liên kết lời mời đã được xoá",
"api_key_deleted": "Đã xóa API Key",
"invite_link_updated": "Đã lưu cài đặt cho liên kết lời mời",
"link_expires_after": "Các liên kết được đặt hết hạn sau...",
"one_day": "1 ngày",
@@ -2,7 +2,7 @@ import appStoreMock from "../../../../../tests/libs/__mocks__/app-store";
import i18nMock from "../../../../../tests/libs/__mocks__/libServerI18n";
import prismock from "../../../../../tests/libs/__mocks__/prisma";
import type { BookingReference, Attendee } from "@prisma/client";
import type { BookingReference, Attendee, Booking } from "@prisma/client";
import type { Prisma } from "@prisma/client";
import type { WebhookTriggerEvents } from "@prisma/client";
import type Stripe from "stripe";
@@ -23,8 +23,8 @@ import type { EventBusyDate } from "@calcom/types/Calendar";
import { getMockPaymentService } from "./MockPaymentService";
logger.setSettings({ minLevel: "silly" });
const log = logger.getChildLogger({ prefix: ["[bookingScenario]"] });
logger.settings.minLevel = 0;
const log = logger.getSubLogger({ prefix: ["[bookingScenario]"] });
type InputWebhook = {
appId: string | null;
@@ -102,7 +102,7 @@ export type InputEventType = {
schedule?: InputUser["schedules"][number];
} & Partial<Omit<Prisma.EventTypeCreateInput, "users" | "schedule">>;
type InputBooking = {
type WhiteListedBookingProps = {
id?: number;
uid?: string;
userId?: number;
@@ -118,6 +118,8 @@ type InputBooking = {
})[];
};
type InputBooking = Partial<Omit<Booking, keyof WhiteListedBookingProps>> & WhiteListedBookingProps;
export const Timezones = {
"+5:30": "Asia/Kolkata",
"+6:00": "Asia/Dhaka",
@@ -1203,3 +1205,35 @@ export const enum BookingLocations {
CalVideo = "integrations:daily",
ZoomVideo = "integrations:zoom",
}
const getMockAppStatus = ({
slug,
failures,
success,
}: {
slug: string;
failures: number;
success: number;
}) => {
const foundEntry = Object.entries(appStoreMetadata).find(([, app]) => {
return app.slug === slug;
});
if (!foundEntry) {
throw new Error("App not found for the slug");
}
const foundApp = foundEntry[1];
return {
appName: foundApp.slug,
type: foundApp.type,
failures,
success,
errors: [],
};
};
export const getMockFailingAppStatus = ({ slug }: { slug: string }) => {
return getMockAppStatus({ slug, failures: 1, success: 0 });
};
export const getMockPassingAppStatus = ({ slug }: { slug: string }) => {
return getMockAppStatus({ slug, failures: 0, success: 1 });
};
+103 -47
View File
@@ -1,6 +1,7 @@
import prismaMock from "../../../../../tests/libs/__mocks__/prisma";
import type { WebhookTriggerEvents, Booking, BookingReference, DestinationCalendar } from "@prisma/client";
import { parse } from "node-html-parser";
import ical from "node-ical";
import { expect } from "vitest";
import "vitest-fetch-mock";
@@ -8,6 +9,7 @@ import "vitest-fetch-mock";
import logger from "@calcom/lib/logger";
import { safeStringify } from "@calcom/lib/safeStringify";
import { BookingStatus } from "@calcom/prisma/enums";
import type { AppsStatus } from "@calcom/types/Calendar";
import type { CalendarEvent } from "@calcom/types/Calendar";
import type { Fixtures } from "@calcom/web/test/fixtures/fixtures";
@@ -19,14 +21,14 @@ declare global {
interface Matchers<R> {
toHaveEmail(
expectedEmail: {
//TODO: Support email HTML parsing to target specific elements
htmlToContain?: string;
title?: string;
to: string;
noIcs?: true;
ics?: {
filename: string;
iCalUID: string;
};
appsStatus?: AppsStatus[];
},
to: string
): R;
@@ -38,21 +40,23 @@ expect.extend({
toHaveEmail(
emails: Fixtures["emails"],
expectedEmail: {
//TODO: Support email HTML parsing to target specific elements
htmlToContain?: string;
title?: string;
to: string;
ics: {
filename: string;
iCalUID: string;
};
noIcs: true;
appsStatus: AppsStatus[];
},
to: string
) {
const { isNot } = this;
const testEmail = emails.get().find((email) => email.to.includes(to));
const emailsToLog = emails
.get()
.map((email) => ({ to: email.to, html: email.html, ics: email.icalEvent }));
if (!testEmail) {
logger.silly("All Emails", JSON.stringify({ numEmails: emailsToLog.length, emailsToLog }));
return {
@@ -63,48 +67,93 @@ expect.extend({
const ics = testEmail.icalEvent;
const icsObject = ics?.content ? ical.sync.parseICS(ics?.content) : null;
let isHtmlContained = true;
let isToAddressExpected = true;
const isIcsFilenameExpected = expectedEmail.ics ? ics?.filename === expectedEmail.ics.filename : true;
const isIcsUIDExpected = expectedEmail.ics
? !!(icsObject ? icsObject[expectedEmail.ics.iCalUID] : null)
: true;
const emailDom = parse(testEmail.html);
if (expectedEmail.htmlToContain) {
isHtmlContained = testEmail.html.includes(expectedEmail.htmlToContain);
const actualEmailContent = {
title: emailDom.querySelector("title")?.innerText,
subject: emailDom.querySelector("subject")?.innerText,
};
const expectedEmailContent = {
title: expectedEmail.title,
};
const isEmailContentMatched = this.equals(
actualEmailContent,
expect.objectContaining(expectedEmailContent)
);
if (!isEmailContentMatched) {
logger.silly("All Emails", JSON.stringify({ numEmails: emailsToLog.length, emailsToLog }));
return {
pass: false,
message: () => `Email content ${isNot ? "is" : "is not"} matching`,
actual: actualEmailContent,
expected: expectedEmailContent,
};
}
isToAddressExpected = expectedEmail.to === testEmail.to;
if (!isHtmlContained || !isToAddressExpected) {
if (!isToAddressExpected) {
logger.silly("All Emails", JSON.stringify({ numEmails: emailsToLog.length, emailsToLog }));
return {
pass: false,
message: () => `To address ${isNot ? "is" : "is not"} matching`,
actual: testEmail.to,
expected: expectedEmail.to,
};
}
if (!expectedEmail.noIcs && !isIcsFilenameExpected) {
return {
pass: false,
actual: ics?.filename,
expected: expectedEmail.ics.filename,
message: () => `ICS Filename ${isNot ? "is" : "is not"} matching`,
};
}
if (!expectedEmail.noIcs && !isIcsUIDExpected) {
return {
pass: false,
actual: JSON.stringify(icsObject),
expected: expectedEmail.ics.iCalUID,
message: () => `Expected ICS UID ${isNot ? "is" : "isn't"} present in actual`,
};
}
if (expectedEmail.appsStatus) {
const actualAppsStatus = emailDom.querySelectorAll('[data-testid="appsStatus"] li').map((li) => {
return li.innerText.trim();
});
const expectedAppStatus = expectedEmail.appsStatus.map((appStatus) => {
if (appStatus.success && !appStatus.failures) {
return `${appStatus.appName} ✅`;
}
return `${appStatus.appName} ❌`;
});
const isAppsStatusCorrect = this.equals(actualAppsStatus, expectedAppStatus);
if (!isAppsStatusCorrect) {
return {
pass: false,
actual: actualAppsStatus,
expected: expectedAppStatus,
message: () => `AppsStatus ${isNot ? "is" : "isn't"} matching`,
};
}
}
return {
pass:
isHtmlContained &&
isToAddressExpected &&
(expectedEmail.noIcs ? true : isIcsFilenameExpected && isIcsUIDExpected),
message: () => {
if (!isHtmlContained) {
return `Email HTML is not as expected. Expected:"${expectedEmail.htmlToContain}" isn't contained in "${testEmail.html}"`;
}
if (!isToAddressExpected) {
return `Email To address is not as expected. Expected:${expectedEmail.to} isn't equal to ${testEmail.to}`;
}
if (!isIcsFilenameExpected) {
return `ICS Filename is not as expected. Expected:${expectedEmail.ics.filename} isn't equal to ${ics?.filename}`;
}
if (!isIcsUIDExpected) {
return `ICS UID is not as expected. Expected:${
expectedEmail.ics.iCalUID
} isn't present in ${JSON.stringify(icsObject)}`;
}
throw new Error("Unknown error");
},
pass: true,
message: () => `Email ${isNot ? "is" : "isn't"} correct`,
};
},
});
@@ -139,9 +188,10 @@ export function expectWebhookToHaveBeenCalledWith(
const parsedBody = JSON.parse((body as string) || "{}");
expect(parsedBody.triggerEvent).toBe(data.triggerEvent);
if (parsedBody.payload.metadata?.videoCallUrl) {
parsedBody.payload.metadata.videoCallUrl = parsedBody.payload.metadata.videoCallUrl
? parsedBody.payload.metadata.videoCallUrl.replace(/\/video\/[a-zA-Z0-9]{22}/, "/video/DYNAMIC_UID")
? parsedBody.payload.metadata.videoCallUrl
: parsedBody.payload.metadata.videoCallUrl;
}
if (data.payload) {
@@ -195,7 +245,7 @@ export function expectSuccessfulBookingCreationEmails({
}) {
expect(emails).toHaveEmail(
{
htmlToContain: "<title>confirmed_event_type_subject</title>",
title: "confirmed_event_type_subject",
to: `${organizer.email}`,
ics: {
filename: "event.ics",
@@ -207,7 +257,7 @@ export function expectSuccessfulBookingCreationEmails({
expect(emails).toHaveEmail(
{
htmlToContain: "<title>confirmed_event_type_subject</title>",
title: "confirmed_event_type_subject",
to: `${booker.name} <${booker.email}>`,
ics: {
filename: "event.ics",
@@ -221,7 +271,7 @@ export function expectSuccessfulBookingCreationEmails({
otherTeamMembers.forEach((otherTeamMember) => {
expect(emails).toHaveEmail(
{
htmlToContain: "<title>confirmed_event_type_subject</title>",
title: "confirmed_event_type_subject",
// Don't know why but organizer and team members of the eventType don'thave their name here like Booker
to: `${otherTeamMember.email}`,
ics: {
@@ -238,7 +288,7 @@ export function expectSuccessfulBookingCreationEmails({
guests.forEach((guest) => {
expect(emails).toHaveEmail(
{
htmlToContain: "<title>confirmed_event_type_subject</title>",
title: "confirmed_event_type_subject",
to: `${guest.email}`,
ics: {
filename: "event.ics",
@@ -261,7 +311,7 @@ export function expectBrokenIntegrationEmails({
// Broken Integration email is only sent to the Organizer
expect(emails).toHaveEmail(
{
htmlToContain: "<title>broken_integration</title>",
title: "broken_integration",
to: `${organizer.email}`,
// No ics goes in case of broken integration email it seems
// ics: {
@@ -274,7 +324,7 @@ export function expectBrokenIntegrationEmails({
// expect(emails).toHaveEmail(
// {
// htmlToContain: "<title>confirmed_event_type_subject</title>",
// title: "confirmed_event_type_subject",
// to: `${booker.name} <${booker.email}>`,
// },
// `${booker.name} <${booker.email}>`
@@ -294,7 +344,7 @@ export function expectCalendarEventCreationFailureEmails({
}) {
expect(emails).toHaveEmail(
{
htmlToContain: "<title>broken_integration</title>",
title: "broken_integration",
to: `${organizer.email}`,
ics: {
filename: "event.ics",
@@ -306,7 +356,7 @@ export function expectCalendarEventCreationFailureEmails({
expect(emails).toHaveEmail(
{
htmlToContain: "<title>calendar_event_creation_failure_subject</title>",
title: "calendar_event_creation_failure_subject",
to: `${booker.name} <${booker.email}>`,
ics: {
filename: "event.ics",
@@ -322,27 +372,30 @@ export function expectSuccessfulBookingRescheduledEmails({
organizer,
booker,
iCalUID,
appsStatus,
}: {
emails: Fixtures["emails"];
organizer: { email: string; name: string };
booker: { email: string; name: string };
iCalUID: string;
appsStatus: AppsStatus[];
}) {
expect(emails).toHaveEmail(
{
htmlToContain: "<title>event_type_has_been_rescheduled_on_time_date</title>",
title: "event_type_has_been_rescheduled_on_time_date",
to: `${organizer.email}`,
ics: {
filename: "event.ics",
iCalUID,
},
appsStatus,
},
`${organizer.email}`
);
expect(emails).toHaveEmail(
{
htmlToContain: "<title>event_type_has_been_rescheduled_on_time_date</title>",
title: "event_type_has_been_rescheduled_on_time_date",
to: `${booker.name} <${booker.email}>`,
ics: {
filename: "event.ics",
@@ -362,7 +415,7 @@ export function expectAwaitingPaymentEmails({
}) {
expect(emails).toHaveEmail(
{
htmlToContain: "<title>awaiting_payment_subject</title>",
title: "awaiting_payment_subject",
to: `${booker.name} <${booker.email}>`,
noIcs: true,
},
@@ -381,7 +434,7 @@ export function expectBookingRequestedEmails({
}) {
expect(emails).toHaveEmail(
{
htmlToContain: "<title>event_awaiting_approval_subject</title>",
title: "event_awaiting_approval_subject",
to: `${organizer.email}`,
noIcs: true,
},
@@ -390,7 +443,7 @@ export function expectBookingRequestedEmails({
expect(emails).toHaveEmail(
{
htmlToContain: "<title>booking_submitted_subject</title>",
title: "booking_submitted_subject",
to: `${booker.email}`,
noIcs: true,
},
@@ -509,6 +562,7 @@ export function expectBookingRescheduledWebhookToHaveBeenFired({
location,
subscriberUrl,
videoCallUrl,
payload,
}: {
organizer: { email: string; name: string };
booker: { email: string; name: string };
@@ -516,10 +570,12 @@ export function expectBookingRescheduledWebhookToHaveBeenFired({
location: string;
paidEvent?: boolean;
videoCallUrl?: string;
payload?: Record<string, unknown>;
}) {
expectWebhookToHaveBeenCalledWith(subscriberUrl, {
triggerEvent: "BOOKING_RESCHEDULED",
payload: {
...payload,
metadata: {
...(videoCallUrl ? { videoCallUrl } : null),
},
+1 -1
View File
@@ -10,7 +10,7 @@ interface CalendarApp {
};
}
const log = logger.getChildLogger({ prefix: ["CalendarManager"] });
const log = logger.getSubLogger({ prefix: ["CalendarManager"] });
/**
* @see [Using type predicates](https://www.typescriptlang.org/docs/handbook/2/narrowing.html#using-type-predicates)
@@ -16,6 +16,8 @@ export type ParseRefreshTokenResponse<S extends z.ZodTypeAny> =
const parseRefreshTokenResponse = (response: any, schema: z.ZodTypeAny) => {
let refreshTokenResponse;
const credentialSyncingEnabled =
APP_CREDENTIAL_SHARING_ENABLED && process.env.CALCOM_CREDENTIAL_SYNC_ENDPOINT;
if (APP_CREDENTIAL_SHARING_ENABLED && process.env.CALCOM_CREDENTIAL_SYNC_ENDPOINT) {
refreshTokenResponse = minimumTokenResponseSchema.safeParse(response);
} else {
@@ -26,7 +28,7 @@ const parseRefreshTokenResponse = (response: any, schema: z.ZodTypeAny) => {
throw new Error("Invalid refreshed tokens were returned");
}
if (!refreshTokenResponse.data.refresh_token) {
if (!refreshTokenResponse.data.refresh_token && credentialSyncingEnabled) {
refreshTokenResponse.data.refresh_token = "refresh_token";
}
@@ -11,6 +11,7 @@ export const InstallAppButtonMap = {
vital: dynamic(() => import("./vital/components/InstallAppButton")),
};
export const AppSettingsComponentsMap = {
"cal-ai": dynamic(() => import("./cal-ai/components/AppSettingsInterface")),
"general-app-settings": dynamic(() =>
import("./templates/general-app-settings/components/AppSettingsInterface")
),
@@ -63,7 +63,7 @@ export default class BasecampCalendarService implements Calendar {
this.userAgent = user_agent as string;
});
this.auth = this.basecampAuth(credential).then((c) => c);
this.log = logger.getChildLogger({ prefix: [`[[lib] ${this.integrationName}`] });
this.log = logger.getSubLogger({ prefix: [`[[lib] ${this.integrationName}`] });
}
private basecampAuth = async (credential: CredentialPayload) => {
@@ -0,0 +1,73 @@
import { useSession } from "next-auth/react";
export function MailLink({ subject, body }: { subject: string; body?: string }) {
const { data } = useSession();
return (
<a href={`mailto:${data?.user?.username}@cal.ai?subject=${subject}&body=${body ? body : ""}`}>
{subject ? subject : body}
</a>
);
}
export default function AppSettings() {
return (
<>
<div className="prose-sm prose prose-a:text-default prose-headings:text-emphasis prose-code:text-default prose-strong:text-default text-default">
<h2>Example questions:</h2>
<ul>
<li>
<MailLink subject="Book a meeting with @cal-user this week at a good time" />
</li>
<li>
<MailLink subject="Find a time to meet with not-a-cal-user@gmail.com this week" />
</li>
<li>
<MailLink subject="Move my meeting with [Someone's Name] to next week" />
</li>
<li>
<MailLink subject="What does my week look like?" />
</li>
<li>
<MailLink subject="When is my next meeting?" />
</li>
<li>
<MailLink subject="What is my least busy day next week?" />
</li>
<li>
<MailLink subject="Cancel the meeting with [Someone's Name]" />
</li>
<li>
<MailLink subject="Reschedule the meeting with not-a-cal-user@gmail.com" />
</li>
<li>
<MailLink subject="Is @cal-user busy on Friday at noon?" />
</li>
<li>
<MailLink subject="Move my meeting at 2pm tomorrow to next week on Friday at 5pm" />
</li>
</ul>
<h3>Or, any of that in a thread:</h3>
<ul>
<li>
What meetings do I have this week?
<ul>
<li>Meeting 1, Meeting 2</li>
</ul>
</li>
<li>Cancel that first meeting</li>
</ul>
<h3>Or, doing multiple things:</h3>
<ul>
<li>
<MailLink
subject=""
body="Cancel all my meetings tomorrow then book a quick chat with @joe at noon, a long meeting with
@jane at 1 (both tomorrow). Also see if Toby is down to grab dinner this week (toby@gmail.com).
Finally send me my updated agenda of everything I have coming up in the next couple days."
/>
</li>
</ul>
</div>
</>
);
}
@@ -58,7 +58,7 @@ export default class CloseComCalendarService implements Calendar {
constructor(credential: CredentialPayload) {
this.integrationName = "closecom_other_calendar";
this.log = logger.getChildLogger({ prefix: [`[[lib] ${this.integrationName}`] });
this.log = logger.getSubLogger({ prefix: [`[[lib] ${this.integrationName}`] });
const parsedCredentialKey = apiKeySchema.safeParse(credential.key);
+1 -1
View File
@@ -2,7 +2,7 @@ import { vi } from "vitest";
vi.mock("@calcom/lib/logger", () => ({
default: {
getChildLogger: () => ({
getSubLogger: () => ({
debug: vi.fn(),
error: vi.fn(),
log: vi.fn(),
@@ -43,7 +43,7 @@ export default class ExchangeCalendarService implements Calendar {
constructor(credential: CredentialPayload) {
this.integrationName = "exchange2013_calendar";
this.log = logger.getChildLogger({ prefix: [`[[lib] ${this.integrationName}`] });
this.log = logger.getSubLogger({ prefix: [`[[lib] ${this.integrationName}`] });
const decryptedCredential = JSON.parse(
symmetricDecrypt(credential.key?.toString() || "", process.env.CALENDSO_ENCRYPTION_KEY || "")
@@ -44,7 +44,7 @@ export default class ExchangeCalendarService implements Calendar {
// this.integrationName = CALENDAR_INTEGRATIONS_TYPES.exchange;
this.integrationName = "exchange2016_calendar";
this.log = logger.getChildLogger({ prefix: [`[[lib] ${this.integrationName}`] });
this.log = logger.getSubLogger({ prefix: [`[[lib] ${this.integrationName}`] });
const decryptedCredential = JSON.parse(
symmetricDecrypt(credential.key?.toString() || "", process.env.CALENDSO_ENCRYPTION_KEY || "")
@@ -48,7 +48,7 @@ export default class ExchangeCalendarService implements Calendar {
constructor(credential: CredentialPayload) {
this.integrationName = "exchange_calendar";
this.log = logger.getChildLogger({ prefix: [`[[lib] ${this.integrationName}`] });
this.log = logger.getSubLogger({ prefix: [`[[lib] ${this.integrationName}`] });
this.payload = JSON.parse(
symmetricDecrypt(credential.key?.toString() || "", process.env.CALENDSO_ENCRYPTION_KEY || "")
);
@@ -16,10 +16,11 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
const { code } = req.query;
const state = decodeOAuthState(req);
if (code && typeof code !== "string") {
if (typeof code !== "string") {
res.status(400).json({ message: "`code` must be a string" });
return;
}
if (!req.session?.user?.id) {
return res.status(401).json({ message: "You must be logged in to do this" });
}
@@ -39,16 +40,16 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
if (code) {
const token = await oAuth2Client.getToken(code);
key = token.res?.data;
}
await prisma.credential.create({
data: {
type: "google_calendar",
key,
userId: req.session.user.id,
appId: "google-calendar",
},
});
await prisma.credential.create({
data: {
type: "google_calendar",
key,
userId: req.session.user.id,
appId: "google-calendar",
},
});
}
if (state?.installGoogleVideo) {
const existingGoogleMeetCredential = await prisma.credential.findFirst({
@@ -68,7 +68,7 @@ export default class GoogleCalendarService implements Calendar {
this.integrationName = "google_calendar";
this.credential = credential;
this.auth = this.googleAuth(credential);
this.log = logger.getChildLogger({ prefix: [`[[lib] ${this.integrationName}`] });
this.log = logger.getSubLogger({ prefix: [`[[lib] ${this.integrationName}`] });
this.credential = credential;
}
@@ -45,7 +45,7 @@ export default class HubspotCalendarService implements Calendar {
this.auth = this.hubspotAuth(credential).then((r) => r);
this.log = logger.getChildLogger({ prefix: [`[[lib] ${this.integrationName}`] });
this.log = logger.getSubLogger({ prefix: [`[[lib] ${this.integrationName}`] });
}
private hubspotContactCreate = async (attendees: Person[]) => {
+1 -1
View File
@@ -9,7 +9,7 @@ import getAppKeysFromSlug from "../../_utils/getAppKeysFromSlug";
import getInstalledAppPath from "../../_utils/getInstalledAppPath";
import createOAuthAppCredential from "../../_utils/oauth/createOAuthAppCredential";
const log = logger.getChildLogger({ prefix: [`[[intercom/api/callback]`] });
const log = logger.getSubLogger({ prefix: [`[[intercom/api/callback]`] });
let client_id = "";
let client_secret = "";
@@ -12,7 +12,7 @@ import { LARK_HOST } from "../common";
import { getAppAccessToken } from "../lib/AppAccessToken";
import type { LarkAuthCredentials } from "../types/LarkCalendar";
const log = logger.getChildLogger({ prefix: [`[[lark/api/callback]`] });
const log = logger.getSubLogger({ prefix: [`[[lark/api/callback]`] });
const callbackQuerySchema = z.object({
code: z.string().min(1),
@@ -8,7 +8,7 @@ import prisma from "@calcom/prisma";
import { getAppKeys } from "../common";
import { sendPostMsg } from "../lib/BotService";
const log = logger.getChildLogger({ prefix: [`[lark/api/events]`] });
const log = logger.getSubLogger({ prefix: [`[lark/api/events]`] });
const larkKeysSchema = z.object({
open_verification_token: z.string(),
@@ -5,7 +5,7 @@ import prisma from "@calcom/prisma";
import { LARK_HOST, getAppKeys, isExpired } from "../common";
const log = logger.getChildLogger({ prefix: [`[[LarkAppCredential]`] });
const log = logger.getSubLogger({ prefix: [`[[LarkAppCredential]`] });
function makePoolingPromise<T>(
promiseCreator: () => Promise<T | null>,
@@ -3,7 +3,7 @@ import logger from "@calcom/lib/logger";
import { LARK_HOST } from "../common";
import { getAppAccessToken } from "./AppAccessToken";
const log = logger.getChildLogger({ prefix: [`[[LarkTenantCredential]`] });
const log = logger.getSubLogger({ prefix: [`[[LarkTenantCredential]`] });
const msg = {
en_us: {
@@ -40,7 +40,7 @@ export default class LarkCalendarService implements Calendar {
constructor(credential: CredentialPayload) {
this.integrationName = "lark_calendar";
this.auth = this.larkAuth(credential);
this.log = logger.getChildLogger({ prefix: [`[[lib] ${this.integrationName}`] });
this.log = logger.getSubLogger({ prefix: [`[[lib] ${this.integrationName}`] });
this.credential = credential;
}
@@ -70,7 +70,7 @@ export default class Office365CalendarService implements Calendar {
this.integrationName = "office365_calendar";
this.auth = this.o365Auth(credential);
this.credential = credential;
this.log = logger.getChildLogger({ prefix: [`[[lib] ${this.integrationName}`] });
this.log = logger.getSubLogger({ prefix: [`[[lib] ${this.integrationName}`] });
}
async createEvent(event: CalendarEvent, credentialId: number): Promise<NewCalendarEventType> {
@@ -15,7 +15,7 @@ import { processRoute } from "../../lib/processRoute";
import transformResponse from "../../lib/transformResponse";
import type { Response } from "../../types/types";
const log = logger.getChildLogger({ prefix: ["[routing-forms]", "[router]"] });
const log = logger.getSubLogger({ prefix: ["[routing-forms]", "[router]"] });
export default function Router({ form, message }: inferSSRProps<typeof getServerSideProps>) {
return (
<>
@@ -16,7 +16,7 @@ interface FormsHandlerOptions {
};
input: TFormSchema;
}
const log = logger.getChildLogger({ prefix: ["[formsHandler]"] });
const log = logger.getSubLogger({ prefix: ["[formsHandler]"] });
export const formsHandler = async ({ ctx, input }: FormsHandlerOptions) => {
const { prisma, user } = ctx;
@@ -57,7 +57,7 @@ export default class SalesforceCalendarService implements Calendar {
constructor(credential: CredentialPayload) {
this.integrationName = "salesforce_other_calendar";
this.conn = this.getClient(credential).then((c) => c);
this.log = logger.getChildLogger({ prefix: [`[[lib] ${this.integrationName}`] });
this.log = logger.getSubLogger({ prefix: [`[[lib] ${this.integrationName}`] });
}
private getClient = async (credential: CredentialPayload) => {
@@ -33,7 +33,7 @@ export default class CloseComCalendarService implements Calendar {
constructor(credential: CredentialPayload) {
this.integrationName = "sendgrid_other_calendar";
this.log = logger.getChildLogger({ prefix: [`[[lib] ${this.integrationName}`] });
this.log = logger.getSubLogger({ prefix: [`[[lib] ${this.integrationName}`] });
const parsedCredentialKey = apiKeySchema.safeParse(credential.key);
@@ -42,7 +42,7 @@ export default class BiginCalendarService implements Calendar {
constructor(credential: CredentialPayload) {
this.auth = this.biginAuth(credential);
this.log = logger.getChildLogger({ prefix: [`[[lib] ${this.integrationName}`] });
this.log = logger.getSubLogger({ prefix: [`[[lib] ${this.integrationName}`] });
}
/***
@@ -14,7 +14,7 @@ import config from "../config.json";
import type { ZohoAuthCredentials } from "../types/ZohoCalendar";
import { appKeysSchema as zohoKeysSchema } from "../zod";
const log = logger.getChildLogger({ prefix: [`[[zohocalendar/api/callback]`] });
const log = logger.getSubLogger({ prefix: [`[[zohocalendar/api/callback]`] });
const OAUTH_BASE_URL = "https://accounts.zoho.com/oauth/v2";
@@ -30,7 +30,7 @@ export default class ZohoCalendarService implements Calendar {
constructor(credential: CredentialPayload) {
this.integrationName = "zoho_calendar";
this.auth = this.zohoAuth(credential);
this.log = logger.getChildLogger({
this.log = logger.getSubLogger({
prefix: [`[[lib] ${this.integrationName}`],
});
}
@@ -61,7 +61,7 @@ export default class ZohoCrmCalendarService implements Calendar {
constructor(credential: CredentialPayload) {
this.integrationName = "zohocrm_other_calendar";
this.auth = this.zohoCrmAuth(credential).then((r) => r);
this.log = logger.getChildLogger({ prefix: [`[[lib] ${this.integrationName}`] });
this.log = logger.getSubLogger({ prefix: [`[[lib] ${this.integrationName}`] });
}
private createContacts = async (attendees: Person[]) => {
+4
View File
@@ -33,6 +33,10 @@ const config = {
"zh-TW",
],
},
fallbackLng: {
default: ["en"],
zh: ["zh-CN"],
},
reloadOnPrerender: process.env.NODE_ENV !== "production",
};
+1 -1
View File
@@ -21,7 +21,7 @@ import type { EventResult } from "@calcom/types/EventManager";
import getCalendarsEvents from "./getCalendarsEvents";
const log = logger.getChildLogger({ prefix: ["CalendarManager"] });
const log = logger.getSubLogger({ prefix: ["CalendarManager"] });
export const getCalendarCredentials = (credentials: Array<CredentialPayload>) => {
const calendarCredentials = getApps(credentials, true)
+1 -1
View File
@@ -33,7 +33,7 @@ import type {
import { createEvent, updateEvent, deleteEvent } from "./CalendarManager";
import { createMeeting, updateMeeting, deleteMeeting } from "./videoClient";
const log = logger.getChildLogger({ prefix: ["EventManager"] });
const log = logger.getSubLogger({ prefix: ["EventManager"] });
export const isDedicatedIntegration = (location: string): boolean => {
return location !== MeetLocationType && location.includes("integrations:");
};
+1 -1
View File
@@ -8,7 +8,7 @@ import { performance } from "@calcom/lib/server/perfObserver";
import type { EventBusyDate } from "@calcom/types/Calendar";
import type { CredentialPayload } from "@calcom/types/Credential";
const log = logger.getChildLogger({ prefix: ["getCalendarsEvents"] });
const log = logger.getSubLogger({ prefix: ["getCalendarsEvents"] });
const getCalendarsEvents = async (
withCredentials: CredentialPayload[],
dateFrom: string,
+1 -1
View File
@@ -26,7 +26,7 @@ import type {
import { getBusyTimes, getBusyTimesForLimitChecks } from "./getBusyTimes";
const log = logger.getChildLogger({ prefix: ["getUserAvailability"] });
const log = logger.getSubLogger({ prefix: ["getUserAvailability"] });
const availabilitySchema = z
.object({
dateFrom: stringToDayjs,
+1 -1
View File
@@ -15,7 +15,7 @@ import type { CredentialPayload } from "@calcom/types/Credential";
import type { EventResult, PartialReference } from "@calcom/types/EventManager";
import type { VideoApiAdapter, VideoApiAdapterFactory, VideoCallData } from "@calcom/types/VideoApiAdapter";
const log = logger.getChildLogger({ prefix: ["[lib] videoClient"] });
const log = logger.getSubLogger({ prefix: ["[lib] videoClient"] });
const translator = short();
@@ -1,4 +1,4 @@
import { TFunction } from "next-i18next";
import type { TFunction } from "next-i18next";
import type { CalendarEvent } from "@calcom/types/Calendar";
@@ -11,7 +11,7 @@ export const AppsStatus = (props: { calEvent: CalendarEvent; t: TFunction }) =>
<Info
label={t("apps_status")}
description={
<ul style={{ lineHeight: "24px" }}>
<ul style={{ lineHeight: "24px" }} data-testid="appsStatus">
{props.calEvent.appsStatus.map((status) => (
<li key={status.type} style={{ fontWeight: 400 }}>
{status.appName}{" "}
+12 -1
View File
@@ -29,5 +29,16 @@ export const getLocale = async (req: GetTokenParams["req"]): Promise<string> =>
const languages = acceptLanguage ? parse(acceptLanguage) : [];
return languages[0]?.code || "en";
const code: string = languages[0]?.code ?? "";
const region: string = languages[0]?.region ?? "";
// the code should consist of 2 or 3 lowercase letters
// the regex underneath is more permissive
const testedCode = /^[a-zA-Z]+$/.test(code) ? code : "en";
// the code should consist of either 2 uppercase letters or 3 digits
// the regex underneath is more permissive
const testedRegion = /^[a-zA-Z0-9]+$/.test(region) ? region : "";
return `${testedCode}${testedRegion !== "" ? "-" : ""}${testedRegion}`;
};
+1 -1
View File
@@ -9,7 +9,7 @@ import logger from "@calcom/lib/logger";
import { getTranslation } from "@calcom/lib/server/i18n";
import { prisma } from "@calcom/prisma";
const log = logger.getChildLogger({ prefix: [`[[Auth] `] });
const log = logger.getSubLogger({ prefix: [`[[Auth] `] });
interface VerifyEmailType {
username?: string;
@@ -19,7 +19,8 @@ export const LargeCalendar = ({ extraDays }: { extraDays: number }) => {
const schedule = useScheduleForEvent({
prefetchNextMonth: !!extraDays && dayjs(date).month() !== dayjs(date).add(extraDays, "day").month(),
});
const displayOverlay = getQueryParam("overlayCalendar") === "true";
const displayOverlay =
getQueryParam("overlayCalendar") === "true" || localStorage.getItem("overlayCalendarSwitchDefault");
const event = useEvent();
const eventDuration = selectedEventDuration || event?.data?.length || 30;
@@ -57,7 +58,9 @@ export const LargeCalendar = ({ extraDays }: { extraDays: number }) => {
start: dayjs(event.start).toDate(),
end: dayjs(event.end).toDate(),
title: "Busy",
status: "ACCEPTED",
options: {
status: "ACCEPTED",
},
} as CalendarEvent;
});
}, [overlayEvents, displayOverlay]);
@@ -1,5 +1,5 @@
import { useSession } from "next-auth/react";
import { useRouter, useSearchParams, usePathname } from "next/navigation";
import { useSearchParams, useRouter, usePathname } from "next/navigation";
import { useState, useCallback, useEffect } from "react";
import dayjs from "@calcom/dayjs";
@@ -17,19 +17,108 @@ import { OverlayCalendarSettingsModal } from "../OverlayCalendar/OverlayCalendar
import { useLocalSet } from "../hooks/useLocalSet";
import { useOverlayCalendarStore } from "./store";
export function OverlayCalendarContainer() {
interface OverlayCalendarSwitchProps {
setContinueWithProvider: (val: boolean) => void;
setCalendarSettingsOverlay: (val: boolean) => void;
enabled?: boolean;
}
function OverlayCalendarSwitch({
setCalendarSettingsOverlay,
setContinueWithProvider,
enabled,
}: OverlayCalendarSwitchProps) {
const { t } = useLocale();
const layout = useBookerStore((state) => state.layout);
const searchParams = useSearchParams();
const { data: session } = useSession();
const router = useRouter();
const pathname = usePathname();
const switchEnabled = enabled;
// Toggle query param for overlay calendar
const toggleOverlayCalendarQueryParam = useCallback(
(state: boolean) => {
const current = new URLSearchParams(Array.from(searchParams.entries()));
if (state) {
current.set("overlayCalendar", "true");
localStorage.setItem("overlayCalendarSwitchDefault", "true");
} else {
current.delete("overlayCalendar");
localStorage.removeItem("overlayCalendarSwitchDefault");
}
// cast to string
const value = current.toString();
const query = value ? `?${value}` : "";
router.push(`${pathname}${query}`);
},
[searchParams, pathname, router]
);
/**
* If a user is not logged in and the overlay calendar query param is true,
* show the continue modal so they can login / create an account
*/
useEffect(() => {
if (!session && switchEnabled) {
toggleOverlayCalendarQueryParam(false);
setContinueWithProvider(true);
}
}, [session, switchEnabled, setContinueWithProvider, toggleOverlayCalendarQueryParam]);
return (
<div
className={classNames(
"hidden gap-2",
layout === "week_view" || layout === "column_view" ? "xl:flex" : "md:flex"
)}>
<div className="flex items-center gap-2 pr-2">
<Switch
data-testid="overlay-calendar-switch"
checked={switchEnabled}
id="overlayCalendar"
onCheckedChange={(state) => {
if (!session) {
setContinueWithProvider(state);
} else {
toggleOverlayCalendarQueryParam(state);
}
}}
/>
<label
htmlFor="overlayCalendar"
className="text-emphasis text-sm font-medium leading-none hover:cursor-pointer">
{t("overlay_my_calendar")}
</label>
</div>
{session && (
<Button
size="base"
data-testid="overlay-calendar-settings-button"
variant="icon"
color="secondary"
StartIcon={Settings}
onClick={() => {
setCalendarSettingsOverlay(true);
}}
/>
)}
</div>
);
}
export function OverlayCalendarContainer() {
const isEmbed = useIsEmbed();
const searchParams = useSearchParams();
const [continueWithProvider, setContinueWithProvider] = useState(false);
const [calendarSettingsOverlay, setCalendarSettingsOverlay] = useState(false);
const { data: session } = useSession();
const setOverlayBusyDates = useOverlayCalendarStore((state) => state.setOverlayBusyDates);
const switchEnabled =
searchParams.get("overlayCalendar") === "true" ||
localStorage.getItem("overlayCalendarSwitchDefault") === "true";
const layout = useBookerStore((state) => state.layout);
const selectedDate = useBookerStore((state) => state.selectedDate);
const router = useRouter();
const pathname = usePathname();
const searchParams = useSearchParams();
const { timezone } = useTimePreferences();
// Move this to a hook
@@ -37,7 +126,6 @@ export function OverlayCalendarContainer() {
credentialId: number;
externalId: string;
}>("toggledConnectedCalendars", []);
const overlayCalendarQueryParam = searchParams.get("overlayCalendar");
const { data: overlayBusyDates } = trpc.viewer.availability.calendarOverlay.useQuery(
{
@@ -50,7 +138,7 @@ export function OverlayCalendarContainer() {
})),
},
{
enabled: !!session && set.size > 0 && overlayCalendarQueryParam === "true",
enabled: !!session && set.size > 0 && switchEnabled,
onError: () => {
clearSet();
},
@@ -76,77 +164,17 @@ export function OverlayCalendarContainer() {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [overlayBusyDates]);
// Toggle query param for overlay calendar
const toggleOverlayCalendarQueryParam = useCallback(
(state: boolean) => {
const current = new URLSearchParams(Array.from(searchParams.entries()));
if (state) {
current.set("overlayCalendar", "true");
} else {
current.delete("overlayCalendar");
}
// cast to string
const value = current.toString();
const query = value ? `?${value}` : "";
router.push(`${pathname}${query}`);
},
[searchParams, pathname, router]
);
/**
* If a user is not logged in and the overlay calendar query param is true,
* show the continue modal so they can login / create an account
*/
useEffect(() => {
if (!session && overlayCalendarQueryParam === "true") {
toggleOverlayCalendarQueryParam(false);
setContinueWithProvider(true);
}
}, [session, overlayCalendarQueryParam, toggleOverlayCalendarQueryParam]);
if (isEmbed) {
return null;
}
return (
<>
<div
className={classNames(
"hidden gap-2",
layout === "week_view" || layout === "column_view" ? "xl:flex" : "md:flex"
)}>
<div className="flex items-center gap-2 pr-2">
<Switch
data-testid="overlay-calendar-switch"
checked={overlayCalendarQueryParam === "true"}
id="overlayCalendar"
onCheckedChange={(state) => {
if (!session) {
setContinueWithProvider(state);
} else {
toggleOverlayCalendarQueryParam(state);
}
}}
/>
<label
htmlFor="overlayCalendar"
className="text-emphasis text-sm font-medium leading-none hover:cursor-pointer">
{t("overlay_my_calendar")}
</label>
</div>
{session && (
<Button
size="base"
data-testid="overlay-calendar-settings-button"
variant="icon"
color="secondary"
StartIcon={Settings}
onClick={() => {
setCalendarSettingsOverlay(true);
}}
/>
)}
</div>
<OverlayCalendarSwitch
setCalendarSettingsOverlay={setCalendarSettingsOverlay}
setContinueWithProvider={setContinueWithProvider}
enabled={switchEnabled}
/>
<OverlayCalendarContinueModal
open={continueWithProvider}
onClose={(val) => {
@@ -1,6 +1,6 @@
import Link from "next/link";
import { useRouter } from "next/navigation";
import { Fragment } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import { Fragment, useEffect, useState } from "react";
import { classNames } from "@calcom/lib";
import { useLocale } from "@calcom/lib/hooks/useLocale";
@@ -43,17 +43,35 @@ const SkeletonLoader = () => {
export function OverlayCalendarSettingsModal(props: IOverlayCalendarContinueModalProps) {
const utils = trpc.useContext();
const [initalised, setInitalised] = useState(false);
const searchParams = useSearchParams();
const setOverlayBusyDates = useOverlayCalendarStore((state) => state.setOverlayBusyDates);
const { data, isLoading } = trpc.viewer.connectedCalendars.useQuery(undefined, {
enabled: !!props.open,
enabled: !!props.open || !!searchParams.get("overlayCalendar"),
});
const { toggleValue, hasItem } = useLocalSet<{
const { toggleValue, hasItem, set } = useLocalSet<{
credentialId: number;
externalId: string;
}>("toggledConnectedCalendars", []);
const router = useRouter();
const { t } = useLocale();
useEffect(() => {
if (data?.connectedCalendars && set.size === 0 && !initalised) {
data?.connectedCalendars.forEach((item) => {
item.calendars?.forEach((cal) => {
const id = { credentialId: item.credentialId, externalId: cal.externalId };
if (cal.primary) {
toggleValue(id);
}
});
});
setInitalised(true);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [data, hasItem, set, initalised]);
return (
<>
<Dialog open={props.open} onOpenChange={props.onClose}>
@@ -48,7 +48,8 @@ const SlotItem = ({
}) => {
const { t } = useLocale();
const overlayCalendarToggled = getQueryParam("overlayCalendar") === "true";
const overlayCalendarToggled =
getQueryParam("overlayCalendar") === "true" || localStorage.getItem("overlayCalendarSwitchDefault");
const [timeFormat, timezone] = useTimePreferences((state) => [state.timeFormat, state.timezone]);
const selectedDuration = useBookerStore((state) => state.selectedDuration);
const bookingData = useBookerStore((state) => state.bookingData);
@@ -6,7 +6,7 @@ import logger from "@calcom/lib/logger";
import { WebhookTriggerEvents } from "@calcom/prisma/enums";
import type { CalendarEvent } from "@calcom/types/Calendar";
const log = logger.getChildLogger({ prefix: ["[handleBookingRequested] book:user"] });
const log = logger.getSubLogger({ prefix: ["[handleBookingRequested] book:user"] });
/**
* Supposed to do whatever is needed when a booking is requested.
@@ -102,6 +102,7 @@ async function getBookingToDelete(id: number | undefined, uid: string | undefine
},
},
uid: true,
id: true,
eventTypeId: true,
destinationCalendar: true,
smsReminderNumber: true,
@@ -245,6 +246,7 @@ async function handler(req: CustomRequest) {
},
attendees: attendeesList,
uid: bookingToDelete?.uid,
bookingId: bookingToDelete?.id,
/* Include recurringEvent information only when cancelling all bookings */
recurringEvent: allRemainingBookings
? parseRecurringEvent(bookingToDelete.eventType?.recurringEvent)
@@ -15,7 +15,7 @@ import { BookingStatus, WebhookTriggerEvents } from "@calcom/prisma/enums";
import { bookingMetadataSchema } from "@calcom/prisma/zod-utils";
import type { AdditionalInformation, CalendarEvent } from "@calcom/types/Calendar";
const log = logger.getChildLogger({ prefix: ["[handleConfirmation] book:user"] });
const log = logger.getSubLogger({ prefix: ["[handleConfirmation] book:user"] });
export async function handleConfirmation(args: {
user: EventManagerUser & { username: string | null };
@@ -96,7 +96,7 @@ import type { EventTypeInfo } from "../../webhooks/lib/sendPayload";
import getBookingResponsesSchema from "./getBookingResponsesSchema";
const translator = short();
const log = logger.getChildLogger({ prefix: ["[api] book:user"] });
const log = logger.getSubLogger({ prefix: ["[api] book:user"] });
type User = Prisma.UserGetPayload<typeof userSelect>;
type BufferedBusyTimes = BufferedBusyTime[];
@@ -685,12 +685,12 @@ async function handler(
eventType,
});
const loggerWithEventDetails = logger.getChildLogger({
const loggerWithEventDetails = logger.getSubLogger({
prefix: ["book:user", `${eventTypeId}:${reqBody.user}/${eventTypeSlug}`],
});
if (isEventTypeLoggingEnabled({ eventTypeId, usernameOrTeamName: reqBody.user })) {
logger.setSettings({ minLevel: "silly" });
logger.settings.minLevel = 0;
}
const fullName = getFullName(bookerName);
@@ -1431,7 +1431,7 @@ async function handler(
metadata.hangoutLink = updatedEvent.hangoutLink;
metadata.conferenceData = updatedEvent.conferenceData;
metadata.entryPoints = updatedEvent.entryPoints;
handleAppsStatus(results, newBooking);
evt.appsStatus = handleAppsStatus(results, newBooking);
}
}
}
@@ -2105,7 +2105,7 @@ async function handler(
booking: (Booking & { appsStatus?: AppsStatus[] }) | null
) {
// Taking care of apps status
const resultStatus: AppsStatus[] = results.map((app) => ({
let resultStatus: AppsStatus[] = results.map((app) => ({
appName: app.appName,
type: app.type,
success: app.success ? 1 : 0,
@@ -2118,8 +2118,7 @@ async function handler(
if (booking !== null) {
booking.appsStatus = resultStatus;
}
evt.appsStatus = resultStatus;
return;
return resultStatus;
}
// From down here we can assume reqAppsStatus is not undefined anymore
// Other status exist, so this is the last booking of a series,
@@ -2134,7 +2133,8 @@ async function handler(
}
return prev;
}, {} as { [key: string]: AppsStatus });
evt.appsStatus = Object.values(calcAppsStatus);
resultStatus = Object.values(calcAppsStatus);
return resultStatus;
}
let videoCallUrl;
@@ -2174,44 +2174,42 @@ async function handler(
results = updateManager.results;
referencesToCreate = updateManager.referencesToCreate;
if (results.length > 0 && results.some((res) => !res.success)) {
const isThereAnIntegrationError = results && results.some((res) => !res.success);
if (isThereAnIntegrationError) {
const error = {
errorCode: "BookingReschedulingMeetingFailed",
message: "Booking Rescheduling failed",
};
loggerWithEventDetails.error(`Booking ${organizerUser.name} failed`, safeStringify({ error, results }));
loggerWithEventDetails.error(
`EventManager.create failure in some of the integrations ${organizerUser.username}`,
safeStringify({ error, results })
);
} else {
const metadata: AdditionalInformation = {};
const calendarResult = results.find((result) => result.type.includes("_calendar"));
evt.iCalUID = Array.isArray(calendarResult?.updatedEvent)
? calendarResult?.updatedEvent[0]?.iCalUID
: calendarResult?.updatedEvent?.iCalUID || undefined;
}
if (results.length) {
// TODO: Handle created event metadata more elegantly
const [updatedEvent] = Array.isArray(results[0].updatedEvent)
? results[0].updatedEvent
: [results[0].updatedEvent];
if (updatedEvent) {
metadata.hangoutLink = updatedEvent.hangoutLink;
metadata.conferenceData = updatedEvent.conferenceData;
metadata.entryPoints = updatedEvent.entryPoints;
handleAppsStatus(results, booking);
videoCallUrl = metadata.hangoutLink || videoCallUrl || updatedEvent?.url;
}
}
if (noEmail !== true && isConfirmedByDefault) {
const copyEvent = cloneDeep(evt);
loggerWithEventDetails.debug("Emails: Sending rescheduled emails for booking confirmation");
await sendRescheduledEmails({
...copyEvent,
additionalInformation: metadata,
additionalNotes, // Resets back to the additionalNote input and not the override value
cancellationReason: `$RCH$${rescheduleReason ? rescheduleReason : ""}`, // Removable code prefix to differentiate cancellation from rescheduling for email
});
}
const { metadata, videoCallUrl: _videoCallUrl } = getVideoCallDetails({
results,
});
videoCallUrl = _videoCallUrl;
evt.appsStatus = handleAppsStatus(results, booking);
// If there is an integration error, we don't send successful rescheduling email, instead broken integration email should be sent that are handled by either CalendarManager or videoClient
if (noEmail !== true && isConfirmedByDefault && !isThereAnIntegrationError) {
const copyEvent = cloneDeep(evt);
loggerWithEventDetails.debug("Emails: Sending rescheduled emails for booking confirmation");
await sendRescheduledEmails({
...copyEvent,
additionalInformation: metadata,
additionalNotes, // Resets back to the additionalNote input and not the override value
cancellationReason: `$RCH$${rescheduleReason ? rescheduleReason : ""}`, // Removable code prefix to differentiate cancellation from rescheduling for email
});
}
// If it's not a reschedule, doesn't require confirmation and there's no price,
// Create a booking
@@ -2234,7 +2232,7 @@ async function handler(
};
loggerWithEventDetails.error(
`Failure in creating events in some of the integrations ${organizerUser.username} failed`,
`EventManager.create failure in some of the integrations ${organizerUser.username}`,
safeStringify({ error, results })
);
} else {
@@ -2296,7 +2294,7 @@ async function handler(
metadata.hangoutLink = results[0].createdEvent?.hangoutLink;
metadata.conferenceData = results[0].createdEvent?.conferenceData;
metadata.entryPoints = results[0].createdEvent?.entryPoints;
handleAppsStatus(results, booking);
evt.appsStatus = handleAppsStatus(results, booking);
videoCallUrl =
metadata.hangoutLink || organizerOrFirstDynamicGroupMemberDefaultLocationUrl || videoCallUrl;
}
@@ -2374,6 +2372,7 @@ async function handler(
videoCallUrl: getVideoCallUrlFromCalEvent(evt),
}
: undefined;
const webhookData = {
...evt,
...eventTypeInfo,
@@ -2558,6 +2557,31 @@ async function handler(
export default handler;
function getVideoCallDetails({
results,
}: {
results: EventResult<AdditionalInformation & { url?: string | undefined; iCalUID?: string | undefined }>[];
}) {
const firstVideoResult = results.find((result) => result.type.includes("_video"));
const metadata: AdditionalInformation = {};
let updatedVideoEvent = null;
if (firstVideoResult && firstVideoResult.success) {
updatedVideoEvent = Array.isArray(firstVideoResult.updatedEvent)
? firstVideoResult.updatedEvent[0]
: firstVideoResult.updatedEvent;
if (updatedVideoEvent) {
metadata.hangoutLink = updatedVideoEvent.hangoutLink;
metadata.conferenceData = updatedVideoEvent.conferenceData;
metadata.entryPoints = updatedVideoEvent.entryPoints;
}
}
const videoCallUrl = metadata.hangoutLink || updatedVideoEvent?.url;
return { videoCallUrl, metadata, updatedVideoEvent };
}
function getRequiresConfirmationFlags({
eventType,
bookingStartTime,
@@ -206,7 +206,7 @@ describe("handleNewBooking", () => {
organizer,
location: BookingLocations.CalVideo,
subscriberUrl: "http://my-webhook.example.com",
videoCallUrl: `${WEBAPP_URL}/video/DYNAMIC_UID`,
videoCallUrl: `${WEBAPP_URL}/video/${createdBooking.uid}`,
});
},
timeout
@@ -353,7 +353,7 @@ describe("handleNewBooking", () => {
organizer,
location: BookingLocations.CalVideo,
subscriberUrl: "http://my-webhook.example.com",
videoCallUrl: `${WEBAPP_URL}/video/DYNAMIC_UID`,
videoCallUrl: `${WEBAPP_URL}/video/${createdBooking.uid}`,
});
},
timeout
@@ -499,7 +499,7 @@ describe("handleNewBooking", () => {
organizer,
location: BookingLocations.CalVideo,
subscriberUrl: "http://my-webhook.example.com",
videoCallUrl: `${WEBAPP_URL}/video/DYNAMIC_UID`,
videoCallUrl: `${WEBAPP_URL}/video/${createdBooking.uid}`,
});
},
timeout
@@ -760,7 +760,7 @@ describe("handleNewBooking", () => {
organizer,
location: BookingLocations.CalVideo,
subscriberUrl: "http://my-webhook.example.com",
videoCallUrl: `${WEBAPP_URL}/video/DYNAMIC_UID`,
videoCallUrl: `${WEBAPP_URL}/video/${createdBooking.uid}`,
});
},
timeout
@@ -1447,7 +1447,7 @@ describe("handleNewBooking", () => {
organizer,
location: BookingLocations.CalVideo,
subscriberUrl,
videoCallUrl: `${WEBAPP_URL}/video/DYNAMIC_UID`,
videoCallUrl: `${WEBAPP_URL}/video/${createdBooking.uid}`,
});
},
timeout
@@ -1883,7 +1883,7 @@ describe("handleNewBooking", () => {
organizer,
location: BookingLocations.CalVideo,
subscriberUrl: "http://my-webhook.example.com",
videoCallUrl: `${WEBAPP_URL}/video/DYNAMIC_UID`,
videoCallUrl: `${WEBAPP_URL}/video/${createdBooking.uid}`,
paidEvent: true,
});
},
@@ -21,6 +21,8 @@ import {
BookingLocations,
getMockBookingReference,
getMockBookingAttendee,
getMockFailingAppStatus,
getMockPassingAppStatus,
} from "@calcom/web/test/utils/bookingScenario/bookingScenario";
import {
expectWorkflowToBeTriggered,
@@ -103,6 +105,9 @@ describe("handleNewBooking", () => {
status: BookingStatus.ACCEPTED,
startTime: `${plus1DateString}T05:00:00.000Z`,
endTime: `${plus1DateString}T05:15:00.000Z`,
metadata: {
videoCallUrl: "https://existing-daily-video-call-url.example.com",
},
references: [
{
type: appStoreMetadata.dailyvideo.type,
@@ -254,6 +259,10 @@ describe("handleNewBooking", () => {
organizer,
emails,
iCalUID: "MOCKED_GOOGLE_CALENDAR_ICS_ID",
appsStatus: [
getMockPassingAppStatus({ slug: appStoreMetadata.dailyvideo.slug }),
getMockPassingAppStatus({ slug: appStoreMetadata.googlecalendar.slug }),
],
});
expectBookingRescheduledWebhookToHaveBeenFired({
@@ -261,7 +270,7 @@ describe("handleNewBooking", () => {
organizer,
location: BookingLocations.CalVideo,
subscriberUrl: "http://my-webhook.example.com",
videoCallUrl: `${WEBAPP_URL}/video/DYNAMIC_UID`,
videoCallUrl: `${WEBAPP_URL}/video/${createdBooking.uid}`,
});
},
timeout
@@ -464,7 +473,7 @@ describe("handleNewBooking", () => {
organizer,
location: BookingLocations.CalVideo,
subscriberUrl: "http://my-webhook.example.com",
videoCallUrl: `${WEBAPP_URL}/video/DYNAMIC_UID`,
videoCallUrl: `${WEBAPP_URL}/video/${createdBooking.uid}`,
});
},
timeout
@@ -525,6 +534,9 @@ describe("handleNewBooking", () => {
status: BookingStatus.ACCEPTED,
startTime: `${plus1DateString}T05:00:00.000Z`,
endTime: `${plus1DateString}T05:15:00.000Z`,
metadata: {
videoCallUrl: "https://existing-daily-video-call-url.example.com",
},
references: [
{
type: appStoreMetadata.dailyvideo.type,
@@ -551,6 +563,9 @@ describe("handleNewBooking", () => {
);
const _calendarMock = mockCalendarToCrashOnUpdateEvent("googlecalendar");
const _videoMock = mockSuccessfulVideoMeetingCreation({
metadataLookupKey: "dailyvideo",
});
const mockBookingData = getMockRequestDataForBooking({
data: {
@@ -559,7 +574,7 @@ describe("handleNewBooking", () => {
responses: {
email: booker.email,
name: booker.name,
location: { optionValue: "", value: "New York" },
location: { optionValue: "", value: BookingLocations.CalVideo },
},
},
});
@@ -577,16 +592,27 @@ describe("handleNewBooking", () => {
},
to: {
description: "",
location: "New York",
location: "integrations:daily",
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
uid: createdBooking.uid!,
eventTypeId: mockBookingData.eventTypeId,
status: BookingStatus.ACCEPTED,
metadata: {
videoCallUrl: `${WEBAPP_URL}/video/${createdBooking?.uid}`,
},
responses: expect.objectContaining({
email: booker.email,
name: booker.name,
}),
// Booking References still use the original booking's references - Not sure how intentional it is.
references: [
{
type: appStoreMetadata.dailyvideo.type,
uid: "MOCK_ID",
meetingId: "MOCK_ID",
meetingPassword: "MOCK_PASS",
meetingUrl: "http://mock-dailyvideo.example.com",
},
{
type: appStoreMetadata.googlecalendar.type,
// A reference is still created in case of event creation failure, with nullish values. Not sure what's the purpose for this.
@@ -607,8 +633,18 @@ describe("handleNewBooking", () => {
expectBookingRescheduledWebhookToHaveBeenFired({
booker,
organizer,
location: "New York",
location: "integrations:daily",
subscriberUrl: "http://my-webhook.example.com",
payload: {
uid: createdBooking.uid,
appsStatus: [
expect.objectContaining(getMockPassingAppStatus({ slug: appStoreMetadata.dailyvideo.slug })),
expect.objectContaining(
getMockFailingAppStatus({ slug: appStoreMetadata.googlecalendar.slug })
),
],
},
videoCallUrl: `${WEBAPP_URL}/video/${createdBooking?.uid}`,
});
},
timeout
@@ -1048,7 +1084,7 @@ describe("handleNewBooking", () => {
organizer,
location: BookingLocations.CalVideo,
subscriberUrl: "http://my-webhook.example.com",
videoCallUrl: `${WEBAPP_URL}/video/DYNAMIC_UID`,
videoCallUrl: `${WEBAPP_URL}/video/${createdBooking.uid}`,
});
},
timeout
@@ -1497,12 +1533,13 @@ describe("handleNewBooking", () => {
emails,
iCalUID: "MOCKED_GOOGLE_CALENDAR_ICS_ID",
});
expectBookingRescheduledWebhookToHaveBeenFired({
booker,
organizer,
location: BookingLocations.CalVideo,
subscriberUrl: "http://my-webhook.example.com",
videoCallUrl: `${WEBAPP_URL}/video/DYNAMIC_UID`,
videoCallUrl: `${WEBAPP_URL}/video/${createdBooking.uid}`,
});
},
timeout
@@ -225,7 +225,7 @@ describe("handleNewBooking", () => {
organizer,
location: BookingLocations.CalVideo,
subscriberUrl: "http://my-webhook.example.com",
videoCallUrl: `${WEBAPP_URL}/video/DYNAMIC_UID`,
videoCallUrl: `${WEBAPP_URL}/video/${createdBooking.uid}`,
});
},
timeout
@@ -537,7 +537,7 @@ describe("handleNewBooking", () => {
organizer,
location: BookingLocations.CalVideo,
subscriberUrl: "http://my-webhook.example.com",
videoCallUrl: `${WEBAPP_URL}/video/DYNAMIC_UID`,
videoCallUrl: `${WEBAPP_URL}/video/${createdBooking.uid}`,
});
},
timeout
@@ -854,7 +854,7 @@ describe("handleNewBooking", () => {
organizer,
location: BookingLocations.CalVideo,
subscriberUrl: "http://my-webhook.example.com",
videoCallUrl: `${WEBAPP_URL}/video/DYNAMIC_UID`,
videoCallUrl: `${WEBAPP_URL}/video/${createdBooking.uid}`,
});
},
timeout

Some files were not shown because too many files have changed in this diff Show More