Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5e3c0cdea1 | ||
|
|
6b6d3d90e4 | ||
|
|
9b348adb6a | ||
|
|
feda420f0c | ||
|
|
4b818de0c8 | ||
|
|
614741d207 | ||
|
|
2550485c49 | ||
|
|
efc3e864bb | ||
|
|
1bf56fbe93 | ||
|
|
629629cb9e | ||
|
|
8c0751b186 | ||
|
|
0b46f61a23 | ||
|
|
0c92fbe11d | ||
|
|
59fa713549 | ||
|
|
63416d4f33 | ||
|
|
9e927af813 | ||
|
|
4b8bdeba74 | ||
|
|
4e4d67c8c0 | ||
|
|
ffda234b3c | ||
|
|
0bc44c36db | ||
|
|
f18bee5c3d | ||
|
|
73e16215bd | ||
|
|
75d7d2f172 | ||
|
|
269dca5b6d | ||
|
|
0fd6bed813 | ||
|
|
33bef6acd0 | ||
|
|
a6c4b31845 | ||
|
|
ea8ba8defc | ||
|
|
94cb78491a | ||
|
|
d12a5c5883 | ||
|
|
7a014761dc | ||
|
|
91ac952a72 | ||
|
|
225055fb0c | ||
|
|
d46e80c2ac | ||
|
|
fe364bd2da | ||
|
|
2756dff735 | ||
|
|
401f64b986 | ||
|
|
a02dcf485f | ||
|
|
f2ecd9818a | ||
|
|
bc81f659aa | ||
|
|
f8f038c5e9 | ||
|
|
fefb6acc57 | ||
|
|
461120ad84 | ||
|
|
bab72a5d2e | ||
|
|
e4011b4a23 |
@@ -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
@@ -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-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.ai - World's first open source AI scheduling 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-ai" target="_blank"><img src="https://api.producthunt.com/widgets/embed-image/v1/featured.svg?post_id=419860&theme=light" alt="Cal.ai - World's first open source AI scheduling 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,6 +1,6 @@
|
||||
{
|
||||
"name": "@calcom/ai",
|
||||
"version": "1.2.0",
|
||||
"version": "1.2.1",
|
||||
"private": true,
|
||||
"author": "Cal.com Inc.",
|
||||
"dependencies": {
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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 } });
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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} </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",
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)"] : [])],
|
||||
});
|
||||
|
||||
|
||||
@@ -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+$/, "");
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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")}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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,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] - ", {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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 ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
};
|
||||
|
||||
@@ -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),
|
||||
},
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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[]) => {
|
||||
|
||||
@@ -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[]) => {
|
||||
|
||||
@@ -33,6 +33,10 @@ const config = {
|
||||
"zh-TW",
|
||||
],
|
||||
},
|
||||
fallbackLng: {
|
||||
default: ["en"],
|
||||
zh: ["zh-CN"],
|
||||
},
|
||||
reloadOnPrerender: process.env.NODE_ENV !== "production",
|
||||
};
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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:");
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}{" "}
|
||||
|
||||
@@ -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}`;
|
||||
};
|
||||
|
||||
@@ -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]);
|
||||
|
||||
+101
-73
@@ -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) => {
|
||||
|
||||
+22
-4
@@ -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
|
||||
|
||||
+3
-3
@@ -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
Reference in New Issue
Block a user