Compare commits
74
Commits
v3.7.13
...
v3.8.0-rc1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5bd0428d18 | ||
|
|
c7089b9db6 | ||
|
|
81ad00a816 | ||
|
|
b5730da56d | ||
|
|
9455d14ad5 | ||
|
|
a056160404 | ||
|
|
6d53cd84f8 | ||
|
|
30cb8f745d | ||
|
|
bafa3c97ac | ||
|
|
bb48ccbad9 | ||
|
|
0a68c6d048 | ||
|
|
bb9f8e5d38 | ||
|
|
c99bb190f1 | ||
|
|
f0d2a45fdf | ||
|
|
e5f9315ee6 | ||
|
|
f7782b2080 | ||
|
|
e1f2dbab84 | ||
|
|
c2edbbcea7 | ||
|
|
c7e78cc181 | ||
|
|
7ba1557d1d | ||
|
|
d5b45b757a | ||
|
|
bb3b262bd7 | ||
|
|
d4f41e803a | ||
|
|
74eafa59e1 | ||
|
|
e6cd016cad | ||
|
|
7b0d5959cb | ||
|
|
7702ded85e | ||
|
|
5acb3e8f97 | ||
|
|
41046aaf27 | ||
|
|
7cffb30daa | ||
|
|
26bacb6c8e | ||
|
|
01b5c1f11e | ||
|
|
f5ba04599b | ||
|
|
994e602c8d | ||
|
|
a776c72d48 | ||
|
|
6adb5b413d | ||
|
|
b183a5779a | ||
|
|
16c1cf7b23 | ||
|
|
cf4ee142de | ||
|
|
a5d99bbec9 | ||
|
|
5d0468a986 | ||
|
|
7fef9db4ce | ||
|
|
296a367955 | ||
|
|
1ff2d51ee5 | ||
|
|
092cb0f691 | ||
|
|
0c74a98721 | ||
|
|
2082a4f4ed | ||
|
|
f06ef84f61 | ||
|
|
1080d7f88f | ||
|
|
4ebd52c9af | ||
|
|
e0ac95578c | ||
|
|
473012f2c0 | ||
|
|
1293588ac4 | ||
|
|
c0c40185fc | ||
|
|
3348e7be2f | ||
|
|
97dcb2a2a9 | ||
|
|
5dad78f8fe | ||
|
|
88cc83a0aa | ||
|
|
9dddf5e525 | ||
|
|
9df9f9bb6b | ||
|
|
c4803be323 | ||
|
|
34d360a4ec | ||
|
|
0e6f44e2b9 | ||
|
|
e377997d49 | ||
|
|
a0451e34c1 | ||
|
|
d2b4760e3a | ||
|
|
8469c91973 | ||
|
|
0990549e7f | ||
|
|
e884348807 | ||
|
|
5684ce2d28 | ||
|
|
bd8ac12f14 | ||
|
|
512628e8ab | ||
|
|
903a154dbc | ||
|
|
1a3d54a0d0 |
+5
-2
@@ -23,6 +23,9 @@ CALCOM_LICENSE_KEY=
|
||||
|
||||
# - DATABASE ************************************************************************************************
|
||||
DATABASE_URL="postgresql://postgres:@localhost:5450/calendso"
|
||||
# Needed to run migrations while using a connection pooler like PgBouncer
|
||||
# Use the same one as DATABASE_URL if you're not using a connection pooler
|
||||
DATABASE_DIRECT_URL="postgresql://postgres:@localhost:5450/calendso"
|
||||
UPSTASH_REDIS_REST_URL=
|
||||
UPSTASH_REDIS_REST_TOKEN=
|
||||
INSIGHTS_DATABASE_URL=
|
||||
@@ -134,8 +137,8 @@ NEXT_PUBLIC_SENDGRID_SENDER_NAME=
|
||||
NEXT_PUBLIC_SENTRY_DSN=
|
||||
|
||||
# Formbricks Experience Management Integration
|
||||
FORMBRICKS_HOST_URL=https://app.formbricks.com
|
||||
FORMBRICKS_ENVIRONMENT_ID=
|
||||
NEXT_PUBLIC_FORMBRICKS_HOST_URL=https://app.formbricks.com
|
||||
NEXT_PUBLIC_FORMBRICKS_ENVIRONMENT_ID=
|
||||
FORMBRICKS_FEEDBACK_SURVEY_ID=
|
||||
|
||||
# AvatarAPI
|
||||
|
||||
@@ -18,6 +18,7 @@ runs:
|
||||
key-1: ${{ hashFiles('packages/prisma/schema.prisma', 'packages/prisma/migrations/**/**.sql', 'packages/prisma/*.ts') }}
|
||||
key-2: ${{ github.event.pull_request.number || github.ref }}
|
||||
DATABASE_URL: ${{ inputs.DATABASE_URL }}
|
||||
DATABASE_DIRECT_URL: ${{ inputs.DATABASE_URL }}
|
||||
E2E_TEST_CALCOM_QA_EMAIL: ${{ inputs.E2E_TEST_CALCOM_QA_EMAIL }}
|
||||
E2E_TEST_CALCOM_QA_PASSWORD: ${{ inputs.E2E_TEST_CALCOM_QA_PASSWORD }}
|
||||
E2E_TEST_CALCOM_QA_GCAL_CREDENTIALS: ${{ inputs.E2E_TEST_CALCOM_QA_GCAL_CREDENTIALS }}
|
||||
|
||||
@@ -11,6 +11,9 @@ jobs:
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:12.1
|
||||
credentials:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
env:
|
||||
POSTGRES_USER: postgres
|
||||
POSTGRES_DB: calendso
|
||||
@@ -18,6 +21,9 @@ jobs:
|
||||
- 5432:5432
|
||||
mailhog:
|
||||
image: mailhog/mailhog:v1.0.1
|
||||
credentials:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
ports:
|
||||
- 8025:8025
|
||||
- 1025:1025
|
||||
@@ -39,6 +45,7 @@ jobs:
|
||||
- uses: ./.github/actions/cache-db
|
||||
env:
|
||||
DATABASE_URL: ${{ secrets.CI_DATABASE_URL }}
|
||||
DATABASE_DIRECT_URL: ${{ secrets.CI_DATABASE_URL }}
|
||||
E2E_TEST_CALCOM_QA_EMAIL: ${{ secrets.E2E_TEST_CALCOM_QA_EMAIL }}
|
||||
E2E_TEST_CALCOM_QA_PASSWORD: ${{ secrets.E2E_TEST_CALCOM_QA_PASSWORD }}
|
||||
E2E_TEST_CALCOM_QA_GCAL_CREDENTIALS: ${{ secrets.E2E_TEST_CALCOM_QA_GCAL_CREDENTIALS }}
|
||||
@@ -50,6 +57,7 @@ jobs:
|
||||
ALLOWED_HOSTNAMES: ${{ vars.CI_ALLOWED_HOSTNAMES }}
|
||||
CALENDSO_ENCRYPTION_KEY: ${{ secrets.CI_CALENDSO_ENCRYPTION_KEY }}
|
||||
DATABASE_URL: ${{ secrets.CI_DATABASE_URL }}
|
||||
DATABASE_DIRECT_URL: ${{ secrets.CI_DATABASE_URL }}
|
||||
DEPLOYSENTINEL_API_KEY: ${{ secrets.DEPLOYSENTINEL_API_KEY }}
|
||||
E2E_TEST_APPLE_CALENDAR_EMAIL: ${{ secrets.E2E_TEST_APPLE_CALENDAR_EMAIL }}
|
||||
E2E_TEST_APPLE_CALENDAR_PASSWORD: ${{ secrets.E2E_TEST_APPLE_CALENDAR_PASSWORD }}
|
||||
|
||||
@@ -11,6 +11,9 @@ jobs:
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:12.1
|
||||
credentials:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
env:
|
||||
POSTGRES_USER: postgres
|
||||
POSTGRES_DB: calendso
|
||||
@@ -41,6 +44,7 @@ jobs:
|
||||
ALLOWED_HOSTNAMES: ${{ vars.CI_ALLOWED_HOSTNAMES }}
|
||||
CALENDSO_ENCRYPTION_KEY: ${{ secrets.CI_CALENDSO_ENCRYPTION_KEY }}
|
||||
DATABASE_URL: ${{ secrets.CI_DATABASE_URL }}
|
||||
DATABASE_DIRECT_URL: ${{ secrets.CI_DATABASE_URL }}
|
||||
DEPLOYSENTINEL_API_KEY: ${{ secrets.DEPLOYSENTINEL_API_KEY }}
|
||||
E2E_TEST_APPLE_CALENDAR_EMAIL: ${{ secrets.E2E_TEST_APPLE_CALENDAR_EMAIL }}
|
||||
E2E_TEST_APPLE_CALENDAR_PASSWORD: ${{ secrets.E2E_TEST_APPLE_CALENDAR_PASSWORD }}
|
||||
|
||||
@@ -11,6 +11,9 @@ jobs:
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:12.1
|
||||
credentials:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
env:
|
||||
POSTGRES_USER: postgres
|
||||
POSTGRES_DB: calendso
|
||||
@@ -18,6 +21,9 @@ jobs:
|
||||
- 5432:5432
|
||||
mailhog:
|
||||
image: mailhog/mailhog:v1.0.1
|
||||
credentials:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
ports:
|
||||
- 8025:8025
|
||||
- 1025:1025
|
||||
@@ -44,6 +50,7 @@ jobs:
|
||||
ALLOWED_HOSTNAMES: ${{ vars.CI_ALLOWED_HOSTNAMES }}
|
||||
CALENDSO_ENCRYPTION_KEY: ${{ secrets.CI_CALENDSO_ENCRYPTION_KEY }}
|
||||
DATABASE_URL: ${{ secrets.CI_DATABASE_URL }}
|
||||
DATABASE_DIRECT_URL: ${{ secrets.CI_DATABASE_URL }}
|
||||
DEPLOYSENTINEL_API_KEY: ${{ secrets.DEPLOYSENTINEL_API_KEY }}
|
||||
E2E_TEST_APPLE_CALENDAR_EMAIL: ${{ secrets.E2E_TEST_APPLE_CALENDAR_EMAIL }}
|
||||
E2E_TEST_APPLE_CALENDAR_PASSWORD: ${{ secrets.E2E_TEST_APPLE_CALENDAR_PASSWORD }}
|
||||
|
||||
@@ -11,6 +11,9 @@ jobs:
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:12.1
|
||||
credentials:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
env:
|
||||
POSTGRES_USER: postgres
|
||||
POSTGRES_DB: calendso
|
||||
@@ -18,6 +21,9 @@ jobs:
|
||||
- 5432:5432
|
||||
mailhog:
|
||||
image: mailhog/mailhog:v1.0.1
|
||||
credentials:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
ports:
|
||||
- 8025:8025
|
||||
- 1025:1025
|
||||
@@ -42,6 +48,7 @@ jobs:
|
||||
ALLOWED_HOSTNAMES: ${{ vars.CI_ALLOWED_HOSTNAMES }}
|
||||
CALENDSO_ENCRYPTION_KEY: ${{ secrets.CI_CALENDSO_ENCRYPTION_KEY }}
|
||||
DATABASE_URL: ${{ secrets.CI_DATABASE_URL }}
|
||||
DATABASE_DIRECT_URL: ${{ secrets.CI_DATABASE_URL }}
|
||||
DEPLOYSENTINEL_API_KEY: ${{ secrets.DEPLOYSENTINEL_API_KEY }}
|
||||
E2E_TEST_APPLE_CALENDAR_EMAIL: ${{ secrets.E2E_TEST_APPLE_CALENDAR_EMAIL }}
|
||||
E2E_TEST_APPLE_CALENDAR_PASSWORD: ${{ secrets.E2E_TEST_APPLE_CALENDAR_PASSWORD }}
|
||||
|
||||
@@ -28,7 +28,7 @@ jobs:
|
||||
with:
|
||||
repo-token: ${{ secrets.EQUITY_BEE_TEAM_LABELER_ACTION_TOKEN }}
|
||||
organization-name: calcom
|
||||
ignore-labels: "admin, app-store, ai, authentication, automated-testing, devops, platform, billing, bookings, caldav, calendar-apps, ci, console, crm-apps, docs, documentation, emails, embeds, event-types, i18n, impersonation, manual-testing, ui, performance, ops-stack, organizations, public-api, routing-forms, seats, teams, webhooks, workflows, zapier"
|
||||
ignore-labels: "admin, app-store, ai, authentication, automated-testing, devops, billing, bookings, caldav, calendar-apps, ci, console, crm-apps, docs, documentation, emails, embeds, event-types, i18n, impersonation, manual-testing, ui, performance, ops-stack, organizations, public-api, routing-forms, seats, teams, webhooks, workflows, zapier"
|
||||
apply-labels-from-issue:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ env:
|
||||
ALLOWED_HOSTNAMES: ${{ vars.CI_ALLOWED_HOSTNAMES }}
|
||||
CALENDSO_ENCRYPTION_KEY: ${{ secrets.CI_CALENDSO_ENCRYPTION_KEY }}
|
||||
DATABASE_URL: ${{ secrets.CI_DATABASE_URL }}
|
||||
DATABASE_DIRECT_URL: ${{ secrets.CI_DATABASE_URL }}
|
||||
E2E_TEST_APPLE_CALENDAR_EMAIL: ${{ secrets.E2E_TEST_APPLE_CALENDAR_EMAIL }}
|
||||
E2E_TEST_APPLE_CALENDAR_PASSWORD: ${{ secrets.E2E_TEST_APPLE_CALENDAR_PASSWORD }}
|
||||
GOOGLE_API_CREDENTIALS: ${{ secrets.CI_GOOGLE_API_CREDENTIALS }}
|
||||
|
||||
@@ -7,6 +7,7 @@ env:
|
||||
ALLOWED_HOSTNAMES: ${{ vars.CI_ALLOWED_HOSTNAMES }}
|
||||
CALENDSO_ENCRYPTION_KEY: ${{ secrets.CI_CALENDSO_ENCRYPTION_KEY }}
|
||||
DATABASE_URL: ${{ secrets.CI_DATABASE_URL }}
|
||||
DATABASE_DIRECT_URL: ${{ secrets.CI_DATABASE_URL }}
|
||||
E2E_TEST_APPLE_CALENDAR_EMAIL: ${{ secrets.E2E_TEST_APPLE_CALENDAR_EMAIL }}
|
||||
E2E_TEST_APPLE_CALENDAR_PASSWORD: ${{ secrets.E2E_TEST_APPLE_CALENDAR_PASSWORD }}
|
||||
E2E_TEST_CALCOM_QA_EMAIL: ${{ secrets.E2E_TEST_CALCOM_QA_EMAIL }}
|
||||
@@ -41,6 +42,9 @@ jobs:
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:12.1
|
||||
credentials:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
env:
|
||||
POSTGRES_USER: postgres
|
||||
POSTGRES_DB: calendso
|
||||
|
||||
+226
-56
@@ -1,9 +1,10 @@
|
||||
// This transform config was generated by Snaplet.
|
||||
// Snaplet found fields that may contain personally identifiable information (PII)
|
||||
// and used that to populate this file.
|
||||
// eslint-disable-next-line @typescript-eslint/triple-slash-reference
|
||||
/// <reference path=".snaplet/snaplet.d.ts" />
|
||||
// This config was generated by Snaplet make sure to check it over before using it.
|
||||
import { copycat as c } from "@snaplet/copycat";
|
||||
import { defineConfig } from "snaplet";
|
||||
|
||||
import type { Transform } from "./structure";
|
||||
// c.setHashKey(REPLACE_ME_WITH_YOUR_HASH_KEY);
|
||||
|
||||
function hasStringProp<T extends string>(x: unknown, key: T): x is { [key in T]: string } {
|
||||
return !!x && typeof x === "object" && key in x;
|
||||
@@ -41,57 +42,226 @@ function replaceSensitiveKeys(record: object) {
|
||||
|
||||
const generateUsername = (x: string) => `${c.firstName(x)}-${c.lastName(x)}${c.int(x, { min: 2, max: 99 })}`;
|
||||
|
||||
const config: Transform = () => ({
|
||||
public: {
|
||||
ApiKey: ({ row }) => ({
|
||||
hashedKey: c.uuid(row.hashedKey),
|
||||
}),
|
||||
App: ({ row }) => ({
|
||||
keys: replaceSensitiveKeys(row.keys),
|
||||
}),
|
||||
Attendee: ({ row }) => ({
|
||||
email: c.email(row.email),
|
||||
name: c.fullName(row.name),
|
||||
timeZone: c.timezone(row.timeZone),
|
||||
}),
|
||||
Credential: ({ row }) => ({
|
||||
key: typeof row.key === "string" ? c.uuid(row.key) : replaceSensitiveKeys(row.key),
|
||||
}),
|
||||
EventType: ({ row }) => ({
|
||||
slug: generateSlug(row.slug),
|
||||
timeZone: c.timezone(row.timeZone),
|
||||
eventName: c.words(row.eventName, { max: 3 }),
|
||||
}),
|
||||
ResetPasswordRequest: ({ row }) => ({
|
||||
email: c.email(row.email),
|
||||
}),
|
||||
Schedule: ({ row }) => ({
|
||||
timeZone: c.timezone(row.timeZone),
|
||||
}),
|
||||
Team: ({ row }) => ({
|
||||
bio: c.sentence(row.bio),
|
||||
name: c.words(row.name, { max: 2 }),
|
||||
slug: generateSlug(row.slug),
|
||||
}),
|
||||
users: ({ row }) =>
|
||||
row.role !== "ADMIN"
|
||||
? {
|
||||
bio: c.sentence(row.bio),
|
||||
email: c.email(row.email),
|
||||
name: c.fullName(row.name),
|
||||
password: c.password(row.password),
|
||||
timeZone: c.timezone(row.timeZone),
|
||||
username: generateUsername(row.username),
|
||||
}
|
||||
: row,
|
||||
VerificationToken: ({ row }) => ({
|
||||
token: c.uuid(row.token),
|
||||
}),
|
||||
Account: ({ row }) => ({
|
||||
access_token: c.uuid(row.access_token),
|
||||
refresh_token: c.uuid(row.refresh_token),
|
||||
}),
|
||||
export default defineConfig({
|
||||
transform: {
|
||||
$mode: "unsafe",
|
||||
public: {
|
||||
Account({ row }) {
|
||||
return {
|
||||
refresh_token: c.uuid(row.refresh_token),
|
||||
access_token: c.uuid(row.access_token),
|
||||
expires_at: c.int(row.expires_at, {
|
||||
min: 0,
|
||||
max: Math.pow(4, 8) - 1,
|
||||
}),
|
||||
token_type: c.uuid(row.token_type),
|
||||
id_token: c.uuid(row.id_token),
|
||||
};
|
||||
},
|
||||
ApiKey: ({ row }) => ({
|
||||
hashedKey: c.uuid(row.hashedKey),
|
||||
note: c.fullName(row.note),
|
||||
createdAt: c.dateString(row.createdAt, {
|
||||
minYear: 2020,
|
||||
}),
|
||||
}),
|
||||
App: ({ row }) => ({
|
||||
keys: replaceSensitiveKeys(row.keys),
|
||||
}),
|
||||
App_RoutingForms_Form({ row }) {
|
||||
return {
|
||||
name: c.fullName(row.name),
|
||||
};
|
||||
},
|
||||
Attendee: ({ row }) => ({
|
||||
email: c.email(row.email),
|
||||
name: c.fullName(row.name),
|
||||
timeZone: c.timezone(row.timeZone),
|
||||
locale: c.fullName(row.locale),
|
||||
}),
|
||||
Availability({ row }) {
|
||||
return {
|
||||
startTime: c
|
||||
.dateString(row.startTime, {
|
||||
minYear: 2020,
|
||||
})
|
||||
.slice(11, 19),
|
||||
endTime: c
|
||||
.dateString(row.endTime, {
|
||||
minYear: 2020,
|
||||
})
|
||||
.slice(11, 19),
|
||||
};
|
||||
},
|
||||
Booking({ row }) {
|
||||
return {
|
||||
title: c.fullName(row.title),
|
||||
startTime: c.dateString(row.startTime, {
|
||||
minYear: 2020,
|
||||
}),
|
||||
endTime: c.dateString(row.endTime, {
|
||||
minYear: 2020,
|
||||
}),
|
||||
location: c.sentence(row.location),
|
||||
metadata: {
|
||||
[c.word(row.metadata)]: c.words(row.metadata),
|
||||
},
|
||||
};
|
||||
},
|
||||
Credential: ({ row }) => ({
|
||||
key: typeof row.key === "string" ? c.uuid(row.key) : replaceSensitiveKeys(row.key),
|
||||
}),
|
||||
EventType: ({ row }) => ({
|
||||
slug: generateSlug(row.slug),
|
||||
timeZone: c.timezone(row.timeZone),
|
||||
eventName: c.words(row.eventName, { max: 3 }),
|
||||
currency: c.sentence(row.currency),
|
||||
}),
|
||||
EventTypeCustomInput({ row }) {
|
||||
return {
|
||||
label: c.fullName(row.label),
|
||||
};
|
||||
},
|
||||
Feature({ row }) {
|
||||
return {
|
||||
slug: c.uuid(row.slug),
|
||||
};
|
||||
},
|
||||
InstantMeetingToken({ row }) {
|
||||
return {
|
||||
token: c.uuid(row.token),
|
||||
};
|
||||
},
|
||||
OAuthClient({ row }) {
|
||||
return {
|
||||
name: c.fullName(row.name),
|
||||
};
|
||||
},
|
||||
OutOfOfficeEntry({ row }) {
|
||||
return {
|
||||
start: c.dateString(row.start, {
|
||||
minYear: 2020,
|
||||
}),
|
||||
};
|
||||
},
|
||||
Payment({ row }) {
|
||||
return {
|
||||
amount: c.int(row.amount, {
|
||||
min: 0,
|
||||
max: Math.pow(4, 8) - 1,
|
||||
}),
|
||||
currency: c.sentence(row.currency),
|
||||
data: {
|
||||
[c.word(row.data)]: c.words(row.data),
|
||||
},
|
||||
};
|
||||
},
|
||||
ResetPasswordRequest: ({ row }) => ({
|
||||
email: c.email(row.email),
|
||||
}),
|
||||
Schedule: ({ row }) => ({
|
||||
name: c.fullName(row.name),
|
||||
timeZone: c.timezone(row.timeZone),
|
||||
}),
|
||||
Session({ row }) {
|
||||
return {
|
||||
sessionToken: c.uuid(row.sessionToken),
|
||||
};
|
||||
},
|
||||
Team: ({ row }) => ({
|
||||
bio: c.sentence(row.bio),
|
||||
name: c.words(row.name, { max: 2 }),
|
||||
slug: generateSlug(row.slug),
|
||||
timeZone: c.timezone(row.timeZone),
|
||||
logoUrl: c.username(row.logoUrl),
|
||||
}),
|
||||
TempOrgRedirect({ row }) {
|
||||
return {
|
||||
from: c.dateString(row.from, {
|
||||
maxYear: 1999,
|
||||
}),
|
||||
toUrl: c.city(row.toUrl),
|
||||
};
|
||||
},
|
||||
VerificationToken({ row }) {
|
||||
return {
|
||||
id: c
|
||||
.int(row.id, {
|
||||
min: 1,
|
||||
max: Math.pow(4, 8) - 1,
|
||||
})
|
||||
.toString(),
|
||||
identifier: c.uuid(row.identifier),
|
||||
token: c.uuid(row.token),
|
||||
expires: c.dateString(row.expires, {
|
||||
minYear: 2020,
|
||||
}),
|
||||
};
|
||||
},
|
||||
VerifiedNumber({ row }) {
|
||||
return {
|
||||
phoneNumber: c.phoneNumber(row.phoneNumber),
|
||||
};
|
||||
},
|
||||
Webhook({ row }) {
|
||||
return {
|
||||
subscriberUrl: c.url(row.subscriberUrl),
|
||||
secret: c.streetAddress(row.secret),
|
||||
};
|
||||
},
|
||||
WebhookScheduledTriggers({ row }) {
|
||||
return {
|
||||
jobName: c.fullName(row.jobName),
|
||||
payload: c.password(row.payload),
|
||||
};
|
||||
},
|
||||
WorkflowStep({ row }) {
|
||||
return {
|
||||
sendTo: c.oneOf(row.sendTo, [
|
||||
"Man",
|
||||
"Woman",
|
||||
"Transgender",
|
||||
"Non-binary/non-conforming",
|
||||
"Not specified",
|
||||
]),
|
||||
sender: c.oneOf(row.sender, [
|
||||
"Man",
|
||||
"Woman",
|
||||
"Transgender",
|
||||
"Non-binary/non-conforming",
|
||||
"Not specified",
|
||||
]),
|
||||
};
|
||||
},
|
||||
users: ({ row }) =>
|
||||
row.role !== "ADMIN"
|
||||
? {
|
||||
bio: c.sentence(row.bio),
|
||||
email: c.email(row.email),
|
||||
name: c.fullName(row.name),
|
||||
password: c.password(row.password),
|
||||
timeZone: c.timezone(row.timeZone),
|
||||
username: generateUsername(row.username),
|
||||
metadata: {
|
||||
[c.word(row.metadata)]: c.words(row.metadata),
|
||||
},
|
||||
}
|
||||
: row,
|
||||
},
|
||||
},
|
||||
subset: {
|
||||
enabled: true,
|
||||
version: "3",
|
||||
targets: [
|
||||
{
|
||||
table: "public.users",
|
||||
rowLimit: 100,
|
||||
},
|
||||
],
|
||||
keepDisconnectedTables: false,
|
||||
followNullableRelations: true,
|
||||
maxCyclesLoop: 0,
|
||||
eager: false,
|
||||
taskSortAlgorithm: "children",
|
||||
},
|
||||
});
|
||||
|
||||
export default config;
|
||||
|
||||
@@ -8,14 +8,14 @@ test.describe("Org", () => {
|
||||
const response = await page.goto("https://i.cal.com/embed");
|
||||
expect(response?.status()).toBe(200);
|
||||
await page.screenshot({ path: "screenshot.jpg" });
|
||||
await expectPageToBeServerSideRendered(page);
|
||||
await expectPageToBeRenderedWithEmbedSsr(page);
|
||||
});
|
||||
|
||||
test("Org User(Peer) Page should be embeddable", async ({ page }) => {
|
||||
const response = await page.goto("https://i.cal.com/peer/embed");
|
||||
expect(response?.status()).toBe(200);
|
||||
await expect(page.locator("text=Peer Richelsen")).toBeVisible();
|
||||
await expectPageToBeServerSideRendered(page);
|
||||
await expectPageToBeRenderedWithEmbedSsr(page);
|
||||
});
|
||||
|
||||
test("Org User Event(peer/meet) Page should be embeddable", async ({ page }) => {
|
||||
@@ -23,14 +23,14 @@ test.describe("Org", () => {
|
||||
expect(response?.status()).toBe(200);
|
||||
await expect(page.locator('[data-testid="decrementMonth"]')).toBeVisible();
|
||||
await expect(page.locator('[data-testid="incrementMonth"]')).toBeVisible();
|
||||
await expectPageToBeServerSideRendered(page);
|
||||
await expectPageToBeRenderedWithEmbedSsr(page);
|
||||
});
|
||||
|
||||
test("Org Team Profile(/sales) page should be embeddable", async ({ page }) => {
|
||||
const response = await page.goto("https://i.cal.com/sales/embed");
|
||||
expect(response?.status()).toBe(200);
|
||||
await expect(page.locator("text=Cal.com Sales")).toBeVisible();
|
||||
await expectPageToBeServerSideRendered(page);
|
||||
await expectPageToBeRenderedWithEmbedSsr(page);
|
||||
});
|
||||
|
||||
test("Org Team Event page(/sales/hippa) should be embeddable", async ({ page }) => {
|
||||
@@ -38,9 +38,10 @@ test.describe("Org", () => {
|
||||
expect(response?.status()).toBe(200);
|
||||
await expect(page.locator('[data-testid="decrementMonth"]')).toBeVisible();
|
||||
await expect(page.locator('[data-testid="incrementMonth"]')).toBeVisible();
|
||||
await expectPageToBeServerSideRendered(page);
|
||||
await expectPageToBeRenderedWithEmbedSsr(page);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Dynamic Group Booking", () => {
|
||||
test("Dynamic Group booking link should load", async ({ page }) => {
|
||||
const users = [
|
||||
@@ -63,12 +64,39 @@ test.describe("Org", () => {
|
||||
expect((await page.locator('[data-testid="event-meta"] [data-testid="avatar"]').all()).length).toBe(3);
|
||||
});
|
||||
});
|
||||
|
||||
test("Organization Homepage - Has Engineering and Marketing Teams", async ({ page }) => {
|
||||
const response = await page.goto("https://i.cal.com");
|
||||
expect(response?.status()).toBe(200);
|
||||
await expect(page.locator("text=Cal.com")).toBeVisible();
|
||||
await expect(page.locator("text=Engineering")).toBeVisible();
|
||||
await expect(page.locator("text=Marketing")).toBeVisible();
|
||||
});
|
||||
|
||||
test.describe("Browse the Engineering Team", async () => {
|
||||
test("By User Navigation", async ({ page }) => {
|
||||
await page.goto("https://i.cal.com");
|
||||
await page.click('text="Engineering"');
|
||||
await expect(page.locator("text=Cal.com Engineering")).toBeVisible();
|
||||
});
|
||||
|
||||
test("By /team/engineering", async ({ page }) => {
|
||||
await page.goto("https://i.cal.com/team/engineering");
|
||||
await expect(page.locator("text=Cal.com Engineering")).toBeVisible();
|
||||
});
|
||||
|
||||
test("By /engineering", async ({ page }) => {
|
||||
await page.goto("https://i.cal.com/engineering");
|
||||
await expect(page.locator("text=Cal.com Engineering")).toBeVisible();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// This ensures that the route is actually mapped to a page that is using withEmbedSsr
|
||||
async function expectPageToBeServerSideRendered(page: Page) {
|
||||
async function expectPageToBeRenderedWithEmbedSsr(page: Page) {
|
||||
expect(
|
||||
await page.evaluate(() => {
|
||||
//@ts-expect-error - __NEXT_DATA__ is a global variable defined by Next.js
|
||||
return window.__NEXT_DATA__.props.pageProps.isEmbed;
|
||||
})
|
||||
).toBe(true);
|
||||
|
||||
@@ -5,12 +5,14 @@
|
||||
"author": "Cal.com Inc.",
|
||||
"dependencies": {
|
||||
"@calcom/prisma": "*",
|
||||
"@langchain/core": "^0.1.26",
|
||||
"@langchain/openai": "^0.0.14",
|
||||
"@t3-oss/env-nextjs": "^0.6.1",
|
||||
"langchain": "^0.0.131",
|
||||
"langchain": "^0.1.17",
|
||||
"mailparser": "^3.6.5",
|
||||
"next": "^13.5.4",
|
||||
"supports-color": "8.1.1",
|
||||
"zod": "^3.22.2"
|
||||
"zod": "^3.22.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/mailparser": "^3.4.0"
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { DynamicStructuredTool } from "langchain/tools";
|
||||
import { DynamicStructuredTool } from "@langchain/core/tools";
|
||||
import { z } from "zod";
|
||||
|
||||
import type { UserList } from "~/src/types/user";
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { DynamicStructuredTool } from "langchain/tools";
|
||||
import { DynamicStructuredTool } from "@langchain/core/tools";
|
||||
import { z } from "zod";
|
||||
|
||||
import { env } from "../env.mjs";
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { DynamicStructuredTool } from "langchain/tools";
|
||||
import { DynamicStructuredTool } from "@langchain/core/tools";
|
||||
import { z } from "zod";
|
||||
|
||||
import { env } from "../env.mjs";
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { DynamicStructuredTool } from "langchain/tools";
|
||||
import { DynamicStructuredTool } from "@langchain/core/tools";
|
||||
import { z } from "zod";
|
||||
|
||||
import { env } from "../env.mjs";
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { DynamicStructuredTool } from "langchain/tools";
|
||||
import { DynamicStructuredTool } from "@langchain/core/tools";
|
||||
import { z } from "zod";
|
||||
|
||||
import { env } from "../env.mjs";
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { DynamicStructuredTool } from "langchain/tools";
|
||||
import { DynamicStructuredTool } from "@langchain/core/tools";
|
||||
import { z } from "zod";
|
||||
|
||||
import { env } from "~/src/env.mjs";
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { DynamicStructuredTool } from "langchain/tools";
|
||||
import { DynamicStructuredTool } from "@langchain/core/tools";
|
||||
import { z } from "zod";
|
||||
|
||||
import { env } from "../env.mjs";
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { ChatOpenAI } from "@langchain/openai";
|
||||
import { initializeAgentExecutorWithOptions } from "langchain/agents";
|
||||
import { ChatOpenAI } from "langchain/chat_models/openai";
|
||||
|
||||
import { env } from "../env.mjs";
|
||||
import createBookingIfAvailable from "../tools/createBooking";
|
||||
|
||||
@@ -41,6 +41,6 @@
|
||||
"typescript": "^4.9.4",
|
||||
"tzdata": "^1.0.30",
|
||||
"uuid": "^8.3.2",
|
||||
"zod": "^3.22.2"
|
||||
"zod": "^3.22.4"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -197,6 +197,7 @@ async function handler(req: NextApiRequest) {
|
||||
dateTo,
|
||||
eventTypeId,
|
||||
userId,
|
||||
returnDateOverrides: true,
|
||||
});
|
||||
const team = await prisma.team.findUnique({
|
||||
where: { id: teamId },
|
||||
@@ -232,6 +233,7 @@ async function handler(req: NextApiRequest) {
|
||||
dateFrom,
|
||||
dateTo,
|
||||
eventTypeId,
|
||||
returnDateOverrides: true,
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { NextApiRequest } from "next";
|
||||
|
||||
import getBookingDataSchemaForApi from "@calcom/features/bookings/lib/getBookingDataSchemaForApi";
|
||||
import handleNewBooking from "@calcom/features/bookings/lib/handleNewBooking";
|
||||
import { defaultResponder } from "@calcom/lib/server";
|
||||
|
||||
@@ -206,7 +207,7 @@ async function handler(req: NextApiRequest) {
|
||||
const { userId, isAdmin } = req;
|
||||
if (isAdmin) req.userId = req.body.userId || userId;
|
||||
|
||||
return await handleNewBooking(req);
|
||||
return await handleNewBooking(req, getBookingDataSchemaForApi);
|
||||
}
|
||||
|
||||
export default defaultResponder(handler);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { CAL_URL } from "@calcom/lib/constants";
|
||||
import { WEBSITE_URL } from "@calcom/lib/constants";
|
||||
|
||||
export default function getCalLink(eventType: {
|
||||
team?: { slug: string | null } | null;
|
||||
@@ -6,7 +6,7 @@ export default function getCalLink(eventType: {
|
||||
users?: { username: string | null }[];
|
||||
slug: string;
|
||||
}) {
|
||||
return `${CAL_URL}/${
|
||||
return `${WEBSITE_URL}/${
|
||||
eventType?.team
|
||||
? `team/${eventType?.team?.slug}`
|
||||
: eventType?.owner
|
||||
|
||||
@@ -3,7 +3,6 @@ import { _generateMetadata } from "app/_utils";
|
||||
import { WithLayout } from "app/layoutHOC";
|
||||
import type { InferGetStaticPropsType } from "next";
|
||||
|
||||
import { getLayout } from "@calcom/features/MainLayoutAppDir";
|
||||
import { APP_NAME } from "@calcom/lib/constants";
|
||||
|
||||
import { validStatuses } from "~/bookings/lib/validStatuses";
|
||||
@@ -23,6 +22,6 @@ export const generateStaticParams = async () => {
|
||||
return validStatuses.map((status) => ({ status }));
|
||||
};
|
||||
|
||||
export default WithLayout({ getLayout, getData, Page })<"P">;
|
||||
export default WithLayout({ getLayout: null, getData, Page })<"P">;
|
||||
|
||||
export const dynamic = "force-static";
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import { _generateMetadata } from "app/_utils";
|
||||
import { WithLayout } from "app/layoutHOC";
|
||||
|
||||
import { getLayout } from "@calcom/features/MainLayoutAppDir";
|
||||
|
||||
import EventTypes from "~/event-types/views/event-types-listing-view";
|
||||
|
||||
export const generateMetadata = async () =>
|
||||
@@ -11,4 +9,4 @@ export const generateMetadata = async () =>
|
||||
(t) => t("event_types_page_subtitle")
|
||||
);
|
||||
|
||||
export default WithLayout({ getLayout, Page: EventTypes })<"P">;
|
||||
export default WithLayout({ getLayout: null, Page: EventTypes })<"P">;
|
||||
|
||||
@@ -3,8 +3,6 @@ import { withAppDirSsr } from "app/WithAppDirSsr";
|
||||
import { _generateMetadata } from "app/_utils";
|
||||
import { WithLayout } from "app/layoutHOC";
|
||||
|
||||
import { getLayout } from "@calcom/features/MainLayoutAppDir";
|
||||
|
||||
import { getServerSideProps } from "@lib/insights/getServerSideProps";
|
||||
import { type inferSSRProps } from "@lib/types/inferSSRProps";
|
||||
|
||||
@@ -16,4 +14,4 @@ export const generateMetadata = async () =>
|
||||
|
||||
const getData = withAppDirSsr<inferSSRProps<typeof getServerSideProps>>(getServerSideProps);
|
||||
|
||||
export default WithLayout({ getLayout, getData, Page: LegacyPage });
|
||||
export default WithLayout({ getLayout: null, getData, Page: LegacyPage });
|
||||
|
||||
@@ -18,7 +18,8 @@ export const generateMetadata = async ({
|
||||
}) => {
|
||||
const legacyCtx = buildLegacyCtx(headers(), cookies(), params, searchParams);
|
||||
const props = await getData(legacyCtx as unknown as GetServerSidePropsContext);
|
||||
const { entity, user, slug, booking } = props;
|
||||
const { eventData, user, slug, booking } = props;
|
||||
const entity = eventData.entity;
|
||||
const { trpc } = await import("@calcom/trpc");
|
||||
const { data: event } = trpc.viewer.public.event.useQuery(
|
||||
{ username: user, eventSlug: slug, isTeamEvent: false, org: entity.orgSlug ?? null },
|
||||
|
||||
@@ -16,7 +16,8 @@ export const generateMetadata = async ({
|
||||
}) => {
|
||||
const legacyCtx = buildLegacyCtx(headers(), cookies(), params, searchParams);
|
||||
const props = await getData(legacyCtx as unknown as GetServerSidePropsContext);
|
||||
const { entity, user, slug, booking } = props;
|
||||
const { eventData, user, slug, booking } = props;
|
||||
const entity = eventData.entity;
|
||||
const { trpc } = await import("@calcom/trpc");
|
||||
const { data: event } = trpc.viewer.public.event.useQuery(
|
||||
{ username: user, eventSlug: slug, isTeamEvent: false, org: entity.orgSlug ?? null },
|
||||
|
||||
@@ -3,8 +3,6 @@ import { withAppDirSsr } from "app/WithAppDirSsr";
|
||||
import { _generateMetadata } from "app/_utils";
|
||||
import { WithLayout } from "app/layoutHOC";
|
||||
|
||||
import { getLayout } from "@calcom/features/MainLayoutAppDir";
|
||||
|
||||
import { getServerSideProps } from "@lib/teams/getServerSideProps";
|
||||
|
||||
export const generateMetadata = async () =>
|
||||
@@ -13,4 +11,4 @@ export const generateMetadata = async () =>
|
||||
(t) => t("create_manage_teams_collaborative")
|
||||
);
|
||||
|
||||
export default WithLayout({ getData: withAppDirSsr(getServerSideProps), getLayout, Page })<"P">;
|
||||
export default WithLayout({ getData: withAppDirSsr(getServerSideProps), getLayout: null, Page })<"P">;
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { _generateMetadata } from "app/_utils";
|
||||
import { WithLayout } from "app/layoutHOC";
|
||||
|
||||
import { getLayout } from "@calcom/features/MainLayoutAppDir";
|
||||
import LegacyPage from "@calcom/features/ee/workflows/pages/index";
|
||||
|
||||
export const generateMetadata = async () =>
|
||||
@@ -10,4 +9,4 @@ export const generateMetadata = async () =>
|
||||
(t) => t("workflows_to_automate_notifications")
|
||||
);
|
||||
|
||||
export default WithLayout({ getLayout, Page: LegacyPage })<"P">;
|
||||
export default WithLayout({ getLayout: null, Page: LegacyPage })<"P">;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import Link from "next/link";
|
||||
import { Fragment } from "react";
|
||||
import { Fragment, useEffect } from "react";
|
||||
|
||||
import { InstallAppButton } from "@calcom/app-store/components";
|
||||
import DisconnectIntegration from "@calcom/features/apps/components/DisconnectIntegration";
|
||||
@@ -15,10 +15,12 @@ import {
|
||||
AppSkeletonLoader as SkeletonLoader,
|
||||
ShellSubHeading,
|
||||
Label,
|
||||
showToast,
|
||||
} from "@calcom/ui";
|
||||
import { Calendar } from "@calcom/ui/components/icon";
|
||||
|
||||
import { QueryCell } from "@lib/QueryCell";
|
||||
import useRouterQuery from "@lib/hooks/useRouterQuery";
|
||||
|
||||
import AppListCard from "@components/AppListCard";
|
||||
import AdditionalCalendarSelector from "@components/apps/AdditionalCalendarSelector";
|
||||
@@ -182,6 +184,15 @@ function ConnectedCalendarsList(props: Props) {
|
||||
export function CalendarListContainer(props: { heading?: boolean; fromOnboarding?: boolean }) {
|
||||
const { t } = useLocale();
|
||||
const { heading = true, fromOnboarding } = props;
|
||||
const { error, setQuery: setError } = useRouterQuery("error");
|
||||
|
||||
useEffect(() => {
|
||||
if (error === "account_already_linked") {
|
||||
showToast(t(error), "error", { id: error });
|
||||
setError(undefined);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
const utils = trpc.useContext();
|
||||
const onChanged = () =>
|
||||
Promise.allSettled([
|
||||
|
||||
@@ -2,7 +2,7 @@ import useAddAppMutation from "@calcom/app-store/_utils/useAddAppMutation";
|
||||
import { doesAppSupportTeamInstall } from "@calcom/app-store/utils";
|
||||
import { Spinner } from "@calcom/features/calendars/weeklyview/components/spinner/Spinner";
|
||||
import type { UserAdminTeams } from "@calcom/features/ee/teams/lib/getUserAdminTeams";
|
||||
import { CAL_URL } from "@calcom/lib/constants";
|
||||
import { WEBAPP_URL } from "@calcom/lib/constants";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import type { RouterOutputs } from "@calcom/trpc/react";
|
||||
import type { AppFrontendPayload } from "@calcom/types/App";
|
||||
@@ -125,7 +125,7 @@ export const InstallAppButtonChild = ({
|
||||
StartIcon={(props) => (
|
||||
<Avatar
|
||||
alt={team.logo || ""}
|
||||
imageSrc={team.logo || `${CAL_URL}/${team.logo}/avatar.png`} // if no image, use default avatar
|
||||
imageSrc={team.logo || `${WEBAPP_URL}/${team.logo}/avatar.png`} // if no image, use default avatar
|
||||
size="sm"
|
||||
{...props}
|
||||
/>
|
||||
|
||||
@@ -9,7 +9,11 @@ export default function InstalledAppsLayout({
|
||||
...rest
|
||||
}: { children: React.ReactNode } & ComponentProps<typeof Shell>) {
|
||||
return (
|
||||
<Shell {...rest} hideHeadingOnMobile>
|
||||
<Shell
|
||||
{...rest}
|
||||
title="Installed Apps"
|
||||
description="Manage your installed apps or change settings"
|
||||
hideHeadingOnMobile>
|
||||
<AppCategoryNavigation baseURL="/apps/installed" containerClassname="min-w-0 w-full">
|
||||
{children}
|
||||
</AppCategoryNavigation>
|
||||
|
||||
@@ -3,8 +3,6 @@ import Link from "next/link";
|
||||
import type { EventTypeSetupProps, FormValues } from "pages/event-types/[type]";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Controller, useFormContext } from "react-hook-form";
|
||||
import short from "short-uuid";
|
||||
import { v5 as uuidv5 } from "uuid";
|
||||
import type { z } from "zod";
|
||||
|
||||
import type { EventNameObjectType } from "@calcom/core/event";
|
||||
@@ -20,8 +18,8 @@ import { FormBuilder } from "@calcom/features/form-builder/FormBuilder";
|
||||
import type { EditableSchema } from "@calcom/features/form-builder/schema";
|
||||
import { BookerLayoutSelector } from "@calcom/features/settings/BookerLayoutSelector";
|
||||
import { classNames } from "@calcom/lib";
|
||||
import { APP_NAME, CAL_URL } from "@calcom/lib/constants";
|
||||
import { IS_VISUAL_REGRESSION_TESTING } from "@calcom/lib/constants";
|
||||
import { APP_NAME, IS_VISUAL_REGRESSION_TESTING, WEBSITE_URL } from "@calcom/lib/constants";
|
||||
import { generateHashedLink } from "@calcom/lib/generateHashedLink";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import type { Prisma } from "@calcom/prisma/client";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
@@ -32,6 +30,7 @@ import {
|
||||
Label,
|
||||
SettingsToggle,
|
||||
showToast,
|
||||
Switch,
|
||||
TextField,
|
||||
Tooltip,
|
||||
} from "@calcom/ui";
|
||||
@@ -41,22 +40,17 @@ import RequiresConfirmationController from "./RequiresConfirmationController";
|
||||
|
||||
const CustomEventTypeModal = dynamic(() => import("@components/eventtype/CustomEventTypeModal"));
|
||||
|
||||
const generateHashedLink = (id: number) => {
|
||||
const translator = short();
|
||||
const seed = `${id}:${new Date().getTime()}`;
|
||||
const uid = translator.fromUUID(uuidv5(seed, uuidv5.URL));
|
||||
return uid;
|
||||
};
|
||||
|
||||
export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupProps, "eventType" | "team">) => {
|
||||
const connectedCalendarsQuery = trpc.viewer.connectedCalendars.useQuery();
|
||||
const { data: user } = trpc.viewer.me.useQuery();
|
||||
const formMethods = useFormContext<FormValues>();
|
||||
const { t } = useLocale();
|
||||
|
||||
const [showEventNameTip, setShowEventNameTip] = useState(false);
|
||||
const [hashedLinkVisible, setHashedLinkVisible] = useState(!!formMethods.getValues("hashedLink"));
|
||||
const [redirectUrlVisible, setRedirectUrlVisible] = useState(!!formMethods.getValues("successRedirectUrl"));
|
||||
const [useEventTypeDestinationCalendarEmail, setUseEventTypeDestinationCalendarEmail] = useState(
|
||||
formMethods.getValues("useEventTypeDestinationCalendarEmail")
|
||||
);
|
||||
const [hashedUrl, setHashedUrl] = useState(eventType.hashedLink?.link);
|
||||
|
||||
const bookingFields: Prisma.JsonObject = {};
|
||||
@@ -82,7 +76,7 @@ export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupProps,
|
||||
const [requiresConfirmation, setRequiresConfirmation] = useState(
|
||||
formMethods.getValues("requiresConfirmation")
|
||||
);
|
||||
const placeholderHashedLink = `${CAL_URL}/d/${hashedUrl}/${formMethods.getValues("slug")}`;
|
||||
const placeholderHashedLink = `${WEBSITE_URL}/d/${hashedUrl}/${formMethods.getValues("slug")}`;
|
||||
const seatsEnabled = formMethods.watch("seatsPerTimeSlotEnabled");
|
||||
const noShowFeeEnabled =
|
||||
formMethods.getValues("metadata")?.apps?.stripe?.enabled === true &&
|
||||
@@ -125,6 +119,8 @@ export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupProps,
|
||||
const seatsLocked = shouldLockDisableProps("seatsPerTimeSlotEnabled");
|
||||
|
||||
const closeEventNameTip = () => setShowEventNameTip(false);
|
||||
const displayDestinationCalendarSelector =
|
||||
!!connectedCalendarsQuery.data?.connectedCalendars.length && !team;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col space-y-4">
|
||||
@@ -134,7 +130,7 @@ export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupProps,
|
||||
* This will fallback to each user selected destination calendar.
|
||||
*/}
|
||||
<div className="border-subtle space-y-6 rounded-lg border p-6">
|
||||
{!!connectedCalendarsQuery.data?.connectedCalendars.length && !team && (
|
||||
{displayDestinationCalendarSelector && (
|
||||
<div className="flex flex-col">
|
||||
<div className="flex justify-between">
|
||||
<div>
|
||||
@@ -180,6 +176,18 @@ export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupProps,
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
{displayDestinationCalendarSelector && (
|
||||
<div className="w-full">
|
||||
<Switch
|
||||
label={t("display_add_to_calendar_organizer")}
|
||||
checked={useEventTypeDestinationCalendarEmail}
|
||||
onCheckedChange={(val) => {
|
||||
setUseEventTypeDestinationCalendarEmail(val);
|
||||
formMethods.setValue("useEventTypeDestinationCalendarEmail", val);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<BookerLayoutSelector fallbackToUserSettings isDark={selectedThemeIsDark} isOuterBorder={true} />
|
||||
|
||||
@@ -11,7 +11,7 @@ import type { EventLocationType } from "@calcom/app-store/locations";
|
||||
import { getEventLocationType, MeetLocationType } from "@calcom/app-store/locations";
|
||||
import useLockedFieldsManager from "@calcom/features/ee/managed-event-types/hooks/useLockedFieldsManager";
|
||||
import { useOrgBranding } from "@calcom/features/ee/organizations/context/provider";
|
||||
import { CAL_URL } from "@calcom/lib/constants";
|
||||
import { WEBAPP_URL, WEBSITE_URL } from "@calcom/lib/constants";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { md } from "@calcom/lib/markdownIt";
|
||||
import { slugify } from "@calcom/lib/slugify";
|
||||
@@ -401,7 +401,7 @@ export const EventSetupTab = (
|
||||
The “Add to calendar” for this event type needs to be a Google Calendar for Meet to work.
|
||||
Change it{" "}
|
||||
<Link
|
||||
href={`${CAL_URL}/event-types/${formMethods.getValues("id")}?tabName=advanced`}
|
||||
href={`${WEBAPP_URL}/event-types/${formMethods.getValues("id")}?tabName=advanced`}
|
||||
className="underline">
|
||||
here.
|
||||
</Link>{" "}
|
||||
@@ -412,7 +412,7 @@ export const EventSetupTab = (
|
||||
{isChildrenManagedEventType && !locationAvailable && locationDetails && (
|
||||
<p className="pl-1 text-sm leading-none text-red-600">
|
||||
{t("app_not_connected", { appName: locationDetails.name })}{" "}
|
||||
<a className="underline" href={`${CAL_URL}/apps/${locationDetails.slug}`}>
|
||||
<a className="underline" href={`${WEBAPP_URL}/apps/${locationDetails.slug}`}>
|
||||
{t("connect_now")}
|
||||
</a>
|
||||
</p>
|
||||
@@ -444,9 +444,10 @@ export const EventSetupTab = (
|
||||
|
||||
const lengthLockedProps = shouldLockDisableProps("length");
|
||||
const descriptionLockedProps = shouldLockDisableProps("description");
|
||||
|
||||
const urlPrefix = orgBranding
|
||||
? orgBranding?.fullDomain.replace(/^(https?:|)\/\//, "")
|
||||
: `${CAL_URL?.replace(/^(https?:|)\/\//, "")}`;
|
||||
: `${WEBSITE_URL?.replace(/^(https?:|)\/\//, "")}`;
|
||||
|
||||
return (
|
||||
<div>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Trans } from "next-i18next";
|
||||
import Link from "next/link";
|
||||
import type { EventTypeSetupProps, FormValues } from "pages/event-types/[type]";
|
||||
import type { EventTypeSetupProps, FormValues, Host } from "pages/event-types/[type]";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import type { ComponentProps, Dispatch, SetStateAction } from "react";
|
||||
import { Controller, useFormContext, useWatch } from "react-hook-form";
|
||||
@@ -21,6 +21,13 @@ interface IUserToValue {
|
||||
email: string;
|
||||
}
|
||||
|
||||
type TeamMember = {
|
||||
value: string;
|
||||
label: string;
|
||||
avatar: string;
|
||||
email: string;
|
||||
};
|
||||
|
||||
const mapUserToValue = ({ id, name, username, avatar, email }: IUserToValue, pendingString: string) => ({
|
||||
value: `${id || ""}`,
|
||||
label: `${name || email || ""}${!username ? ` (${pendingString})` : ""}`,
|
||||
@@ -109,7 +116,7 @@ const AssignAllTeamMembers = ({
|
||||
onInactive?: () => void;
|
||||
}) => {
|
||||
const { t } = useLocale();
|
||||
const formMethods = useFormContext<FormValues>();
|
||||
const { setValue } = useFormContext<FormValues>();
|
||||
|
||||
return (
|
||||
<Controller<FormValues>
|
||||
@@ -117,10 +124,10 @@ const AssignAllTeamMembers = ({
|
||||
render={() => (
|
||||
<SettingsToggle
|
||||
title={t("automatically_add_all_team_members")}
|
||||
description={t("including_people_who_join_in_future")}
|
||||
labelClassName="mt-0.5 font-normal"
|
||||
checked={assignAllTeamMembers}
|
||||
onCheckedChange={(active) => {
|
||||
formMethods.setValue("assignAllTeamMembers", active);
|
||||
setValue("assignAllTeamMembers", active);
|
||||
setAssignAllTeamMembers(active);
|
||||
if (active) {
|
||||
onActive();
|
||||
@@ -128,7 +135,6 @@ const AssignAllTeamMembers = ({
|
||||
onInactive();
|
||||
}
|
||||
}}
|
||||
toggleSwitchAtTheEnd
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
@@ -148,13 +154,13 @@ const CheckedHostField = ({
|
||||
labelText?: string;
|
||||
placeholder: string;
|
||||
isFixed: boolean;
|
||||
value: { isFixed: boolean; userId: number }[];
|
||||
onChange?: (options: { isFixed: boolean; userId: number }[]) => void;
|
||||
value: Host[];
|
||||
onChange?: (options: Host[]) => void;
|
||||
options?: Options<CheckedSelectOption>;
|
||||
helperText?: React.ReactNode | string;
|
||||
} & Omit<Partial<ComponentProps<typeof CheckedTeamSelect>>, "onChange" | "value">) => {
|
||||
return (
|
||||
<div className="bg-muted flex flex-col space-y-5 p-4">
|
||||
<div className="flex flex-col rounded-md">
|
||||
<div>
|
||||
{labelText ? <Label>{labelText}</Label> : <></>}
|
||||
<CheckedTeamSelect
|
||||
@@ -165,23 +171,22 @@ const CheckedHostField = ({
|
||||
options.map((option) => ({
|
||||
isFixed,
|
||||
userId: parseInt(option.value, 10),
|
||||
priority: option.priority ?? 2,
|
||||
}))
|
||||
);
|
||||
}}
|
||||
value={(value || [])
|
||||
.filter(({ isFixed: _isFixed }) => isFixed === _isFixed)
|
||||
.map(
|
||||
(host) =>
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
options.find((member) => member.value === host.userId.toString())!
|
||||
)
|
||||
.map((host) => {
|
||||
const option = options.find((member) => member.value === host.userId.toString());
|
||||
return option ? { ...option, priority: host.priority ?? 2, isFixed } : options[0];
|
||||
})
|
||||
.filter(Boolean)}
|
||||
controlShouldRenderValue={false}
|
||||
options={options}
|
||||
placeholder={placeholder}
|
||||
{...rest}
|
||||
/>
|
||||
{helperText && <p className="text-subtle text-sm">{helperText}</p>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -199,6 +204,156 @@ const FixedHostHelper = (
|
||||
</Trans>
|
||||
);
|
||||
|
||||
const FixedHosts = ({
|
||||
teamMembers,
|
||||
value,
|
||||
onChange,
|
||||
assignAllTeamMembers,
|
||||
setAssignAllTeamMembers,
|
||||
isRoundRobinEvent = false,
|
||||
}: {
|
||||
value: Host[];
|
||||
onChange: (hosts: Host[]) => void;
|
||||
teamMembers: TeamMember[];
|
||||
assignAllTeamMembers: boolean;
|
||||
setAssignAllTeamMembers: Dispatch<SetStateAction<boolean>>;
|
||||
isRoundRobinEvent?: boolean;
|
||||
}) => {
|
||||
const { t } = useLocale();
|
||||
const { getValues, setValue } = useFormContext<FormValues>();
|
||||
|
||||
const hasActiveFixedHosts = isRoundRobinEvent && getValues("hosts").some((host) => host.isFixed);
|
||||
|
||||
const [isDisabled, setIsDisabled] = useState(hasActiveFixedHosts);
|
||||
|
||||
return (
|
||||
<div className="mt-5 rounded-lg">
|
||||
{!isRoundRobinEvent ? (
|
||||
<>
|
||||
<div className="border-subtle mt-5 rounded-t-md border p-6 pb-5">
|
||||
<Label className="mb-1 text-sm font-semibold">{t("fixed_hosts")}</Label>
|
||||
<p className="text-subtle max-w-full break-words text-sm leading-tight">{FixedHostHelper}</p>
|
||||
</div>
|
||||
<div className="border-subtle rounded-b-md border border-t-0">
|
||||
<AddMembersWithSwitch
|
||||
teamMembers={teamMembers}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
assignAllTeamMembers={assignAllTeamMembers}
|
||||
setAssignAllTeamMembers={setAssignAllTeamMembers}
|
||||
automaticAddAllEnabled={!isRoundRobinEvent}
|
||||
isFixed={true}
|
||||
onActive={() =>
|
||||
setValue(
|
||||
"hosts",
|
||||
teamMembers.map((teamMember) => ({
|
||||
isFixed: true,
|
||||
userId: parseInt(teamMember.value, 10),
|
||||
priority: 2,
|
||||
}))
|
||||
)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<SettingsToggle
|
||||
toggleSwitchAtTheEnd={true}
|
||||
title={t("fixed_hosts")}
|
||||
description={FixedHostHelper}
|
||||
checked={isDisabled}
|
||||
labelClassName="text-sm"
|
||||
descriptionClassName=" text-sm text-subtle"
|
||||
onCheckedChange={(checked) => {
|
||||
if (!checked) {
|
||||
const rrHosts = getValues("hosts")
|
||||
.filter((host) => !host.isFixed)
|
||||
.sort((a, b) => (b.priority ?? 2) - (a.priority ?? 2));
|
||||
setValue("hosts", rrHosts);
|
||||
}
|
||||
setIsDisabled(checked);
|
||||
}}
|
||||
childrenClassName="lg:ml-0">
|
||||
<div className="border-subtle flex flex-col gap-6 rounded-bl-md rounded-br-md border border-t-0">
|
||||
<AddMembersWithSwitch
|
||||
teamMembers={teamMembers}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
assignAllTeamMembers={assignAllTeamMembers}
|
||||
setAssignAllTeamMembers={setAssignAllTeamMembers}
|
||||
automaticAddAllEnabled={!isRoundRobinEvent}
|
||||
isFixed={true}
|
||||
onActive={() =>
|
||||
setValue(
|
||||
"hosts",
|
||||
teamMembers.map((teamMember) => ({
|
||||
isFixed: true,
|
||||
userId: parseInt(teamMember.value, 10),
|
||||
priority: 2,
|
||||
}))
|
||||
)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</SettingsToggle>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const AddMembersWithSwitch = ({
|
||||
teamMembers,
|
||||
value,
|
||||
onChange,
|
||||
assignAllTeamMembers,
|
||||
setAssignAllTeamMembers,
|
||||
automaticAddAllEnabled,
|
||||
onActive,
|
||||
isFixed,
|
||||
}: {
|
||||
value: Host[];
|
||||
onChange: (hosts: Host[]) => void;
|
||||
teamMembers: TeamMember[];
|
||||
assignAllTeamMembers: boolean;
|
||||
setAssignAllTeamMembers: Dispatch<SetStateAction<boolean>>;
|
||||
automaticAddAllEnabled: boolean;
|
||||
onActive: () => void;
|
||||
isFixed: boolean;
|
||||
}) => {
|
||||
const { t } = useLocale();
|
||||
const { setValue } = useFormContext<FormValues>();
|
||||
|
||||
return (
|
||||
<div className="rounded-md ">
|
||||
<div className="flex flex-col rounded-md px-6 pb-2 pt-6">
|
||||
{automaticAddAllEnabled ? (
|
||||
<div className="mb-2">
|
||||
<AssignAllTeamMembers
|
||||
assignAllTeamMembers={assignAllTeamMembers}
|
||||
setAssignAllTeamMembers={setAssignAllTeamMembers}
|
||||
onActive={onActive}
|
||||
onInactive={() => setValue("hosts", [])}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<></>
|
||||
)}
|
||||
{!assignAllTeamMembers || !automaticAddAllEnabled ? (
|
||||
<CheckedHostField
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
isFixed={isFixed}
|
||||
options={teamMembers.sort(sortByLabel)}
|
||||
placeholder={t("add_attendees")}
|
||||
/>
|
||||
) : (
|
||||
<></>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const RoundRobinHosts = ({
|
||||
teamMembers,
|
||||
value,
|
||||
@@ -206,68 +361,44 @@ const RoundRobinHosts = ({
|
||||
assignAllTeamMembers,
|
||||
setAssignAllTeamMembers,
|
||||
}: {
|
||||
value: { isFixed: boolean; userId: number }[];
|
||||
onChange: (hosts: { isFixed: boolean; userId: number }[]) => void;
|
||||
teamMembers: {
|
||||
value: string;
|
||||
label: string;
|
||||
avatar: string;
|
||||
email: string;
|
||||
}[];
|
||||
value: Host[];
|
||||
onChange: (hosts: Host[]) => void;
|
||||
teamMembers: TeamMember[];
|
||||
assignAllTeamMembers: boolean;
|
||||
setAssignAllTeamMembers: Dispatch<SetStateAction<boolean>>;
|
||||
}) => {
|
||||
const { t } = useLocale();
|
||||
const formMethods = useFormContext<FormValues>();
|
||||
|
||||
const { setValue } = useFormContext<FormValues>();
|
||||
|
||||
return (
|
||||
<div className="border-subtle mt-6 space-y-5 rounded-lg border px-4 py-6 sm:px-6">
|
||||
<div className="flex flex-col gap-8">
|
||||
<CheckedHostField
|
||||
options={teamMembers.sort(sortByLabel)}
|
||||
isFixed={true}
|
||||
onChange={(changeValue) => {
|
||||
onChange([...value.filter(({ isFixed }) => !isFixed), ...changeValue]);
|
||||
}}
|
||||
<div className="rounded-lg ">
|
||||
<div className="border-subtle mt-5 rounded-t-md border p-6 pb-5">
|
||||
<Label className="mb-1 text-sm font-semibold">{t("round_robin_hosts")}</Label>
|
||||
<p className="text-subtle max-w-full break-words text-sm leading-tight">{t("round_robin_helper")}</p>
|
||||
</div>
|
||||
<div className="border-subtle rounded-b-md border border-t-0">
|
||||
<AddMembersWithSwitch
|
||||
teamMembers={teamMembers}
|
||||
value={value}
|
||||
placeholder={t("add_fixed_hosts")}
|
||||
labelText={t("fixed_hosts")}
|
||||
helperText={FixedHostHelper}
|
||||
onChange={onChange}
|
||||
assignAllTeamMembers={assignAllTeamMembers}
|
||||
setAssignAllTeamMembers={setAssignAllTeamMembers}
|
||||
automaticAddAllEnabled={true}
|
||||
isFixed={false}
|
||||
onActive={() =>
|
||||
setValue(
|
||||
"hosts",
|
||||
teamMembers
|
||||
.map((teamMember) => ({
|
||||
isFixed: false,
|
||||
userId: parseInt(teamMember.value, 10),
|
||||
priority: 2,
|
||||
}))
|
||||
.sort((a, b) => b.priority - a.priority)
|
||||
)
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="bg-muted flex flex-col">
|
||||
<Label className="p-4">{t("round_robin_hosts")}</Label>
|
||||
<div className="px-4 pb-2">
|
||||
<AssignAllTeamMembers
|
||||
assignAllTeamMembers={assignAllTeamMembers}
|
||||
setAssignAllTeamMembers={setAssignAllTeamMembers}
|
||||
onActive={() =>
|
||||
formMethods.setValue(
|
||||
"hosts",
|
||||
teamMembers.map((teamMember) => ({
|
||||
isFixed: false,
|
||||
userId: parseInt(teamMember.value, 10),
|
||||
}))
|
||||
)
|
||||
}
|
||||
onInactive={() => formMethods.setValue("hosts", [])}
|
||||
/>
|
||||
</div>
|
||||
{assignAllTeamMembers ? (
|
||||
<div className="mb-4" />
|
||||
) : (
|
||||
<CheckedHostField
|
||||
options={teamMembers.sort(sortByLabel)}
|
||||
onChange={(changeValue) =>
|
||||
onChange([...value.filter(({ isFixed }) => isFixed), ...changeValue])
|
||||
}
|
||||
value={value}
|
||||
isFixed={false}
|
||||
placeholder={t("add_attendees")}
|
||||
helperText={t("round_robin_helper")}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -282,14 +413,14 @@ const ChildrenEventTypes = ({
|
||||
assignAllTeamMembers: boolean;
|
||||
setAssignAllTeamMembers: Dispatch<SetStateAction<boolean>>;
|
||||
}) => {
|
||||
const formMethods = useFormContext<FormValues>();
|
||||
const { setValue } = useFormContext<FormValues>();
|
||||
return (
|
||||
<div className="border-subtle mt-6 space-y-5 rounded-lg border px-4 py-6 sm:px-6">
|
||||
<div className="flex flex-col gap-4">
|
||||
<AssignAllTeamMembers
|
||||
assignAllTeamMembers={assignAllTeamMembers}
|
||||
setAssignAllTeamMembers={setAssignAllTeamMembers}
|
||||
onActive={() => formMethods.setValue("children", childrenEventTypeOptions)}
|
||||
onActive={() => setValue("children", childrenEventTypeOptions)}
|
||||
/>
|
||||
{!assignAllTeamMembers ? (
|
||||
<Controller<FormValues>
|
||||
@@ -311,12 +442,7 @@ const Hosts = ({
|
||||
assignAllTeamMembers,
|
||||
setAssignAllTeamMembers,
|
||||
}: {
|
||||
teamMembers: {
|
||||
value: string;
|
||||
label: string;
|
||||
avatar: string;
|
||||
email: string;
|
||||
}[];
|
||||
teamMembers: TeamMember[];
|
||||
assignAllTeamMembers: boolean;
|
||||
setAssignAllTeamMembers: Dispatch<SetStateAction<boolean>>;
|
||||
}) => {
|
||||
@@ -348,61 +474,45 @@ const Hosts = ({
|
||||
});
|
||||
}, [schedulingType, resetField, getValues, submitCount]);
|
||||
|
||||
const formMethods = useFormContext<FormValues>();
|
||||
return (
|
||||
<Controller<FormValues>
|
||||
name="hosts"
|
||||
render={({ field: { onChange, value } }) => {
|
||||
const schedulingTypeRender = {
|
||||
COLLECTIVE: (
|
||||
<div className="border-subtle mt-6 space-y-5 rounded-lg border px-4 py-6 sm:px-6">
|
||||
<div className="flex flex-col gap-4">
|
||||
<AssignAllTeamMembers
|
||||
assignAllTeamMembers={assignAllTeamMembers}
|
||||
setAssignAllTeamMembers={setAssignAllTeamMembers}
|
||||
onActive={() =>
|
||||
formMethods.setValue(
|
||||
"hosts",
|
||||
teamMembers.map((teamMember) => ({
|
||||
isFixed: true,
|
||||
userId: parseInt(teamMember.value, 10),
|
||||
}))
|
||||
)
|
||||
}
|
||||
onInactive={() => formMethods.setValue("hosts", [])}
|
||||
/>
|
||||
{assignAllTeamMembers ? (
|
||||
<></>
|
||||
) : (
|
||||
<CheckedHostField
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
isFixed={true}
|
||||
options={teamMembers.sort(sortByLabel)}
|
||||
placeholder={t("add_attendees")}
|
||||
labelText={t("team")}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<FixedHosts
|
||||
teamMembers={teamMembers}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
assignAllTeamMembers={assignAllTeamMembers}
|
||||
setAssignAllTeamMembers={setAssignAllTeamMembers}
|
||||
/>
|
||||
),
|
||||
ROUND_ROBIN: (
|
||||
<>
|
||||
<RoundRobinHosts
|
||||
<FixedHosts
|
||||
teamMembers={teamMembers}
|
||||
value={value}
|
||||
onChange={(changeValue) => {
|
||||
onChange([...value.filter((host: Host) => !host.isFixed), ...changeValue]);
|
||||
}}
|
||||
assignAllTeamMembers={assignAllTeamMembers}
|
||||
setAssignAllTeamMembers={setAssignAllTeamMembers}
|
||||
isRoundRobinEvent={true}
|
||||
/>
|
||||
<RoundRobinHosts
|
||||
teamMembers={teamMembers}
|
||||
value={value}
|
||||
onChange={(changeValue) => {
|
||||
onChange(
|
||||
[...value.filter((host: Host) => host.isFixed), ...changeValue].sort(
|
||||
(a, b) => b.priority - a.priority
|
||||
)
|
||||
);
|
||||
}}
|
||||
assignAllTeamMembers={assignAllTeamMembers}
|
||||
setAssignAllTeamMembers={setAssignAllTeamMembers}
|
||||
teamMembers={teamMembers}
|
||||
onChange={onChange}
|
||||
value={value}
|
||||
/>
|
||||
{/*<TextField
|
||||
required
|
||||
type="number"
|
||||
label={t("minimum_round_robin_hosts_count")}
|
||||
defaultValue={1}
|
||||
{...formMethods.register("minimumHostCount")}
|
||||
addOnSuffix={<>{t("hosts")}</>}
|
||||
/>*/}
|
||||
</>
|
||||
),
|
||||
MANAGED: <></>,
|
||||
@@ -449,39 +559,47 @@ export const EventTeamTab = ({
|
||||
);
|
||||
});
|
||||
const isManagedEventType = eventType.schedulingType === SchedulingType.MANAGED;
|
||||
const formMethods = useFormContext<FormValues>();
|
||||
const { getValues, setValue } = useFormContext<FormValues>();
|
||||
const [assignAllTeamMembers, setAssignAllTeamMembers] = useState<boolean>(
|
||||
formMethods.getValues("assignAllTeamMembers") ?? false
|
||||
getValues("assignAllTeamMembers") ?? false
|
||||
);
|
||||
|
||||
return (
|
||||
<div>
|
||||
{team && !isManagedEventType && (
|
||||
<div className="space-y-5">
|
||||
<div className="flex flex-col">
|
||||
<Label>{t("scheduling_type")}</Label>
|
||||
<Controller<FormValues>
|
||||
name="schedulingType"
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<Select
|
||||
options={schedulingTypeOptions}
|
||||
value={schedulingTypeOptions.find((opt) => opt.value === value)}
|
||||
className="w-full"
|
||||
onChange={(val) => {
|
||||
onChange(val?.value);
|
||||
formMethods.setValue("assignAllTeamMembers", false);
|
||||
setAssignAllTeamMembers(false);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<>
|
||||
<div className="border-subtle flex flex-col rounded-md">
|
||||
<div className="border-subtle rounded-t-md border p-6 pb-5">
|
||||
<Label className="mb-1 text-sm font-semibold">{t("assignment")}</Label>
|
||||
<p className="text-subtle max-w-full break-words text-sm leading-tight">
|
||||
{t("assignment_description")}
|
||||
</p>
|
||||
</div>
|
||||
<div className="border-subtle rounded-b-md border border-t-0 p-6">
|
||||
<Label>{t("scheduling_type")}</Label>
|
||||
<Controller<FormValues>
|
||||
name="schedulingType"
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<Select
|
||||
options={schedulingTypeOptions}
|
||||
value={schedulingTypeOptions.find((opt) => opt.value === value)}
|
||||
className="w-full"
|
||||
onChange={(val) => {
|
||||
onChange(val?.value);
|
||||
setValue("assignAllTeamMembers", false);
|
||||
setAssignAllTeamMembers(false);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Hosts
|
||||
assignAllTeamMembers={assignAllTeamMembers}
|
||||
setAssignAllTeamMembers={setAssignAllTeamMembers}
|
||||
teamMembers={teamMembersOptions}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{team && isManagedEventType && (
|
||||
<ChildrenEventTypes
|
||||
|
||||
@@ -352,7 +352,7 @@ function EventTypeSingleLayout({
|
||||
StartIcon={Code}
|
||||
color="secondary"
|
||||
variant="icon"
|
||||
namespace={eventType.slug}
|
||||
namespace=""
|
||||
tooltip={t("embed")}
|
||||
tooltipSide="bottom"
|
||||
tooltipOffset={4}
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useState } from "react";
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
|
||||
import { useOrgBranding } from "@calcom/features/ee/organizations/context/provider";
|
||||
import { CAL_URL, IS_SELF_HOSTED } from "@calcom/lib/constants";
|
||||
import { WEBSITE_URL, IS_SELF_HOSTED } from "@calcom/lib/constants";
|
||||
import type { TRPCClientErrorLike } from "@calcom/trpc/client";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import type { AppRouter } from "@calcom/trpc/server/routers/_app";
|
||||
@@ -45,7 +45,7 @@ export const UsernameAvailabilityField = ({
|
||||
|
||||
const usernamePrefix = orgBranding
|
||||
? orgBranding?.fullDomain.replace(/^(https?:|)\/\//, "")
|
||||
: `${CAL_URL?.replace(/^(https?:|)\/\//, "")}`;
|
||||
: `${WEBSITE_URL?.replace(/^(https?:|)\/\//, "")}`;
|
||||
|
||||
return (
|
||||
<Controller
|
||||
|
||||
@@ -11,10 +11,8 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
|
||||
if (!session) {
|
||||
return {
|
||||
redirect: {
|
||||
redirect: {
|
||||
permanent: false,
|
||||
destination: "/auth/login",
|
||||
},
|
||||
permanent: false,
|
||||
destination: "/auth/login",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -11,7 +11,12 @@ export default function useRouterQuery<T extends string>(name: T) {
|
||||
const setQuery = useCallback(
|
||||
(newValue: string | number | null | undefined) => {
|
||||
const _searchParams = new URLSearchParams(searchParams ?? undefined);
|
||||
_searchParams.set(name, newValue as string);
|
||||
if (typeof newValue === "undefined") {
|
||||
// when newValue is of type undefined, clear the search param.
|
||||
_searchParams.delete(name);
|
||||
} else {
|
||||
_searchParams.set(name, newValue as string);
|
||||
}
|
||||
router.replace(`${pathname}?${_searchParams.toString()}`);
|
||||
},
|
||||
[name, pathname, router, searchParams]
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { GetServerSidePropsContext } from "next";
|
||||
import { z } from "zod";
|
||||
|
||||
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
|
||||
import { getBookingForReschedule, getMultipleDurationValue } from "@calcom/features/bookings/lib/get-booking";
|
||||
import { getBookingForReschedule } from "@calcom/features/bookings/lib/get-booking";
|
||||
import type { GetBookingType } from "@calcom/features/bookings/lib/get-booking";
|
||||
import { getSlugOrRequestedSlug } from "@calcom/features/ee/organizations/lib/orgDomains";
|
||||
import { orgDomainConfig } from "@calcom/features/ee/organizations/lib/orgDomains";
|
||||
@@ -82,12 +82,11 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
|
||||
|
||||
return {
|
||||
props: {
|
||||
entity: eventData.entity,
|
||||
duration: getMultipleDurationValue(
|
||||
eventData.metadata?.multipleDuration,
|
||||
queryDuration,
|
||||
eventData.length
|
||||
),
|
||||
eventData: {
|
||||
entity: eventData.entity,
|
||||
length: eventData.length,
|
||||
metadata: eventData.metadata,
|
||||
},
|
||||
booking,
|
||||
away: false,
|
||||
user: teamSlug,
|
||||
|
||||
@@ -10,7 +10,7 @@ import { FilterToggle } from "@calcom/features/bookings/components/FilterToggle"
|
||||
import { FiltersContainer } from "@calcom/features/bookings/components/FiltersContainer";
|
||||
import type { filterQuerySchema } from "@calcom/features/bookings/lib/useFilterQuery";
|
||||
import { useFilterQuery } from "@calcom/features/bookings/lib/useFilterQuery";
|
||||
import { ShellMain } from "@calcom/features/shell/Shell";
|
||||
import Shell from "@calcom/features/shell/Shell";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { useParamsWithFallback } from "@calcom/lib/hooks/useParamsWithFallback";
|
||||
import type { RouterOutputs } from "@calcom/trpc/react";
|
||||
@@ -147,7 +147,13 @@ export default function Bookings() {
|
||||
const [animationParentRef] = useAutoAnimate<HTMLDivElement>();
|
||||
|
||||
return (
|
||||
<ShellMain hideHeadingOnMobile heading={t("bookings")} subtitle={t("bookings_description")}>
|
||||
<Shell
|
||||
withoutMain={false}
|
||||
hideHeadingOnMobile
|
||||
heading={t("bookings")}
|
||||
subtitle={t("bookings_description")}
|
||||
title="Bookings"
|
||||
description="Create events to share for people to book on your calendar.">
|
||||
<div className="flex flex-col">
|
||||
<div className="flex flex-row flex-wrap justify-between">
|
||||
<HorizontalTabs tabs={tabs} />
|
||||
@@ -247,6 +253,6 @@ export default function Bookings() {
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</ShellMain>
|
||||
</Shell>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -82,6 +82,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
|
||||
cancellationReason: true,
|
||||
responses: true,
|
||||
rejectionReason: true,
|
||||
userPrimaryEmail: true,
|
||||
user: {
|
||||
select: {
|
||||
id: true,
|
||||
|
||||
@@ -203,7 +203,7 @@ export default function Success(props: PageProps) {
|
||||
duration: calculatedDuration,
|
||||
organizer: {
|
||||
name: users[0].name || "Nameless",
|
||||
email: users[0].email || "Email-less",
|
||||
email: bookingInfo?.userPrimaryEmail || users[0].email || "Email-less",
|
||||
timeZone: users[0].timeZone,
|
||||
},
|
||||
confirmed: !needsConfirmation,
|
||||
@@ -450,7 +450,9 @@ export default function Success(props: PageProps) {
|
||||
</span>
|
||||
<Badge variant="blue">{t("Host")}</Badge>
|
||||
</div>
|
||||
<p className="text-default">{bookingInfo.user.email}</p>
|
||||
<p className="text-default">
|
||||
{bookingInfo?.userPrimaryEmail ?? bookingInfo.user.email}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{bookingInfo?.attendees.map((attendee) => (
|
||||
|
||||
@@ -16,18 +16,20 @@ import CreateEventTypeDialog from "@calcom/features/eventtypes/components/Create
|
||||
import { DuplicateDialog } from "@calcom/features/eventtypes/components/DuplicateDialog";
|
||||
import { TeamsFilter } from "@calcom/features/filters/components/TeamsFilter";
|
||||
import { getTeamsFiltersFromQuery } from "@calcom/features/filters/lib/getTeamsFiltersFromQuery";
|
||||
import { ShellMain } from "@calcom/features/shell/Shell";
|
||||
import Shell from "@calcom/features/shell/Shell";
|
||||
import { APP_NAME, WEBAPP_URL } from "@calcom/lib/constants";
|
||||
import { CAL_URL } from "@calcom/lib/constants";
|
||||
import { WEBSITE_URL } from "@calcom/lib/constants";
|
||||
import { useCompatSearchParams } from "@calcom/lib/hooks/useCompatSearchParams";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import useMediaQuery from "@calcom/lib/hooks/useMediaQuery";
|
||||
import { useRouterQuery } from "@calcom/lib/hooks/useRouterQuery";
|
||||
import { useTypedQuery } from "@calcom/lib/hooks/useTypedQuery";
|
||||
import { HttpError } from "@calcom/lib/http-error";
|
||||
import type { User } from "@calcom/prisma/client";
|
||||
import { SchedulingType } from "@calcom/prisma/enums";
|
||||
import type { RouterOutputs } from "@calcom/trpc/react";
|
||||
import { trpc, TRPCClientError } from "@calcom/trpc/react";
|
||||
import type { UserProfile } from "@calcom/types/UserProfile";
|
||||
import {
|
||||
Alert,
|
||||
Avatar,
|
||||
@@ -76,6 +78,7 @@ import useMeQuery from "@lib/hooks/useMeQuery";
|
||||
import SkeletonLoader from "@components/eventtype/SkeletonLoader";
|
||||
|
||||
type EventTypeGroups = RouterOutputs["viewer"]["eventTypes"]["getByViewer"]["eventTypeGroups"];
|
||||
|
||||
type EventTypeGroupProfile = EventTypeGroups[number]["profile"];
|
||||
type GetByViewerResponse = RouterOutputs["viewer"]["eventTypes"]["getByViewer"] | undefined;
|
||||
|
||||
@@ -89,16 +92,27 @@ interface EventTypeListHeadingProps {
|
||||
type EventTypeGroup = EventTypeGroups[number];
|
||||
type EventType = EventTypeGroup["eventTypes"][number];
|
||||
|
||||
type DeNormalizedEventType = Omit<EventType, "userIds"> & {
|
||||
users: (Pick<User, "id" | "name" | "username" | "avatarUrl"> & {
|
||||
nonProfileUsername: string | null;
|
||||
profile: UserProfile;
|
||||
})[];
|
||||
};
|
||||
|
||||
type DeNormalizedEventTypeGroup = Omit<EventTypeGroup, "eventTypes"> & {
|
||||
eventTypes: DeNormalizedEventType[];
|
||||
};
|
||||
|
||||
interface EventTypeListProps {
|
||||
group: EventTypeGroup;
|
||||
group: DeNormalizedEventTypeGroup;
|
||||
groupIndex: number;
|
||||
readOnly: boolean;
|
||||
bookerUrl: string | null;
|
||||
types: EventType[];
|
||||
types: DeNormalizedEventType[];
|
||||
}
|
||||
|
||||
interface MobileTeamsTabProps {
|
||||
eventTypeGroups: EventTypeGroups;
|
||||
eventTypeGroups: DeNormalizedEventTypeGroup[];
|
||||
}
|
||||
|
||||
const querySchema = z.object({
|
||||
@@ -136,7 +150,15 @@ const MobileTeamsTab: FC<MobileTeamsTabProps> = (props) => {
|
||||
);
|
||||
};
|
||||
|
||||
const Item = ({ type, group, readOnly }: { type: EventType; group: EventTypeGroup; readOnly: boolean }) => {
|
||||
const Item = ({
|
||||
type,
|
||||
group,
|
||||
readOnly,
|
||||
}: {
|
||||
type: DeNormalizedEventType;
|
||||
group: DeNormalizedEventTypeGroup;
|
||||
readOnly: boolean;
|
||||
}) => {
|
||||
const { t } = useLocale();
|
||||
|
||||
const content = () => (
|
||||
@@ -166,11 +188,7 @@ const Item = ({ type, group, readOnly }: { type: EventType; group: EventTypeGrou
|
||||
return readOnly ? (
|
||||
<div className="flex-1 overflow-hidden pr-4 text-sm">
|
||||
{content()}
|
||||
<EventTypeDescription
|
||||
// @ts-expect-error FIXME: We have a type mismatch here @hariombalhara @sean-brydon
|
||||
eventType={type}
|
||||
shortenDescription
|
||||
/>
|
||||
<EventTypeDescription eventType={type} shortenDescription />
|
||||
</div>
|
||||
) : (
|
||||
<Link
|
||||
@@ -197,7 +215,6 @@ const Item = ({ type, group, readOnly }: { type: EventType; group: EventTypeGrou
|
||||
)}
|
||||
</div>
|
||||
<EventTypeDescription
|
||||
// @ts-expect-error FIXME: We have a type mismatch here @hariombalhara @sean-brydon
|
||||
eventType={{ ...type, descriptionAsSafeHTML: type.safeDescription }}
|
||||
shortenDescription
|
||||
/>
|
||||
@@ -243,7 +260,7 @@ export const EventTypeList = ({
|
||||
await utils.viewer.eventTypes.getByViewer.cancel();
|
||||
const previousValue = utils.viewer.eventTypes.getByViewer.getData();
|
||||
if (previousValue) {
|
||||
const newList = [...types];
|
||||
const newList = [...types.map(normalizeEventType)];
|
||||
const itemIndex = newList.findIndex((item) => item.id === id);
|
||||
if (itemIndex !== -1 && newList[itemIndex]) {
|
||||
newList[itemIndex].hidden = !newList[itemIndex].hidden;
|
||||
@@ -272,13 +289,13 @@ export const EventTypeList = ({
|
||||
});
|
||||
|
||||
async function moveEventType(index: number, increment: 1 | -1) {
|
||||
const newList = [...types];
|
||||
const newList = [...types.map(normalizeEventType)];
|
||||
|
||||
const type = types[index];
|
||||
const tmp = types[index + increment];
|
||||
if (tmp) {
|
||||
newList[index] = tmp;
|
||||
newList[index + increment] = type;
|
||||
newList[index] = normalizeEventType(tmp);
|
||||
newList[index + increment] = normalizeEventType(type);
|
||||
}
|
||||
|
||||
await utils.viewer.eventTypes.getByViewer.cancel();
|
||||
@@ -306,7 +323,7 @@ export const EventTypeList = ({
|
||||
}
|
||||
|
||||
// inject selection data into url for correct router history
|
||||
const openDuplicateModal = (eventType: EventType, group: EventTypeGroup) => {
|
||||
const openDuplicateModal = (eventType: DeNormalizedEventType, group: DeNormalizedEventTypeGroup) => {
|
||||
const newSearchParams = new URLSearchParams(searchParams ?? undefined);
|
||||
function setParamsIfDefined(key: string, value: string | number | boolean | null | undefined) {
|
||||
if (value) newSearchParams.set(key, value.toString());
|
||||
@@ -331,7 +348,7 @@ export const EventTypeList = ({
|
||||
await utils.viewer.eventTypes.getByViewer.cancel();
|
||||
const previousValue = utils.viewer.eventTypes.getByViewer.getData();
|
||||
if (previousValue) {
|
||||
const newList = types.filter((item) => item.id !== id);
|
||||
const newList = types.filter((item) => item.id !== id).map(normalizeEventType);
|
||||
|
||||
utils.viewer.eventTypes.getByViewer.setData(undefined, {
|
||||
...previousValue,
|
||||
@@ -390,7 +407,7 @@ export const EventTypeList = ({
|
||||
const embedLink = `${group.profile.slug}/${type.slug}`;
|
||||
const calLink = `${bookerUrl}/${embedLink}`;
|
||||
const isPrivateURLEnabled = type.hashedLink?.link;
|
||||
const placeholderHashedLink = `${CAL_URL}/d/${type.hashedLink?.link}/${type.slug}`;
|
||||
const placeholderHashedLink = `${WEBSITE_URL}/d/${type.hashedLink?.link}/${type.slug}`;
|
||||
const isManagedEventType = type.schedulingType === SchedulingType.MANAGED;
|
||||
const isChildrenManagedEventType =
|
||||
type.metadata?.managedEventConfig !== undefined && type.schedulingType !== SchedulingType.MANAGED;
|
||||
@@ -522,7 +539,7 @@ export const EventTypeList = ({
|
||||
{!isManagedEventType && (
|
||||
<DropdownMenuItem className="outline-none">
|
||||
<EventTypeEmbedButton
|
||||
namespace={type.slug}
|
||||
namespace=""
|
||||
as={DropdownItem}
|
||||
type="button"
|
||||
StartIcon={Code}
|
||||
@@ -821,7 +838,7 @@ const Actions = () => {
|
||||
);
|
||||
};
|
||||
|
||||
const EmptyEventTypeList = ({ group }: { group: EventTypeGroup }) => {
|
||||
const EmptyEventTypeList = ({ group }: { group: DeNormalizedEventTypeGroup }) => {
|
||||
const { t } = useLocale();
|
||||
return (
|
||||
<>
|
||||
@@ -843,7 +860,7 @@ const EmptyEventTypeList = ({ group }: { group: EventTypeGroup }) => {
|
||||
const Main = ({
|
||||
status,
|
||||
errorMessage,
|
||||
data,
|
||||
data: rawData,
|
||||
filters,
|
||||
}: {
|
||||
status: string;
|
||||
@@ -854,7 +871,7 @@ const Main = ({
|
||||
const isMobile = useMediaQuery("(max-width: 768px)");
|
||||
const searchParams = useCompatSearchParams();
|
||||
|
||||
if (!data || status === "pending") {
|
||||
if (!rawData || status === "pending") {
|
||||
return <SkeletonLoader />;
|
||||
}
|
||||
|
||||
@@ -863,7 +880,11 @@ const Main = ({
|
||||
}
|
||||
|
||||
const isFilteredByOnlyOneItem =
|
||||
(filters?.teamIds?.length === 1 || filters?.userIds?.length === 1) && data.eventTypeGroups.length === 1;
|
||||
(filters?.teamIds?.length === 1 || filters?.userIds?.length === 1) &&
|
||||
rawData.eventTypeGroups.length === 1;
|
||||
|
||||
const data = denormalizePayload(rawData);
|
||||
|
||||
return (
|
||||
<>
|
||||
{data.eventTypeGroups.length > 1 || isFilteredByOnlyOneItem ? (
|
||||
@@ -871,7 +892,7 @@ const Main = ({
|
||||
{isMobile ? (
|
||||
<MobileTeamsTab eventTypeGroups={data.eventTypeGroups} />
|
||||
) : (
|
||||
data.eventTypeGroups.map((group: EventTypeGroup, index: number) => (
|
||||
data.eventTypeGroups.map((group, index: number) => (
|
||||
<div
|
||||
className="mt-4 flex flex-col"
|
||||
data-testid={`slug-${group.profile.slug}`}
|
||||
@@ -953,7 +974,10 @@ const EventTypesPage: React.FC & {
|
||||
}, [orgBranding, user]);
|
||||
|
||||
return (
|
||||
<ShellMain
|
||||
<Shell
|
||||
withoutMain={false}
|
||||
title="Event Types"
|
||||
description="Create events to share for people to book on your calendar."
|
||||
withoutSeo
|
||||
heading={t("event_types_page_title")}
|
||||
hideHeadingOnMobile
|
||||
@@ -965,8 +989,39 @@ const EventTypesPage: React.FC & {
|
||||
description="Create events to share for people to book on your calendar."
|
||||
/>
|
||||
<Main data={data} status={status} errorMessage={error?.message} filters={filters} />
|
||||
</ShellMain>
|
||||
</Shell>
|
||||
);
|
||||
};
|
||||
|
||||
export default EventTypesPage;
|
||||
|
||||
function normalizeEventType(eventType: DeNormalizedEventType): EventType {
|
||||
return {
|
||||
...eventType,
|
||||
userIds: eventType.users.map((user) => user.id),
|
||||
};
|
||||
}
|
||||
|
||||
function denormalizePayload(data: NonNullable<GetByViewerResponse>) {
|
||||
return {
|
||||
...data,
|
||||
eventTypeGroups: data.eventTypeGroups.map((eventTypeGroup) => {
|
||||
return {
|
||||
...eventTypeGroup,
|
||||
eventTypes: eventTypeGroup.eventTypes.map((eventType) => {
|
||||
const { userIds, ...rest } = eventType;
|
||||
return {
|
||||
...rest,
|
||||
users: userIds.map((userId) => {
|
||||
const user = data.allUsersAcrossAllEventTypes.get(userId);
|
||||
if (!user) {
|
||||
throw new Error(`User with id ${userId} not found in allUsersAcrossAllEventTypes`);
|
||||
}
|
||||
return user;
|
||||
}),
|
||||
};
|
||||
}),
|
||||
};
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@ import { validateCustomEventName } from "@calcom/core/event";
|
||||
import type { EventLocationType } from "@calcom/core/location";
|
||||
import type { ChildrenEventType } from "@calcom/features/eventtypes/components/ChildrenEventTypeSelect";
|
||||
import { validateIntervalLimitOrder } from "@calcom/lib";
|
||||
import { CAL_URL } from "@calcom/lib/constants";
|
||||
import { WEBSITE_URL } from "@calcom/lib/constants";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { useTypedQuery } from "@calcom/lib/hooks/useTypedQuery";
|
||||
import { HttpError } from "@calcom/lib/http-error";
|
||||
@@ -82,6 +82,8 @@ const EventWebhooksTab = dynamic(() =>
|
||||
|
||||
const ManagedEventTypeDialog = dynamic(() => import("@components/eventtype/ManagedEventDialog"));
|
||||
|
||||
export type Host = { isFixed: boolean; userId: number; priority: number };
|
||||
|
||||
export type FormValues = {
|
||||
id: number;
|
||||
title: string;
|
||||
@@ -139,13 +141,14 @@ export type FormValues = {
|
||||
bookingLimits?: IntervalLimit;
|
||||
onlyShowFirstAvailableSlot: boolean;
|
||||
children: ChildrenEventType[];
|
||||
hosts: { userId: number; isFixed: boolean }[];
|
||||
hosts: Host[];
|
||||
bookingFields: z.infer<typeof eventTypeBookingFields>;
|
||||
availability?: AvailabilityOption;
|
||||
bookerLayouts: BookerLayoutSettings;
|
||||
multipleDurationEnabled: boolean;
|
||||
users: EventTypeSetup["users"];
|
||||
assignAllTeamMembers: boolean;
|
||||
useEventTypeDestinationCalendarEmail: boolean;
|
||||
};
|
||||
|
||||
export type CustomInputParsed = typeof customInputSchema._output;
|
||||
@@ -296,6 +299,7 @@ const EventTypePage = (props: EventTypeSetupProps) => {
|
||||
hosts: eventType.hosts,
|
||||
successRedirectUrl: eventType.successRedirectUrl || "",
|
||||
users: eventType.users,
|
||||
useEventTypeDestinationCalendarEmail: eventType.useEventTypeDestinationCalendarEmail,
|
||||
children: eventType.children.map((ch) => ({
|
||||
...ch,
|
||||
created: true,
|
||||
@@ -421,10 +425,9 @@ const EventTypePage = (props: EventTypeSetupProps) => {
|
||||
).length;
|
||||
}
|
||||
|
||||
const permalink = `${CAL_URL}/${team ? `team/${team.slug}` : eventType.users[0].username}/${
|
||||
const permalink = `${WEBSITE_URL}/${team ? `team/${team.slug}` : eventType.users[0].username}/${
|
||||
eventType.slug
|
||||
}`;
|
||||
|
||||
const tabMap = {
|
||||
setup: (
|
||||
<EventSetupTab
|
||||
|
||||
+11
-1
@@ -207,7 +207,17 @@ const nextConfig = {
|
||||
images: {
|
||||
unoptimized: true,
|
||||
},
|
||||
webpack: (config, { webpack, buildId }) => {
|
||||
webpack: (config, { webpack, buildId, isServer }) => {
|
||||
if (isServer) {
|
||||
// Module not found fix @see https://github.com/boxyhq/jackson/issues/1535#issuecomment-1704381612
|
||||
config.plugins.push(
|
||||
new webpack.IgnorePlugin({
|
||||
resourceRegExp:
|
||||
/(^@google-cloud\/spanner|^@mongodb-js\/zstd|^@sap\/hana-client\/extension\/Stream$|^@sap\/hana-client|^@sap\/hana-client$|^aws-crt|^aws4$|^better-sqlite3$|^bson-ext$|^cardinal$|^cloudflare:sockets$|^hdb-pool$|^ioredis$|^kerberos$|^mongodb-client-encryption$|^mysql$|^oracledb$|^pg-native$|^pg-query-stream$|^react-native-sqlite-storage$|^snappy\/package\.json$|^snappy$|^sql.js$|^sqlite3$|^typeorm-aurora-data-api-driver$)/,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
config.plugins.push(
|
||||
new CopyWebpackPlugin({
|
||||
patterns: [
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@calcom/web",
|
||||
"version": "3.7.13",
|
||||
"version": "3.8.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"analyze": "ANALYZE=true next build",
|
||||
@@ -24,7 +24,7 @@
|
||||
"yarn": "3.4.1"
|
||||
},
|
||||
"dependencies": {
|
||||
"@boxyhq/saml-jackson": "1.8.2",
|
||||
"@boxyhq/saml-jackson": "1.18.6",
|
||||
"@calcom/app-store": "*",
|
||||
"@calcom/app-store-cli": "*",
|
||||
"@calcom/core": "*",
|
||||
@@ -130,7 +130,7 @@
|
||||
"tailwindcss-radix": "^2.6.0",
|
||||
"turndown": "^7.1.1",
|
||||
"uuid": "^8.3.2",
|
||||
"zod": "^3.22.2"
|
||||
"zod": "^3.22.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.19.6",
|
||||
|
||||
@@ -38,7 +38,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
|
||||
const oldPassword = req.body.oldPassword;
|
||||
const newPassword = req.body.newPassword;
|
||||
|
||||
const currentPassword = user.password;
|
||||
const currentPassword = user.password?.hash;
|
||||
if (!currentPassword) {
|
||||
return res.status(400).json({ error: ErrorCode.UserMissingPassword });
|
||||
}
|
||||
@@ -53,12 +53,16 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
|
||||
}
|
||||
|
||||
const hashedPassword = await hashPassword(newPassword);
|
||||
await prisma.user.update({
|
||||
await prisma.userPassword.upsert({
|
||||
where: {
|
||||
id: user.id,
|
||||
userId: user.id,
|
||||
},
|
||||
data: {
|
||||
password: hashedPassword,
|
||||
create: {
|
||||
hash: hashedPassword,
|
||||
userId: user.id,
|
||||
},
|
||||
update: {
|
||||
hash: hashedPassword,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -41,7 +41,13 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
|
||||
email: maybeRequest.email,
|
||||
},
|
||||
data: {
|
||||
password: hashedPassword,
|
||||
password: {
|
||||
upsert: {
|
||||
create: { hash: hashedPassword },
|
||||
update: { hash: hashedPassword },
|
||||
},
|
||||
},
|
||||
emailVerified: new Date(),
|
||||
},
|
||||
});
|
||||
} catch (e) {
|
||||
|
||||
@@ -41,7 +41,7 @@ async function handler(req: NextApiRequest) {
|
||||
data: {
|
||||
username,
|
||||
email: userEmail,
|
||||
password: hashedPassword,
|
||||
password: { create: { hash: hashedPassword } },
|
||||
role: "ADMIN",
|
||||
name: parsedQuery.data.full_name,
|
||||
emailVerified: new Date(),
|
||||
|
||||
@@ -23,13 +23,13 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
|
||||
return res.status(500).json({ error: ErrorCode.InternalServerError });
|
||||
}
|
||||
|
||||
const user = await prisma.user.findUnique({ where: { id: session.user.id } });
|
||||
const user = await prisma.user.findUnique({ where: { id: session.user.id }, include: { password: true } });
|
||||
if (!user) {
|
||||
console.error(`Session references user that no longer exists.`);
|
||||
return res.status(401).json({ message: "Not authenticated" });
|
||||
}
|
||||
|
||||
if (!user.password && user.identityProvider === IdentityProvider.CAL) {
|
||||
if (!user.password?.hash && user.identityProvider === IdentityProvider.CAL) {
|
||||
return res.status(400).json({ error: ErrorCode.UserMissingPassword });
|
||||
}
|
||||
|
||||
@@ -37,8 +37,8 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
|
||||
return res.json({ message: "Two factor disabled" });
|
||||
}
|
||||
|
||||
if (user.password && user.identityProvider === IdentityProvider.CAL) {
|
||||
const isCorrectPassword = await verifyPassword(req.body.password, user.password);
|
||||
if (user.password?.hash && user.identityProvider === IdentityProvider.CAL) {
|
||||
const isCorrectPassword = await verifyPassword(req.body.password, user.password.hash);
|
||||
if (!isCorrectPassword) {
|
||||
return res.status(400).json({ error: ErrorCode.IncorrectPassword });
|
||||
}
|
||||
|
||||
@@ -25,7 +25,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
|
||||
return res.status(500).json({ error: ErrorCode.InternalServerError });
|
||||
}
|
||||
|
||||
const user = await prisma.user.findUnique({ where: { id: session.user.id } });
|
||||
const user = await prisma.user.findUnique({ where: { id: session.user.id }, include: { password: true } });
|
||||
if (!user) {
|
||||
console.error(`Session references user that no longer exists.`);
|
||||
return res.status(401).json({ message: "Not authenticated" });
|
||||
@@ -35,7 +35,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
|
||||
return res.status(400).json({ error: ErrorCode.ThirdPartyIdentityProviderEnabled });
|
||||
}
|
||||
|
||||
if (!user.password) {
|
||||
if (!user.password?.hash) {
|
||||
return res.status(400).json({ error: ErrorCode.UserMissingPassword });
|
||||
}
|
||||
|
||||
@@ -48,7 +48,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
|
||||
return res.status(500).json({ error: ErrorCode.InternalServerError });
|
||||
}
|
||||
|
||||
const isCorrectPassword = await verifyPassword(req.body.password, user.password);
|
||||
const isCorrectPassword = await verifyPassword(req.body.password, user.password.hash);
|
||||
if (!isCorrectPassword) {
|
||||
return res.status(400).json({ error: ErrorCode.IncorrectPassword });
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { z } from "zod";
|
||||
import dayjs from "@calcom/dayjs";
|
||||
import { WEBAPP_URL } from "@calcom/lib/constants";
|
||||
import { prisma } from "@calcom/prisma";
|
||||
import { userMetadata } from "@calcom/prisma/zod-utils";
|
||||
|
||||
const verifySchema = z.object({
|
||||
token: z.string(),
|
||||
@@ -26,23 +27,71 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
|
||||
return res.status(401).json({ message: "Token expired" });
|
||||
}
|
||||
|
||||
const user = await prisma.user.update({
|
||||
const user = await prisma.user.findFirst({
|
||||
where: {
|
||||
email: foundToken?.identifier,
|
||||
},
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
return res.status(401).json({ message: "Cannot find a user attached to this token" });
|
||||
}
|
||||
|
||||
const userMetadataParsed = userMetadata.parse(user.metadata);
|
||||
// Attach the new email and verify
|
||||
if (userMetadataParsed?.emailChangeWaitingForVerification) {
|
||||
// Ensure this email isnt in use
|
||||
const existingUser = await prisma.user.findUnique({
|
||||
where: { email: userMetadataParsed?.emailChangeWaitingForVerification },
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (existingUser) {
|
||||
return res.status(401).json({ message: "A User already exists with this email" });
|
||||
}
|
||||
|
||||
const updatedEmail = userMetadataParsed.emailChangeWaitingForVerification;
|
||||
delete userMetadataParsed.emailChangeWaitingForVerification;
|
||||
|
||||
// Update and re-verify
|
||||
await prisma.user.update({
|
||||
where: {
|
||||
id: user.id,
|
||||
},
|
||||
data: {
|
||||
email: updatedEmail,
|
||||
metadata: userMetadataParsed,
|
||||
},
|
||||
});
|
||||
|
||||
await cleanUpVerificationTokens(foundToken.id);
|
||||
|
||||
return res.status(200).json({
|
||||
updatedEmail,
|
||||
});
|
||||
}
|
||||
|
||||
await prisma.user.update({
|
||||
where: {
|
||||
id: user.id,
|
||||
},
|
||||
data: {
|
||||
emailVerified: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
const hasCompletedOnboarding = user.completedOnboarding;
|
||||
|
||||
return res.redirect(`${WEBAPP_URL}/${hasCompletedOnboarding ? "/event-types" : "/getting-started"}`);
|
||||
}
|
||||
|
||||
async function cleanUpVerificationTokens(id: number) {
|
||||
// Delete token from DB after it has been used
|
||||
await prisma.verificationToken.delete({
|
||||
where: {
|
||||
id: foundToken?.id,
|
||||
id,
|
||||
},
|
||||
});
|
||||
|
||||
const hasCompletedOnboarding = user.completedOnboarding;
|
||||
|
||||
res.redirect(`${WEBAPP_URL}/${hasCompletedOnboarding ? "/event-types" : "/getting-started"}`);
|
||||
}
|
||||
|
||||
@@ -17,9 +17,7 @@ async function handler(req: NextApiRequest & { userId?: number }, res: NextApiRe
|
||||
const session = await getServerSession({ req, res });
|
||||
/* To mimic API behavior and comply with types */
|
||||
req.userId = session?.user?.id || -1;
|
||||
const booking = await handleNewBooking(req, {
|
||||
isNotAnApiCall: true,
|
||||
});
|
||||
const booking = await handleNewBooking(req);
|
||||
return booking;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,13 +1,11 @@
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
|
||||
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
|
||||
import handleNewBooking from "@calcom/features/bookings/lib/handleNewBooking";
|
||||
import type { BookingResponse, RecurringBookingCreateBody } from "@calcom/features/bookings/types";
|
||||
import { handleNewRecurringBooking } from "@calcom/features/bookings/lib/handleNewRecurringBooking";
|
||||
import type { BookingResponse } from "@calcom/features/bookings/types";
|
||||
import { checkRateLimitAndThrowError } from "@calcom/lib/checkRateLimitAndThrowError";
|
||||
import getIP from "@calcom/lib/getIP";
|
||||
import { defaultResponder } from "@calcom/lib/server";
|
||||
import { SchedulingType } from "@calcom/prisma/client";
|
||||
import type { AppsStatus } from "@calcom/types/Calendar";
|
||||
|
||||
// @TODO: Didn't look at the contents of this function in order to not break old booking page.
|
||||
|
||||
@@ -18,102 +16,12 @@ async function handler(req: NextApiRequest & { userId?: number }, res: NextApiRe
|
||||
rateLimitingType: "core",
|
||||
identifier: userIp,
|
||||
});
|
||||
const data: RecurringBookingCreateBody[] = req.body;
|
||||
const session = await getServerSession({ req, res });
|
||||
const createdBookings: BookingResponse[] = [];
|
||||
const allRecurringDates: { start: string | undefined; end: string | undefined }[] = data.map((booking) => {
|
||||
return { start: booking.start, end: booking.end };
|
||||
});
|
||||
const appsStatus: AppsStatus[] | undefined = undefined;
|
||||
|
||||
/* To mimic API behavior and comply with types */
|
||||
req.userId = session?.user?.id || -1;
|
||||
const numSlotsToCheckForAvailability = 2;
|
||||
|
||||
let thirdPartyRecurringEventId = null;
|
||||
const createdBookings: BookingResponse[] = await handleNewRecurringBooking(req);
|
||||
|
||||
// for round robin, the first slot needs to be handled first to define the lucky user
|
||||
const firstBooking = data[0];
|
||||
const isRoundRobin = firstBooking.schedulingType === SchedulingType.ROUND_ROBIN;
|
||||
|
||||
let luckyUsers = undefined;
|
||||
|
||||
if (isRoundRobin) {
|
||||
const recurringEventReq: NextApiRequest & { userId?: number } = req;
|
||||
|
||||
recurringEventReq.body = {
|
||||
...firstBooking,
|
||||
appsStatus,
|
||||
allRecurringDates,
|
||||
isFirstRecurringSlot: true,
|
||||
thirdPartyRecurringEventId,
|
||||
numSlotsToCheckForAvailability,
|
||||
currentRecurringIndex: 0,
|
||||
noEmail: false,
|
||||
};
|
||||
|
||||
const firstBookingResult = await handleNewBooking(recurringEventReq, {
|
||||
isNotAnApiCall: true,
|
||||
});
|
||||
luckyUsers = firstBookingResult.luckyUsers?.map((user) => user.id);
|
||||
}
|
||||
|
||||
for (let key = isRoundRobin ? 1 : 0; key < data.length; key++) {
|
||||
const booking = data[key];
|
||||
// Disable AppStatus in Recurring Booking Email as it requires us to iterate backwards to be able to compute the AppsStatus for all the bookings except the very first slot and then send that slot's email with statuses
|
||||
// It is also doubtful that how useful is to have the AppsStatus of all the bookings in the email.
|
||||
// It is more important to iterate forward and check for conflicts for only first few bookings defined by 'numSlotsToCheckForAvailability'
|
||||
// if (key === 0) {
|
||||
// const calcAppsStatus: { [key: string]: AppsStatus } = createdBookings
|
||||
// .flatMap((book) => (book.appsStatus !== undefined ? book.appsStatus : []))
|
||||
// .reduce((prev, curr) => {
|
||||
// if (prev[curr.type]) {
|
||||
// prev[curr.type].failures += curr.failures;
|
||||
// prev[curr.type].success += curr.success;
|
||||
// } else {
|
||||
// prev[curr.type] = curr;
|
||||
// }
|
||||
// return prev;
|
||||
// }, {} as { [key: string]: AppsStatus });
|
||||
// appsStatus = Object.values(calcAppsStatus);
|
||||
// }
|
||||
|
||||
const recurringEventReq: NextApiRequest & { userId?: number } = req;
|
||||
|
||||
recurringEventReq.body = {
|
||||
...booking,
|
||||
appsStatus,
|
||||
allRecurringDates,
|
||||
isFirstRecurringSlot: key == 0,
|
||||
thirdPartyRecurringEventId,
|
||||
numSlotsToCheckForAvailability,
|
||||
currentRecurringIndex: key,
|
||||
noEmail: key !== 0,
|
||||
luckyUsers,
|
||||
};
|
||||
|
||||
const promiseEachRecurringBooking: ReturnType<typeof handleNewBooking> = handleNewBooking(
|
||||
recurringEventReq,
|
||||
{
|
||||
isNotAnApiCall: true,
|
||||
}
|
||||
);
|
||||
|
||||
const eachRecurringBooking = await promiseEachRecurringBooking;
|
||||
|
||||
createdBookings.push(eachRecurringBooking);
|
||||
|
||||
if (!thirdPartyRecurringEventId) {
|
||||
if (eachRecurringBooking.references && eachRecurringBooking.references.length > 0) {
|
||||
for (const reference of eachRecurringBooking.references!) {
|
||||
if (reference.thirdPartyRecurringEventId) {
|
||||
thirdPartyRecurringEventId = reference.thirdPartyRecurringEventId;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return createdBookings;
|
||||
}
|
||||
|
||||
|
||||
@@ -119,7 +119,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
|
||||
endTime: booking.endTime.toISOString(),
|
||||
organizer: {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
email: booking?.userPrimaryEmail ?? user.email,
|
||||
name,
|
||||
timeZone: user.timeZone,
|
||||
language: { translate: tOrganizer, locale: user.locale ?? "en" },
|
||||
|
||||
@@ -230,7 +230,7 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
startTime: booking.startTime.toISOString(),
|
||||
endTime: booking.endTime.toISOString(),
|
||||
organizer: {
|
||||
email: booking.user?.email || "Email-less",
|
||||
email: booking?.userPrimaryEmail || booking.user?.email || "Email-less",
|
||||
name: booking.user?.name || "Nameless",
|
||||
timeZone: booking.user?.timeZone || "Europe/London",
|
||||
language: { translate: t, locale: booking?.user?.locale ?? "en" },
|
||||
|
||||
@@ -6,9 +6,12 @@ import type { GetStaticPaths } from "next";
|
||||
import Link from "next/link";
|
||||
|
||||
import { IS_PRODUCTION } from "@calcom/lib/constants";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import prisma from "@calcom/prisma";
|
||||
import { showToast } from "@calcom/ui";
|
||||
|
||||
import { getStaticProps } from "@lib/apps/[slug]/getStaticProps";
|
||||
import useRouterQuery from "@lib/hooks/useRouterQuery";
|
||||
import type { inferSSRProps } from "@lib/types/inferSSRProps";
|
||||
|
||||
import PageWrapper from "@components/PageWrapper";
|
||||
@@ -17,6 +20,12 @@ import App from "@components/apps/App";
|
||||
const md = new MarkdownIt("default", { html: true, breaks: true });
|
||||
|
||||
function SingleAppPage(props: inferSSRProps<typeof getStaticProps>) {
|
||||
const { error, setQuery: setError } = useRouterQuery("error");
|
||||
const { t } = useLocale();
|
||||
if (error === "account_already_linked") {
|
||||
showToast(t(error), "error", { id: error });
|
||||
setError(undefined);
|
||||
}
|
||||
// If it's not production environment, it would be a better idea to inform that the App is disabled.
|
||||
if (props.isAppDisabled) {
|
||||
if (!IS_PRODUCTION) {
|
||||
|
||||
@@ -26,6 +26,8 @@ export default function Apps({ apps }: PageProps) {
|
||||
<Shell
|
||||
isPublic
|
||||
backPath="/apps"
|
||||
title="Apps Store"
|
||||
description="Connecting people, technology and the workplace."
|
||||
smallHeading
|
||||
heading={
|
||||
<>
|
||||
@@ -45,7 +47,7 @@ export default function Apps({ apps }: PageProps) {
|
||||
<div className="mb-16">
|
||||
<div className="grid-col-1 grid grid-cols-1 gap-3 md:grid-cols-3">
|
||||
{apps
|
||||
.sort((a, b) => (b.installCount || 0) - (a.installCount || 0))
|
||||
?.sort((a, b) => (b.installCount || 0) - (a.installCount || 0))
|
||||
.map((app) => {
|
||||
return <AppCard key={app.slug} app={app} />;
|
||||
})}
|
||||
|
||||
@@ -16,7 +16,12 @@ export default function Apps({ categories }: Omit<inferSSRProps<typeof getServer
|
||||
const { t, isLocaleReady } = useLocale();
|
||||
|
||||
return (
|
||||
<Shell isPublic large hideHeadingOnMobile>
|
||||
<Shell
|
||||
isPublic
|
||||
large
|
||||
hideHeadingOnMobile
|
||||
title="Apps Store"
|
||||
description="Connecting people, technology and the workplace.">
|
||||
<div className="text-md flex items-center gap-1 px-4 pb-3 pt-3 font-normal md:px-8 lg:px-0 lg:pt-0">
|
||||
<Link
|
||||
href="/apps"
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import type { ChangeEventHandler } from "react";
|
||||
import { useState } from "react";
|
||||
|
||||
import { getLayout } from "@calcom/features/MainLayout";
|
||||
import Shell from "@calcom/features/shell/Shell";
|
||||
import { classNames } from "@calcom/lib";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import type { inferSSRProps } from "@calcom/types/inferSSRProps";
|
||||
@@ -103,4 +103,14 @@ export default function Apps({
|
||||
export { getServerSideProps };
|
||||
|
||||
Apps.PageWrapper = PageWrapper;
|
||||
Apps.getLayout = getLayout;
|
||||
Apps.getLayout = (page: React.ReactElement) => {
|
||||
return (
|
||||
<Shell
|
||||
title="Apps Store"
|
||||
description="Create forms to direct attendees to the correct destinations."
|
||||
withoutMain={true}
|
||||
hideHeadingOnMobile>
|
||||
{page}
|
||||
</Shell>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
"use client";
|
||||
|
||||
import type { GetServerSidePropsContext } from "next";
|
||||
import { useSession } from "next-auth/react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useEffect } from "react";
|
||||
import { Toaster } from "react-hot-toast";
|
||||
import { z } from "zod";
|
||||
|
||||
import { WEBAPP_URL } from "@calcom/lib/constants";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { showToast } from "@calcom/ui";
|
||||
|
||||
import PageWrapper from "@components/PageWrapper";
|
||||
|
||||
interface PageProps {
|
||||
token: string;
|
||||
updateSession: string;
|
||||
updatedEmail: string;
|
||||
}
|
||||
|
||||
function VerifyEmailChange(props: PageProps) {
|
||||
const { update } = useSession();
|
||||
const { t, isLocaleReady } = useLocale();
|
||||
const router = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
async function updateSession() {
|
||||
await update({ email: props.updatedEmail });
|
||||
router.push("/event-types");
|
||||
}
|
||||
if (props.updateSession) {
|
||||
updateSession();
|
||||
if (isLocaleReady) {
|
||||
showToast(t("verify_email_change_success_toast", { email: props.updatedEmail }), "success");
|
||||
}
|
||||
} else {
|
||||
if (isLocaleReady) {
|
||||
showToast(t("verify_email_change_failure_toast"), "error");
|
||||
}
|
||||
}
|
||||
// We only need this to run on inital mount. These props can't and won't change due to it being fetched serveside.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="flex h-screen w-full items-center justify-center">
|
||||
<Toaster position="bottom-right" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const tokenSchema = z.object({
|
||||
token: z.string(),
|
||||
});
|
||||
|
||||
export async function getServerSideProps(context: GetServerSidePropsContext) {
|
||||
const { token } = tokenSchema.parse(context.query);
|
||||
|
||||
if (!token) {
|
||||
return {
|
||||
notFound: true,
|
||||
};
|
||||
}
|
||||
|
||||
const params = new URLSearchParams({
|
||||
token,
|
||||
});
|
||||
|
||||
const response = await fetch(`${WEBAPP_URL}/api/auth/verify-email?${params.toString()}`, {
|
||||
method: "POST",
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
return {
|
||||
props: {
|
||||
updateSession: false,
|
||||
token,
|
||||
updatedEmail: false,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
return {
|
||||
props: {
|
||||
updateSession: true,
|
||||
token,
|
||||
updatedEmail: data.updatedEmail ?? null,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export default VerifyEmailChange;
|
||||
VerifyEmailChange.PageWrapper = PageWrapper;
|
||||
@@ -3,9 +3,8 @@ import Link from "next/link";
|
||||
import { useRouter, usePathname } from "next/navigation";
|
||||
import { useCallback } from "react";
|
||||
|
||||
import { getLayout } from "@calcom/features/MainLayout";
|
||||
import { NewScheduleButton, ScheduleListItem } from "@calcom/features/schedules";
|
||||
import { ShellMain } from "@calcom/features/shell/Shell";
|
||||
import Shell from "@calcom/features/shell/Shell";
|
||||
import { AvailabilitySliderTable } from "@calcom/features/timezone-buddy/components/AvailabilitySliderTable";
|
||||
import { useCompatSearchParams } from "@calcom/lib/hooks/useCompatSearchParams";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
@@ -167,9 +166,12 @@ export default function AvailabilityPage() {
|
||||
);
|
||||
return (
|
||||
<div>
|
||||
<ShellMain
|
||||
<Shell
|
||||
heading={t("availability")}
|
||||
title="Availability"
|
||||
description="Configure times when you are available for bookings."
|
||||
hideHeadingOnMobile
|
||||
withoutMain={false}
|
||||
subtitle={t("configure_availability")}
|
||||
CTA={
|
||||
<div className="flex gap-2">
|
||||
@@ -189,11 +191,9 @@ export default function AvailabilityPage() {
|
||||
</div>
|
||||
}>
|
||||
{searchParams?.get("type") === "team" ? <AvailabilitySliderTable /> : <AvailabilityListWithQuery />}
|
||||
</ShellMain>
|
||||
</Shell>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
AvailabilityPage.getLayout = getLayout;
|
||||
|
||||
AvailabilityPage.PageWrapper = PageWrapper;
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import type { GetStaticPaths } from "next";
|
||||
|
||||
import { getLayout } from "@calcom/features/MainLayout";
|
||||
|
||||
import PageWrapper from "@components/PageWrapper";
|
||||
|
||||
import { validStatuses } from "~/bookings/lib/validStatuses";
|
||||
@@ -12,11 +10,9 @@ export { getStaticProps } from "~/bookings/views/bookings-listing-view.getStatic
|
||||
const BookingsListingPage = new Proxy<{
|
||||
(): JSX.Element;
|
||||
PageWrapper?: typeof PageWrapper;
|
||||
getLayout?: typeof getLayout;
|
||||
}>(BookingsListingView, {});
|
||||
|
||||
BookingsListingPage.PageWrapper = PageWrapper;
|
||||
BookingsListingPage.getLayout = getLayout;
|
||||
|
||||
export const getStaticPaths: GetStaticPaths = () => {
|
||||
return {
|
||||
|
||||
@@ -8,6 +8,7 @@ export type {
|
||||
EventTypeSetup,
|
||||
EventTypeSetupProps,
|
||||
FormValues,
|
||||
Host,
|
||||
} from "~/event-types/views/event-types-single-view";
|
||||
|
||||
EventTypePageWrapper.PageWrapper = PageWrapper;
|
||||
|
||||
@@ -1,12 +1,9 @@
|
||||
import { getLayout } from "@calcom/features/MainLayout";
|
||||
|
||||
import PageWrapper from "@components/PageWrapper";
|
||||
|
||||
import EventTypesPage from "~/event-types/views/event-types-listing-view";
|
||||
|
||||
export { getServerSideProps } from "@lib/event-types/getServerSideProps";
|
||||
|
||||
EventTypesPage.getLayout = getLayout;
|
||||
EventTypesPage.PageWrapper = PageWrapper;
|
||||
|
||||
export default EventTypesPage;
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
"use client";
|
||||
|
||||
import { getLayout } from "@calcom/features/MainLayout";
|
||||
import {
|
||||
AverageEventDurationChart,
|
||||
BookingKPICards,
|
||||
@@ -11,7 +10,7 @@ import {
|
||||
} from "@calcom/features/insights/components";
|
||||
import { FiltersProvider } from "@calcom/features/insights/context/FiltersProvider";
|
||||
import { Filters } from "@calcom/features/insights/filters";
|
||||
import { ShellMain } from "@calcom/features/shell/Shell";
|
||||
import Shell from "@calcom/features/shell/Shell";
|
||||
import { UpgradeTip } from "@calcom/features/tips";
|
||||
import { WEBAPP_URL } from "@calcom/lib/constants";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
@@ -47,7 +46,12 @@ export default function InsightsPage() {
|
||||
|
||||
return (
|
||||
<div>
|
||||
<ShellMain heading="Insights" subtitle={t("insights_subtitle")}>
|
||||
<Shell
|
||||
withoutMain={false}
|
||||
heading="Insights"
|
||||
subtitle={t("insights_subtitle")}
|
||||
title="Insights"
|
||||
description="View booking insights across your events.">
|
||||
<UpgradeTip
|
||||
plan="team"
|
||||
title={t("make_informed_decisions")}
|
||||
@@ -99,12 +103,11 @@ export default function InsightsPage() {
|
||||
</FiltersProvider>
|
||||
)}
|
||||
</UpgradeTip>
|
||||
</ShellMain>
|
||||
</Shell>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
InsightsPage.PageWrapper = PageWrapper;
|
||||
InsightsPage.getLayout = getLayout;
|
||||
|
||||
export { getServerSideProps };
|
||||
|
||||
@@ -139,6 +139,9 @@ const AppearanceView = ({
|
||||
showToast(t("error_updating_settings"), "error");
|
||||
}
|
||||
},
|
||||
onSettled: async () => {
|
||||
await utils.viewer.me.invalidate();
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
@@ -184,6 +187,7 @@ const AppearanceView = ({
|
||||
</div>
|
||||
<SectionBottomActions className="mb-6" align="end">
|
||||
<Button
|
||||
loading={mutation.isPending}
|
||||
disabled={isUserThemeSubmitting || !isUserThemeDirty}
|
||||
type="submit"
|
||||
data-testid="update-theme-btn"
|
||||
@@ -210,6 +214,7 @@ const AppearanceView = ({
|
||||
title={t("bookerlayout_user_settings_title")}
|
||||
description={t("bookerlayout_user_settings_description")}
|
||||
isDisabled={isBookerLayoutFormSubmitting || !isBookerLayoutFormDirty}
|
||||
isLoading={mutation.isPending}
|
||||
/>
|
||||
</Form>
|
||||
|
||||
@@ -295,6 +300,7 @@ const AppearanceView = ({
|
||||
</div>
|
||||
<SectionBottomActions align="end">
|
||||
<Button
|
||||
loading={mutation.isPending}
|
||||
disabled={isBrandColorsFormSubmitting || !isBrandColorsFormDirty}
|
||||
color="primary"
|
||||
type="submit">
|
||||
|
||||
@@ -5,7 +5,6 @@ import { Controller, useForm, useFormState } from "react-hook-form";
|
||||
import dayjs from "@calcom/dayjs";
|
||||
import SectionBottomActions from "@calcom/features/settings/SectionBottomActions";
|
||||
import { getLayout } from "@calcom/features/settings/layouts/SettingsLayout";
|
||||
import { ShellMain } from "@calcom/features/shell/Shell";
|
||||
import { useHasTeamPlan } from "@calcom/lib/hooks/useHasPaidPlan";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
@@ -245,10 +244,8 @@ const OutOfOfficePage = () => {
|
||||
return (
|
||||
<>
|
||||
<Meta title={t("out_of_office")} description={t("out_of_office_description")} borderInShellHeader />
|
||||
<ShellMain>
|
||||
<OutOfOfficeSection />
|
||||
<OutOfOfficeEntriesList />
|
||||
</ShellMain>
|
||||
<OutOfOfficeSection />
|
||||
<OutOfOfficeEntriesList />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -93,8 +93,6 @@ const ProfileView = () => {
|
||||
const updateProfileMutation = trpc.viewer.updateProfile.useMutation({
|
||||
onSuccess: async (res) => {
|
||||
await update(res);
|
||||
showToast(t("settings_updated_successfully"), "success");
|
||||
|
||||
// signout user only in case of password reset
|
||||
if (res.signOutUser && tempFormValues && res.passwordReset) {
|
||||
showToast(t("password_reset_email", { email: tempFormValues.email }), "success");
|
||||
@@ -105,6 +103,12 @@ const ProfileView = () => {
|
||||
utils.viewer.shouldVerifyEmail.invalidate();
|
||||
}
|
||||
|
||||
if (res.hasEmailBeenChanged && res.sendEmailVerification) {
|
||||
showToast(t("change_of_email_toast", { email: tempFormValues?.email }), "success");
|
||||
} else {
|
||||
showToast(t("settings_updated_successfully"), "success");
|
||||
}
|
||||
|
||||
setConfirmAuthEmailChangeWarningDialogOpen(false);
|
||||
setTempFormValues(null);
|
||||
},
|
||||
@@ -342,6 +346,20 @@ const ProfileView = () => {
|
||||
type="creation"
|
||||
Icon={AlertTriangle}>
|
||||
<div className="mb-10">
|
||||
<div className="mb-4 grid gap-2 md:grid-cols-2">
|
||||
<div>
|
||||
<span className="text-emphasis mb-2 block text-sm font-medium leading-none">
|
||||
{t("old_email_address")}
|
||||
</span>
|
||||
<p className="text-subtle leading-none">{user.email}</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-emphasis mb-2 block text-sm font-medium leading-none">
|
||||
{t("new_email_address")}
|
||||
</span>
|
||||
<p className="text-subtle leading-none">{tempFormValues?.email}</p>
|
||||
</div>
|
||||
</div>
|
||||
<PasswordField
|
||||
data-testid="password"
|
||||
name="password"
|
||||
@@ -356,6 +374,7 @@ const ProfileView = () => {
|
||||
</div>
|
||||
<DialogFooter showDivider>
|
||||
<Button
|
||||
data-testId="profile-update-email-submit-button"
|
||||
color="primary"
|
||||
loading={confirmPasswordMutation.isPending}
|
||||
onClick={(e) => onConfirmPassword(e)}>
|
||||
@@ -489,7 +508,12 @@ const ProfileForm = ({
|
||||
<TextField label={t("full_name")} {...formMethods.register("name")} />
|
||||
</div>
|
||||
<div className="mt-6">
|
||||
<TextField label={t("email")} hint={t("change_email_hint")} {...formMethods.register("email")} />
|
||||
<TextField
|
||||
label={t("email")}
|
||||
hint={t("change_email_hint")}
|
||||
data-testId="profile-form-email"
|
||||
{...formMethods.register("email")}
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-6">
|
||||
<Label>{t("about")}</Label>
|
||||
@@ -506,7 +530,12 @@ const ProfileForm = ({
|
||||
</div>
|
||||
</div>
|
||||
<SectionBottomActions align="end">
|
||||
<Button loading={isPending} disabled={isDisabled} color="primary" type="submit">
|
||||
<Button
|
||||
loading={isPending}
|
||||
disabled={isDisabled}
|
||||
color="primary"
|
||||
type="submit"
|
||||
data-testId="profile-submit-button">
|
||||
{t("update")}
|
||||
</Button>
|
||||
</SectionBottomActions>
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useSearchParams } from "next/navigation";
|
||||
|
||||
import { Booker } from "@calcom/atoms";
|
||||
import { getBookerWrapperClasses } from "@calcom/features/bookings/Booker/utils/getBookerWrapperClasses";
|
||||
import { BookerSeo } from "@calcom/features/bookings/components/BookerSeo";
|
||||
@@ -14,6 +16,15 @@ export type PageProps = inferSSRProps<typeof getServerSideProps> & EmbedProps;
|
||||
|
||||
export { getServerSideProps };
|
||||
|
||||
export const getMultipleDurationValue = (
|
||||
multipleDurationConfig: number[] | undefined,
|
||||
queryDuration: string | string[] | null | undefined,
|
||||
defaultValue: number
|
||||
) => {
|
||||
if (!multipleDurationConfig) return null;
|
||||
if (multipleDurationConfig.includes(Number(queryDuration))) return Number(queryDuration);
|
||||
return defaultValue;
|
||||
};
|
||||
export default function Type({
|
||||
slug,
|
||||
user,
|
||||
@@ -21,10 +32,11 @@ export default function Type({
|
||||
away,
|
||||
isEmbed,
|
||||
isBrandingHidden,
|
||||
entity,
|
||||
duration,
|
||||
eventData,
|
||||
isInstantMeeting,
|
||||
}: PageProps) {
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
return (
|
||||
<main className={getBookerWrapperClasses({ isEmbed: !!isEmbed })}>
|
||||
<BookerSeo
|
||||
@@ -33,7 +45,7 @@ export default function Type({
|
||||
rescheduleUid={booking?.uid}
|
||||
hideBranding={isBrandingHidden}
|
||||
isTeamEvent
|
||||
entity={entity}
|
||||
entity={eventData.entity}
|
||||
bookingData={booking}
|
||||
/>
|
||||
<Booker
|
||||
@@ -44,8 +56,16 @@ export default function Type({
|
||||
isInstantMeeting={isInstantMeeting}
|
||||
hideBranding={isBrandingHidden}
|
||||
isTeamEvent
|
||||
entity={entity}
|
||||
duration={duration}
|
||||
entity={eventData.entity}
|
||||
durationConfig={eventData.metadata?.multipleDuration}
|
||||
/* TODO: Currently unused, evaluate it is needed-
|
||||
* Possible alternative approach is to have onDurationChange.
|
||||
*/
|
||||
duration={getMultipleDurationValue(
|
||||
eventData.metadata?.multipleDuration,
|
||||
searchParams?.get("duration"),
|
||||
eventData.length
|
||||
)}
|
||||
/>
|
||||
</main>
|
||||
);
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { getLayout } from "@calcom/features/MainLayout";
|
||||
import { TeamsListing } from "@calcom/features/ee/teams/components";
|
||||
import { ShellMain } from "@calcom/features/shell/Shell";
|
||||
import Shell from "@calcom/features/shell/Shell";
|
||||
import { WEBAPP_URL } from "@calcom/lib/constants";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
@@ -18,8 +17,11 @@ function Teams() {
|
||||
const [user] = trpc.viewer.me.useSuspenseQuery();
|
||||
|
||||
return (
|
||||
<ShellMain
|
||||
<Shell
|
||||
withoutMain={false}
|
||||
heading={t("teams")}
|
||||
title="Teams"
|
||||
description="Create and manage teams to use collaborative features."
|
||||
hideHeadingOnMobile
|
||||
subtitle={t("create_manage_teams_collaborative")}
|
||||
CTA={
|
||||
@@ -35,11 +37,10 @@ function Teams() {
|
||||
)
|
||||
}>
|
||||
<TeamsListing />
|
||||
</ShellMain>
|
||||
</Shell>
|
||||
);
|
||||
}
|
||||
|
||||
Teams.requiresLicense = false;
|
||||
Teams.PageWrapper = PageWrapper;
|
||||
Teams.getLayout = getLayout;
|
||||
export default Teams;
|
||||
|
||||
@@ -86,7 +86,8 @@ testBothFutureAndLegacyRoutes.describe("Forgot password", async () => {
|
||||
});
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
expect(await verifyPassword(newPassword, updatedUser.password!)).toBeTruthy();
|
||||
const updatedPassword = updatedUser.password!.hash;
|
||||
expect(await verifyPassword(newPassword, updatedPassword)).toBeTruthy();
|
||||
|
||||
// finally, make sure the same URL cannot be used to reset the password again, as it should be expired.
|
||||
await page.goto(`/auth/forgot-password/${id}`);
|
||||
|
||||
@@ -21,29 +21,23 @@ test.describe("Availablity tests", () => {
|
||||
});
|
||||
|
||||
test("Date Overrides", async ({ page }) => {
|
||||
await test.step("Can add a date override", async () => {
|
||||
await page.getByTestId("schedules").first().click();
|
||||
await page.getByTestId("add-override").click();
|
||||
await page.locator('[id="modal-title"]').waitFor();
|
||||
await page.getByTestId("incrementMonth").click();
|
||||
await page.locator('[data-testid="day"][data-disabled="false"]').first().click();
|
||||
await page.getByTestId("date-override-mark-unavailable").click();
|
||||
await page.getByTestId("add-override-submit-btn").click();
|
||||
await page.getByTestId("dialog-rejection").click();
|
||||
await expect(page.locator('[data-testid="date-overrides-list"] > li')).toHaveCount(1);
|
||||
await page.locator('[form="availability-form"][type="submit"]').click();
|
||||
});
|
||||
|
||||
await test.step("Date override is displayed in troubleshooter", async () => {
|
||||
const response = await page.waitForResponse("**/api/trpc/availability/schedule.update?batch=1");
|
||||
const json = await response.json();
|
||||
// @ts-expect-error trust me bro
|
||||
const date = json[0].result.data.json.schedule.availability.find((a) => !!a.date);
|
||||
const troubleshooterURL = `/availability/troubleshoot?date=${dayjs(date.date).format("YYYY-MM-DD")}`;
|
||||
await page.goto(troubleshooterURL);
|
||||
await page.waitForLoadState("networkidle");
|
||||
await expect(page.locator('[data-testid="troubleshooter-busy-time"]')).toHaveCount(1);
|
||||
});
|
||||
await page.getByTestId("schedules").first().click();
|
||||
await page.getByTestId("add-override").click();
|
||||
await page.locator('[id="modal-title"]').waitFor();
|
||||
await page.getByTestId("incrementMonth").click();
|
||||
await page.locator('[data-testid="day"][data-disabled="false"]').first().click();
|
||||
await page.getByTestId("date-override-mark-unavailable").click();
|
||||
await page.getByTestId("add-override-submit-btn").click();
|
||||
await page.getByTestId("dialog-rejection").click();
|
||||
await expect(page.locator('[data-testid="date-overrides-list"] > li')).toHaveCount(1);
|
||||
await page.locator('[form="availability-form"][type="submit"]').click();
|
||||
const response = await page.waitForResponse("**/api/trpc/availability/schedule.update?batch=1");
|
||||
const json = await response.json();
|
||||
const nextMonth = dayjs().add(1, "month").startOf("month");
|
||||
const troubleshooterURL = `/availability/troubleshoot?date=${nextMonth.format("YYYY-MM-DD")}`;
|
||||
await page.goto(troubleshooterURL);
|
||||
await page.waitForLoadState("networkidle");
|
||||
await expect(page.locator('[data-testid="troubleshooter-busy-time"]')).toHaveCount(1);
|
||||
});
|
||||
|
||||
test("Schedule listing", async ({ page }) => {
|
||||
|
||||
@@ -13,6 +13,7 @@ export const createEmailsFixture = () => {
|
||||
if (IS_MAILHOG_ENABLED) {
|
||||
const mailhogAPI = mailhog();
|
||||
return {
|
||||
messages: mailhogAPI.messages.bind(mailhogAPI),
|
||||
search: (query: string, kind?: string, start?: number, limit?: number) => {
|
||||
if (kind === "from" || kind === "to") {
|
||||
if (!hasUUID(query)) {
|
||||
@@ -27,6 +28,7 @@ export const createEmailsFixture = () => {
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
messages: unimplemented,
|
||||
search: unimplemented,
|
||||
deleteMessage: unimplemented,
|
||||
};
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { Page } from "@playwright/test";
|
||||
import type { Team } from "@prisma/client";
|
||||
|
||||
import { prisma } from "@calcom/prisma";
|
||||
import { teamMetadataSchema } from "@calcom/prisma/zod-utils";
|
||||
|
||||
const getRandomSlug = () => `org-${Math.random().toString(36).substring(7)}`;
|
||||
|
||||
@@ -15,8 +16,12 @@ export const createOrgsFixture = (page: Page) => {
|
||||
slug: opts.slug || getRandomSlug(),
|
||||
requestedSlug: opts.requestedSlug,
|
||||
});
|
||||
store.orgs.push(org);
|
||||
return org;
|
||||
const orgWithMetadata = {
|
||||
...org,
|
||||
metadata: teamMetadataSchema.parse(org.metadata),
|
||||
};
|
||||
store.orgs.push(orgWithMetadata);
|
||||
return orgWithMetadata;
|
||||
},
|
||||
get: () => store.orgs,
|
||||
deleteAll: async () => {
|
||||
|
||||
@@ -196,6 +196,9 @@ export const createUsersFixture = (
|
||||
password: opts?.password ?? uname,
|
||||
};
|
||||
},
|
||||
/**
|
||||
* In case organizationId is passed, it simulates a scenario where a nonexistent user is added to an organization.
|
||||
*/
|
||||
create: async (
|
||||
opts?:
|
||||
| (CustomUserOpts & {
|
||||
@@ -649,7 +652,6 @@ type SupportedTestWorkflows = PrismaType.WorkflowCreateInput;
|
||||
|
||||
type CustomUserOptsKeys =
|
||||
| "username"
|
||||
| "password"
|
||||
| "completedOnboarding"
|
||||
| "locale"
|
||||
| "name"
|
||||
@@ -666,6 +668,7 @@ type CustomUserOpts = Partial<Pick<Prisma.User, CustomUserOptsKeys>> & {
|
||||
useExactUsername?: boolean;
|
||||
roleInOrganization?: MembershipRole;
|
||||
schedule?: Schedule;
|
||||
password?: string | null;
|
||||
};
|
||||
|
||||
// creates the actual user in the db.
|
||||
@@ -687,7 +690,11 @@ const createUser = (
|
||||
username: uname,
|
||||
name: opts?.name,
|
||||
email: opts?.email ?? `${uname}@example.com`,
|
||||
password: hashPassword(uname),
|
||||
password: {
|
||||
create: {
|
||||
hash: hashPassword(uname),
|
||||
},
|
||||
},
|
||||
emailVerified: new Date(),
|
||||
completedOnboarding: opts?.completedOnboarding ?? true,
|
||||
timeZone: opts?.timeZone ?? TimeZoneEnum.UK,
|
||||
@@ -726,6 +733,7 @@ const createUser = (
|
||||
throw new Error("Missing role for user in organization");
|
||||
}
|
||||
return {
|
||||
organizationId,
|
||||
profiles: {
|
||||
create: {
|
||||
uid: ProfileRepository.generateProfileUid(),
|
||||
@@ -787,7 +795,7 @@ async function confirmPendingPayment(page: Page) {
|
||||
|
||||
// login using a replay of an E2E routine.
|
||||
export async function login(
|
||||
user: Pick<Prisma.User, "username"> & Partial<Pick<Prisma.User, "password" | "email">>,
|
||||
user: Pick<Prisma.User, "username"> & Partial<Pick<Prisma.User, "email">> & { password?: string | null },
|
||||
page: Page
|
||||
) {
|
||||
// get locators
|
||||
@@ -808,7 +816,7 @@ export async function login(
|
||||
}
|
||||
|
||||
export async function apiLogin(
|
||||
user: Pick<Prisma.User, "username"> & Partial<Pick<Prisma.User, "password" | "email">>,
|
||||
user: Pick<Prisma.User, "username"> & Partial<Pick<Prisma.User, "email">> & { password: string | null },
|
||||
page: Page
|
||||
) {
|
||||
const csrfToken = await page
|
||||
|
||||
@@ -225,7 +225,10 @@ export async function getEmailsReceivedByUser({
|
||||
if (!emails) return null;
|
||||
const matchingEmails = await emails.search(userEmail, "to");
|
||||
if (!matchingEmails?.total) {
|
||||
console.log(`No emails received by ${userEmail}`);
|
||||
console.log(
|
||||
`No emails received by ${userEmail}. All emails sent to:`,
|
||||
(await emails.messages())?.items.map((e) => e.to)
|
||||
);
|
||||
}
|
||||
return matchingEmails;
|
||||
}
|
||||
@@ -358,6 +361,9 @@ export async function doOnOrgDomain(
|
||||
"x-cal-force-slug": orgSlug,
|
||||
});
|
||||
await callback({ page });
|
||||
await page.setExtraHTTPHeaders({
|
||||
"x-cal-force-slug": "",
|
||||
});
|
||||
}
|
||||
|
||||
// When App directory is there, this is the 404 page text. We should work on fixing the 404 page as it changed due to app directory.
|
||||
|
||||
@@ -42,6 +42,8 @@ test.describe("2FA Tests", async () => {
|
||||
await fillOtp({ page, secret: "123456", noRetry: true });
|
||||
await expect(page.locator('[data-testid="error-submitting-code"]')).toBeVisible();
|
||||
|
||||
await removeOtpInput(page);
|
||||
|
||||
await fillOtp({
|
||||
page,
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
@@ -162,6 +164,14 @@ test.describe("2FA Tests", async () => {
|
||||
});
|
||||
});
|
||||
|
||||
async function removeOtpInput(page: Page) {
|
||||
await page.locator('input[name="2fa6"]').waitFor({ state: "visible", timeout: 30_000 });
|
||||
|
||||
// Remove one OTP input
|
||||
await page.locator('input[name="2fa6"]').focus();
|
||||
await page.keyboard.press("Backspace");
|
||||
}
|
||||
|
||||
async function fillOtp({ page, secret, noRetry }: { page: Page; secret: string; noRetry?: boolean }) {
|
||||
let token = authenticator.generate(secret);
|
||||
if (!noRetry && !totpAuthenticatorCheck(token, secret)) {
|
||||
|
||||
@@ -0,0 +1,488 @@
|
||||
import type { Page } from "@playwright/test";
|
||||
import { expect } from "@playwright/test";
|
||||
|
||||
import { getOrgUsernameFromEmail } from "@calcom/features/auth/signup/utils/getOrgUsernameFromEmail";
|
||||
import { getOrgFullOrigin } from "@calcom/features/ee/organizations/lib/orgDomains";
|
||||
import { MembershipRole, SchedulingType } from "@calcom/prisma/enums";
|
||||
|
||||
import { test } from "../lib/fixtures";
|
||||
import {
|
||||
bookTimeSlot,
|
||||
doOnOrgDomain,
|
||||
NotFoundPageTextAppDir,
|
||||
selectFirstAvailableTimeSlotNextMonth,
|
||||
testName,
|
||||
} from "../lib/testUtils";
|
||||
import { expectExistingUserToBeInvitedToOrganization } from "../team/expects";
|
||||
import { acceptTeamOrOrgInvite, inviteExistingUserToOrganization } from "./lib/inviteUser";
|
||||
|
||||
test.describe("Bookings", () => {
|
||||
test.afterEach(({ orgs, users }) => {
|
||||
orgs.deleteAll();
|
||||
users.deleteAll();
|
||||
});
|
||||
|
||||
test.describe("Team Event", () => {
|
||||
test("Can create a booking for Collective EventType", async ({ page, users, orgs }) => {
|
||||
const org = await orgs.create({
|
||||
name: "TestOrg",
|
||||
});
|
||||
const teamMatesObj = [
|
||||
{ name: "teammate-1" },
|
||||
{ name: "teammate-2" },
|
||||
{ name: "teammate-3" },
|
||||
{ name: "teammate-4" },
|
||||
];
|
||||
|
||||
const owner = await users.create(
|
||||
{
|
||||
username: "pro-user",
|
||||
name: "pro-user",
|
||||
organizationId: org.id,
|
||||
roleInOrganization: MembershipRole.MEMBER,
|
||||
},
|
||||
{
|
||||
hasTeam: true,
|
||||
teammates: teamMatesObj,
|
||||
schedulingType: SchedulingType.COLLECTIVE,
|
||||
}
|
||||
);
|
||||
const { team } = await owner.getFirstTeamMembership();
|
||||
const teamEvent = await owner.getFirstTeamEvent(team.id);
|
||||
|
||||
await expectPageToBeNotFound({ page, url: `/team/${team.slug}/${teamEvent.slug}` });
|
||||
await doOnOrgDomain(
|
||||
{
|
||||
orgSlug: org.slug,
|
||||
page,
|
||||
},
|
||||
async () => {
|
||||
await bookTeamEvent({ page, team, event: teamEvent });
|
||||
// All the teammates should be in the booking
|
||||
for (const teammate of teamMatesObj.concat([{ name: owner.name || "" }])) {
|
||||
await expect(page.getByText(teammate.name, { exact: true })).toBeVisible();
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// TODO: Assert whether the user received an email
|
||||
});
|
||||
|
||||
test("Can create a booking for Round Robin EventType", async ({ page, users, orgs }) => {
|
||||
const org = await orgs.create({
|
||||
name: "TestOrg",
|
||||
});
|
||||
const teamMatesObj = [
|
||||
{ name: "teammate-1" },
|
||||
{ name: "teammate-2" },
|
||||
{ name: "teammate-3" },
|
||||
{ name: "teammate-4" },
|
||||
];
|
||||
const owner = await users.create(
|
||||
{
|
||||
username: "pro-user",
|
||||
name: "pro-user",
|
||||
organizationId: org.id,
|
||||
roleInOrganization: MembershipRole.MEMBER,
|
||||
},
|
||||
{
|
||||
hasTeam: true,
|
||||
teammates: teamMatesObj,
|
||||
schedulingType: SchedulingType.ROUND_ROBIN,
|
||||
}
|
||||
);
|
||||
|
||||
const { team } = await owner.getFirstTeamMembership();
|
||||
const teamEvent = await owner.getFirstTeamEvent(team.id);
|
||||
|
||||
await expectPageToBeNotFound({ page, url: `/team/${team.slug}/${teamEvent.slug}` });
|
||||
|
||||
await doOnOrgDomain(
|
||||
{
|
||||
orgSlug: org.slug,
|
||||
page,
|
||||
},
|
||||
async () => {
|
||||
await bookTeamEvent({ page, team, event: teamEvent });
|
||||
|
||||
// Since all the users have the same leastRecentlyBooked value
|
||||
// Anyone of the teammates could be the Host of the booking.
|
||||
const chosenUser = await page.getByTestId("booking-host-name").textContent();
|
||||
expect(chosenUser).not.toBeNull();
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
expect(teamMatesObj.concat([{ name: owner.name! }]).some(({ name }) => name === chosenUser)).toBe(
|
||||
true
|
||||
);
|
||||
}
|
||||
);
|
||||
// TODO: Assert whether the user received an email
|
||||
});
|
||||
|
||||
test("Can access booking page with event slug and team page in lowercase/uppercase/mixedcase", async ({
|
||||
page,
|
||||
orgs,
|
||||
users,
|
||||
}) => {
|
||||
const org = await orgs.create({
|
||||
name: "TestOrg",
|
||||
});
|
||||
const teamMatesObj = [
|
||||
{ name: "teammate-1" },
|
||||
{ name: "teammate-2" },
|
||||
{ name: "teammate-3" },
|
||||
{ name: "teammate-4" },
|
||||
];
|
||||
|
||||
const owner = await users.create(
|
||||
{
|
||||
username: "pro-user",
|
||||
name: "pro-user",
|
||||
organizationId: org.id,
|
||||
roleInOrganization: MembershipRole.MEMBER,
|
||||
},
|
||||
{
|
||||
hasTeam: true,
|
||||
teammates: teamMatesObj,
|
||||
schedulingType: SchedulingType.COLLECTIVE,
|
||||
}
|
||||
);
|
||||
const { team } = await owner.getFirstTeamMembership();
|
||||
const { slug: teamEventSlug } = await owner.getFirstTeamEvent(team.id);
|
||||
|
||||
const teamSlugUpperCase = team.slug?.toUpperCase();
|
||||
const teamEventSlugUpperCase = teamEventSlug.toUpperCase();
|
||||
|
||||
// This is the most closest to the actual user flow as org1.cal.com maps to /org/orgSlug
|
||||
await page.goto(`/org/${org.slug}/${teamSlugUpperCase}/${teamEventSlugUpperCase}`);
|
||||
await page.waitForSelector("[data-testid=day]");
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("User Event", () => {
|
||||
test("Can create a booking", async ({ page, users, orgs }) => {
|
||||
const org = await orgs.create({
|
||||
name: "TestOrg",
|
||||
});
|
||||
|
||||
const user = await users.create({
|
||||
username: "pro-user",
|
||||
name: "pro-user",
|
||||
organizationId: org.id,
|
||||
roleInOrganization: MembershipRole.MEMBER,
|
||||
});
|
||||
|
||||
const event = await user.getFirstEventAsOwner();
|
||||
await page.goto(`/${user.username}/${event.slug}`);
|
||||
|
||||
// Shouldn't be servable on the non-org domain
|
||||
await expect(page.locator(`text=${NotFoundPageTextAppDir}`)).toBeVisible();
|
||||
|
||||
await doOnOrgDomain(
|
||||
{
|
||||
orgSlug: org.slug,
|
||||
page,
|
||||
},
|
||||
async () => {
|
||||
await bookUserEvent({ page, user, event });
|
||||
}
|
||||
);
|
||||
});
|
||||
test.describe("User Event with same slug as another user's", () => {
|
||||
test("booking is created for first user when first user is booked", async ({ page, users, orgs }) => {
|
||||
const org = await orgs.create({
|
||||
name: "TestOrg",
|
||||
});
|
||||
|
||||
const user1 = await users.create({
|
||||
username: "user1",
|
||||
name: "User 1",
|
||||
organizationId: org.id,
|
||||
roleInOrganization: MembershipRole.MEMBER,
|
||||
});
|
||||
|
||||
const user2 = await users.create({
|
||||
username: "user2",
|
||||
name: "User2",
|
||||
organizationId: org.id,
|
||||
roleInOrganization: MembershipRole.MEMBER,
|
||||
});
|
||||
|
||||
const user1Event = await user1.getFirstEventAsOwner();
|
||||
|
||||
await doOnOrgDomain(
|
||||
{
|
||||
orgSlug: org.slug,
|
||||
page,
|
||||
},
|
||||
async () => {
|
||||
await bookUserEvent({ page, user: user1, event: user1Event });
|
||||
}
|
||||
);
|
||||
});
|
||||
test("booking is created for second user when second user is booked", async ({ page, users, orgs }) => {
|
||||
const org = await orgs.create({
|
||||
name: "TestOrg",
|
||||
});
|
||||
|
||||
const user1 = await users.create({
|
||||
username: "user1",
|
||||
name: "User 1",
|
||||
organizationId: org.id,
|
||||
roleInOrganization: MembershipRole.MEMBER,
|
||||
});
|
||||
|
||||
const user2 = await users.create({
|
||||
username: "user2",
|
||||
name: "User2",
|
||||
organizationId: org.id,
|
||||
roleInOrganization: MembershipRole.MEMBER,
|
||||
});
|
||||
|
||||
const user2Event = await user2.getFirstEventAsOwner();
|
||||
|
||||
await doOnOrgDomain(
|
||||
{
|
||||
orgSlug: org.slug,
|
||||
page,
|
||||
},
|
||||
async () => {
|
||||
await bookUserEvent({ page, user: user2, event: user2Event });
|
||||
}
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Scenario with same username in and outside organization", () => {
|
||||
test("Can create a booking for user with same username in and outside organization", async ({
|
||||
page,
|
||||
users,
|
||||
orgs,
|
||||
}) => {
|
||||
const org = await orgs.create({
|
||||
name: "TestOrg",
|
||||
});
|
||||
|
||||
const username = "john";
|
||||
const userInsideOrganization = await users.create({
|
||||
username,
|
||||
name: "John Inside Organization",
|
||||
organizationId: org.id,
|
||||
roleInOrganization: MembershipRole.MEMBER,
|
||||
eventTypes: [
|
||||
{
|
||||
title: "John Inside Org's Meeting",
|
||||
slug: "john-inside-org-meeting",
|
||||
length: 15,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const userOutsideOrganization = await users.create({
|
||||
username,
|
||||
name: "John Outside Organization",
|
||||
eventTypes: [
|
||||
{
|
||||
title: "John Outside Org's Meeting",
|
||||
slug: "john-outside-org-meeting",
|
||||
length: 15,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const eventForUserInsideOrganization = await userInsideOrganization.getFirstEventAsOwner();
|
||||
const eventForUserOutsideOrganization = await userOutsideOrganization.getFirstEventAsOwner();
|
||||
|
||||
// John Inside Org's meeting can't be accessed on userOutsideOrganization's namespace
|
||||
await expectPageToBeNotFound({
|
||||
page,
|
||||
url: `/${userOutsideOrganization.username}/john-inside-org-meeting`,
|
||||
});
|
||||
|
||||
await bookUserEvent({ page, user: userOutsideOrganization, event: eventForUserOutsideOrganization });
|
||||
|
||||
await doOnOrgDomain(
|
||||
{
|
||||
orgSlug: org.slug,
|
||||
page,
|
||||
},
|
||||
async () => {
|
||||
// John Outside Org's meeting can't be accessed on userInsideOrganization's namespaces
|
||||
await expectPageToBeNotFound({
|
||||
page,
|
||||
url: `/${userInsideOrganization.username}/john-outside-org-meeting`,
|
||||
});
|
||||
await bookUserEvent({ page, user: userInsideOrganization, event: eventForUserInsideOrganization });
|
||||
}
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Inviting an existing user and then", () => {
|
||||
test("create a booking on new link", async ({ page, browser, users, orgs, emails }) => {
|
||||
const org = await orgs.create({
|
||||
name: "TestOrg",
|
||||
});
|
||||
|
||||
const owner = await users.create({
|
||||
username: "owner",
|
||||
name: "owner",
|
||||
organizationId: org.id,
|
||||
roleInOrganization: MembershipRole.OWNER,
|
||||
});
|
||||
|
||||
const userOutsideOrganization = await users.create({
|
||||
username: "john",
|
||||
name: "John Outside Organization",
|
||||
});
|
||||
|
||||
await owner.apiLogin();
|
||||
|
||||
const { invitedUserEmail } = await inviteExistingUserToOrganization({
|
||||
page,
|
||||
organizationId: org.id,
|
||||
user: userOutsideOrganization,
|
||||
usersFixture: users,
|
||||
});
|
||||
|
||||
const inviteLink = await expectExistingUserToBeInvitedToOrganization(page, emails, invitedUserEmail);
|
||||
if (!inviteLink) {
|
||||
throw new Error("Invite link not found");
|
||||
}
|
||||
const usernameInOrg = getOrgUsernameFromEmail(
|
||||
invitedUserEmail,
|
||||
org.metadata?.orgAutoAcceptEmail ?? null
|
||||
);
|
||||
|
||||
const usernameOutsideOrg = userOutsideOrganization.username;
|
||||
// Before invite is accepted the booking page isn't available
|
||||
await expectPageToBeNotFound({ page, url: `/${usernameInOrg}` });
|
||||
await userOutsideOrganization.apiLogin();
|
||||
await acceptTeamOrOrgInvite(page);
|
||||
await test.step("Book through new link", async () => {
|
||||
await doOnOrgDomain(
|
||||
{
|
||||
orgSlug: org.slug,
|
||||
page,
|
||||
},
|
||||
async () => {
|
||||
await bookUserEvent({
|
||||
page,
|
||||
user: {
|
||||
username: usernameInOrg,
|
||||
name: userOutsideOrganization.name,
|
||||
},
|
||||
event: await userOutsideOrganization.getFirstEventAsOwner(),
|
||||
});
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
await test.step("Booking through old link redirects to new link on org domain", async () => {
|
||||
const event = await userOutsideOrganization.getFirstEventAsOwner();
|
||||
await expectRedirectToOrgDomain({
|
||||
page,
|
||||
org,
|
||||
eventSlug: `/${usernameOutsideOrg}/${event.slug}`,
|
||||
expectedEventSlug: `/${usernameInOrg}/${event.slug}`,
|
||||
});
|
||||
// As the redirection correctly happens, the booking would work too which we have verified in previous step. But we can't test that with org domain as that domain doesn't exist.
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
async function bookUserEvent({
|
||||
page,
|
||||
user,
|
||||
event,
|
||||
}: {
|
||||
page: Page;
|
||||
user: {
|
||||
username: string | null;
|
||||
name: string | null;
|
||||
};
|
||||
event: { slug: string; title: string };
|
||||
}) {
|
||||
await page.goto(`/${user.username}/${event.slug}`);
|
||||
|
||||
await selectFirstAvailableTimeSlotNextMonth(page);
|
||||
await bookTimeSlot(page);
|
||||
await expect(page.getByTestId("success-page")).toBeVisible();
|
||||
|
||||
// The title of the booking
|
||||
const BookingTitle = `${event.title} between ${user.name} and ${testName}`;
|
||||
await expect(page.getByTestId("booking-title")).toHaveText(BookingTitle);
|
||||
// The booker should be in the attendee list
|
||||
await expect(page.getByTestId(`attendee-name-${testName}`)).toHaveText(testName);
|
||||
}
|
||||
|
||||
async function bookTeamEvent({
|
||||
page,
|
||||
team,
|
||||
event,
|
||||
}: {
|
||||
page: Page;
|
||||
team: {
|
||||
slug: string | null;
|
||||
name: string | null;
|
||||
};
|
||||
event: { slug: string; title: string };
|
||||
}) {
|
||||
// Note that even though the default way to access a team booking in an organization is to not use /team in the URL, but it isn't testable with playwright as the rewrite is taken care of by Next.js config which can't handle on the fly org slug's handling
|
||||
// So, we are using /team in the URL to access the team booking
|
||||
// There are separate tests to verify that the next.config.js rewrites are working
|
||||
// Also there are additional checkly tests that verify absolute e2e flow. They are in __checks__/organization.spec.ts
|
||||
await page.goto(`/team/${team.slug}/${event.slug}`);
|
||||
|
||||
await selectFirstAvailableTimeSlotNextMonth(page);
|
||||
await bookTimeSlot(page);
|
||||
await expect(page.getByTestId("success-page")).toBeVisible();
|
||||
|
||||
// The title of the booking
|
||||
const BookingTitle = `${event.title} between ${team.name} and ${testName}`;
|
||||
await expect(page.getByTestId("booking-title")).toHaveText(BookingTitle);
|
||||
// The booker should be in the attendee list
|
||||
await expect(page.getByTestId(`attendee-name-${testName}`)).toHaveText(testName);
|
||||
}
|
||||
|
||||
async function expectPageToBeNotFound({ page, url }: { page: Page; url: string }) {
|
||||
await page.goto(`${url}`);
|
||||
await expect(page.locator(`text=${NotFoundPageTextAppDir}`)).toBeVisible();
|
||||
}
|
||||
|
||||
async function expectRedirectToOrgDomain({
|
||||
page,
|
||||
org,
|
||||
eventSlug,
|
||||
expectedEventSlug,
|
||||
}: {
|
||||
page: Page;
|
||||
org: { slug: string | null };
|
||||
eventSlug: string;
|
||||
expectedEventSlug: string;
|
||||
}) {
|
||||
if (!org.slug) {
|
||||
throw new Error("Org slug is not defined");
|
||||
}
|
||||
page.goto(eventSlug).catch((e) => {
|
||||
console.log("Expected navigation error to happen");
|
||||
});
|
||||
|
||||
const orgSlug = org.slug;
|
||||
|
||||
const orgRedirectUrl = await new Promise(async (resolve) => {
|
||||
page.on("request", (request) => {
|
||||
if (request.isNavigationRequest()) {
|
||||
const requestedUrl = request.url();
|
||||
console.log("Requested navigation to", requestedUrl);
|
||||
// Resolve on redirection to org domain
|
||||
if (requestedUrl.includes(orgSlug)) {
|
||||
resolve(requestedUrl);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
expect(orgRedirectUrl).toContain(`${getOrgFullOrigin(org.slug)}${expectedEventSlug}`);
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import type { Page } from "@playwright/test";
|
||||
import type { createUsersFixture } from "playwright/fixtures/users";
|
||||
|
||||
export const inviteUserToOrganization = async ({
|
||||
page,
|
||||
organizationId,
|
||||
email,
|
||||
usersFixture,
|
||||
}: {
|
||||
page: Page;
|
||||
organizationId: number;
|
||||
email: string;
|
||||
usersFixture: ReturnType<typeof createUsersFixture>;
|
||||
}) => {
|
||||
await page.goto("/settings/organizations/members");
|
||||
await page.waitForLoadState("networkidle");
|
||||
const invitedUserEmail = usersFixture.trackEmail({
|
||||
username: email.split("@")[0],
|
||||
domain: email.split("@")[1],
|
||||
});
|
||||
await inviteAnEmail(page, invitedUserEmail);
|
||||
return { invitedUserEmail };
|
||||
};
|
||||
|
||||
export const inviteExistingUserToOrganization = async ({
|
||||
page,
|
||||
organizationId,
|
||||
user,
|
||||
usersFixture,
|
||||
}: {
|
||||
page: Page;
|
||||
organizationId: number;
|
||||
user: {
|
||||
email: string;
|
||||
};
|
||||
usersFixture: ReturnType<typeof createUsersFixture>;
|
||||
}) => {
|
||||
await page.goto("/settings/organizations/members");
|
||||
await page.waitForLoadState("networkidle");
|
||||
|
||||
await inviteAnEmail(page, user.email);
|
||||
await page.waitForSelector('[data-testid="toast-success"]');
|
||||
return { invitedUserEmail: user.email };
|
||||
};
|
||||
|
||||
export async function acceptTeamOrOrgInvite(page: Page) {
|
||||
await page.goto("/settings/teams");
|
||||
await page.click('[data-testid^="accept-invitation"]');
|
||||
await page.waitForLoadState("networkidle");
|
||||
}
|
||||
|
||||
async function inviteAnEmail(page: Page, invitedUserEmail: string) {
|
||||
await page.locator('button:text("Add")').click();
|
||||
await page.locator('input[name="inviteUser"]').fill(invitedUserEmail);
|
||||
await page.locator('button:text("Send invite")').click();
|
||||
await page.waitForLoadState("networkidle");
|
||||
}
|
||||
@@ -377,7 +377,7 @@ async function signupFromInviteLink({
|
||||
return { email };
|
||||
}
|
||||
|
||||
async function signupFromEmailInviteLink({
|
||||
export async function signupFromEmailInviteLink({
|
||||
browser,
|
||||
inviteLink,
|
||||
expectedUsername,
|
||||
@@ -385,8 +385,8 @@ async function signupFromEmailInviteLink({
|
||||
}: {
|
||||
browser: Browser;
|
||||
inviteLink: string;
|
||||
expectedUsername: string;
|
||||
expectedEmail: string;
|
||||
expectedUsername?: string;
|
||||
expectedEmail?: string;
|
||||
}) {
|
||||
// Follow invite link in new window
|
||||
const context = await browser.newContext();
|
||||
@@ -396,10 +396,14 @@ async function signupFromEmailInviteLink({
|
||||
await signupPage.locator(`[data-testid="signup-usernamefield"]`).waitFor({ state: "visible" });
|
||||
await expect(signupPage.locator(`[data-testid="signup-usernamefield"]`)).toBeDisabled();
|
||||
// await for value. initial value is ""
|
||||
await expect(signupPage.locator(`[data-testid="signup-usernamefield"]`)).toHaveValue(expectedUsername);
|
||||
if (expectedUsername) {
|
||||
await expect(signupPage.locator(`[data-testid="signup-usernamefield"]`)).toHaveValue(expectedUsername);
|
||||
}
|
||||
|
||||
await expect(signupPage.locator(`[data-testid="signup-emailfield"]`)).toBeDisabled();
|
||||
await expect(signupPage.locator(`[data-testid="signup-emailfield"]`)).toHaveValue(expectedEmail);
|
||||
if (expectedEmail) {
|
||||
await expect(signupPage.locator(`[data-testid="signup-emailfield"]`)).toHaveValue(expectedEmail);
|
||||
}
|
||||
|
||||
await signupPage.waitForLoadState("networkidle");
|
||||
// Check required fields
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
import { expect } from "@playwright/test";
|
||||
|
||||
import { IS_TEAM_BILLING_ENABLED } from "@calcom/lib/constants";
|
||||
import { prisma } from "@calcom/prisma";
|
||||
import { MembershipRole } from "@calcom/prisma/enums";
|
||||
|
||||
import { test } from "../lib/fixtures";
|
||||
import { fillStripeTestCheckout } from "../lib/testUtils";
|
||||
|
||||
test.describe("Teams", () => {
|
||||
test.afterEach(({ orgs, users }) => {
|
||||
orgs.deleteAll();
|
||||
users.deleteAll();
|
||||
});
|
||||
|
||||
test("Can create teams via Wizard", async ({ page, users, orgs }) => {
|
||||
const org = await orgs.create({
|
||||
name: "TestOrg",
|
||||
});
|
||||
const user = await users.create({
|
||||
organizationId: org.id,
|
||||
roleInOrganization: MembershipRole.ADMIN,
|
||||
});
|
||||
const inviteeEmail = `${user.username}+invitee@example.com`;
|
||||
await user.apiLogin();
|
||||
await page.goto("/teams");
|
||||
|
||||
await test.step("Can create team", async () => {
|
||||
// Click text=Create Team
|
||||
await page.locator("text=Create a new Team").click();
|
||||
await page.waitForURL((url) => url.pathname === "/settings/teams/new");
|
||||
// Fill input[name="name"]
|
||||
await page.locator('input[name="name"]').fill(`${user.username}'s Team`);
|
||||
// Click text=Continue
|
||||
await page.click("[type=submit]");
|
||||
// TODO: Figure out a way to make this more reliable
|
||||
// eslint-disable-next-line playwright/no-conditional-in-test
|
||||
if (IS_TEAM_BILLING_ENABLED) await fillStripeTestCheckout(page);
|
||||
await expect(page).toHaveURL(/\/settings\/teams\/(\d+)\/onboard-members.*$/i);
|
||||
await page.waitForSelector('[data-testid="pending-member-list"]');
|
||||
expect(await page.getByTestId("pending-member-item").count()).toBe(1);
|
||||
});
|
||||
|
||||
await test.step("Can add members", async () => {
|
||||
await page.getByTestId("new-member-button").click();
|
||||
await page.locator('[placeholder="email\\@example\\.com"]').fill(inviteeEmail);
|
||||
await page.getByTestId("invite-new-member-button").click();
|
||||
await expect(page.locator(`li:has-text("${inviteeEmail}")`)).toBeVisible();
|
||||
|
||||
// locator.count() does not await for the expected number of elements
|
||||
// https://github.com/microsoft/playwright/issues/14278
|
||||
// using toHaveCount() is more reliable
|
||||
await expect(page.getByTestId("pending-member-item")).toHaveCount(2);
|
||||
});
|
||||
|
||||
await test.step("Can remove members", async () => {
|
||||
await expect(page.getByTestId("pending-member-item")).toHaveCount(2);
|
||||
const lastRemoveMemberButton = page.getByTestId("remove-member-button").last();
|
||||
await lastRemoveMemberButton.click();
|
||||
await page.waitForLoadState("networkidle");
|
||||
await expect(page.getByTestId("pending-member-item")).toHaveCount(1);
|
||||
|
||||
// Cleanup here since this user is created without our fixtures.
|
||||
await prisma.user.delete({ where: { email: inviteeEmail } });
|
||||
});
|
||||
|
||||
await test.step("Can finish team creation", async () => {
|
||||
await page.getByTestId("publish-button").click();
|
||||
await expect(page).toHaveURL(/\/settings\/teams\/(\d+)\/profile$/i);
|
||||
});
|
||||
|
||||
await test.step("Can disband team", async () => {
|
||||
await page.waitForURL(/\/settings\/teams\/(\d+)\/profile$/i);
|
||||
await page.getByTestId("disband-team-button").click();
|
||||
await page.getByTestId("dialog-confirmation").click();
|
||||
await page.waitForURL("/teams");
|
||||
expect(await page.locator(`text=${user.username}'s Team`).count()).toEqual(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,8 +1,14 @@
|
||||
import { expect } from "@playwright/test";
|
||||
|
||||
import { WEBAPP_URL } from "@calcom/lib/constants";
|
||||
|
||||
import { test } from "./lib/fixtures";
|
||||
|
||||
test.describe.configure({ mode: "parallel" });
|
||||
|
||||
test.afterEach(({ users }) => users.deleteAll());
|
||||
test.afterEach(async ({ users }) => {
|
||||
await users.deleteAll();
|
||||
});
|
||||
|
||||
test.describe("Teams", () => {
|
||||
test("Profile page is loaded for users in Organization", async ({ page, users }) => {
|
||||
@@ -20,3 +26,158 @@ test.describe("Teams", () => {
|
||||
await page.getByTestId("profile-upload-avatar").isVisible();
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Update Profile", () => {
|
||||
test("Cannot update a users email when existing user has same email (verification enabled)", async ({
|
||||
page,
|
||||
users,
|
||||
prisma,
|
||||
features,
|
||||
}) => {
|
||||
const emailVerificationEnabled = features.get("email-verification");
|
||||
// eslint-disable-next-line playwright/no-conditional-in-test, playwright/no-skipped-test
|
||||
if (!emailVerificationEnabled?.enabled) test.skip();
|
||||
|
||||
const user = await users.create({
|
||||
name: "update-profile-user",
|
||||
});
|
||||
|
||||
const [emailInfo, emailDomain] = user.email.split("@");
|
||||
const email = `${emailInfo}-updated@${emailDomain}`;
|
||||
|
||||
await user.apiLogin();
|
||||
await page.goto("/settings/my-account/profile");
|
||||
|
||||
const emailInput = page.getByTestId("profile-form-email");
|
||||
|
||||
await emailInput.fill(email);
|
||||
|
||||
await page.getByTestId("profile-submit-button").click();
|
||||
|
||||
await page.getByTestId("password").fill(user?.username ?? "Nameless User");
|
||||
|
||||
await page.getByTestId("profile-update-email-submit-button").click();
|
||||
|
||||
const toastLocator = await page.waitForSelector('[data-testId="toast-success"]');
|
||||
|
||||
const textContent = await toastLocator.textContent();
|
||||
|
||||
expect(textContent).toContain(email);
|
||||
|
||||
// Instead of dealing with emails in e2e lets just get the token and navigate to it
|
||||
const verificationToken = await prisma.verificationToken.findFirst({
|
||||
where: {
|
||||
identifier: user.email,
|
||||
},
|
||||
});
|
||||
|
||||
const params = new URLSearchParams({
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
token: verificationToken!.token,
|
||||
});
|
||||
|
||||
await users.create({
|
||||
email,
|
||||
});
|
||||
|
||||
const verifyUrl = `${WEBAPP_URL}/auth/verify-email-change?${params.toString()}`;
|
||||
|
||||
await page.goto(verifyUrl);
|
||||
|
||||
const errorLocator = await page.waitForSelector('[data-testId="toast-error"]');
|
||||
|
||||
expect(errorLocator).toBeDefined();
|
||||
|
||||
await page.goto("/settings/my-account/profile");
|
||||
const emailInputUpdated = page.getByTestId("profile-form-email");
|
||||
expect(await emailInputUpdated.inputValue()).toEqual(user.email);
|
||||
});
|
||||
|
||||
test("Can update a users email (verification enabled)", async ({ page, users, prisma, features }) => {
|
||||
const emailVerificationEnabled = features.get("email-verification");
|
||||
// eslint-disable-next-line playwright/no-conditional-in-test, playwright/no-skipped-test
|
||||
if (!emailVerificationEnabled?.enabled) test.skip();
|
||||
|
||||
const user = await users.create({
|
||||
name: "update-profile-user",
|
||||
});
|
||||
|
||||
const [emailInfo, emailDomain] = user.email.split("@");
|
||||
const email = `${emailInfo}-updated@${emailDomain}`;
|
||||
|
||||
await user.apiLogin();
|
||||
await page.goto("/settings/my-account/profile");
|
||||
|
||||
const emailInput = page.getByTestId("profile-form-email");
|
||||
|
||||
await emailInput.fill(email);
|
||||
|
||||
await page.getByTestId("profile-submit-button").click();
|
||||
|
||||
await page.getByTestId("password").fill(user?.username ?? "Nameless User");
|
||||
|
||||
await page.getByTestId("profile-update-email-submit-button").click();
|
||||
|
||||
const toastLocator = await page.waitForSelector('[data-testId="toast-success"]');
|
||||
|
||||
const textContent = await toastLocator.textContent();
|
||||
|
||||
expect(textContent).toContain(email);
|
||||
|
||||
// Instead of dealing with emails in e2e lets just get the token and navigate to it
|
||||
const verificationToken = await prisma.verificationToken.findFirst({
|
||||
where: {
|
||||
identifier: user.email,
|
||||
},
|
||||
});
|
||||
|
||||
const params = new URLSearchParams({
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
token: verificationToken!.token,
|
||||
});
|
||||
|
||||
const verifyUrl = `${WEBAPP_URL}/auth/verify-email-change?${params.toString()}`;
|
||||
|
||||
await page.goto(verifyUrl);
|
||||
|
||||
const successLocator = await page.waitForSelector('[data-testId="toast-success"]');
|
||||
|
||||
expect(await successLocator.textContent()).toContain(email);
|
||||
await page.goto("/settings/my-account/profile");
|
||||
const emailInputUpdated = page.getByTestId("profile-form-email");
|
||||
expect(await emailInputUpdated.inputValue()).toEqual(email);
|
||||
});
|
||||
test("Can update a users email (verification disabled)", async ({ page, users, prisma, features }) => {
|
||||
const emailVerificationEnabled = features.get("email-verification");
|
||||
// eslint-disable-next-line playwright/no-conditional-in-test, playwright/no-skipped-test
|
||||
if (emailVerificationEnabled?.enabled) test.skip();
|
||||
|
||||
const user = await users.create({
|
||||
name: "update-profile-user",
|
||||
});
|
||||
|
||||
const [emailInfo, emailDomain] = user.email.split("@");
|
||||
const email = `${emailInfo}-updated@${emailDomain}`;
|
||||
|
||||
await user.apiLogin();
|
||||
await page.goto("/settings/my-account/profile");
|
||||
|
||||
const emailInput = page.getByTestId("profile-form-email");
|
||||
|
||||
await emailInput.fill(email);
|
||||
|
||||
await page.getByTestId("profile-submit-button").click();
|
||||
|
||||
await page.getByTestId("password").fill(user?.username ?? "Nameless User");
|
||||
|
||||
await page.getByTestId("profile-update-email-submit-button").click();
|
||||
|
||||
const toastLocator = await page.waitForSelector('[data-testId="toast-success"]');
|
||||
|
||||
expect(await toastLocator.isVisible()).toBe(true);
|
||||
|
||||
const emailInputUpdated = page.getByTestId("profile-form-email");
|
||||
|
||||
expect(await emailInputUpdated.inputValue()).toEqual(email);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -10,7 +10,7 @@ export async function expectInvitationEmailToBeReceived(
|
||||
page: Page,
|
||||
emails: ReturnType<typeof createEmailsFixture>,
|
||||
userEmail: string,
|
||||
subject: string,
|
||||
subject?: string | null,
|
||||
returnLink?: string
|
||||
) {
|
||||
if (!emails) return null;
|
||||
@@ -21,10 +21,20 @@ export async function expectInvitationEmailToBeReceived(
|
||||
expect(receivedEmails?.total).toBe(1);
|
||||
|
||||
const [firstReceivedEmail] = (receivedEmails as Messages).items;
|
||||
|
||||
expect(firstReceivedEmail.subject).toBe(subject);
|
||||
if (subject) {
|
||||
expect(firstReceivedEmail.subject).toBe(subject);
|
||||
}
|
||||
if (!returnLink) return;
|
||||
const dom = new JSDOM(firstReceivedEmail.html);
|
||||
const anchor = dom.window.document.querySelector(`a[href*="${returnLink}"]`);
|
||||
return anchor?.getAttribute("href");
|
||||
}
|
||||
|
||||
export async function expectExistingUserToBeInvitedToOrganization(
|
||||
page: Page,
|
||||
emails: ReturnType<typeof createEmailsFixture>,
|
||||
userEmail: string,
|
||||
subject?: string | null
|
||||
) {
|
||||
return expectInvitationEmailToBeReceived(page, emails, userEmail, subject, "settings/team");
|
||||
}
|
||||
|
||||
@@ -2,15 +2,13 @@ import { expect } from "@playwright/test";
|
||||
|
||||
import { IS_TEAM_BILLING_ENABLED } from "@calcom/lib/constants";
|
||||
import { prisma } from "@calcom/prisma";
|
||||
import { MembershipRole, SchedulingType } from "@calcom/prisma/enums";
|
||||
import { SchedulingType } from "@calcom/prisma/enums";
|
||||
|
||||
import { test } from "./lib/fixtures";
|
||||
import { testBothFutureAndLegacyRoutes } from "./lib/future-legacy-routes";
|
||||
import {
|
||||
bookTimeSlot,
|
||||
doOnOrgDomain,
|
||||
fillStripeTestCheckout,
|
||||
NotFoundPageTextAppDir,
|
||||
selectFirstAvailableTimeSlotNextMonth,
|
||||
testName,
|
||||
todo,
|
||||
@@ -282,210 +280,3 @@ testBothFutureAndLegacyRoutes.describe("Teams - NonOrg", (routeVariant) => {
|
||||
todo("Reschedule a Collective EventType booking");
|
||||
todo("Reschedule a Round Robin EventType booking");
|
||||
});
|
||||
|
||||
test.describe("Teams - Org", () => {
|
||||
test.afterEach(({ orgs, users }) => {
|
||||
orgs.deleteAll();
|
||||
users.deleteAll();
|
||||
});
|
||||
|
||||
test("Can create teams via Wizard", async ({ page, users, orgs }) => {
|
||||
const org = await orgs.create({
|
||||
name: "TestOrg",
|
||||
});
|
||||
const user = await users.create({
|
||||
organizationId: org.id,
|
||||
roleInOrganization: MembershipRole.ADMIN,
|
||||
});
|
||||
const inviteeEmail = `${user.username}+invitee@example.com`;
|
||||
await user.apiLogin();
|
||||
await page.goto("/teams");
|
||||
|
||||
await test.step("Can create team", async () => {
|
||||
// Click text=Create Team
|
||||
await page.locator("text=Create a new Team").click();
|
||||
await page.waitForURL((url) => url.pathname === "/settings/teams/new");
|
||||
// Fill input[name="name"]
|
||||
await page.locator('input[name="name"]').fill(`${user.username}'s Team`);
|
||||
// Click text=Continue
|
||||
await page.click("[type=submit]");
|
||||
// TODO: Figure out a way to make this more reliable
|
||||
// eslint-disable-next-line playwright/no-conditional-in-test
|
||||
if (IS_TEAM_BILLING_ENABLED) await fillStripeTestCheckout(page);
|
||||
await expect(page).toHaveURL(/\/settings\/teams\/(\d+)\/onboard-members.*$/i);
|
||||
await page.waitForSelector('[data-testid="pending-member-list"]');
|
||||
expect(await page.getByTestId("pending-member-item").count()).toBe(1);
|
||||
});
|
||||
|
||||
await test.step("Can add members", async () => {
|
||||
await page.getByTestId("new-member-button").click();
|
||||
await page.locator('[placeholder="email\\@example\\.com"]').fill(inviteeEmail);
|
||||
await page.getByTestId("invite-new-member-button").click();
|
||||
await expect(page.locator(`li:has-text("${inviteeEmail}")`)).toBeVisible();
|
||||
|
||||
// locator.count() does not await for the expected number of elements
|
||||
// https://github.com/microsoft/playwright/issues/14278
|
||||
// using toHaveCount() is more reliable
|
||||
await expect(page.getByTestId("pending-member-item")).toHaveCount(2);
|
||||
});
|
||||
|
||||
await test.step("Can remove members", async () => {
|
||||
await expect(page.getByTestId("pending-member-item")).toHaveCount(2);
|
||||
const lastRemoveMemberButton = page.getByTestId("remove-member-button").last();
|
||||
await lastRemoveMemberButton.click();
|
||||
await page.waitForLoadState("networkidle");
|
||||
await expect(page.getByTestId("pending-member-item")).toHaveCount(1);
|
||||
|
||||
// Cleanup here since this user is created without our fixtures.
|
||||
await prisma.user.delete({ where: { email: inviteeEmail } });
|
||||
});
|
||||
|
||||
await test.step("Can finish team creation", async () => {
|
||||
await page.getByTestId("publish-button").click();
|
||||
await expect(page).toHaveURL(/\/settings\/teams\/(\d+)\/profile$/i);
|
||||
});
|
||||
|
||||
await test.step("Can disband team", async () => {
|
||||
await page.waitForURL(/\/settings\/teams\/(\d+)\/profile$/i);
|
||||
await page.getByTestId("disband-team-button").click();
|
||||
await page.getByTestId("dialog-confirmation").click();
|
||||
await page.waitForURL("/teams");
|
||||
expect(await page.locator(`text=${user.username}'s Team`).count()).toEqual(0);
|
||||
});
|
||||
});
|
||||
|
||||
test("Can create a booking for Collective EventType", async ({ page, users, orgs }) => {
|
||||
const org = await orgs.create({
|
||||
name: "TestOrg",
|
||||
});
|
||||
const teamMatesObj = [
|
||||
{ name: "teammate-1" },
|
||||
{ name: "teammate-2" },
|
||||
{ name: "teammate-3" },
|
||||
{ name: "teammate-4" },
|
||||
];
|
||||
|
||||
const owner = await users.create(
|
||||
{
|
||||
username: "pro-user",
|
||||
name: "pro-user",
|
||||
organizationId: org.id,
|
||||
roleInOrganization: MembershipRole.MEMBER,
|
||||
},
|
||||
{
|
||||
hasTeam: true,
|
||||
teammates: teamMatesObj,
|
||||
schedulingType: SchedulingType.COLLECTIVE,
|
||||
}
|
||||
);
|
||||
const { team } = await owner.getFirstTeamMembership();
|
||||
const { title: teamEventTitle, slug: teamEventSlug } = await owner.getFirstTeamEvent(team.id);
|
||||
|
||||
await page.goto(`/team/${team.slug}/${teamEventSlug}`);
|
||||
|
||||
await expect(page.locator(`text=${NotFoundPageTextAppDir}`)).toBeVisible();
|
||||
await doOnOrgDomain(
|
||||
{
|
||||
orgSlug: org.slug,
|
||||
page,
|
||||
},
|
||||
async () => {
|
||||
await page.goto(`/team/${team.slug}/${teamEventSlug}`);
|
||||
await selectFirstAvailableTimeSlotNextMonth(page);
|
||||
await bookTimeSlot(page);
|
||||
await expect(page.getByTestId("success-page")).toBeVisible();
|
||||
|
||||
// The title of the booking
|
||||
const BookingTitle = `${teamEventTitle} between ${team.name} and ${testName}`;
|
||||
await expect(page.getByTestId("booking-title")).toHaveText(BookingTitle);
|
||||
// The booker should be in the attendee list
|
||||
await expect(page.getByTestId(`attendee-name-${testName}`)).toHaveText(testName);
|
||||
|
||||
// All the teammates should be in the booking
|
||||
for (const teammate of teamMatesObj.concat([{ name: owner.name || "" }])) {
|
||||
await expect(page.getByText(teammate.name, { exact: true })).toBeVisible();
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// TODO: Assert whether the user received an email
|
||||
});
|
||||
|
||||
test("Can create a booking for Round Robin EventType", async ({ page, users }) => {
|
||||
const teamMatesObj = [
|
||||
{ name: "teammate-1" },
|
||||
{ name: "teammate-2" },
|
||||
{ name: "teammate-3" },
|
||||
{ name: "teammate-4" },
|
||||
];
|
||||
const owner = await users.create(
|
||||
{ username: "pro-user", name: "pro-user" },
|
||||
{
|
||||
hasTeam: true,
|
||||
teammates: teamMatesObj,
|
||||
schedulingType: SchedulingType.ROUND_ROBIN,
|
||||
}
|
||||
);
|
||||
|
||||
const { team } = await owner.getFirstTeamMembership();
|
||||
const { title: teamEventTitle, slug: teamEventSlug } = await owner.getFirstTeamEvent(team.id);
|
||||
|
||||
await page.goto(`/team/${team.slug}/${teamEventSlug}`);
|
||||
await selectFirstAvailableTimeSlotNextMonth(page);
|
||||
await bookTimeSlot(page);
|
||||
await expect(page.locator("[data-testid=success-page]")).toBeVisible();
|
||||
|
||||
// The person who booked the meeting should be in the attendee list
|
||||
await expect(page.getByTestId(`attendee-name-${testName}`)).toHaveText(testName);
|
||||
|
||||
// The title of the booking
|
||||
const BookingTitle = `${teamEventTitle} between ${team.name} and ${testName}`;
|
||||
await expect(page.getByTestId("booking-title")).toHaveText(BookingTitle);
|
||||
|
||||
// Since all the users have the same leastRecentlyBooked value
|
||||
// Anyone of the teammates could be the Host of the booking.
|
||||
const chosenUser = await page.getByTestId("booking-host-name").textContent();
|
||||
expect(chosenUser).not.toBeNull();
|
||||
expect(teamMatesObj.concat([{ name: owner.name! }]).some(({ name }) => name === chosenUser)).toBe(true);
|
||||
// TODO: Assert whether the user received an email
|
||||
});
|
||||
|
||||
test("Can access booking page with event slug and team page in lowercase/uppercase/mixedcase", async ({
|
||||
page,
|
||||
orgs,
|
||||
users,
|
||||
}) => {
|
||||
const org = await orgs.create({
|
||||
name: "TestOrg",
|
||||
});
|
||||
const teamMatesObj = [
|
||||
{ name: "teammate-1" },
|
||||
{ name: "teammate-2" },
|
||||
{ name: "teammate-3" },
|
||||
{ name: "teammate-4" },
|
||||
];
|
||||
|
||||
const owner = await users.create(
|
||||
{
|
||||
username: "pro-user",
|
||||
name: "pro-user",
|
||||
organizationId: org.id,
|
||||
roleInOrganization: MembershipRole.MEMBER,
|
||||
},
|
||||
{
|
||||
hasTeam: true,
|
||||
teammates: teamMatesObj,
|
||||
schedulingType: SchedulingType.COLLECTIVE,
|
||||
}
|
||||
);
|
||||
const { team } = await owner.getFirstTeamMembership();
|
||||
const { slug: teamEventSlug } = await owner.getFirstTeamEvent(team.id);
|
||||
|
||||
const teamSlugUpperCase = team.slug?.toUpperCase();
|
||||
const teamEventSlugUpperCase = teamEventSlug.toUpperCase();
|
||||
|
||||
// This is the most closest to the actual user flow as org1.cal.com maps to /org/orgSlug
|
||||
await page.goto(`/org/${org.slug}/${teamSlugUpperCase}/${teamEventSlugUpperCase}`);
|
||||
await page.waitForSelector("[data-testid=day]");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -13,10 +13,17 @@
|
||||
"reset_password_subject": "{{appName}}: Reset password instructions",
|
||||
"verify_email_subject": "{{appName}}: Verify your account",
|
||||
"check_your_email": "Check your email",
|
||||
"old_email_address":"Old Email",
|
||||
"new_email_address":"New Email",
|
||||
"verify_email_page_body": "We've sent an email to {{email}}. It is important to verify your email address to guarantee the best email and calendar deliverability from {{appName}}.",
|
||||
"verify_email_banner_body": "Verify your email address to guarantee the best email and calendar deliverability",
|
||||
"verify_email_email_header": "Verify your email address",
|
||||
"verify_email_email_button": "Verify email",
|
||||
"verify_email_change_description":"You have recently requested to change the email address you use to log into your {{appName}} account. Please click the button below to confirm your new email address.",
|
||||
"verify_email_change_success_toast":"Updated your email to {{email}}",
|
||||
"verify_email_change_failure_toast":"Failed to update email.",
|
||||
"change_of_email":"Verify your new {{appName}} email address",
|
||||
"change_of_email_toast":"We have sent a verification link to {{email}}. We will update your email address once you click this link.",
|
||||
"copy_somewhere_safe": "Save this API key somewhere safe. You will not be able to view it again.",
|
||||
"verify_email_email_body": "Please verify your email address by clicking the button below.",
|
||||
"verify_email_by_code_email_body": "Please verify your email address by using the below code.",
|
||||
@@ -800,6 +807,7 @@
|
||||
"additional_input_description": "Require scheduler to input additional inputs prior the booking is confirmed",
|
||||
"label": "Label",
|
||||
"placeholder": "Placeholder",
|
||||
"display_add_to_calendar_organizer":"Display 'Add to calendar' email as the organizer",
|
||||
"type": "Type",
|
||||
"edit": "Edit",
|
||||
"add_input": "Add an Input",
|
||||
@@ -2187,7 +2195,7 @@
|
||||
"create_your_org_description": "Upgrade to Enterprise and receive a subdomain, unified billing, Insights, extensive whitelabeling and more",
|
||||
"other_payment_app_enabled": "You can only enable one payment app per event type",
|
||||
"admin_delete_organization_description": "<ul><li>Teams that are member of this organization will also be deleted along with their event-types</li><li>Users that were part of the organization will not be deleted and their event-types will also remain intact.</li><li>Usernames would be changed to allow them to exist outside the organization</li></ul>",
|
||||
"admin_delete_organization_title": "Delete organization?",
|
||||
"admin_delete_organization_title": "Delete {{organizationName}}?",
|
||||
"published": "Published",
|
||||
"unpublished": "Unpublished",
|
||||
"publish": "Publish",
|
||||
@@ -2237,11 +2245,22 @@
|
||||
"add_a_redirect": "Add a redirect",
|
||||
"create_entry": "Create entry",
|
||||
"time_range": "Time range",
|
||||
"automatically_add_all_team_members": "Automatically add all team members",
|
||||
"including_people_who_join_in_future": "Including people who join the team in the future",
|
||||
"automatically_add_all_team_members": "Add all team members, including future members",
|
||||
"redirect_to": "Redirect to",
|
||||
"having_trouble_finding_time": "Having trouble finding a time?",
|
||||
"show_more": "Show more",
|
||||
"assignment_description":"Schedule meetings when everyone is available or rotate through members on your team",
|
||||
"lowest": "lowest",
|
||||
"low": "low",
|
||||
"medium": "medium",
|
||||
"high": "high",
|
||||
"Highest": "highest",
|
||||
"send_booker_to": "Send Booker to",
|
||||
"set_priority": "Set Priority",
|
||||
"priority_for_user": "Priority for {{userName}}",
|
||||
"change_priority": "change priority",
|
||||
"field_identifiers_as_variables": "Use field identifiers as variables for your custom event redirect",
|
||||
"field_identifiers_as_variables_with_example": "Use field identifiers as variables for your custom event redirect (e.g. {{variable}})",
|
||||
"account_already_linked": "Account is already linked",
|
||||
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Add your new strings above here ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
|
||||
}
|
||||
|
||||
@@ -13,10 +13,17 @@
|
||||
"reset_password_subject": "{{appName}}: הנחיות לאיפוס סיסמה",
|
||||
"verify_email_subject": "{{appName}}: יש לאמת את החשבון",
|
||||
"check_your_email": "בדוק/בדקי את הדוא״ל שלך",
|
||||
"old_email_address": "כתובת דוא״ל ישנה",
|
||||
"new_email_address": "כתובת דוא״ל חדשה",
|
||||
"verify_email_page_body": "שלחנו הודעת דוא״ל לכתובת {{email}}. חשוב שתאמת/י את כתובת הדוא״ל שלך כדי להבטיח שתוכל/י לקבל הודעות מ-{{appName}} לכתובת הדוא״ל וללוח השנה שלך.",
|
||||
"verify_email_banner_body": "יש לאמת את כתובת הדוא״ל כדי להבטיח שתוכל/י לקבל הודעות לכתובת הדוא״ל וללוח השנה בצורה הטובה ביותר",
|
||||
"verify_email_email_header": "יש לאמת את כתובת הדוא״ל שלך",
|
||||
"verify_email_email_button": "אימות כתובת דוא״ל",
|
||||
"verify_email_change_description": "בדקות האחרונות ביקשת לשנות את כתובת הדוא״ל שמשמשת אותך לכניסה לחשבון שלך אצל {{appName}}. נא ללחוץ על הכפתור להלן כדי לאשר את כתובת הדוא״ל החדשה שלך.",
|
||||
"verify_email_change_success_toast": "כתובת הדוא״ל שלך עודכנה ל־{{email}}",
|
||||
"verify_email_change_failure_toast": "עדכון כתובת הדוא״ל נכשל.",
|
||||
"change_of_email": "נא לאמת את כתובת הדוא״ל החדשה שלך באתר {{appName}}",
|
||||
"change_of_email_toast": "שלחנו קישור לאימות אל {{email}}. נעדכן את כתובת הדוא״ל שלך לאחר לחיצה על הקישור הזה.",
|
||||
"copy_somewhere_safe": "חשוב לשמור את מפתח ה-API הזה במקום בטוח. לא תהיה אפשרות לראות אותו שוב.",
|
||||
"verify_email_email_body": "נא לאמת את כתובת הדוא״ל שלך על ידי לחיצה על הכפתור להלן.",
|
||||
"verify_email_by_code_email_body": "נא לאמת את כתובת הדוא״ל באמצעות הקוד שמתחת.",
|
||||
@@ -2237,11 +2244,22 @@
|
||||
"add_a_redirect": "הוספת הפנייה",
|
||||
"create_entry": "יצירת רשומה",
|
||||
"time_range": "טווח זמן",
|
||||
"automatically_add_all_team_members": "הוספת כל חברי הצוות אוטומטית",
|
||||
"including_people_who_join_in_future": "כולל אנשים שיצטרפו לצוות בעתיד",
|
||||
"automatically_add_all_team_members": "הוספת כל חברי הצוות, כולל חברים עתידיים",
|
||||
"redirect_to": "הפניה אל",
|
||||
"having_trouble_finding_time": "לא הצלחת למצוא חלון זמן פנוי?",
|
||||
"show_more": "הצג עוד",
|
||||
"assignment_description": "תזמון פגישות כשכולם זמינים או להחליף בסבב בין החברים בצוות שלך",
|
||||
"lowest": "הנמוכה ביותר",
|
||||
"low": "נמוכה",
|
||||
"medium": "בינונית",
|
||||
"high": "גבוהה",
|
||||
"Highest": "הגבוהה ביותר",
|
||||
"send_booker_to": "שליחת מזמן אל",
|
||||
"set_priority": "הגדרת עדיפות",
|
||||
"priority_for_user": "העדיפות של {{userName}}",
|
||||
"change_priority": "שינוי עדיפות",
|
||||
"field_identifiers_as_variables": "להשתמש במזהי השדות כמשתנים להפניות האירועים בהתאמה אישית",
|
||||
"field_identifiers_as_variables_with_example": "להשתמש במזהי השדות כמשתנים להפניות האירועים בהתאמה אישית (למשל: {{variable}})",
|
||||
"account_already_linked": "החשבון כבר מקושר",
|
||||
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Add your new strings above here ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
|
||||
}
|
||||
|
||||
@@ -108,6 +108,7 @@ describe("handleChildrenEventTypes", () => {
|
||||
timeZone,
|
||||
requiresBookerEmailVerification,
|
||||
lockTimeZoneToggleOnBookingPage,
|
||||
useEventTypeDestinationCalendarEmail,
|
||||
...evType
|
||||
} = mockFindFirstEventType({
|
||||
id: 123,
|
||||
@@ -156,6 +157,7 @@ describe("handleChildrenEventTypes", () => {
|
||||
scheduleId,
|
||||
requiresBookerEmailVerification,
|
||||
lockTimeZoneToggleOnBookingPage,
|
||||
useEventTypeDestinationCalendarEmail,
|
||||
...evType
|
||||
} = mockFindFirstEventType({
|
||||
metadata: { managedEventConfig: {} },
|
||||
@@ -250,6 +252,7 @@ describe("handleChildrenEventTypes", () => {
|
||||
timeZone,
|
||||
requiresBookerEmailVerification,
|
||||
lockTimeZoneToggleOnBookingPage,
|
||||
useEventTypeDestinationCalendarEmail,
|
||||
...evType
|
||||
} = mockFindFirstEventType({
|
||||
id: 123,
|
||||
@@ -298,6 +301,7 @@ describe("handleChildrenEventTypes", () => {
|
||||
userId,
|
||||
requiresBookerEmailVerification,
|
||||
lockTimeZoneToggleOnBookingPage,
|
||||
useEventTypeDestinationCalendarEmail,
|
||||
...evType
|
||||
} = mockFindFirstEventType({
|
||||
metadata: { managedEventConfig: {} },
|
||||
@@ -350,6 +354,7 @@ describe("handleChildrenEventTypes", () => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
requiresBookerEmailVerification,
|
||||
lockTimeZoneToggleOnBookingPage,
|
||||
useEventTypeDestinationCalendarEmail,
|
||||
...evType
|
||||
} = mockFindFirstEventType({
|
||||
metadata: { managedEventConfig: {} },
|
||||
|
||||
@@ -8,8 +8,8 @@ import { buildUser } from "@calcom/lib/test/builder";
|
||||
it("can find lucky user with maximize availability", async () => {
|
||||
const user1 = buildUser({
|
||||
id: 1,
|
||||
username: "test",
|
||||
name: "Test User",
|
||||
username: "test1",
|
||||
name: "Test User 1",
|
||||
email: "test@example.com",
|
||||
bookings: [
|
||||
{
|
||||
@@ -43,3 +43,151 @@ it("can find lucky user with maximize availability", async () => {
|
||||
})
|
||||
).resolves.toStrictEqual(users[1]);
|
||||
});
|
||||
|
||||
it("can find lucky user with maximize availability and priority ranking", async () => {
|
||||
const user1 = buildUser({
|
||||
id: 1,
|
||||
username: "test1",
|
||||
name: "Test User 1",
|
||||
email: "test@example.com",
|
||||
priority: 2,
|
||||
bookings: [
|
||||
{
|
||||
createdAt: new Date("2022-01-25T05:30:00.000Z"),
|
||||
},
|
||||
{
|
||||
createdAt: new Date("2022-01-25T06:30:00.000Z"),
|
||||
},
|
||||
],
|
||||
});
|
||||
const user2 = buildUser({
|
||||
id: 2,
|
||||
username: "test2",
|
||||
name: "Test User 2",
|
||||
email: "tes2t@example.com",
|
||||
bookings: [
|
||||
{
|
||||
createdAt: new Date("2022-01-25T04:30:00.000Z"),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const users = [user1, user2];
|
||||
// TODO: we may be able to use native prisma generics somehow?
|
||||
prismaMock.user.findMany.mockResolvedValue(users);
|
||||
prismaMock.booking.findMany.mockResolvedValue([]);
|
||||
const test = await getLuckyUser("MAXIMIZE_AVAILABILITY", {
|
||||
availableUsers: users,
|
||||
eventTypeId: 1,
|
||||
});
|
||||
|
||||
// both users have medium priority (one user has no priority set, default to medium) so pick least recently booked
|
||||
await expect(
|
||||
getLuckyUser("MAXIMIZE_AVAILABILITY", {
|
||||
availableUsers: users,
|
||||
eventTypeId: 1,
|
||||
})
|
||||
).resolves.toStrictEqual(users[1]);
|
||||
|
||||
const userLowest = buildUser({
|
||||
id: 1,
|
||||
username: "test1",
|
||||
name: "Test User 1",
|
||||
email: "test@example.com",
|
||||
priority: 0,
|
||||
bookings: [
|
||||
{
|
||||
createdAt: new Date("2022-01-25T03:30:00.000Z"),
|
||||
},
|
||||
],
|
||||
});
|
||||
const userMedium = buildUser({
|
||||
id: 2,
|
||||
username: "test2",
|
||||
name: "Test User 2",
|
||||
email: "tes2t@example.com",
|
||||
priority: 2,
|
||||
bookings: [
|
||||
{
|
||||
createdAt: new Date("2022-01-25T04:30:00.000Z"),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const userHighest = buildUser({
|
||||
id: 2,
|
||||
username: "test2",
|
||||
name: "Test User 2",
|
||||
email: "tes2t@example.com",
|
||||
priority: 4,
|
||||
bookings: [
|
||||
{
|
||||
createdAt: new Date("2022-01-25T05:30:00.000Z"),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const usersWithPriorities = [userLowest, userMedium, userHighest];
|
||||
// TODO: we may be able to use native prisma generics somehow?
|
||||
prismaMock.user.findMany.mockResolvedValue(usersWithPriorities);
|
||||
prismaMock.booking.findMany.mockResolvedValue([]);
|
||||
|
||||
// pick the user with the highest priority
|
||||
await expect(
|
||||
getLuckyUser("MAXIMIZE_AVAILABILITY", {
|
||||
availableUsers: usersWithPriorities,
|
||||
eventTypeId: 1,
|
||||
})
|
||||
).resolves.toStrictEqual(usersWithPriorities[2]);
|
||||
|
||||
const userLow = buildUser({
|
||||
id: 1,
|
||||
username: "test1",
|
||||
name: "Test User 1",
|
||||
email: "test@example.com",
|
||||
priority: 0,
|
||||
bookings: [
|
||||
{
|
||||
createdAt: new Date("2022-01-25T02:30:00.000Z"),
|
||||
},
|
||||
],
|
||||
});
|
||||
const userHighLeastRecentBooking = buildUser({
|
||||
id: 2,
|
||||
username: "test2",
|
||||
name: "Test User 2",
|
||||
email: "tes2t@example.com",
|
||||
priority: 3,
|
||||
bookings: [
|
||||
{
|
||||
createdAt: new Date("2022-01-25T03:30:00.000Z"),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const userHighRecentBooking = buildUser({
|
||||
id: 3,
|
||||
username: "test3",
|
||||
name: "Test User 3",
|
||||
email: "test3t@example.com",
|
||||
priority: 3,
|
||||
bookings: [
|
||||
{
|
||||
createdAt: new Date("2022-01-25T04:30:00.000Z"),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const usersWithSamePriorities = [userLow, userHighLeastRecentBooking, userHighRecentBooking];
|
||||
// TODO: we may be able to use native prisma generics somehow?
|
||||
prismaMock.user.findMany.mockResolvedValue(usersWithSamePriorities);
|
||||
prismaMock.booking.findMany.mockResolvedValue([]);
|
||||
|
||||
// pick the least recently booked user of the two with the highest priority
|
||||
await expect(
|
||||
getLuckyUser("MAXIMIZE_AVAILABILITY", {
|
||||
availableUsers: usersWithSamePriorities,
|
||||
eventTypeId: 1,
|
||||
})
|
||||
).resolves.toStrictEqual(usersWithSamePriorities[1]);
|
||||
});
|
||||
|
||||
@@ -8,7 +8,7 @@ import { expect, vi } from "vitest";
|
||||
import "vitest-fetch-mock";
|
||||
|
||||
import dayjs from "@calcom/dayjs";
|
||||
import { CAL_URL } from "@calcom/lib/constants";
|
||||
import { WEBSITE_URL } from "@calcom/lib/constants";
|
||||
import logger from "@calcom/lib/logger";
|
||||
import { safeStringify } from "@calcom/lib/safeStringify";
|
||||
import { BookingStatus } from "@calcom/prisma/enums";
|
||||
@@ -307,16 +307,18 @@ export function expectWebhookToHaveBeenCalledWith(
|
||||
export function expectWorkflowToBeTriggered({
|
||||
emails,
|
||||
organizer,
|
||||
destinationEmail,
|
||||
}: {
|
||||
emails: Fixtures["emails"];
|
||||
organizer: { email: string; name: string; timeZone: string };
|
||||
destinationEmail?: string;
|
||||
}) {
|
||||
const subjectPattern = /^Reminder: /i;
|
||||
expect(emails.get()).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
subject: expect.stringMatching(subjectPattern),
|
||||
to: organizer.email,
|
||||
to: destinationEmail ?? organizer.email,
|
||||
}),
|
||||
])
|
||||
);
|
||||
@@ -370,6 +372,7 @@ export function expectSuccessfulBookingCreationEmails({
|
||||
recurrence,
|
||||
bookingTimeRange,
|
||||
booking,
|
||||
destinationEmail,
|
||||
}: {
|
||||
emails: Fixtures["emails"];
|
||||
organizer: { email: string; name: string; timeZone: string };
|
||||
@@ -381,8 +384,9 @@ export function expectSuccessfulBookingCreationEmails({
|
||||
eventDomain?: string;
|
||||
bookingTimeRange?: { start: Date; end: Date };
|
||||
booking: { uid: string; urlOrigin?: string };
|
||||
destinationEmail?: string;
|
||||
}) {
|
||||
const bookingUrlOrigin = booking.urlOrigin || CAL_URL;
|
||||
const bookingUrlOrigin = booking.urlOrigin || WEBSITE_URL;
|
||||
expect(emails).toHaveEmail(
|
||||
{
|
||||
titleTag: "confirmed_event_type_subject",
|
||||
@@ -409,7 +413,7 @@ export function expectSuccessfulBookingCreationEmails({
|
||||
},
|
||||
}
|
||||
: null),
|
||||
to: `${organizer.email}`,
|
||||
to: `${destinationEmail ?? organizer.email}`,
|
||||
ics: {
|
||||
filename: "event.ics",
|
||||
iCalUID: `${iCalUID}`,
|
||||
@@ -417,7 +421,7 @@ export function expectSuccessfulBookingCreationEmails({
|
||||
method: "REQUEST",
|
||||
},
|
||||
},
|
||||
`${organizer.email}`
|
||||
`${destinationEmail ?? organizer.email}`
|
||||
);
|
||||
|
||||
expect(emails).toHaveEmail(
|
||||
@@ -742,7 +746,7 @@ export function expectBookingRequestRescheduledEmails({
|
||||
booking: { uid: string; urlOrigin?: string };
|
||||
bookNewTimePath: string;
|
||||
}) {
|
||||
const bookingUrlOrigin = booking.urlOrigin || CAL_URL;
|
||||
const bookingUrlOrigin = booking.urlOrigin || WEBSITE_URL;
|
||||
|
||||
expect(emails).toHaveEmail(
|
||||
{
|
||||
|
||||
+1
-1
@@ -80,7 +80,7 @@
|
||||
"@changesets/cli": "^2.26.1",
|
||||
"@deploysentinel/playwright": "^0.3.3",
|
||||
"@playwright/test": "^1.31.2",
|
||||
"@snaplet/copycat": "^0.3.0",
|
||||
"@snaplet/copycat": "^4.1.0",
|
||||
"@testing-library/jest-dom": "^5.16.5",
|
||||
"@types/jsdom": "^21.1.3",
|
||||
"@types/jsonwebtoken": "^9.0.3",
|
||||
|
||||
@@ -58,7 +58,6 @@ import sendgrid_config_json from "./sendgrid/config.json";
|
||||
import shimmervideo_config_json from "./shimmervideo/config.json";
|
||||
import signal_config_json from "./signal/config.json";
|
||||
import sirius_video_config_json from "./sirius_video/config.json";
|
||||
import skiff_config_json from "./skiff/config.json";
|
||||
import { metadata as stripepayment__metadata_ts } from "./stripepayment/_metadata";
|
||||
import sylapsvideo_config_json from "./sylapsvideo/config.json";
|
||||
import { metadata as tandemvideo__metadata_ts } from "./tandemvideo/_metadata";
|
||||
@@ -141,7 +140,6 @@ export const appStoreMetadata = {
|
||||
shimmervideo: shimmervideo_config_json,
|
||||
signal: signal_config_json,
|
||||
sirius_video: sirius_video_config_json,
|
||||
skiff: skiff_config_json,
|
||||
stripepayment: stripepayment__metadata_ts,
|
||||
sylapsvideo: sylapsvideo_config_json,
|
||||
tandemvideo: tandemvideo__metadata_ts,
|
||||
|
||||
@@ -58,7 +58,6 @@ export const apiHandlers = {
|
||||
shimmervideo: import("./shimmervideo/api"),
|
||||
signal: import("./signal/api"),
|
||||
sirius_video: import("./sirius_video/api"),
|
||||
skiff: import("./skiff/api"),
|
||||
stripepayment: import("./stripepayment/api"),
|
||||
sylapsvideo: import("./sylapsvideo/api"),
|
||||
tandemvideo: import("./tandemvideo/api"),
|
||||
|
||||
@@ -5,7 +5,6 @@ import { useEffect, useRef } from "react";
|
||||
import useAddAppMutation from "@calcom/app-store/_utils/useAddAppMutation";
|
||||
import classNames from "@calcom/lib/classNames";
|
||||
import { WEBAPP_URL } from "@calcom/lib/constants";
|
||||
import { CAL_URL } from "@calcom/lib/constants";
|
||||
import { deriveAppDictKeyFromType } from "@calcom/lib/deriveAppDictKeyFromType";
|
||||
import { useHasTeamPlan } from "@calcom/lib/hooks/useHasPaidPlan";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
@@ -156,7 +155,7 @@ export const AppDependencyComponent = ({
|
||||
<div>
|
||||
<>
|
||||
<Link
|
||||
href={`${CAL_URL}/apps/${dependency.slug}`}
|
||||
href={`${WEBAPP_URL}/apps/${dependency.slug}`}
|
||||
className="text-info flex items-center underline">
|
||||
<span className="mr-1">
|
||||
{t("connect_app", { dependencyName: dependency.name })}
|
||||
|
||||
@@ -200,7 +200,7 @@ const DailyVideoApiAdapter = (): VideoApiAdapter => {
|
||||
createInstantCalVideoRoom: (endTime: string) => createInstantMeeting(endTime),
|
||||
getRecordingDownloadLink: async (recordingId: string): Promise<GetAccessLinkResponseSchema> => {
|
||||
try {
|
||||
const res = await fetcher(`/recordings/${recordingId}/access-link?valid_for_secs=172800`).then(
|
||||
const res = await fetcher(`/recordings/${recordingId}/access-link?valid_for_secs=43200`).then(
|
||||
getAccessLinkResponseSchema.parse
|
||||
);
|
||||
return Promise.resolve(res);
|
||||
|
||||
@@ -11,6 +11,7 @@ import { encodeOAuthState } from "../../_utils/oauth/encodeOAuthState";
|
||||
export const scopes = [
|
||||
"https://www.googleapis.com/auth/calendar.readonly",
|
||||
"https://www.googleapis.com/auth/calendar.events",
|
||||
"https://www.googleapis.com/auth/userinfo.profile",
|
||||
];
|
||||
|
||||
let client_id = "";
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
import type { Auth } from "googleapis";
|
||||
import { google } from "googleapis";
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
|
||||
import { WEBAPP_URL_FOR_OAUTH, WEBAPP_URL } from "@calcom/lib/constants";
|
||||
import { getSafeRedirectUrl } from "@calcom/lib/getSafeRedirectUrl";
|
||||
import { HttpError } from "@calcom/lib/http-error";
|
||||
import logger from "@calcom/lib/logger";
|
||||
import { defaultHandler, defaultResponder } from "@calcom/lib/server";
|
||||
import prisma from "@calcom/prisma";
|
||||
import { Prisma } from "@calcom/prisma/client";
|
||||
|
||||
import getAppKeysFromSlug from "../../_utils/getAppKeysFromSlug";
|
||||
import getInstalledAppPath from "../../_utils/getInstalledAppPath";
|
||||
@@ -71,15 +74,6 @@ async function getHandler(req: NextApiRequest, res: NextApiResponse) {
|
||||
}
|
||||
}
|
||||
|
||||
const credential = await prisma.credential.create({
|
||||
data: {
|
||||
type: "google_calendar",
|
||||
key,
|
||||
userId: req.session.user.id,
|
||||
appId: "google-calendar",
|
||||
},
|
||||
});
|
||||
|
||||
// Set the primary calendar as the first selected calendar
|
||||
|
||||
// We can ignore this type error because we just validated the key when we init oAuth2Client
|
||||
@@ -93,10 +87,27 @@ async function getHandler(req: NextApiRequest, res: NextApiResponse) {
|
||||
});
|
||||
|
||||
const cals = await calendar.calendarList.list({ fields: "items(id,summary,primary,accessRole)" });
|
||||
|
||||
const primaryCal = cals.data.items?.find((cal) => cal.primary);
|
||||
// Primary calendar won't be null, this check satisfies typescript.
|
||||
if (!primaryCal?.id) {
|
||||
throw new HttpError({ message: "Internal Error", statusCode: 500 });
|
||||
}
|
||||
|
||||
if (primaryCal?.id) {
|
||||
// Update the user's profile photo with google profile photo if it's null
|
||||
// Since we don't want to block the user from using the app if this fails, we don't await this
|
||||
updateProfilePhoto(oAuth2Client, req.session.user.id);
|
||||
|
||||
const credential = await prisma.credential.create({
|
||||
data: {
|
||||
type: "google_calendar",
|
||||
key,
|
||||
userId: req.session.user.id,
|
||||
appId: "google-calendar",
|
||||
},
|
||||
});
|
||||
// Wrapping in a try/catch to reduce chance of race conditions-
|
||||
// also this improves performance for most of the happy-paths.
|
||||
try {
|
||||
await prisma.selectedCalendar.create({
|
||||
data: {
|
||||
userId: req.session.user.id,
|
||||
@@ -105,6 +116,19 @@ async function getHandler(req: NextApiRequest, res: NextApiResponse) {
|
||||
integration: "google_calendar",
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
await prisma.credential.delete({ where: { id: credential.id } });
|
||||
let errorMessage = "something_went_wrong";
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === "P2002") {
|
||||
errorMessage = "account_already_linked";
|
||||
}
|
||||
res.redirect(
|
||||
`${
|
||||
getSafeRedirectUrl(state?.onErrorReturnTo) ??
|
||||
getInstalledAppPath({ variant: "calendar", slug: "google-calendar" })
|
||||
}?error=${errorMessage}`
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -138,6 +162,23 @@ async function getHandler(req: NextApiRequest, res: NextApiResponse) {
|
||||
);
|
||||
}
|
||||
|
||||
async function updateProfilePhoto(oAuth2Client: Auth.OAuth2Client, userId: number) {
|
||||
try {
|
||||
const oauth2 = google.oauth2({ version: "v2", auth: oAuth2Client });
|
||||
const userDetails = await oauth2.userinfo.get();
|
||||
if (userDetails.data?.picture) {
|
||||
await prisma.user.update({
|
||||
where: { id: userId, avatarUrl: null, avatar: null },
|
||||
data: {
|
||||
avatarUrl: userDetails.data.picture,
|
||||
},
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error("Error updating avatarUrl from google calendar connect", error);
|
||||
}
|
||||
}
|
||||
|
||||
export default defaultHandler({
|
||||
GET: Promise.resolve({ default: defaultResponder(getHandler) }),
|
||||
});
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user