Compare commits
67
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
31da30b982 | ||
|
|
d149f8385e | ||
|
|
ea5a0bd5f0 | ||
|
|
67c9fd92e1 | ||
|
|
50be997753 | ||
|
|
f00c14d0c1 | ||
|
|
8d4dd0241a | ||
|
|
90f97c3c92 | ||
|
|
1407886612 | ||
|
|
139e47fe3b | ||
|
|
9d4ecf2baa | ||
|
|
9d1cf3e70e | ||
|
|
6beb279a27 | ||
|
|
69e4b12f52 | ||
|
|
b8098ed3ab | ||
|
|
db55b11034 | ||
|
|
2273b7ea24 | ||
|
|
0a90212980 | ||
|
|
2efcfab715 | ||
|
|
3c1dfe8b74 | ||
|
|
e47ddf9835 | ||
|
|
08c62b554e | ||
|
|
b0b94b91fe | ||
|
|
af61b6d341 | ||
|
|
28693aba96 | ||
|
|
2dc0e2a6f2 | ||
|
|
6cbe1c1cf1 | ||
|
|
1c2da58d2a | ||
|
|
7bfdaa3d64 | ||
|
|
c38d2afcf5 | ||
|
|
807848cce2 | ||
|
|
e9d409c0d5 | ||
|
|
0c43d60039 | ||
|
|
1173822344 | ||
|
|
a934cec51a | ||
|
|
257acc3f71 | ||
|
|
91063c41f8 | ||
|
|
1e1298fdbc | ||
|
|
95120e1ff4 | ||
|
|
ee00f0d52c | ||
|
|
246abe0dd9 | ||
|
|
14e845ea1f | ||
|
|
8ba65c4450 | ||
|
|
aff494f21c | ||
|
|
18ad7e348e | ||
|
|
78f3d76ae2 | ||
|
|
6f9e04c6d9 | ||
|
|
fdb0e5d004 | ||
|
|
268a142370 | ||
|
|
29a3892669 | ||
|
|
0223027958 | ||
|
|
d7df154059 | ||
|
|
51d78e6f9d | ||
|
|
d5894d9fbc | ||
|
|
8b0539028e | ||
|
|
c7600d8ead | ||
|
|
6f5b8ead75 | ||
|
|
fc159fb9ea | ||
|
|
b3297f2843 | ||
|
|
1dade81299 | ||
|
|
b2839830c8 | ||
|
|
fa884e8516 | ||
|
|
3418a25a85 | ||
|
|
89cc175d73 | ||
|
|
c7c8564b63 | ||
|
|
b620581d7a | ||
|
|
c0530cc91c |
@@ -0,0 +1,36 @@
|
||||
# Dependencies
|
||||
node_modules
|
||||
**/node_modules
|
||||
|
||||
# Build outputs
|
||||
.next
|
||||
**/.next
|
||||
dist
|
||||
**/dist
|
||||
build
|
||||
**/build
|
||||
|
||||
# Git
|
||||
.git
|
||||
.github
|
||||
|
||||
# Testing
|
||||
coverage
|
||||
**/coverage
|
||||
test-results
|
||||
**/test-results
|
||||
.turbo
|
||||
**/.turbo
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Examples and docs
|
||||
docs
|
||||
@@ -0,0 +1,182 @@
|
||||
name: "Docker Build and Test"
|
||||
description: "Reusable action to build and test Docker images for Cal.com"
|
||||
|
||||
inputs:
|
||||
platform:
|
||||
description: "Target platform (linux/amd64 or arm64)"
|
||||
required: true
|
||||
platform-suffix:
|
||||
description: "Suffix to add to image tags (e.g., -arm)"
|
||||
required: false
|
||||
default: ""
|
||||
dockerhub-username:
|
||||
description: "Docker Hub username"
|
||||
required: true
|
||||
dockerhub-token:
|
||||
description: "Docker Hub token"
|
||||
required: true
|
||||
github-token:
|
||||
description: "GitHub token for GHCR"
|
||||
required: true
|
||||
postgres-user:
|
||||
description: "PostgreSQL user"
|
||||
required: true
|
||||
postgres-password:
|
||||
description: "PostgreSQL password"
|
||||
required: true
|
||||
postgres-db:
|
||||
description: "PostgreSQL database name"
|
||||
required: true
|
||||
database-host:
|
||||
description: "Database host"
|
||||
required: true
|
||||
push-image:
|
||||
description: "Whether to push the built image"
|
||||
required: false
|
||||
default: "false"
|
||||
|
||||
runs:
|
||||
using: "composite"
|
||||
steps:
|
||||
- name: Log in to the Docker Hub registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ inputs.dockerhub-username }}
|
||||
password: ${{ inputs.dockerhub-token }}
|
||||
logout: true
|
||||
|
||||
- name: Log in to the Github Container registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ inputs.github-token }}
|
||||
|
||||
- name: Docker meta
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: |
|
||||
docker.io/calendso/calendso
|
||||
docker.io/calcom/cal.com
|
||||
ghcr.io/calcom/cal.com
|
||||
flavor: |
|
||||
latest=${{ !github.event.release.prerelease }}
|
||||
suffix=${{ inputs.platform-suffix }}
|
||||
|
||||
- name: Copy env
|
||||
shell: bash
|
||||
run: |
|
||||
grep -o '^[^#]*' .env.example > .env
|
||||
cat .env >> $GITHUB_ENV
|
||||
echo "DATABASE_HOST=localhost:5432" >> $GITHUB_ENV
|
||||
eval $(sed -e '/^#/d' -e 's/^/export /' -e 's/$/;/' .env) ;
|
||||
|
||||
- name: Start database
|
||||
shell: bash
|
||||
run: |
|
||||
docker compose up -d database
|
||||
|
||||
- name: Show database logs and container status
|
||||
shell: bash
|
||||
run: |
|
||||
echo "--- Container Status ---"
|
||||
docker compose ps database
|
||||
|
||||
echo "--- Container Logs ---"
|
||||
docker compose logs database
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
with:
|
||||
driver-opts: |
|
||||
network=container:database
|
||||
buildkitd-flags: |
|
||||
--allow-insecure-entitlement security.insecure --allow-insecure-entitlement network.host
|
||||
|
||||
- name: Build image
|
||||
id: docker_build
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: ./
|
||||
file: ./Dockerfile
|
||||
load: true
|
||||
push: false
|
||||
platforms: ${{ inputs.platform }}
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
build-args: |
|
||||
NEXT_PUBLIC_WEBAPP_URL=http://localhost:3000
|
||||
NEXT_PUBLIC_API_V2_URL=http://localhost:5555/api/v2
|
||||
NEXT_PUBLIC_LICENSE_CONSENT=agree
|
||||
DATABASE_URL=postgresql://${{ inputs.postgres-user }}:${{ inputs.postgres-password }}@${{ inputs.database-host }}/${{ inputs.postgres-db }}
|
||||
DATABASE_DIRECT_URL=postgresql://${{ inputs.postgres-user }}:${{ inputs.postgres-password }}@${{ inputs.database-host }}/${{ inputs.postgres-db }}
|
||||
|
||||
- name: Test runtime
|
||||
shell: bash
|
||||
run: |
|
||||
tags="${{ steps.meta.outputs.tags }}"
|
||||
IFS=',' read -ra ADDR <<< "$tags"
|
||||
tag=${ADDR[0]}
|
||||
|
||||
docker run --rm --network stack \
|
||||
-p 3000:3000 \
|
||||
-e DATABASE_URL=postgresql://${{ inputs.postgres-user }}:${{ inputs.postgres-password }}@database/${{ inputs.postgres-db }} \
|
||||
-e DATABASE_DIRECT_URL=postgresql://${{ inputs.postgres-user }}:${{ inputs.postgres-password }}@database/${{ inputs.postgres-db }} \
|
||||
-e NEXTAUTH_SECRET=${{ env.NEXTAUTH_SECRET }} \
|
||||
-e CALENDSO_ENCRYPTION_KEY=${{ env.CALENDSO_ENCRYPTION_KEY }} \
|
||||
$tag &
|
||||
|
||||
server_pid=$!
|
||||
|
||||
echo "Waiting for the server to start..."
|
||||
sleep 120
|
||||
|
||||
echo http://localhost:3000/auth/login
|
||||
|
||||
for i in {1..60}; do
|
||||
echo "Checking server health ($i/60)..."
|
||||
response=$(curl -o /dev/null -s -w "%{http_code}" http://localhost:3000/auth/login)
|
||||
echo "HTTP Status Code: $response"
|
||||
if [[ "$response" == "200" ]] || [[ "$response" == "307" ]]; then
|
||||
echo "Server is healthy"
|
||||
kill $server_pid
|
||||
exit 0
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
|
||||
echo "Server health check failed"
|
||||
kill $server_pid
|
||||
exit 1
|
||||
env:
|
||||
NEXTAUTH_SECRET: "EI4qqDpcfdvf4A+0aQEEx8JjHxHSy4uWiZw/F32K+pA="
|
||||
CALENDSO_ENCRYPTION_KEY: "0zfLtY99wjeLnsM7qsa8xsT+Q0oSgnOL"
|
||||
|
||||
- name: Push image
|
||||
id: docker_push
|
||||
uses: docker/build-push-action@v6
|
||||
if: ${{ inputs.push-image == 'true' && !github.event.release.prerelease }}
|
||||
with:
|
||||
context: ./
|
||||
file: ./Dockerfile
|
||||
push: true
|
||||
platforms: ${{ inputs.platform }}
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
build-args: |
|
||||
NEXT_PUBLIC_WEBAPP_URL=http://localhost:3000
|
||||
NEXT_PUBLIC_API_V2_URL=http://localhost:5555/api/v2
|
||||
NEXT_PUBLIC_LICENSE_CONSENT=agree
|
||||
DATABASE_URL=postgresql://${{ inputs.postgres-user }}:${{ inputs.postgres-password }}@${{ inputs.database-host }}/${{ inputs.postgres-db }}
|
||||
DATABASE_DIRECT_URL=postgresql://${{ inputs.postgres-user }}:${{ inputs.postgres-password }}@${{ inputs.database-host }}/${{ inputs.postgres-db }}
|
||||
|
||||
- name: Image digest
|
||||
shell: bash
|
||||
run: echo ${{ steps.docker_build.outputs.digest }}
|
||||
|
||||
- name: Cleanup
|
||||
shell: bash
|
||||
if: always()
|
||||
run: |
|
||||
docker compose down
|
||||
@@ -6,23 +6,31 @@ on:
|
||||
- main
|
||||
|
||||
concurrency: ${{ github.workflow }}-${{ github.ref }}
|
||||
permissions: {} # remove permissions and set explicitly under "permissions:" below
|
||||
permissions:
|
||||
id-token: write # for NPM provenance - developers can verify where and how it was built
|
||||
contents: write # to create release by changesets/action below
|
||||
pull-requests: write # to create pull request by changesets/action below
|
||||
|
||||
jobs:
|
||||
release:
|
||||
if: github.repository == 'calcom/cal.com' # prevent this action from running on forks
|
||||
permissions:
|
||||
id-token: write # for NPM provenance - developers can verify where and how it was built
|
||||
contents: write # to create release by changesets/action below
|
||||
pull-requests: write # to create pull request by changesets/action below
|
||||
name: Release
|
||||
runs-on: buildjet-4vcpu-ubuntu-2204
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout Repo
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Setup Node and registry for OIDC
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "20.17.0"
|
||||
registry-url: "https://registry.npmjs.org"
|
||||
always-auth: false
|
||||
- name: Setup node, yarn and install dependencies
|
||||
uses: ./.github/actions/yarn-install
|
||||
- name: Install npm at version supporting OIDC
|
||||
run: npm install -g npm@11.5.1
|
||||
- name: Create Release Pull Request or Publish to npm
|
||||
id: changesets
|
||||
uses: changesets/action@v1
|
||||
@@ -33,8 +41,7 @@ jobs:
|
||||
title: "chore: version packages" # PR title
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
NPM_CONFIG_PROVENANCE: "true"
|
||||
VITE_BOOKER_EMBED_OAUTH_CLIENT_ID: ${{ secrets.VITE_BOOKER_EMBED_OAUTH_CLIENT_ID }}
|
||||
VITE_BOOKER_EMBED_API_URL: ${{ secrets.VITE_BOOKER_EMBED_API_URL }}
|
||||
NEXT_PUBLIC_WEBAPP_URL: ${{ secrets.NEXT_PUBLIC_WEBAPP_URL }}
|
||||
NEXT_PUBLIC_WEBAPP_URL: ${{ secrets.NEXT_PUBLIC_WEBAPP_URL }}
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
name: "Release Docker"
|
||||
|
||||
on: # yamllint disable-line rule:truthy
|
||||
release:
|
||||
types:
|
||||
- created
|
||||
- published
|
||||
env:
|
||||
POSTGRES_USER: "unicorn_user"
|
||||
POSTGRES_PASSWORD: "magical_password"
|
||||
POSTGRES_DB: "calendso"
|
||||
DATABASE_HOST: "database:5432"
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "v*"
|
||||
# in case manual trigger is needed
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
@@ -12,11 +17,9 @@ on: # yamllint disable-line rule:truthy
|
||||
description: "v{Major}.{Minor}.{Patch}"
|
||||
|
||||
jobs:
|
||||
release:
|
||||
name: "Remote Release"
|
||||
|
||||
runs-on: "ubuntu-latest"
|
||||
|
||||
release-amd64:
|
||||
name: "Release AMD64"
|
||||
runs-on: buildjet-4vcpu-ubuntu-2204
|
||||
steps:
|
||||
- name: checkout
|
||||
uses: actions/checkout@v4
|
||||
@@ -24,25 +27,84 @@ jobs:
|
||||
- name: "Determine tag"
|
||||
run: 'echo "RELEASE_TAG=${GITHUB_REF#refs/tags/}" >> $GITHUB_ENV'
|
||||
|
||||
- name: "Run remote release workflow"
|
||||
uses: "actions/github-script@v6"
|
||||
- name: Build and test Docker image
|
||||
uses: ./.github/actions/docker-build-and-test
|
||||
with:
|
||||
# Requires a personal access token with Actions Read and write permissions on calcom/docker.
|
||||
github-token: "${{ secrets.DOCKER_REPO_ACCESS_TOKEN }}"
|
||||
script: |
|
||||
try {
|
||||
const response = await github.rest.actions.createWorkflowDispatch({
|
||||
owner: context.repo.owner,
|
||||
repo: 'docker',
|
||||
workflow_id: 'create-release.yaml',
|
||||
ref: 'main',
|
||||
inputs: {
|
||||
"RELEASE_TAG": process.env.RELEASE_TAG
|
||||
},
|
||||
});
|
||||
platform: "linux/amd64"
|
||||
platform-suffix: ""
|
||||
dockerhub-username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
dockerhub-token: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
postgres-user: ${{ env.POSTGRES_USER }}
|
||||
postgres-password: ${{ env.POSTGRES_PASSWORD }}
|
||||
postgres-db: ${{ env.POSTGRES_DB }}
|
||||
database-host: ${{ env.DATABASE_HOST }}
|
||||
push-image: "true"
|
||||
|
||||
console.log(response);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
core.setFailed(error.message);
|
||||
- name: Notify Slack on Success
|
||||
if: success()
|
||||
uses: slackapi/slack-github-action@v1.24.0
|
||||
with:
|
||||
payload: |
|
||||
{
|
||||
"text": ":large_green_circle: Workflow *${{ github.workflow }}* (AMD64) succeeded in job *${{ github.job }}*.\nSee: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}"
|
||||
}
|
||||
env:
|
||||
SLACK_WEBHOOK_URL: ${{ secrets.CI_SLACK_WEBHOOK_URL }}
|
||||
|
||||
- name: Notify Slack on Failure
|
||||
if: failure()
|
||||
uses: slackapi/slack-github-action@v1.24.0
|
||||
with:
|
||||
payload: |
|
||||
{
|
||||
"text": ":red_circle: Workflow *${{ github.workflow }}* (AMD64) failed in job *${{ github.job }}*.\nSee: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}"
|
||||
}
|
||||
env:
|
||||
SLACK_WEBHOOK_URL: ${{ secrets.CI_SLACK_WEBHOOK_URL }}
|
||||
|
||||
release-arm:
|
||||
name: "Release ARM"
|
||||
runs-on: ubuntu-24.04-arm
|
||||
steps:
|
||||
- name: checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: "Determine tag"
|
||||
run: 'echo "RELEASE_TAG=${GITHUB_REF#refs/tags/}" >> $GITHUB_ENV'
|
||||
|
||||
- name: Build and test Docker image
|
||||
uses: ./.github/actions/docker-build-and-test
|
||||
with:
|
||||
platform: "arm64"
|
||||
platform-suffix: "-arm"
|
||||
dockerhub-username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
dockerhub-token: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
postgres-user: ${{ env.POSTGRES_USER }}
|
||||
postgres-password: ${{ env.POSTGRES_PASSWORD }}
|
||||
postgres-db: ${{ env.POSTGRES_DB }}
|
||||
database-host: ${{ env.DATABASE_HOST }}
|
||||
push-image: "true"
|
||||
|
||||
- name: Notify Slack on Success
|
||||
if: success()
|
||||
uses: slackapi/slack-github-action@v1.24.0
|
||||
with:
|
||||
payload: |
|
||||
{
|
||||
"text": ":large_green_circle: Workflow *${{ github.workflow }}* (ARM) succeeded in job *${{ github.job }}*.\nSee: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}"
|
||||
}
|
||||
env:
|
||||
SLACK_WEBHOOK_URL: ${{ secrets.CI_SLACK_WEBHOOK_URL }}
|
||||
|
||||
- name: Notify Slack on Failure
|
||||
if: failure()
|
||||
uses: slackapi/slack-github-action@v1.24.0
|
||||
with:
|
||||
payload: |
|
||||
{
|
||||
"text": ":red_circle: Workflow *${{ github.workflow }}* (ARM) failed in job *${{ github.job }}*.\nSee: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}"
|
||||
}
|
||||
env:
|
||||
SLACK_WEBHOOK_URL: ${{ secrets.CI_SLACK_WEBHOOK_URL }}
|
||||
|
||||
Vendored
+91
@@ -0,0 +1,91 @@
|
||||
FROM --platform=$BUILDPLATFORM node:20 AS builder
|
||||
|
||||
WORKDIR /calcom
|
||||
|
||||
## If we want to read any ENV variable from .env file, we need to first accept and pass it as an argument to the Dockerfile
|
||||
ARG NEXT_PUBLIC_LICENSE_CONSENT
|
||||
ARG NEXT_PUBLIC_WEBSITE_TERMS_URL
|
||||
ARG NEXT_PUBLIC_WEBSITE_PRIVACY_POLICY_URL
|
||||
ARG CALCOM_TELEMETRY_DISABLED
|
||||
ARG DATABASE_URL
|
||||
ARG NEXTAUTH_SECRET=secret
|
||||
ARG CALENDSO_ENCRYPTION_KEY=secret
|
||||
ARG MAX_OLD_SPACE_SIZE=4096
|
||||
ARG NEXT_PUBLIC_API_V2_URL
|
||||
ARG CSP_POLICY
|
||||
|
||||
## We need these variables as required by Next.js build to create rewrites
|
||||
ARG NEXT_PUBLIC_SINGLE_ORG_SLUG
|
||||
ARG ORGANIZATIONS_ENABLED
|
||||
|
||||
ENV NEXT_PUBLIC_WEBAPP_URL=http://NEXT_PUBLIC_WEBAPP_URL_PLACEHOLDER \
|
||||
NEXT_PUBLIC_API_V2_URL=$NEXT_PUBLIC_API_V2_URL \
|
||||
NEXT_PUBLIC_LICENSE_CONSENT=$NEXT_PUBLIC_LICENSE_CONSENT \
|
||||
NEXT_PUBLIC_WEBSITE_TERMS_URL=$NEXT_PUBLIC_WEBSITE_TERMS_URL \
|
||||
NEXT_PUBLIC_WEBSITE_PRIVACY_POLICY_URL=$NEXT_PUBLIC_WEBSITE_PRIVACY_POLICY_URL \
|
||||
CALCOM_TELEMETRY_DISABLED=$CALCOM_TELEMETRY_DISABLED \
|
||||
DATABASE_URL=$DATABASE_URL \
|
||||
DATABASE_DIRECT_URL=$DATABASE_URL \
|
||||
NEXTAUTH_SECRET=${NEXTAUTH_SECRET} \
|
||||
CALENDSO_ENCRYPTION_KEY=${CALENDSO_ENCRYPTION_KEY} \
|
||||
NEXT_PUBLIC_SINGLE_ORG_SLUG=$NEXT_PUBLIC_SINGLE_ORG_SLUG \
|
||||
ORGANIZATIONS_ENABLED=$ORGANIZATIONS_ENABLED \
|
||||
NODE_OPTIONS=--max-old-space-size=${MAX_OLD_SPACE_SIZE} \
|
||||
BUILD_STANDALONE=true \
|
||||
CSP_POLICY=$CSP_POLICY
|
||||
|
||||
COPY package.json yarn.lock .yarnrc.yml playwright.config.ts turbo.json i18n.json ./
|
||||
COPY .yarn ./.yarn
|
||||
COPY apps/web ./apps/web
|
||||
COPY apps/api/v2 ./apps/api/v2
|
||||
COPY packages ./packages
|
||||
COPY tests ./tests
|
||||
|
||||
RUN yarn config set httpTimeout 1200000
|
||||
RUN npx turbo prune --scope=@calcom/web --scope=@calcom/trpc --docker
|
||||
RUN yarn install
|
||||
# Build and make embed servable from web/public/embed folder
|
||||
RUN yarn workspace @calcom/trpc run build
|
||||
RUN yarn --cwd packages/embeds/embed-core workspace @calcom/embed-core run build
|
||||
RUN yarn --cwd apps/web workspace @calcom/web run build
|
||||
RUN rm -rf node_modules/.cache .yarn/cache apps/web/.next/cache
|
||||
|
||||
FROM node:20 AS builder-two
|
||||
|
||||
WORKDIR /calcom
|
||||
ARG NEXT_PUBLIC_WEBAPP_URL=http://localhost:3000
|
||||
|
||||
ENV NODE_ENV=production
|
||||
|
||||
COPY package.json .yarnrc.yml turbo.json i18n.json ./
|
||||
COPY .yarn ./.yarn
|
||||
COPY --from=builder /calcom/yarn.lock ./yarn.lock
|
||||
COPY --from=builder /calcom/node_modules ./node_modules
|
||||
COPY --from=builder /calcom/packages ./packages
|
||||
COPY --from=builder /calcom/apps/web ./apps/web
|
||||
COPY --from=builder /calcom/packages/prisma/schema.prisma ./prisma/schema.prisma
|
||||
COPY scripts scripts
|
||||
RUN chmod +x scripts/*
|
||||
|
||||
# Save value used during this build stage. If NEXT_PUBLIC_WEBAPP_URL and BUILT_NEXT_PUBLIC_WEBAPP_URL differ at
|
||||
# run-time, then start.sh will find/replace static values again.
|
||||
ENV NEXT_PUBLIC_WEBAPP_URL=$NEXT_PUBLIC_WEBAPP_URL \
|
||||
BUILT_NEXT_PUBLIC_WEBAPP_URL=$NEXT_PUBLIC_WEBAPP_URL
|
||||
|
||||
RUN scripts/replace-placeholder.sh http://NEXT_PUBLIC_WEBAPP_URL_PLACEHOLDER ${NEXT_PUBLIC_WEBAPP_URL}
|
||||
|
||||
FROM node:20 AS runner
|
||||
|
||||
WORKDIR /calcom
|
||||
COPY --from=builder-two /calcom ./
|
||||
ARG NEXT_PUBLIC_WEBAPP_URL=http://localhost:3000
|
||||
ENV NEXT_PUBLIC_WEBAPP_URL=$NEXT_PUBLIC_WEBAPP_URL \
|
||||
BUILT_NEXT_PUBLIC_WEBAPP_URL=$NEXT_PUBLIC_WEBAPP_URL
|
||||
|
||||
ENV NODE_ENV=production
|
||||
EXPOSE 3000
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=30s --retries=5 \
|
||||
CMD wget --spider http://localhost:3000 || exit 1
|
||||
|
||||
CMD ["/calcom/scripts/start.sh"]
|
||||
@@ -4,7 +4,7 @@
|
||||
<img src="https://user-images.githubusercontent.com/8019099/210054112-5955e812-a76e-4160-9ddd-58f2c72f1cce.png" alt="Logo">
|
||||
</a>
|
||||
|
||||
<h3 align="center">Cal.com (formerly Calendso)</h3>
|
||||
<h3 align="center">Cal.com</h3>
|
||||
|
||||
<p align="center">
|
||||
The open-source Calendly successor.
|
||||
@@ -175,6 +175,7 @@ yarn dx
|
||||
```
|
||||
|
||||
#### Development tip
|
||||
|
||||
1. Add `export NODE_OPTIONS=“--max-old-space-size=16384”` to your shell script to increase the memory limit for the node process. Alternatively, you can run this in your terminal before running the app. Replace 16384 with the amount of RAM you want to allocate to the node process.
|
||||
|
||||
2. Add `NEXT_PUBLIC_LOGGER_LEVEL={level}` to your .env file to control the logging verbosity for all tRPC queries and mutations.\
|
||||
@@ -226,7 +227,7 @@ for Logger level to be set at info, for example.
|
||||
|
||||
3. Now open your psql shell with the DB you created: `psql -h localhost -U postgres -d <DB name>`
|
||||
|
||||
4. Inside the psql shell execute `\conninfo`. And you will get the following info.
|
||||
4. Inside the psql shell execute `\conninfo`. And you will get the following info.
|
||||

|
||||
|
||||
5. Now extract all the info and add it to your DATABASE_URL. The url would look something like this
|
||||
@@ -375,15 +376,268 @@ Executable doesn't exist at /Users/alice/Library/Caches/ms-playwright/chromium-1
|
||||
|
||||
### Docker
|
||||
|
||||
The Docker configuration for Cal.com is an effort powered by people within the community.
|
||||
**Official support**: Our team will begin to officially support the Dockerfile and docker-compose resources in this
|
||||
repository.
|
||||
|
||||
If you want to contribute to the Docker repository, [reply here](https://github.com/calcom/docker/discussions/32).
|
||||
**Important**: Cal.com will **not** be supporting installations that use these Docker resources. While we provide and maintain the Docker configurations, support for Docker-based installations is the responsibility of the user.
|
||||
|
||||
The Docker configuration can be found [in our docker repository](https://github.com/calcom/docker).
|
||||
This image can be found on DockerHub at [https://hub.docker.com/r/calcom/cal.com](https://hub.docker.com/r/calcom/cal.com).
|
||||
|
||||
Issues with Docker? Find your answer or open a new discussion [here](https://github.com/calcom/docker/discussions) to ask the community.
|
||||
**Note for ARM Users**: Use the {version}-arm suffix for pulling images. Example: `docker pull calcom/cal.com:v5.6.19-arm`.
|
||||
|
||||
Cal.com, Inc. does not provide official support for Docker, but we will accept fixes and documentation. Use at your own risk.
|
||||
#### Requirements
|
||||
|
||||
Make sure you have `docker` & `docker compose` installed on the server / system. Both are installed by most docker utilities, including Docker Desktop and Rancher Desktop.
|
||||
|
||||
Note: `docker compose` without the hyphen is now the primary method of using docker-compose, per the Docker documentation.
|
||||
|
||||
#### (Most users) Running Cal.com with Docker Compose
|
||||
|
||||
If you are evaluating Cal.com or running with minimal to no modifications, this option is for you.
|
||||
|
||||
1. Clone calcom/cal.com
|
||||
|
||||
```bash
|
||||
git clone --recursive https://github.com/calcom/cal.com.git
|
||||
```
|
||||
|
||||
2. Change into the directory
|
||||
|
||||
```bash
|
||||
cd cal.com
|
||||
```
|
||||
|
||||
3. Prepare your configuration: Rename `.env.example` to `.env` and then update `.env`
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
Most configurations can be left as-is, but for configuration options see [Important Run-time variables](#important-run-time-variables) below.
|
||||
|
||||
**Push Notifications (VAPID Keys)**
|
||||
If you see an error like:
|
||||
|
||||
```
|
||||
Error: No key set vapidDetails.publicKey
|
||||
```
|
||||
|
||||
This means your environment variables for Web Push are missing.
|
||||
You must generate and set `NEXT_PUBLIC_VAPID_PUBLIC_KEY` and `VAPID_PRIVATE_KEY`.
|
||||
|
||||
Generate them with:
|
||||
|
||||
```bash
|
||||
npx web-push generate-vapid-keys
|
||||
```
|
||||
|
||||
Then update your `.env` file:
|
||||
|
||||
```env
|
||||
NEXT_PUBLIC_VAPID_PUBLIC_KEY=your_public_key_here
|
||||
VAPID_PRIVATE_KEY=your_private_key_here
|
||||
```
|
||||
|
||||
Do **not** commit real keys to `.env.example` — only placeholders.
|
||||
|
||||
Update the appropriate values in your .env file, then proceed.
|
||||
|
||||
4. (optional) Pre-Pull the images by running the following command:
|
||||
|
||||
```bash
|
||||
docker compose pull
|
||||
```
|
||||
|
||||
This will use the default image locations as specified by `image:` in the docker-compose.yaml file.
|
||||
|
||||
Note: To aid with support, by default Scarf.sh is used as registry proxy for download metrics.
|
||||
|
||||
5. Start Cal.com via docker compose
|
||||
|
||||
(Most basic users, and for First Run) To run the complete stack, which includes a local Postgres database, Cal.com web app, and Prisma Studio:
|
||||
|
||||
```bash
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
To run Cal.com web app and Prisma Studio against a remote database, ensure that DATABASE_URL is configured for an available database and run:
|
||||
|
||||
```bash
|
||||
docker compose up -d calcom studio
|
||||
```
|
||||
|
||||
To run only the Cal.com web app, ensure that DATABASE_URL is configured for an available database and run:
|
||||
|
||||
```bash
|
||||
docker compose up -d calcom
|
||||
```
|
||||
|
||||
**Note: to run in attached mode for debugging, remove `-d` from your desired run command.**
|
||||
|
||||
6. Open a browser to [http://localhost:3000](http://localhost:3000), or your defined NEXT_PUBLIC_WEBAPP_URL. The first time you run Cal.com, a setup wizard will initialize. Define your first user, and you're ready to go!
|
||||
|
||||
#### Updating Cal.com
|
||||
|
||||
1. Stop the Cal.com stack
|
||||
|
||||
```bash
|
||||
docker compose down
|
||||
```
|
||||
|
||||
2. Pull the latest changes
|
||||
|
||||
```bash
|
||||
docker compose pull
|
||||
```
|
||||
|
||||
3. Update env vars as necessary.
|
||||
4. Re-start the Cal.com stack
|
||||
|
||||
```bash
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
#### (Advanced users) Build and Run Cal.com
|
||||
|
||||
1. Clone calcom/docker.
|
||||
|
||||
```bash
|
||||
git clone https://github.com/calcom/cal.com.git calcom-docker
|
||||
```
|
||||
|
||||
2. Change into the directory
|
||||
|
||||
```bash
|
||||
cd calcom-docker
|
||||
```
|
||||
|
||||
3. Update the calcom submodule. This project depends on the Cal.com source code, which is included here as a Git submodule. To make sure you get everything you need, update the submodule with the command below.
|
||||
|
||||
```bash
|
||||
git submodule update --remote --init
|
||||
```
|
||||
|
||||
Note: DO NOT use recursive submodule update, otherwise you will receive a git authentication error.
|
||||
|
||||
4. Rename `.env.example` to `.env` and then update `.env`
|
||||
|
||||
For configuration options see [Build-time variables](#build-time-variables) below. Update the appropriate values in your .env file, then proceed.
|
||||
|
||||
5. Build the Cal.com docker image:
|
||||
|
||||
Note: Due to application configuration requirements, an available database is currently required during the build process.
|
||||
|
||||
a) If hosting elsewhere, configure the `DATABASE_URL` in the .env file, and skip the next step
|
||||
|
||||
b) If a local or temporary database is required, start a local database via docker compose.
|
||||
|
||||
```bash
|
||||
docker compose up -d database
|
||||
```
|
||||
|
||||
6. Build Cal.com via docker compose (DOCKER_BUILDKIT=0 must be provided to allow a network bridge to be used at build time. This requirement will be removed in the future)
|
||||
|
||||
```bash
|
||||
DOCKER_BUILDKIT=0 docker compose build calcom
|
||||
```
|
||||
|
||||
7. Start Cal.com via docker compose
|
||||
|
||||
(Most basic users, and for First Run) To run the complete stack, which includes a local Postgres database, Cal.com web app, and Prisma Studio:
|
||||
|
||||
```bash
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
To run Cal.com web app and Prisma Studio against a remote database, ensure that DATABASE_URL is configured for an available database and run:
|
||||
|
||||
```bash
|
||||
docker compose up -d calcom studio
|
||||
```
|
||||
|
||||
To run only the Cal.com web app, ensure that DATABASE_URL is configured for an available database and run:
|
||||
|
||||
```bash
|
||||
docker compose up -d calcom
|
||||
```
|
||||
|
||||
**Note: to run in attached mode for debugging, remove `-d` from your desired run command.**
|
||||
|
||||
8. Open a browser to [http://localhost:3000](http://localhost:3000), or your defined NEXT_PUBLIC_WEBAPP_URL. The first time you run Cal.com, a setup wizard will initialize. Define your first user, and you're ready to go!
|
||||
|
||||
#### Configuration
|
||||
|
||||
##### Important Run-time variables
|
||||
|
||||
These variables must also be provided at runtime
|
||||
|
||||
| Variable | Description | Required | Default |
|
||||
| ----------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------- | ------------------------------------------------------------------- |
|
||||
| DATABASE_URL | database url with credentials - if using a connection pooler, this setting should point there | required | `postgresql://unicorn_user:magical_password@database:5432/calendso` |
|
||||
| CALCOM_LICENSE_KEY | Enterprise License Key | optional | |
|
||||
| NEXT_PUBLIC_WEBAPP_URL | Base URL of the site. NOTE: if this value differs from the value used at build-time, there will be a slight delay during container start (to update the statically built files). | optional | `http://localhost:3000` |
|
||||
| NEXTAUTH_URL | Location of the auth server. By default, this is the Cal.com docker instance itself. | optional | `{NEXT_PUBLIC_WEBAPP_URL}/api/auth` |
|
||||
| NEXTAUTH_SECRET | must match build variable | required | `secret` |
|
||||
| CALENDSO_ENCRYPTION_KEY | must match build variable | required | `secret` |
|
||||
|
||||
##### Build-time variables
|
||||
|
||||
If building the image yourself, these variables must be provided at the time of the docker build, and can be provided by updating the .env file. Currently, if you require changes to these variables, you must follow the instructions to build and publish your own image.
|
||||
|
||||
Updating these variables is not required for evaluation, but is required for running in production. Instructions for generating variables can be found in the [Cal.com instructions](https://github.com/calcom/cal.com)
|
||||
|
||||
| Variable | Description | Required | Default |
|
||||
| -------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------ | -------- | ------------------------------------------------------------------- |
|
||||
| DATABASE_URL | database url with credentials - if using a connection pooler, this setting should point there | required | `postgresql://unicorn_user:magical_password@database:5432/calendso` |
|
||||
| MAX_OLD_SPACE_SIZE | Needed for Nodejs/NPM build options | required | 4096 |
|
||||
| NEXT_PUBLIC_LICENSE_CONSENT | license consent - true/false | required | |
|
||||
| NEXTAUTH_SECRET | Cookie encryption key | required | `secret` |
|
||||
| CALENDSO_ENCRYPTION_KEY | Authentication encryption key | required | `secret` |
|
||||
| NEXT_PUBLIC_WEBAPP_URL | Base URL injected into static files | optional | `http://localhost:3000` |
|
||||
| NEXT_PUBLIC_WEBSITE_TERMS_URL | custom URL for terms and conditions website | optional | `https://cal.com/terms` |
|
||||
| NEXT_PUBLIC_WEBSITE_PRIVACY_POLICY_URL | custom URL for privacy policy website | optional | `https://cal.com/privacy` |
|
||||
| NEXT_PUBLIC_API_V2_URL | URL for the v2 API, only required for custom integrations or custom booking experiences using [Cal.com Platform](https://cal.com/platform) | optional | |
|
||||
| CALCOM_TELEMETRY_DISABLED | Allow Cal.com to collect anonymous usage data (set to `1` to disable) | optional | |
|
||||
| NEXT_PUBLIC_SINGLE_ORG_SLUG | Required if ORGANIZATIONS_ENABLED is true | optional | |
|
||||
| ORGANIZATIONS_ENABLED | Used for Enterprise or Organizations plan | optional | |
|
||||
|
||||
#### Troubleshooting
|
||||
|
||||
##### SSL edge termination
|
||||
|
||||
If running behind a load balancer which handles SSL certificates, you will need to add the environmental variable `NODE_TLS_REJECT_UNAUTHORIZED=0` to prevent requests from being rejected. Only do this if you know what you are doing and trust the services/load-balancers directing traffic to your service.
|
||||
|
||||
##### Failed to commit changes: Invalid 'prisma.user.create()'
|
||||
|
||||
Certain versions may have trouble creating a user if the field `metadata` is empty. Using an empty json object `{}` as the field value should resolve this issue. Also, the `id` field will autoincrement, so you may also try leaving the value of `id` as empty.
|
||||
|
||||
##### CLIENT_FETCH_ERROR
|
||||
|
||||
If you experience this error, it may be the way the default Auth callback in the server is using the WEBAPP_URL as a base url. The container does not necessarily have access to the same DNS as your local machine, and therefor needs to be configured to resolve to itself. You may be able to correct this by configuring `NEXTAUTH_URL=http://localhost:3000/api/auth`, to help the backend loop back to itself.
|
||||
|
||||
```
|
||||
docker-calcom-1 | @calcom/web:start: [next-auth][error][CLIENT_FETCH_ERROR]
|
||||
docker-calcom-1 | @calcom/web:start: https://next-auth.js.org/errors#client_fetch_error request to http://testing.localhost:3000/api/auth/session failed, reason: getaddrinfo ENOTFOUND testing.localhost {
|
||||
docker-calcom-1 | @calcom/web:start: error: {
|
||||
docker-calcom-1 | @calcom/web:start: message: 'request to http://testing.localhost:3000/api/auth/session failed, reason: getaddrinfo ENOTFOUND testing.localhost',
|
||||
docker-calcom-1 | @calcom/web:start: stack: 'FetchError: request to http://testing.localhost:3000/api/auth/session failed, reason: getaddrinfo ENOTFOUND testing.localhost\n' +
|
||||
docker-calcom-1 | @calcom/web:start: ' at ClientRequest.<anonymous> (/calcom/node_modules/next/dist/compiled/node-fetch/index.js:1:65756)\n' +
|
||||
docker-calcom-1 | @calcom/web:start: ' at ClientRequest.emit (node:events:513:28)\n' +
|
||||
docker-calcom-1 | @calcom/web:start: ' at ClientRequest.emit (node:domain:489:12)\n' +
|
||||
docker-calcom-1 | @calcom/web:start: ' at Socket.socketErrorListener (node:_http_client:494:9)\n' +
|
||||
docker-calcom-1 | @calcom/web:start: ' at Socket.emit (node:events:513:28)\n' +
|
||||
docker-calcom-1 | @calcom/web:start: ' at Socket.emit (node:domain:489:12)\n' +
|
||||
docker-calcom-1 | @calcom/web:start: ' at emitErrorNT (node:internal/streams/destroy:157:8)\n' +
|
||||
docker-calcom-1 | @calcom/web:start: ' at emitErrorCloseNT (node:internal/streams/destroy:122:3)\n' +
|
||||
docker-calcom-1 | @calcom/web:start: ' at processTicksAndRejections (node:internal/process/task_queues:83:21)',
|
||||
docker-calcom-1 | @calcom/web:start: name: 'FetchError'
|
||||
docker-calcom-1 | @calcom/web:start: },
|
||||
docker-calcom-1 | @calcom/web:start: url: 'http://testing.localhost:3000/api/auth/session',
|
||||
docker-calcom-1 | @calcom/web:start: message: 'request to http://testing.localhost:3000/api/auth/session failed, reason: getaddrinfo ENOTFOUND testing.localhost'
|
||||
docker-calcom-1 | @calcom/web:start: }
|
||||
```
|
||||
|
||||
<img referrerpolicy="no-referrer-when-downgrade" src="https://static.scarf.sh/a.png?x-pxid=81cda9f7-a102-453b-ac01-51c35650bd70" />
|
||||
|
||||
### Railway
|
||||
|
||||
@@ -425,7 +679,7 @@ See the [roadmap project](https://cal.com/roadmap) for a list of proposed featur
|
||||
|
||||
Cal.com, Inc. is a commercial open source company, which means some parts of this open source repository require a commercial license. The concept is called "Open Core" where the core technology (99%) is fully open source, licensed under [AGPLv3](https://opensource.org/license/agpl-v3) and the last 1% is covered under a commercial license (["/ee" Enterprise Edition](https://github.com/calcom/cal.com/tree/main/packages/features/ee)) which we believe is entirely relevant for larger organisations that require enterprise features. Enterprise features are built by the core engineering team of Cal.com, Inc. which is hired in full-time. Find their compensation on https://cal.com/open.
|
||||
|
||||
> [!NOTE]
|
||||
> [!NOTE]
|
||||
> Our philosophy is simple, all "Singleplayer APIs" are open-source under AGPLv3. All commercial "Multiplayer APIs" are under a commercial license.
|
||||
|
||||
| | AGPLv3 | EE |
|
||||
@@ -497,8 +751,8 @@ Don't code but still want to contribute? Join our [Discussions](https://github.c
|
||||
- Set CSP_POLICY="non-strict" env variable, which enables [Strict CSP](https://web.dev/strict-csp/) except for unsafe-inline in style-src . If you have some custom changes in your instance, you might have to make some code change to make your instance CSP compatible. Right now it enables strict CSP only on login page and on other SSR pages it is enabled in Report only mode to detect possible issues. On, SSG pages it is still not supported.
|
||||
|
||||
## Single Org Mode
|
||||
Refer to docs [here](./docs/self-hosting/guides/organization/single-organization-setup) for a detailed documentation with screenshots.
|
||||
|
||||
Refer to docs [here](./docs/self-hosting/guides/organization/single-organization-setup) for a detailed documentation with screenshots.
|
||||
|
||||
## Integrations
|
||||
|
||||
@@ -655,6 +909,7 @@ following
|
||||
We use changesets to generate changelogs and publish public packages (packages with `private: true` are ignored).
|
||||
|
||||
An example of good readme is [atoms readme](https://github.com/calcom/cal.com/blob/main/packages/platform/atoms/README.md). Every public package must:
|
||||
|
||||
1. Follow semantic versioning when using changesets.
|
||||
2. Mark breaking changes using `❗️Breaking change`
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ import { createMocks } from "node-mocks-http";
|
||||
import { describe, expect, it, beforeAll } from "vitest";
|
||||
import { ZodError } from "zod";
|
||||
|
||||
import prisma from "@calcom/prisma";
|
||||
import { prisma } from "@calcom/prisma";
|
||||
|
||||
import { handler } from "../../../pages/api/bookings/_get";
|
||||
|
||||
@@ -217,6 +217,22 @@ describe("GET /api/bookings", async () => {
|
||||
describe("Expand feature to add relational data in return payload", () => {
|
||||
it("Returns only team data when expand=team is set", async () => {
|
||||
const adminUser = await prisma.user.findFirstOrThrow({ where: { email: "owner1-acme@example.com" } });
|
||||
|
||||
// Find a team booking and a non-team booking from seed data
|
||||
const team1 = await prisma.team.findFirst({ where: { slug: "team1" } });
|
||||
const teamEventType = await prisma.eventType.findFirst({
|
||||
where: { teamId: team1?.id },
|
||||
include: { bookings: true },
|
||||
});
|
||||
const teamBooking = teamEventType?.bookings[0];
|
||||
|
||||
const nonTeamBooking = await prisma.booking.findFirst({
|
||||
where: {
|
||||
eventType: { teamId: null },
|
||||
userId: proUser.id,
|
||||
},
|
||||
});
|
||||
|
||||
const { req } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
|
||||
method: "GET",
|
||||
query: {
|
||||
@@ -229,14 +245,23 @@ describe("GET /api/bookings", async () => {
|
||||
req.isOrganizationOwnerOrAdmin = true;
|
||||
|
||||
const responseData = await handler(req);
|
||||
console.log("bookings=>", responseData.bookings);
|
||||
responseData.bookings.forEach((booking) => {
|
||||
if (booking.id === 31) expect(booking.eventType?.team?.slug).toBe("team1");
|
||||
if (booking.id === 19) {
|
||||
// The team field can be either null or undefined due to nullish() in the schema
|
||||
expect(booking.eventType?.team === null || booking.eventType?.team === undefined).toBe(true);
|
||||
|
||||
// Verify team booking has team data
|
||||
if (teamBooking) {
|
||||
const returnedTeamBooking = responseData.bookings.find((b) => b.id === teamBooking.id);
|
||||
expect(returnedTeamBooking?.eventType?.team?.slug).toBe("team1");
|
||||
}
|
||||
|
||||
// Verify non-team booking has null/undefined team
|
||||
if (nonTeamBooking) {
|
||||
const returnedNonTeamBooking = responseData.bookings.find((b) => b.id === nonTeamBooking.id);
|
||||
if (returnedNonTeamBooking) {
|
||||
expect(
|
||||
returnedNonTeamBooking.eventType?.team === null ||
|
||||
returnedNonTeamBooking.eventType?.team === undefined
|
||||
).toBe(true);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -16,14 +16,8 @@ ENV DATABASE_URL=${DATABASE_URL}
|
||||
COPY . .
|
||||
|
||||
RUN yarn install
|
||||
|
||||
# Build prisma schema and make sure that it is linked to v2 node_modules
|
||||
RUN yarn workspace @calcom/api-v2 run generate-schemas
|
||||
RUN rm -rf apps/api/v2/node_modules
|
||||
RUN yarn install
|
||||
|
||||
RUN yarn workspace @calcom/platform-libraries run build
|
||||
|
||||
RUN yarn workspace @calcom/api-v2 run build
|
||||
|
||||
EXPOSE 80
|
||||
|
||||
@@ -9,6 +9,8 @@ import { SchedulesModule_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/
|
||||
import { InstantBookingModule } from "@/lib/modules/instant-booking.module";
|
||||
import { RecurringBookingModule } from "@/lib/modules/recurring-booking.module";
|
||||
import { RegularBookingModule } from "@/lib/modules/regular-booking.module";
|
||||
import { PrismaEventTypeRepository } from "@/lib/repositories/prisma-event-type.repository";
|
||||
import { PrismaTeamRepository } from "@/lib/repositories/prisma-team.repository";
|
||||
import { ApiKeysRepository } from "@/modules/api-keys/api-keys-repository";
|
||||
import { AppsRepository } from "@/modules/apps/apps.repository";
|
||||
import { BillingModule } from "@/modules/billing/billing.module";
|
||||
@@ -55,6 +57,8 @@ import { Module } from "@nestjs/common";
|
||||
AppsRepository,
|
||||
CalendarsRepository,
|
||||
SelectedCalendarsRepository,
|
||||
PrismaEventTypeRepository,
|
||||
PrismaTeamRepository,
|
||||
],
|
||||
controllers: [BookingsController_2024_04_15],
|
||||
})
|
||||
|
||||
@@ -507,6 +507,122 @@ describe("Bookings Endpoints 2024-04-15", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("event type booking requires authentication", () => {
|
||||
let eventTypeRequiringAuthenticationId: number;
|
||||
let unauthorizedUser: User;
|
||||
let unauthorizedUserApiKeyString: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
const eventTypeRequiringAuthentication = await eventTypesRepositoryFixture.create(
|
||||
{
|
||||
title: `event-type-requiring-authentication-${randomString()}`,
|
||||
slug: `event-type-requiring-authentication-${randomString()}`,
|
||||
length: 60,
|
||||
requiresConfirmation: true,
|
||||
bookingRequiresAuthentication: true,
|
||||
},
|
||||
user.id
|
||||
);
|
||||
eventTypeRequiringAuthenticationId = eventTypeRequiringAuthentication.id;
|
||||
|
||||
const unauthorizedUserEmail = `unauthorized-user-${randomString()}@api.com`;
|
||||
unauthorizedUser = await userRepositoryFixture.create({
|
||||
email: unauthorizedUserEmail,
|
||||
});
|
||||
const { keyString } = await apiKeysRepositoryFixture.createApiKey(unauthorizedUser.id, null);
|
||||
unauthorizedUserApiKeyString = keyString;
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
if (unauthorizedUser) {
|
||||
await userRepositoryFixture.deleteByEmail(unauthorizedUser.email);
|
||||
}
|
||||
});
|
||||
|
||||
it("can't be booked without credentials", async () => {
|
||||
const body: CreateBookingInput_2024_04_15 = {
|
||||
start: "2040-05-23T09:30:00.000Z",
|
||||
end: "2040-05-23T10:30:00.000Z",
|
||||
eventTypeId: eventTypeRequiringAuthenticationId,
|
||||
timeZone: "Europe/London",
|
||||
language: "en",
|
||||
metadata: {},
|
||||
hashedLink: "",
|
||||
responses: {
|
||||
name: "External Attendee",
|
||||
email: "external@example.com",
|
||||
location: {
|
||||
value: "link",
|
||||
optionValue: "",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
await request(app.getHttpServer()).post("/v2/bookings").send(body).expect(401);
|
||||
});
|
||||
|
||||
it("can't be booked with unauthorized user credentials", async () => {
|
||||
const body: CreateBookingInput_2024_04_15 = {
|
||||
start: "2040-05-23T10:30:00.000Z",
|
||||
end: "2040-05-23T11:30:00.000Z",
|
||||
eventTypeId: eventTypeRequiringAuthenticationId,
|
||||
timeZone: "Europe/London",
|
||||
language: "en",
|
||||
metadata: {},
|
||||
hashedLink: "",
|
||||
responses: {
|
||||
name: "External Attendee",
|
||||
email: "external@example.com",
|
||||
location: {
|
||||
value: "link",
|
||||
optionValue: "",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
await request(app.getHttpServer())
|
||||
.post("/v2/bookings")
|
||||
.send(body)
|
||||
.set({ Authorization: `Bearer cal_test_${unauthorizedUserApiKeyString}` })
|
||||
.expect(403);
|
||||
});
|
||||
|
||||
it("can be booked with event type owner credentials", async () => {
|
||||
const body: CreateBookingInput_2024_04_15 = {
|
||||
start: "2040-05-23T11:30:00.000Z",
|
||||
end: "2040-05-23T12:30:00.000Z",
|
||||
eventTypeId: eventTypeRequiringAuthenticationId,
|
||||
timeZone: "Europe/London",
|
||||
language: "en",
|
||||
metadata: {},
|
||||
hashedLink: "",
|
||||
responses: {
|
||||
name: "External Attendee",
|
||||
email: "external@example.com",
|
||||
location: {
|
||||
value: "link",
|
||||
optionValue: "",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const response = await request(app.getHttpServer())
|
||||
.post("/v2/bookings")
|
||||
.send(body)
|
||||
.set({ Authorization: `Bearer cal_test_${apiKeyString}` })
|
||||
.expect(201);
|
||||
|
||||
const responseBody: ApiSuccessResponse<RegularBookingCreateResult> = response.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
expect(responseBody.data).toBeDefined();
|
||||
expect(responseBody.data.id).toBeDefined();
|
||||
|
||||
if (responseBody.data.id) {
|
||||
await bookingsRepositoryFixture.deleteById(responseBody.data.id);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await userRepositoryFixture.deleteByEmail(user.email);
|
||||
await bookingsRepositoryFixture.deleteAllBookings(user.id, user.email);
|
||||
|
||||
@@ -7,13 +7,20 @@ import { MarkNoShowOutput_2024_04_15 } from "@/ee/bookings/2024-04-15/outputs/ma
|
||||
import { PlatformBookingsService } from "@/ee/bookings/shared/platform-bookings.service";
|
||||
import { sha256Hash, isApiKey, stripApiKey } from "@/lib/api-key";
|
||||
import { VERSION_2024_04_15, VERSION_2024_06_11, VERSION_2024_06_14 } from "@/lib/api-versions";
|
||||
import { PrismaEventTypeRepository } from "@/lib/repositories/prisma-event-type.repository";
|
||||
import { PrismaTeamRepository } from "@/lib/repositories/prisma-team.repository";
|
||||
import { InstantBookingCreateService } from "@/lib/services/instant-booking-create.service";
|
||||
import { RecurringBookingService } from "@/lib/services/recurring-booking.service";
|
||||
import { RegularBookingService } from "@/lib/services/regular-booking.service";
|
||||
import { ApiKeysRepository } from "@/modules/api-keys/api-keys-repository";
|
||||
import {
|
||||
AuthOptionalUser,
|
||||
GetOptionalUser,
|
||||
} from "@/modules/auth/decorators/get-optional-user/get-optional-user.decorator";
|
||||
import { GetUser } from "@/modules/auth/decorators/get-user/get-user.decorator";
|
||||
import { Permissions } from "@/modules/auth/decorators/permissions/permissions.decorator";
|
||||
import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard";
|
||||
import { OptionalApiAuthGuard } from "@/modules/auth/guards/optional-api-auth/optional-api-auth.guard";
|
||||
import { PermissionsGuard } from "@/modules/auth/guards/permissions/permissions.guard";
|
||||
import { BillingService } from "@/modules/billing/services/billing.service";
|
||||
import { KyselyReadService } from "@/modules/kysely/kysely-read.service";
|
||||
@@ -38,6 +45,8 @@ import {
|
||||
NotFoundException,
|
||||
UseGuards,
|
||||
BadRequestException,
|
||||
UnauthorizedException,
|
||||
ForbiddenException,
|
||||
} from "@nestjs/common";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
import { ApiQuery, ApiExcludeController as DocsExcludeController } from "@nestjs/swagger";
|
||||
@@ -113,7 +122,9 @@ export class BookingsController_2024_04_15 {
|
||||
private readonly usersService: UsersService,
|
||||
private readonly regularBookingService: RegularBookingService,
|
||||
private readonly recurringBookingService: RecurringBookingService,
|
||||
private readonly instantBookingCreateService: InstantBookingCreateService
|
||||
private readonly instantBookingCreateService: InstantBookingCreateService,
|
||||
private readonly eventTypeRepository: PrismaEventTypeRepository,
|
||||
private readonly teamRepository: PrismaTeamRepository
|
||||
) {}
|
||||
|
||||
@Get("/")
|
||||
@@ -166,8 +177,12 @@ export class BookingsController_2024_04_15 {
|
||||
}
|
||||
|
||||
@Get("/:bookingUid/reschedule")
|
||||
async getBookingForReschedule(@Param("bookingUid") bookingUid: string): Promise<ApiResponse<unknown>> {
|
||||
const booking = await getBookingForReschedule(bookingUid);
|
||||
@UseGuards(OptionalApiAuthGuard)
|
||||
async getBookingForReschedule(
|
||||
@Param("bookingUid") bookingUid: string,
|
||||
@GetOptionalUser() user: AuthOptionalUser
|
||||
): Promise<ApiResponse<unknown>> {
|
||||
const booking = await getBookingForReschedule(bookingUid, user?.id);
|
||||
|
||||
if (!booking) {
|
||||
throw new NotFoundException(`Booking with UID=${bookingUid} does not exist.`);
|
||||
@@ -190,6 +205,7 @@ export class BookingsController_2024_04_15 {
|
||||
clientId?.toString() || (await this.getOAuthClientIdFromEventType(body.eventTypeId));
|
||||
const { orgSlug, locationUrl } = body;
|
||||
try {
|
||||
await this.checkBookingRequiresAuthentication(req, body.eventTypeId);
|
||||
const bookingRequest = await this.createNextApiBookingRequest(req, oAuthClientId, locationUrl, isEmbed);
|
||||
const booking = await this.regularBookingService.createBooking({
|
||||
bookingData: bookingRequest.body,
|
||||
@@ -443,6 +459,48 @@ export class BookingsController_2024_04_15 {
|
||||
return oAuthClientParams.platformClientId;
|
||||
}
|
||||
|
||||
private async checkBookingRequiresAuthentication(req: Request, eventTypeId: number): Promise<void> {
|
||||
const eventType = await this.eventTypeRepository.findByIdIncludeHostsAndTeamMembers({
|
||||
id: eventTypeId,
|
||||
});
|
||||
|
||||
if (!eventType?.bookingRequiresAuthentication) {
|
||||
return;
|
||||
}
|
||||
|
||||
const userId = await this.getOwnerId(req);
|
||||
|
||||
if (!userId) {
|
||||
throw new UnauthorizedException(
|
||||
"This event type requires authentication. Please provide valid credentials."
|
||||
);
|
||||
}
|
||||
|
||||
const isEventTypeOwner = eventType.userId === userId;
|
||||
const isHost = eventType.hosts.some((host) => host.userId === userId);
|
||||
const isTeamAdminOrOwner =
|
||||
eventType.team?.members.some((member) => member.userId === userId) ?? false;
|
||||
|
||||
let isOrgAdminOrOwner = false;
|
||||
if (eventType.team?.parentId) {
|
||||
const orgTeam = await this.teamRepository.getTeamByIdIfUserIsAdmin({
|
||||
userId,
|
||||
teamId: eventType.team.parentId,
|
||||
});
|
||||
isOrgAdminOrOwner = !!orgTeam;
|
||||
} else if (eventType.team?.isOrganization) {
|
||||
isOrgAdminOrOwner = isTeamAdminOrOwner;
|
||||
}
|
||||
|
||||
const isAuthorized = isEventTypeOwner || isHost || isTeamAdminOrOwner || isOrgAdminOrOwner;
|
||||
|
||||
if (!isAuthorized) {
|
||||
throw new ForbiddenException(
|
||||
"You are not authorized to book this event type. You must be the event type owner, a host, a team admin/owner, or an organization admin/owner."
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private async getOAuthClientsParams(clientId: string, isEmbed = false): Promise<OAuthRequestParams> {
|
||||
const res = { ...DEFAULT_PLATFORM_PARAMS };
|
||||
|
||||
@@ -502,7 +560,10 @@ export class BookingsController_2024_04_15 {
|
||||
return clone as unknown as NextApiRequest & { userId?: number } & OAuthRequestParams;
|
||||
}
|
||||
|
||||
async setPlatformAttendeesEmails(requestBody: any, oAuthClientId: string): Promise<void> {
|
||||
async setPlatformAttendeesEmails(
|
||||
requestBody: { responses?: { email?: string; guests?: string[] } },
|
||||
oAuthClientId: string
|
||||
): Promise<void> {
|
||||
if (requestBody?.responses?.email) {
|
||||
requestBody.responses.email = await this.platformBookingsService.getPlatformAttendeeEmail(
|
||||
requestBody.responses.email,
|
||||
@@ -564,6 +625,9 @@ export class BookingsController_2024_04_15 {
|
||||
|
||||
if (err instanceof Error) {
|
||||
const error = err as Error;
|
||||
if (err instanceof HttpException) {
|
||||
throw new HttpException(err.getResponse(), err.getStatus());
|
||||
}
|
||||
if (Object.values(ErrorCode).includes(error.message as unknown as ErrorCode)) {
|
||||
throw new HttpException(error.message, 400);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { BookingReferencesRepository_2024_08_13 } from "@/ee/bookings/2024-08-13/booking-references.repository";
|
||||
import { BookingsRepository_2024_08_13 } from "@/ee/bookings/2024-08-13/bookings.repository";
|
||||
import { BookingGuestsController_2024_08_13 } from "@/ee/bookings/2024-08-13/controllers/booking-guests.controller";
|
||||
import { BookingsController_2024_08_13 } from "@/ee/bookings/2024-08-13/controllers/bookings.controller";
|
||||
import { BookingReferencesRepository_2024_08_13 } from "@/ee/bookings/2024-08-13/repositories/booking-references.repository";
|
||||
import { BookingsRepository_2024_08_13 } from "@/ee/bookings/2024-08-13/repositories/bookings.repository";
|
||||
import { BookingGuestsService_2024_08_13 } from "@/ee/bookings/2024-08-13/services/booking-guests.service";
|
||||
import { BookingReferencesService_2024_08_13 } from "@/ee/bookings/2024-08-13/services/booking-references.service";
|
||||
import { BookingsService_2024_08_13 } from "@/ee/bookings/2024-08-13/services/bookings.service";
|
||||
import { CalVideoService } from "@/ee/bookings/2024-08-13/services/cal-video.service";
|
||||
@@ -15,6 +17,7 @@ import { CalendarsService } from "@/ee/calendars/services/calendars.service";
|
||||
import { EventTypesModule_2024_04_15 } from "@/ee/event-types/event-types_2024_04_15/event-types.module";
|
||||
import { EventTypesModule_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/event-types.module";
|
||||
import { EventTypesRepository_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/event-types.repository";
|
||||
import { OutputEventTypesService_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/services/output-event-types.service";
|
||||
import { SchedulesModule_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/schedules.module";
|
||||
import { InstantBookingModule } from "@/lib/modules/instant-booking.module";
|
||||
import { RecurringBookingModule } from "@/lib/modules/recurring-booking.module";
|
||||
@@ -71,9 +74,11 @@ import { Module } from "@nestjs/common";
|
||||
OAuthClientRepository,
|
||||
OAuthClientUsersService,
|
||||
BookingsService_2024_08_13,
|
||||
BookingGuestsService_2024_08_13,
|
||||
InputBookingsService_2024_08_13,
|
||||
OutputBookingsService_2024_08_13,
|
||||
OutputBookingReferencesService_2024_08_13,
|
||||
OutputEventTypesService_2024_06_14,
|
||||
BookingsRepository_2024_08_13,
|
||||
EventTypesRepository_2024_06_14,
|
||||
BookingSeatRepository,
|
||||
@@ -92,7 +97,7 @@ import { Module } from "@nestjs/common";
|
||||
BookingReferencesRepository_2024_08_13,
|
||||
CalVideoService,
|
||||
],
|
||||
controllers: [BookingsController_2024_08_13],
|
||||
controllers: [BookingsController_2024_08_13, BookingGuestsController_2024_08_13],
|
||||
exports: [InputBookingsService_2024_08_13, OutputBookingsService_2024_08_13, BookingsService_2024_08_13],
|
||||
})
|
||||
export class BookingsModule_2024_08_13 {}
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import { BookingUidGuard } from "@/ee/bookings/2024-08-13/guards/booking-uid.guard";
|
||||
import { AddGuestsOutput_2024_08_13 } from "@/ee/bookings/2024-08-13/outputs/add-guests.output";
|
||||
import { BookingGuestsService_2024_08_13 } from "@/ee/bookings/2024-08-13/services/booking-guests.service";
|
||||
import { VERSION_2024_08_13_VALUE, VERSION_2024_08_13 } from "@/lib/api-versions";
|
||||
import { API_KEY_OR_ACCESS_TOKEN_HEADER } from "@/lib/docs/headers";
|
||||
import { GetUser } from "@/modules/auth/decorators/get-user/get-user.decorator";
|
||||
import { Permissions } from "@/modules/auth/decorators/permissions/permissions.decorator";
|
||||
import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard";
|
||||
import { PermissionsGuard } from "@/modules/auth/guards/permissions/permissions.guard";
|
||||
import { ApiAuthGuardUser } from "@/modules/auth/strategies/api-auth/api-auth.strategy";
|
||||
import { Controller, Post, Logger, Body, UseGuards, Param, HttpCode, HttpStatus } from "@nestjs/common";
|
||||
import { ApiOperation, ApiTags as DocsTags, ApiHeader } from "@nestjs/swagger";
|
||||
|
||||
import { BOOKING_WRITE, SUCCESS_STATUS } from "@calcom/platform-constants";
|
||||
import { AddGuestsInput_2024_08_13 } from "@calcom/platform-types";
|
||||
|
||||
@Controller({
|
||||
path: "/v2/bookings/:bookingUid/guests",
|
||||
version: VERSION_2024_08_13_VALUE,
|
||||
})
|
||||
@UseGuards(PermissionsGuard)
|
||||
@DocsTags("Bookings / Guests")
|
||||
@ApiHeader({
|
||||
name: "cal-api-version",
|
||||
description: `Must be set to ${VERSION_2024_08_13}. This header is required as this endpoint does not exist in older API versions.`,
|
||||
example: VERSION_2024_08_13,
|
||||
required: true,
|
||||
})
|
||||
export class BookingGuestsController_2024_08_13 {
|
||||
private readonly logger = new Logger("BookingGuestsController_2024_08_13");
|
||||
|
||||
constructor(private readonly bookingGuestsService: BookingGuestsService_2024_08_13) {}
|
||||
|
||||
@Post("/")
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@Permissions([BOOKING_WRITE])
|
||||
@UseGuards(ApiAuthGuard, BookingUidGuard)
|
||||
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
|
||||
@ApiOperation({
|
||||
summary: "Add guests to an existing booking",
|
||||
description: `Add one or more guests to an existing booking.
|
||||
|
||||
**Email Notifications:**
|
||||
When guests are added, the following notifications are sent (unless disabled by event type settings):
|
||||
|
||||
- **Organizer & Team Members:** Receive an "Add Guests" notification email informing them that new guests have been added to the booking.
|
||||
|
||||
- **New Guests:** Receive a "Scheduled Event" email with full booking details and calendar invite. If they have a phone number, they also receive an SMS notification.
|
||||
|
||||
- **Existing Guests:** Receive an "Add Guests" notification email informing them that additional guests have been added to the booking.
|
||||
|
||||
<Note>The cal-api-version header is required for this endpoint. Without it, the request will fail with a 404 error.</Note>
|
||||
`,
|
||||
})
|
||||
async addGuests(
|
||||
@Param("bookingUid") bookingUid: string,
|
||||
@Body() body: AddGuestsInput_2024_08_13,
|
||||
@GetUser() user: ApiAuthGuardUser
|
||||
): Promise<AddGuestsOutput_2024_08_13> {
|
||||
const booking = await this.bookingGuestsService.addGuests(bookingUid, body, user);
|
||||
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
data: booking,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -134,6 +134,8 @@ export class BookingsController_2024_08_13 {
|
||||
If you are creating a seated booking for an event type with 'show attendees' disabled, then to retrieve attendees in the response either set 'show attendees' to true on event type level or
|
||||
you have to provide an authentication method of event type owner, host, team admin or owner or org admin or owner.
|
||||
|
||||
For event types that have SMS reminders workflow, you need to pass the attendee's phone number in the request body via \`attendee.phoneNumber\` (e.g., "+19876543210" in international format). This is an optional field, but becomes required when SMS reminders are enabled for the event type. For the complete attendee object structure, see the [attendee object](https://cal.com/docs/api-reference/v2/bookings/create-a-booking#body-attendee) documentation.
|
||||
|
||||
<Note>Please make sure to pass in the cal-api-version header value as mentioned in the Headers section. Not passing the correct value will default to an older version of this endpoint.</Note>
|
||||
`,
|
||||
})
|
||||
@@ -288,7 +290,7 @@ export class BookingsController_2024_08_13 {
|
||||
{ $ref: getSchemaPath(RescheduleSeatedBookingInput_2024_08_13) },
|
||||
],
|
||||
},
|
||||
description: `Accepts different types of reschedule booking input: Reschedule Booking (Option 1) or Reschedule Seated Booking (Option 2).
|
||||
description: `Accepts different types of reschedule booking input: Reschedule Booking (Option 1) or Reschedule Seated Booking (Option 2). If you're rescheduling a seated booking as org admin of booking host, pass booking input for Reschedule Booking (Option 1) along with your access token in the request header.
|
||||
|
||||
If you are rescheduling a seated booking for an event type with 'show attendees' disabled, then to retrieve attendees in the response either set 'show attendees' to true on event type level or
|
||||
you have to provide an authentication method of event type owner, host, team admin or owner or org admin or owner.`,
|
||||
@@ -326,7 +328,7 @@ export class BookingsController_2024_08_13 {
|
||||
\nCancelling seated bookings:
|
||||
It is possible to cancel specific seat within a booking as an attendee or all of the seats as the host.
|
||||
\n1. As an attendee - provide :bookingUid in the request URL \`/bookings/:bookingUid/cancel\` and seatUid in the request body \`{"seatUid": "123-123-123"}\` . This will remove this particular attendance from the booking.
|
||||
\n2. As the host - host can cancel booking for all attendees aka for every seat. Provide :bookingUid in the request URL \`/bookings/:bookingUid/cancel\` and cancellationReason in the request body \`{"cancellationReason": "Will travel"}\` and \`Authorization: Bearer token\` request header where token is event type owner (host) credential. This will cancel the booking for all attendees.
|
||||
\n2. As the host or org admin of host - host can cancel booking for all attendees aka for every seat, this also applies to org admins. Provide :bookingUid in the request URL \`/bookings/:bookingUid/cancel\` and cancellationReason in the request body \`{"cancellationReason": "Will travel"}\` and \`Authorization: Bearer token\` request header where token is event type owner (host) credential. This will cancel the booking for all attendees.
|
||||
|
||||
\nCancelling recurring seated bookings:
|
||||
For recurring seated bookings it is not possible to cancel all of them with 1 call
|
||||
|
||||
@@ -0,0 +1,523 @@
|
||||
import { bootstrap } from "@/app";
|
||||
import { AppModule } from "@/app.module";
|
||||
import { AddGuestsOutput_2024_08_13 } from "@/ee/bookings/2024-08-13/outputs/add-guests.output";
|
||||
import { CreateBookingOutput_2024_08_13 } from "@/ee/bookings/2024-08-13/outputs/create-booking.output";
|
||||
import { CreateScheduleInput_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/inputs/create-schedule.input";
|
||||
import { SchedulesModule_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/schedules.module";
|
||||
import { SchedulesService_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/services/schedules.service";
|
||||
import { PermissionsGuard } from "@/modules/auth/guards/permissions/permissions.guard";
|
||||
import { PrismaModule } from "@/modules/prisma/prisma.module";
|
||||
import { UsersModule } from "@/modules/users/users.module";
|
||||
import { INestApplication } from "@nestjs/common";
|
||||
import { NestExpressApplication } from "@nestjs/platform-express";
|
||||
import { Test } from "@nestjs/testing";
|
||||
import * as request from "supertest";
|
||||
import { BookingsRepositoryFixture } from "test/fixtures/repository/bookings.repository.fixture";
|
||||
import { EventTypesRepositoryFixture } from "test/fixtures/repository/event-types.repository.fixture";
|
||||
import { OAuthClientRepositoryFixture } from "test/fixtures/repository/oauth-client.repository.fixture";
|
||||
import { TeamRepositoryFixture } from "test/fixtures/repository/team.repository.fixture";
|
||||
import { TokensRepositoryFixture } from "test/fixtures/repository/tokens.repository.fixture";
|
||||
import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture";
|
||||
import { randomString } from "test/utils/randomString";
|
||||
|
||||
import {
|
||||
CAL_API_VERSION_HEADER,
|
||||
ERROR_STATUS,
|
||||
SUCCESS_STATUS,
|
||||
VERSION_2024_08_13,
|
||||
} from "@calcom/platform-constants";
|
||||
import {
|
||||
AttendeeAddGuestsEmail,
|
||||
AttendeeScheduledEmail,
|
||||
OrganizerAddGuestsEmail,
|
||||
} from "@calcom/platform-libraries/emails";
|
||||
import type { BookingOutput_2024_08_13, CreateBookingInput_2024_08_13 } from "@calcom/platform-types";
|
||||
import type { User, Team } from "@calcom/prisma/client";
|
||||
|
||||
const attendeeAddGuestsEmailSpy = jest
|
||||
.spyOn(AttendeeAddGuestsEmail.prototype, "getHtml")
|
||||
.mockImplementation(() => Promise.resolve("<p>email</p>"));
|
||||
const organizerAddGuestsEmailSpy = jest
|
||||
.spyOn(OrganizerAddGuestsEmail.prototype, "getHtml")
|
||||
.mockImplementation(() => Promise.resolve("<p>email</p>"));
|
||||
const attendeeScheduledEmailSpy = jest
|
||||
.spyOn(AttendeeScheduledEmail.prototype, "getHtml")
|
||||
.mockImplementation(() => Promise.resolve("<p>email</p>"));
|
||||
|
||||
type TestUser = {
|
||||
user: User;
|
||||
accessToken: string;
|
||||
refreshToken: string;
|
||||
};
|
||||
|
||||
type TestSetup = {
|
||||
organizer: TestUser;
|
||||
unrelatedUser: TestUser;
|
||||
guest: TestUser;
|
||||
eventTypeId: number;
|
||||
bookingUid: string;
|
||||
};
|
||||
|
||||
describe("Bookings Endpoints 2024-08-13 add guests", () => {
|
||||
let app: INestApplication;
|
||||
let organization: Team;
|
||||
|
||||
let userRepositoryFixture: UserRepositoryFixture;
|
||||
let bookingsRepositoryFixture: BookingsRepositoryFixture;
|
||||
let schedulesService: SchedulesService_2024_04_15;
|
||||
let eventTypesRepositoryFixture: EventTypesRepositoryFixture;
|
||||
let oauthClientRepositoryFixture: OAuthClientRepositoryFixture;
|
||||
let teamRepositoryFixture: TeamRepositoryFixture;
|
||||
let tokensRepositoryFixture: TokensRepositoryFixture;
|
||||
|
||||
let testSetup: TestSetup;
|
||||
|
||||
beforeAll(async () => {
|
||||
const moduleRef = await Test.createTestingModule({
|
||||
imports: [AppModule, PrismaModule, UsersModule, SchedulesModule_2024_04_15],
|
||||
})
|
||||
.overrideGuard(PermissionsGuard)
|
||||
.useValue({
|
||||
canActivate: () => true,
|
||||
})
|
||||
.compile();
|
||||
|
||||
userRepositoryFixture = new UserRepositoryFixture(moduleRef);
|
||||
bookingsRepositoryFixture = new BookingsRepositoryFixture(moduleRef);
|
||||
eventTypesRepositoryFixture = new EventTypesRepositoryFixture(moduleRef);
|
||||
oauthClientRepositoryFixture = new OAuthClientRepositoryFixture(moduleRef);
|
||||
teamRepositoryFixture = new TeamRepositoryFixture(moduleRef);
|
||||
schedulesService = moduleRef.get<SchedulesService_2024_04_15>(SchedulesService_2024_04_15);
|
||||
tokensRepositoryFixture = new TokensRepositoryFixture(moduleRef);
|
||||
|
||||
organization = await teamRepositoryFixture.create({
|
||||
name: `user-emails-2024-08-13-organization-${randomString()}`,
|
||||
});
|
||||
|
||||
await setupTestData();
|
||||
|
||||
app = moduleRef.createNestApplication();
|
||||
bootstrap(app as NestExpressApplication);
|
||||
|
||||
await app.init();
|
||||
});
|
||||
|
||||
async function setupTestData() {
|
||||
const oAuthClient = await createOAuthClient(organization.id, true);
|
||||
|
||||
const organizerUser = await userRepositoryFixture.create({
|
||||
email: `user-emails-2024-08-13-organizer-${randomString()}@api.com`,
|
||||
platformOAuthClients: {
|
||||
connect: { id: oAuthClient.id },
|
||||
},
|
||||
});
|
||||
|
||||
const unrelatedUserData = await userRepositoryFixture.create({
|
||||
email: `user-emails-2024-08-13-unrelated-${randomString()}@api.com`,
|
||||
platformOAuthClients: {
|
||||
connect: { id: oAuthClient.id },
|
||||
},
|
||||
});
|
||||
|
||||
const guestUser = await userRepositoryFixture.create({
|
||||
email: `user-emails-2024-08-13-guest-${randomString()}@api.com`,
|
||||
platformOAuthClients: {
|
||||
connect: { id: oAuthClient.id },
|
||||
},
|
||||
});
|
||||
|
||||
const organizerTokens = await tokensRepositoryFixture.createTokens(organizerUser.id, oAuthClient.id);
|
||||
const unrelatedUserTokens = await tokensRepositoryFixture.createTokens(
|
||||
unrelatedUserData.id,
|
||||
oAuthClient.id
|
||||
);
|
||||
const guestTokens = await tokensRepositoryFixture.createTokens(guestUser.id, oAuthClient.id);
|
||||
|
||||
const schedule: CreateScheduleInput_2024_04_15 = {
|
||||
name: `user-add-guests-2024-08-13-schedule-${randomString()}`,
|
||||
timeZone: "Europe/Rome",
|
||||
isDefault: true,
|
||||
};
|
||||
await schedulesService.createUserSchedule(organizerUser.id, schedule);
|
||||
|
||||
const eventType = await eventTypesRepositoryFixture.create(
|
||||
{
|
||||
title: `user-add-guests-2024-08-13-event-type-${randomString()}`,
|
||||
slug: `user-add-guests-2024-08-13-event-type-${randomString()}`,
|
||||
length: 60,
|
||||
},
|
||||
organizerUser.id
|
||||
);
|
||||
|
||||
testSetup = {
|
||||
organizer: {
|
||||
user: organizerUser,
|
||||
accessToken: organizerTokens.accessToken,
|
||||
refreshToken: organizerTokens.refreshToken,
|
||||
},
|
||||
unrelatedUser: {
|
||||
user: unrelatedUserData,
|
||||
accessToken: unrelatedUserTokens.accessToken,
|
||||
refreshToken: unrelatedUserTokens.refreshToken,
|
||||
},
|
||||
guest: {
|
||||
user: guestUser,
|
||||
accessToken: guestTokens.accessToken,
|
||||
refreshToken: guestTokens.refreshToken,
|
||||
},
|
||||
eventTypeId: eventType.id,
|
||||
bookingUid: "",
|
||||
};
|
||||
}
|
||||
|
||||
async function createOAuthClient(organizationId: number, emailsEnabled: boolean) {
|
||||
const data = {
|
||||
logo: "logo-url",
|
||||
name: "name",
|
||||
redirectUris: ["http://localhost:5555"],
|
||||
permissions: 32,
|
||||
areEmailsEnabled: emailsEnabled,
|
||||
};
|
||||
const secret = "secret";
|
||||
|
||||
const client = await oauthClientRepositoryFixture.create(organizationId, data, secret);
|
||||
return client;
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
attendeeAddGuestsEmailSpy.mockClear();
|
||||
organizerAddGuestsEmailSpy.mockClear();
|
||||
attendeeScheduledEmailSpy.mockClear();
|
||||
});
|
||||
|
||||
describe("POST /v2/bookings/:bookingUid/guests", () => {
|
||||
beforeAll(async () => {
|
||||
const createBookingBody: CreateBookingInput_2024_08_13 = {
|
||||
start: new Date(Date.UTC(2030, 0, 8, 13, 0, 0)).toISOString(),
|
||||
eventTypeId: testSetup.eventTypeId,
|
||||
attendee: {
|
||||
name: "Mr Proper",
|
||||
email: "mr_proper@gmail.com",
|
||||
timeZone: "Europe/Rome",
|
||||
language: "it",
|
||||
},
|
||||
location: "https://meet.google.com/abc-def-ghi",
|
||||
bookingFieldsResponses: {
|
||||
customField: "customValue",
|
||||
},
|
||||
metadata: {
|
||||
userId: "100",
|
||||
},
|
||||
};
|
||||
|
||||
const createBookingResponse = await request(app.getHttpServer())
|
||||
.post("/v2/bookings")
|
||||
.send(createBookingBody)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
|
||||
.expect(201);
|
||||
|
||||
const createBookingResponseBody: CreateBookingOutput_2024_08_13 = createBookingResponse.body;
|
||||
expect(createBookingResponseBody.status).toEqual(SUCCESS_STATUS);
|
||||
|
||||
if (!responseDataIsBooking(createBookingResponseBody.data)) {
|
||||
throw new Error(
|
||||
"Invalid response data - expected booking but received array of possibly recurring bookings"
|
||||
);
|
||||
}
|
||||
|
||||
testSetup.bookingUid = createBookingResponseBody.data.uid;
|
||||
});
|
||||
|
||||
describe("Authentication", () => {
|
||||
it("should return 401 when adding guests without authentication", async () => {
|
||||
const addGuestsBody = {
|
||||
guests: [{ email: "unauthenticated.guest@example.com" }],
|
||||
};
|
||||
|
||||
const addGuestsResponse = await request(app.getHttpServer())
|
||||
.post(`/v2/bookings/${testSetup.bookingUid}/guests`)
|
||||
.send(addGuestsBody)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13);
|
||||
|
||||
expect(addGuestsResponse.status).toBe(401);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Authorization - Organizer", () => {
|
||||
it("should allow booking organizer to add multiple guests", async () => {
|
||||
const addGuestsBody = {
|
||||
guests: [{ email: "organizer.guest1@example.com" }, { email: "organizer.guest2@example.com" }],
|
||||
};
|
||||
|
||||
const addGuestsResponse = await request(app.getHttpServer())
|
||||
.post(`/v2/bookings/${testSetup.bookingUid}/guests`)
|
||||
.send(addGuestsBody)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
|
||||
.set("Authorization", `Bearer ${testSetup.organizer.accessToken}`)
|
||||
.expect(200);
|
||||
|
||||
const addGuestsResponseBody: AddGuestsOutput_2024_08_13 = addGuestsResponse.body;
|
||||
|
||||
verifyAddGuestsResponse(
|
||||
addGuestsResponseBody,
|
||||
["organizer.guest1@example.com", "organizer.guest2@example.com"],
|
||||
true
|
||||
);
|
||||
});
|
||||
|
||||
it("should return 403 when unrelated user tries to add guests", async () => {
|
||||
const addGuestsBody = {
|
||||
guests: [
|
||||
{ email: "non-organizer.guest1@example.com" },
|
||||
{ email: "non-organizer.guest2@example.com" },
|
||||
],
|
||||
};
|
||||
|
||||
await request(app.getHttpServer())
|
||||
.post(`/v2/bookings/${testSetup.bookingUid}/guests`)
|
||||
.send(addGuestsBody)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
|
||||
.set("Authorization", `Bearer ${testSetup.unrelatedUser.accessToken}`)
|
||||
.expect(403);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Authorization - guest", () => {
|
||||
it("should allow organizer to add a user as guest, then guest can add more guests", async () => {
|
||||
// Step 1: Organizer adds guest user as a guest
|
||||
const addguestBody = {
|
||||
guests: [{ email: testSetup.guest.user.email }],
|
||||
};
|
||||
|
||||
const addguestResponse = await request(app.getHttpServer())
|
||||
.post(`/v2/bookings/${testSetup.bookingUid}/guests`)
|
||||
.send(addguestBody)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
|
||||
.set("Authorization", `Bearer ${testSetup.organizer.accessToken}`)
|
||||
.expect(200);
|
||||
|
||||
const addguestResponseBody: AddGuestsOutput_2024_08_13 = addguestResponse.body;
|
||||
|
||||
verifyAddGuestsResponse(addguestResponseBody, [testSetup.guest.user.email], true);
|
||||
|
||||
// Step 2: Now the guest can add their own guests
|
||||
const addGuestsBody = {
|
||||
guests: [{ email: "guest.guest1@example.com" }],
|
||||
};
|
||||
|
||||
const addGuestsResponse = await request(app.getHttpServer())
|
||||
.post(`/v2/bookings/${testSetup.bookingUid}/guests`)
|
||||
.send(addGuestsBody)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
|
||||
.set("Authorization", `Bearer ${testSetup.guest.accessToken}`)
|
||||
.expect(200);
|
||||
|
||||
const addGuestsResponseBody: AddGuestsOutput_2024_08_13 = addGuestsResponse.body;
|
||||
|
||||
verifyAddGuestsResponse(addGuestsResponseBody, ["guest.guest1@example.com"], true);
|
||||
});
|
||||
|
||||
it("should return 403 when non-guest user tries to add guests", async () => {
|
||||
const addGuestsBody = {
|
||||
guests: [{ email: "non-guest.guest1@example.com" }, { email: "non-guest.guest2@example.com" }],
|
||||
};
|
||||
|
||||
await request(app.getHttpServer())
|
||||
.post(`/v2/bookings/${testSetup.bookingUid}/guests`)
|
||||
.send(addGuestsBody)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
|
||||
.set("Authorization", `Bearer ${testSetup.unrelatedUser.accessToken}`)
|
||||
.expect(403);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Validation", () => {
|
||||
it("should return 400 when attempting to add duplicate guest email", async () => {
|
||||
const addGuestsBody = {
|
||||
guests: [{ email: "organizer.guest1@example.com" }],
|
||||
};
|
||||
|
||||
const addGuestsResponse = await request(app.getHttpServer())
|
||||
.post(`/v2/bookings/${testSetup.bookingUid}/guests`)
|
||||
.send(addGuestsBody)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
|
||||
.set("Authorization", `Bearer ${testSetup.organizer.accessToken}`);
|
||||
|
||||
expect(addGuestsResponse.status).toEqual(400);
|
||||
expect(addGuestsResponse.body.status).toEqual(ERROR_STATUS);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /v2/bookings/:bookingUid/guests - Emails Disabled", () => {
|
||||
let emailsDisabledSetup: TestSetup;
|
||||
|
||||
beforeAll(async () => {
|
||||
const oAuthClientDisabled = await createOAuthClient(organization.id, false);
|
||||
|
||||
const organizerUser = await userRepositoryFixture.create({
|
||||
email: `user-no-emails-2024-08-13-organizer-${randomString()}@api.com`,
|
||||
platformOAuthClients: {
|
||||
connect: { id: oAuthClientDisabled.id },
|
||||
},
|
||||
});
|
||||
|
||||
const organizerTokens = await tokensRepositoryFixture.createTokens(
|
||||
organizerUser.id,
|
||||
oAuthClientDisabled.id
|
||||
);
|
||||
|
||||
const schedule: CreateScheduleInput_2024_04_15 = {
|
||||
name: `user-no-emails-2024-08-13-schedule-${randomString()}`,
|
||||
timeZone: "Europe/Rome",
|
||||
isDefault: true,
|
||||
};
|
||||
await schedulesService.createUserSchedule(organizerUser.id, schedule);
|
||||
|
||||
const eventType = await eventTypesRepositoryFixture.create(
|
||||
{
|
||||
title: `user-no-emails-2024-08-13-event-type-${randomString()}`,
|
||||
slug: `user-no-emails-2024-08-13-event-type-${randomString()}`,
|
||||
length: 60,
|
||||
},
|
||||
organizerUser.id
|
||||
);
|
||||
|
||||
const createBookingBody: CreateBookingInput_2024_08_13 = {
|
||||
start: new Date(Date.UTC(2030, 0, 9, 14, 0, 0)).toISOString(),
|
||||
eventTypeId: eventType.id,
|
||||
attendee: {
|
||||
name: "No Email User",
|
||||
email: "no.email.user@gmail.com",
|
||||
timeZone: "Europe/Rome",
|
||||
language: "it",
|
||||
},
|
||||
location: "https://meet.google.com/no-emails",
|
||||
bookingFieldsResponses: {
|
||||
customField: "noEmailValue",
|
||||
},
|
||||
metadata: {
|
||||
userId: "200",
|
||||
},
|
||||
};
|
||||
|
||||
const createBookingResponse = await request(app.getHttpServer())
|
||||
.post("/v2/bookings")
|
||||
.send(createBookingBody)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
|
||||
.expect(201);
|
||||
|
||||
const createBookingResponseBody: CreateBookingOutput_2024_08_13 = createBookingResponse.body;
|
||||
|
||||
if (!responseDataIsBooking(createBookingResponseBody.data)) {
|
||||
throw new Error(
|
||||
"Invalid response data - expected booking but received array of possibly recurring bookings"
|
||||
);
|
||||
}
|
||||
|
||||
emailsDisabledSetup = {
|
||||
organizer: {
|
||||
user: organizerUser,
|
||||
accessToken: organizerTokens.accessToken,
|
||||
refreshToken: organizerTokens.refreshToken,
|
||||
},
|
||||
unrelatedUser: testSetup.unrelatedUser,
|
||||
guest: testSetup.guest,
|
||||
eventTypeId: eventType.id,
|
||||
bookingUid: createBookingResponseBody.data.uid,
|
||||
};
|
||||
});
|
||||
|
||||
it("should NOT send emails when adding guests (emails disabled)", async () => {
|
||||
attendeeAddGuestsEmailSpy.mockClear();
|
||||
organizerAddGuestsEmailSpy.mockClear();
|
||||
attendeeScheduledEmailSpy.mockClear();
|
||||
|
||||
const addGuestsBody = {
|
||||
guests: [{ email: "no-email.guest1@example.com" }, { email: "no-email.guest2@example.com" }],
|
||||
};
|
||||
|
||||
const addGuestsResponse = await request(app.getHttpServer())
|
||||
.post(`/v2/bookings/${emailsDisabledSetup.bookingUid}/guests`)
|
||||
.send(addGuestsBody)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
|
||||
.set("Authorization", `Bearer ${emailsDisabledSetup.organizer.accessToken}`)
|
||||
.expect(200);
|
||||
|
||||
const addGuestsResponseBody: AddGuestsOutput_2024_08_13 = addGuestsResponse.body;
|
||||
|
||||
verifyAddGuestsResponse(
|
||||
addGuestsResponseBody,
|
||||
["no-email.guest1@example.com", "no-email.guest2@example.com"],
|
||||
false
|
||||
);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await userRepositoryFixture.deleteByEmail(emailsDisabledSetup.organizer.user.email);
|
||||
await bookingsRepositoryFixture.deleteAllBookings(
|
||||
emailsDisabledSetup.organizer.user.id,
|
||||
emailsDisabledSetup.organizer.user.email
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await teamRepositoryFixture.delete(organization.id);
|
||||
|
||||
await userRepositoryFixture.deleteByEmail(testSetup.organizer.user.email);
|
||||
await userRepositoryFixture.deleteByEmail(testSetup.unrelatedUser.user.email);
|
||||
await userRepositoryFixture.deleteByEmail(testSetup.guest.user.email);
|
||||
|
||||
await bookingsRepositoryFixture.deleteAllBookings(
|
||||
testSetup.organizer.user.id,
|
||||
testSetup.organizer.user.email
|
||||
);
|
||||
|
||||
await app.close();
|
||||
});
|
||||
|
||||
function responseDataIsBooking(data: unknown): data is BookingOutput_2024_08_13 {
|
||||
return !Array.isArray(data) && typeof data === "object" && data !== null && "id" in data;
|
||||
}
|
||||
|
||||
function verifyAddGuestsResponse(
|
||||
responseBody: AddGuestsOutput_2024_08_13,
|
||||
expectedGuestEmails: string[],
|
||||
shouldEmailsBeSent: boolean
|
||||
): BookingOutput_2024_08_13 {
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
|
||||
if (!responseDataIsBooking(responseBody.data)) {
|
||||
throw new Error(
|
||||
"Invalid response data - expected booking but received array of possibly recurring bookings"
|
||||
);
|
||||
}
|
||||
|
||||
const bookingData = responseBody.data;
|
||||
|
||||
expect(bookingData.guests).toBeDefined();
|
||||
const guestEmails = bookingData.guests;
|
||||
|
||||
expectedGuestEmails.forEach((email) => {
|
||||
expect(guestEmails).toContain(email);
|
||||
});
|
||||
|
||||
expect(bookingData.guests).toBeDefined();
|
||||
expectedGuestEmails.forEach((email) => {
|
||||
expect(bookingData.guests).toContain(email);
|
||||
});
|
||||
|
||||
if (shouldEmailsBeSent) {
|
||||
expect(attendeeAddGuestsEmailSpy).toHaveBeenCalled();
|
||||
expect(organizerAddGuestsEmailSpy).toHaveBeenCalled();
|
||||
expect(attendeeScheduledEmailSpy).toHaveBeenCalled();
|
||||
} else {
|
||||
expect(attendeeAddGuestsEmailSpy).not.toHaveBeenCalled();
|
||||
expect(organizerAddGuestsEmailSpy).not.toHaveBeenCalled();
|
||||
expect(attendeeScheduledEmailSpy).not.toHaveBeenCalled();
|
||||
}
|
||||
|
||||
return bookingData;
|
||||
}
|
||||
});
|
||||
+116
@@ -70,6 +70,7 @@ describe("Managed user bookings 2024-08-13", () => {
|
||||
|
||||
let firstManagedUserEventTypeId: number;
|
||||
let eventTypeRequiresConfirmationId: number;
|
||||
let seatedEventTypeId: number;
|
||||
|
||||
const orgAdminManagedUserEmail = `managed-user-bookings-2024-08-13-org-admin-${randomString()}@api.com`;
|
||||
let orgAdminManagedUser: CreateManagedUserData;
|
||||
@@ -309,6 +310,20 @@ describe("Managed user bookings 2024-08-13", () => {
|
||||
eventTypeRequiresConfirmationId = eventTypeRequiresConfirmation.id;
|
||||
});
|
||||
|
||||
it("should create a seated event type for first managed user", async () => {
|
||||
const seatedEventType = await eventTypesRepositoryFixture.create(
|
||||
{
|
||||
title: `managed-user-bookings-seated-event-type-${randomString()}`,
|
||||
slug: `managed-user-bookings-seated-event-type-${randomString()}`,
|
||||
length: 30,
|
||||
seatsPerTimeSlot: 5,
|
||||
seatsShowAttendees: true,
|
||||
},
|
||||
firstManagedUser.user.id
|
||||
);
|
||||
seatedEventTypeId = seatedEventType.id;
|
||||
});
|
||||
|
||||
describe("bookings using original emails", () => {
|
||||
it("managed user should be booked by managed user attendee and booking shows up in both users' bookings", async () => {
|
||||
const body: CreateBookingInput_2024_08_13 = {
|
||||
@@ -773,6 +788,107 @@ describe("Managed user bookings 2024-08-13", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("seated booking management by org admin", () => {
|
||||
let seatedBookingUid: string;
|
||||
|
||||
it("should create a seated booking with multiple attendees", async () => {
|
||||
const bodyOne: CreateBookingInput_2024_08_13 = {
|
||||
start: new Date(Date.UTC(2030, 0, 10, 10, 0, 0)).toISOString(),
|
||||
eventTypeId: seatedEventTypeId,
|
||||
attendee: {
|
||||
name: secondManagedUser.user.name!,
|
||||
email: secondManagedUser.user.email,
|
||||
timeZone: secondManagedUser.user.timeZone,
|
||||
language: secondManagedUser.user.locale,
|
||||
},
|
||||
};
|
||||
|
||||
const responseOne = await request(app.getHttpServer())
|
||||
.post("/v2/bookings")
|
||||
.send(bodyOne)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
|
||||
.expect(201);
|
||||
|
||||
const responseBodyForFirstAttendee: ApiSuccessResponse<BookingOutput_2024_08_13> = responseOne.body;
|
||||
expect(responseBodyForFirstAttendee.status).toEqual(SUCCESS_STATUS);
|
||||
expect(responseBodyForFirstAttendee.data).toBeDefined();
|
||||
|
||||
const bookingDataForFirstAttendee = responseBodyForFirstAttendee.data as BookingOutput_2024_08_13;
|
||||
seatedBookingUid = bookingDataForFirstAttendee.uid;
|
||||
expect(bookingDataForFirstAttendee.attendees).toBeDefined();
|
||||
expect(bookingDataForFirstAttendee.attendees.length).toBeGreaterThan(0);
|
||||
|
||||
const bodyTwo: CreateBookingInput_2024_08_13 = {
|
||||
start: new Date(Date.UTC(2030, 0, 10, 10, 0, 0)).toISOString(),
|
||||
eventTypeId: seatedEventTypeId,
|
||||
attendee: {
|
||||
name: thirdManagedUser.user.name!,
|
||||
email: thirdManagedUser.user.email,
|
||||
timeZone: thirdManagedUser.user.timeZone,
|
||||
language: thirdManagedUser.user.locale,
|
||||
},
|
||||
};
|
||||
|
||||
const responseTwo = await request(app.getHttpServer())
|
||||
.post("/v2/bookings")
|
||||
.send(bodyTwo)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
|
||||
.expect(201);
|
||||
|
||||
const responseBodyForSecondAttendee: ApiSuccessResponse<BookingOutput_2024_08_13> = responseTwo.body;
|
||||
expect(responseBodyForSecondAttendee.status).toEqual(SUCCESS_STATUS);
|
||||
expect(responseBodyForSecondAttendee.data).toBeDefined();
|
||||
|
||||
const bookingDataForSecondAttendee = responseBodyForSecondAttendee.data as BookingOutput_2024_08_13;
|
||||
expect(bookingDataForSecondAttendee.attendees).toBeDefined();
|
||||
expect(bookingDataForSecondAttendee.attendees.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("should allow org admin to reschedule all seats in a seated booking for a managed user", async () => {
|
||||
const newStartTime = new Date(Date.UTC(2030, 0, 10, 11, 0, 0)).toISOString();
|
||||
|
||||
const response = await request(app.getHttpServer())
|
||||
.post(`/v2/bookings/${seatedBookingUid}/reschedule`)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
|
||||
.set("Authorization", `Bearer ${orgAdminManagedUser.accessToken}`)
|
||||
.send({
|
||||
start: newStartTime,
|
||||
})
|
||||
.expect(201);
|
||||
|
||||
const responseBody: ApiSuccessResponse<BookingOutput_2024_08_13> = response.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
expect(responseBody.data).toBeDefined();
|
||||
|
||||
const bookingData = responseBody.data as BookingOutput_2024_08_13;
|
||||
expect(bookingData.start).toEqual(newStartTime);
|
||||
|
||||
seatedBookingUid = bookingData.uid;
|
||||
});
|
||||
|
||||
it("should allow org admin to cancel all seats in a seated booking for a managed user", async () => {
|
||||
const response = await request(app.getHttpServer())
|
||||
.post(`/v2/bookings/${seatedBookingUid}/cancel`)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
|
||||
.set("Authorization", `Bearer ${orgAdminManagedUser.accessToken}`)
|
||||
.send({
|
||||
cancellationReason: "Org admin cancelled the booking",
|
||||
})
|
||||
.expect(200);
|
||||
|
||||
const responseBody: GetBookingOutput_2024_08_13 = response.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
expect(responseBody.data).toBeDefined();
|
||||
|
||||
const bookingData = responseBody.data as BookingOutput_2024_08_13;
|
||||
expect(bookingData.status).toEqual("cancelled");
|
||||
expect(bookingData.uid).toEqual(seatedBookingUid);
|
||||
|
||||
const cancelledBooking = await bookingsRepositoryFixture.getByUid(seatedBookingUid);
|
||||
expect(cancelledBooking?.status).toEqual("CANCELLED");
|
||||
});
|
||||
});
|
||||
|
||||
describe("event type booking requires authentication", () => {
|
||||
let eventTypeRequiringAuthenticationId: number;
|
||||
|
||||
|
||||
+423
-4
@@ -23,10 +23,13 @@ import { UserRepositoryFixture } from "test/fixtures/repository/users.repository
|
||||
import { randomString } from "test/utils/randomString";
|
||||
import { withApiAuth } from "test/utils/withApiAuth";
|
||||
|
||||
|
||||
|
||||
import { CAL_API_VERSION_HEADER, SUCCESS_STATUS, VERSION_2024_08_13 } from "@calcom/platform-constants";
|
||||
import type { CreateBookingInput_2024_08_13 } from "@calcom/platform-types";
|
||||
import type { Booking, User, PlatformOAuthClient, Team } from "@calcom/prisma/client";
|
||||
|
||||
|
||||
describe("Bookings Endpoints 2024-08-13", () => {
|
||||
describe("Reassign bookings", () => {
|
||||
let app: INestApplication;
|
||||
@@ -47,12 +50,23 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
||||
|
||||
const teamUserEmail = `reassign-bookings-2024-08-13-user1-${randomString()}@api.com`;
|
||||
const teamUserEmail2 = `reassign-bookings-2024-08-13-user2-${randomString()}@api.com`;
|
||||
const teamUserEmail3 = `reassign-bookings-2024-08-13-user3-${randomString()}@api.com`;
|
||||
let teamUser1: User;
|
||||
let teamUser2: User;
|
||||
let teamUser3: User;
|
||||
|
||||
let teamRoundRobinEventTypeId: number;
|
||||
let teamRoundRobinFixedHostEventTypeId: number;
|
||||
let teamRoundRobinNonFixedEventTypeId: number;
|
||||
let teamRoundRobinWithRescheduleReasonEventTypeId: number;
|
||||
|
||||
let teamRoundRobinNonFixedEventTypeTitle: string;
|
||||
let teamRoundRobinFixedHostEventTypeTitle: string;
|
||||
let teamRoundRobinWithRescheduleReasonEventTypeTitle: string;
|
||||
|
||||
let roundRobinBooking: Booking;
|
||||
let rescheduleReasonBookingUid: string;
|
||||
let rescheduleReasonBookingInitialHostId: number;
|
||||
|
||||
beforeAll(async () => {
|
||||
const moduleRef = await withApiAuth(
|
||||
@@ -96,23 +110,37 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
||||
|
||||
teamUser1 = await userRepositoryFixture.create({
|
||||
email: teamUserEmail,
|
||||
locale: "it",
|
||||
locale: "en",
|
||||
name: `reassign-bookings-2024-08-13-user1-${randomString()}`,
|
||||
});
|
||||
|
||||
teamUser2 = await userRepositoryFixture.create({
|
||||
email: teamUserEmail2,
|
||||
locale: "it",
|
||||
locale: "en",
|
||||
name: `reassign-bookings-2024-08-13-user2-${randomString()}`,
|
||||
});
|
||||
|
||||
teamUser3 = await userRepositoryFixture.create({
|
||||
email: teamUserEmail3,
|
||||
locale: "en",
|
||||
name: `reassign-bookings-2024-08-13-user3-${randomString()}`,
|
||||
});
|
||||
|
||||
const userSchedule: CreateScheduleInput_2024_04_15 = {
|
||||
name: `reassign-bookings-2024-08-13-schedule-${randomString()}`,
|
||||
timeZone: "Europe/Rome",
|
||||
isDefault: true,
|
||||
availabilities: [
|
||||
{
|
||||
days: [0, 1, 2, 3, 4, 5, 6], // All days of the week
|
||||
startTime: new Date(Date.UTC(2024, 0, 1, 0, 0, 0)), // 00:00 UTC
|
||||
endTime: new Date(Date.UTC(2024, 0, 1, 23, 59, 0)), // 23:59 UTC
|
||||
},
|
||||
],
|
||||
};
|
||||
await schedulesService.createUserSchedule(teamUser1.id, userSchedule);
|
||||
await schedulesService.createUserSchedule(teamUser2.id, userSchedule);
|
||||
await schedulesService.createUserSchedule(teamUser3.id, userSchedule);
|
||||
|
||||
await profileRepositoryFixture.create({
|
||||
uid: `usr-${teamUser1.id}`,
|
||||
@@ -144,6 +172,21 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
||||
},
|
||||
});
|
||||
|
||||
await profileRepositoryFixture.create({
|
||||
uid: `usr-${teamUser3.id}`,
|
||||
username: teamUserEmail3,
|
||||
organization: {
|
||||
connect: {
|
||||
id: organization.id,
|
||||
},
|
||||
},
|
||||
user: {
|
||||
connect: {
|
||||
id: teamUser3.id,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await membershipsRepositoryFixture.create({
|
||||
role: "MEMBER",
|
||||
team: { connect: { id: team.id } },
|
||||
@@ -158,6 +201,13 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
||||
accepted: true,
|
||||
});
|
||||
|
||||
await membershipsRepositoryFixture.create({
|
||||
role: "MEMBER",
|
||||
team: { connect: { id: team.id } },
|
||||
user: { connect: { id: teamUser3.id } },
|
||||
accepted: true,
|
||||
});
|
||||
|
||||
const team1EventType = await eventTypesRepositoryFixture.createTeamEventType({
|
||||
schedulingType: "ROUND_ROBIN",
|
||||
team: {
|
||||
@@ -230,12 +280,187 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
||||
create: {
|
||||
email: "bob@gmail.com",
|
||||
name: "Bob",
|
||||
locale: "it",
|
||||
locale: "en",
|
||||
timeZone: "Europe/Rome",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const teamNonFixedEventType = await eventTypesRepositoryFixture.createTeamEventType({
|
||||
schedulingType: "ROUND_ROBIN",
|
||||
team: {
|
||||
connect: { id: team.id },
|
||||
},
|
||||
users: {
|
||||
connect: [{ id: teamUser2.id }, { id: teamUser3.id }],
|
||||
},
|
||||
title: `reassign-bookings-2024-08-13-non-fixed-event-type-${randomString()}`,
|
||||
slug: `reassign-bookings-2024-08-13-non-fixed-event-type-${randomString()}`,
|
||||
length: 60,
|
||||
assignAllTeamMembers: false,
|
||||
bookingFields: [],
|
||||
locations: [{ type: "inPerson", address: "via 10, rome, italy" }],
|
||||
});
|
||||
|
||||
teamRoundRobinNonFixedEventTypeId = teamNonFixedEventType.id;
|
||||
teamRoundRobinNonFixedEventTypeTitle = teamNonFixedEventType.title;
|
||||
|
||||
await hostsRepositoryFixture.create({
|
||||
isFixed: false,
|
||||
user: {
|
||||
connect: {
|
||||
id: teamUser2.id,
|
||||
},
|
||||
},
|
||||
eventType: {
|
||||
connect: {
|
||||
id: teamNonFixedEventType.id,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await hostsRepositoryFixture.create({
|
||||
isFixed: false,
|
||||
user: {
|
||||
connect: {
|
||||
id: teamUser3.id,
|
||||
},
|
||||
},
|
||||
eventType: {
|
||||
connect: {
|
||||
id: teamNonFixedEventType.id,
|
||||
},
|
||||
},
|
||||
});
|
||||
const team2EventType = await eventTypesRepositoryFixture.createTeamEventType({
|
||||
schedulingType: "ROUND_ROBIN",
|
||||
team: {
|
||||
connect: { id: team.id },
|
||||
},
|
||||
users: {
|
||||
connect: [{ id: teamUser1.id }, { id: teamUser2.id }, { id: teamUser3.id }],
|
||||
},
|
||||
title: `reassign-bookings-2024-08-13-fixed-event-type-${randomString()}`,
|
||||
slug: `reassign-bookings-2024-08-13-fixed-event-type-${randomString()}`,
|
||||
length: 60,
|
||||
assignAllTeamMembers: false,
|
||||
bookingFields: [],
|
||||
locations: [{ type: "inPerson", address: "via 10, rome, italy" }],
|
||||
});
|
||||
|
||||
teamRoundRobinFixedHostEventTypeId = team2EventType.id;
|
||||
teamRoundRobinFixedHostEventTypeTitle = team2EventType.title;
|
||||
|
||||
await hostsRepositoryFixture.create({
|
||||
isFixed: true,
|
||||
user: {
|
||||
connect: {
|
||||
id: teamUser1.id,
|
||||
},
|
||||
},
|
||||
eventType: {
|
||||
connect: {
|
||||
id: team2EventType.id,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await hostsRepositoryFixture.create({
|
||||
isFixed: false,
|
||||
user: {
|
||||
connect: {
|
||||
id: teamUser2.id,
|
||||
},
|
||||
},
|
||||
eventType: {
|
||||
connect: {
|
||||
id: team2EventType.id,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await hostsRepositoryFixture.create({
|
||||
isFixed: false,
|
||||
user: {
|
||||
connect: {
|
||||
id: teamUser3.id,
|
||||
},
|
||||
},
|
||||
eventType: {
|
||||
connect: {
|
||||
id: team2EventType.id,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const teamEventTypeWithRescheduleReason = await eventTypesRepositoryFixture.createTeamEventType({
|
||||
schedulingType: "ROUND_ROBIN",
|
||||
team: {
|
||||
connect: { id: team.id },
|
||||
},
|
||||
users: {
|
||||
connect: [{ id: teamUser1.id }, { id: teamUser2.id }],
|
||||
},
|
||||
title: `reassign-bookings-2024-08-13-reschedule-reason-event-type-${randomString()}`,
|
||||
slug: `reassign-bookings-2024-08-13-reschedule-reason-event-type-${randomString()}`,
|
||||
length: 60,
|
||||
assignAllTeamMembers: false,
|
||||
bookingFields: [
|
||||
{
|
||||
name: "rescheduleReason",
|
||||
type: "textarea",
|
||||
defaultLabel: "Reason for rescheduling",
|
||||
required: true,
|
||||
sources: [
|
||||
{
|
||||
id: "default",
|
||||
type: "default",
|
||||
label: "Default",
|
||||
},
|
||||
],
|
||||
editable: "system",
|
||||
views: [
|
||||
{
|
||||
id: "reschedule",
|
||||
label: "Reschedule View",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
locations: [{ type: "inPerson", address: "via 10, rome, italy" }],
|
||||
});
|
||||
|
||||
teamRoundRobinWithRescheduleReasonEventTypeId = teamEventTypeWithRescheduleReason.id;
|
||||
teamRoundRobinWithRescheduleReasonEventTypeTitle = teamEventTypeWithRescheduleReason.title;
|
||||
|
||||
await hostsRepositoryFixture.create({
|
||||
isFixed: false,
|
||||
user: {
|
||||
connect: {
|
||||
id: teamUser1.id,
|
||||
},
|
||||
},
|
||||
eventType: {
|
||||
connect: {
|
||||
id: teamEventTypeWithRescheduleReason.id,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await hostsRepositoryFixture.create({
|
||||
isFixed: false,
|
||||
user: {
|
||||
connect: {
|
||||
id: teamUser2.id,
|
||||
},
|
||||
},
|
||||
eventType: {
|
||||
connect: {
|
||||
id: teamEventTypeWithRescheduleReason.id,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
app = moduleRef.createNestApplication();
|
||||
bootstrap(app as NestExpressApplication);
|
||||
|
||||
@@ -307,6 +532,198 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("should have correct title when reassigning round robin booking with non-fixed host", async () => {
|
||||
const nonFixedHostBookingBody: CreateBookingInput_2024_08_13 = {
|
||||
start: new Date(Date.UTC(2050, 0, 9, 13, 0, 0)).toISOString(),
|
||||
eventTypeId: teamRoundRobinNonFixedEventTypeId,
|
||||
attendee: {
|
||||
name: "Charlie",
|
||||
email: "charlie@gmail.com",
|
||||
timeZone: "Europe/Rome",
|
||||
language: "en",
|
||||
},
|
||||
meetingUrl: "https://meet.google.com/abc-def-ghi",
|
||||
};
|
||||
|
||||
const createResponse = await request(app.getHttpServer())
|
||||
.post("/v2/bookings")
|
||||
.send(nonFixedHostBookingBody)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
|
||||
.expect(201);
|
||||
|
||||
const bookingUid = createResponse.body.data.uid;
|
||||
const booking = await bookingsRepositoryFixture.getByUid(bookingUid);
|
||||
|
||||
expect(booking).toBeDefined();
|
||||
expect(booking?.userId).toEqual(teamUser2.id);
|
||||
|
||||
const expectedInitialTitle = `${teamRoundRobinNonFixedEventTypeTitle} between ${teamUser2.name} and Charlie`;
|
||||
expect(booking?.title).toEqual(expectedInitialTitle);
|
||||
|
||||
return request(app.getHttpServer())
|
||||
.post(`/v2/bookings/${bookingUid}/reassign`)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
|
||||
.expect(200)
|
||||
.then(async (response) => {
|
||||
const responseBody: ReassignBookingOutput_2024_08_13 = response.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
expect(responseBody.data).toBeDefined();
|
||||
|
||||
const data: ReassignBookingOutput_2024_08_13["data"] = responseBody.data;
|
||||
expect(data.bookingUid).toEqual(bookingUid);
|
||||
expect(data.reassignedTo.id).toEqual(teamUser3.id);
|
||||
|
||||
const reassigned = await bookingsRepositoryFixture.getByUid(bookingUid);
|
||||
expect(reassigned?.userId).toEqual(teamUser3.id);
|
||||
|
||||
const expectedReassignedTitle = `${teamRoundRobinNonFixedEventTypeTitle} between ${teamUser3.name} and Charlie`;
|
||||
expect(reassigned?.title).toEqual(expectedReassignedTitle);
|
||||
});
|
||||
});
|
||||
|
||||
it("should have correct title when reassigning round robin booking with fixed host", async () => {
|
||||
const fixedHostBookingBody: CreateBookingInput_2024_08_13 = {
|
||||
start: new Date(Date.UTC(2050, 0, 8, 13, 0, 0)).toISOString(),
|
||||
eventTypeId: teamRoundRobinFixedHostEventTypeId,
|
||||
attendee: {
|
||||
name: "Alice",
|
||||
email: "alice@gmail.com",
|
||||
timeZone: "Europe/Rome",
|
||||
language: "en",
|
||||
},
|
||||
meetingUrl: "https://meet.google.com/abc-def-ghi",
|
||||
};
|
||||
|
||||
const createResponse = await request(app.getHttpServer())
|
||||
.post("/v2/bookings")
|
||||
.send(fixedHostBookingBody)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
|
||||
.expect(201);
|
||||
|
||||
const bookingUid = createResponse.body.data.uid;
|
||||
const booking = await bookingsRepositoryFixture.getByUid(bookingUid);
|
||||
|
||||
expect(booking).toBeDefined();
|
||||
expect(booking?.userId).toEqual(teamUser1.id);
|
||||
|
||||
const expectedInitialTitle = `${teamRoundRobinFixedHostEventTypeTitle} between ${team.name} and Alice`;
|
||||
expect(booking?.title).toEqual(expectedInitialTitle);
|
||||
|
||||
return request(app.getHttpServer())
|
||||
.post(`/v2/bookings/${bookingUid}/reassign/${teamUser3.id}`)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
|
||||
.expect(200)
|
||||
.then(async (response) => {
|
||||
const responseBody: ReassignBookingOutput_2024_08_13 = response.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
expect(responseBody.data).toBeDefined();
|
||||
|
||||
const data: ReassignBookingOutput_2024_08_13["data"] = responseBody.data;
|
||||
expect(data.bookingUid).toEqual(bookingUid);
|
||||
expect(data.reassignedTo.id).toEqual(teamUser1.id);
|
||||
|
||||
const reassigned = await bookingsRepositoryFixture.getByUid(bookingUid);
|
||||
expect(reassigned?.userId).toEqual(teamUser1.id);
|
||||
|
||||
const expectedReassignedTitle = `${teamRoundRobinFixedHostEventTypeTitle} between ${team.name} and Alice`;
|
||||
expect(reassigned?.title).toEqual(expectedReassignedTitle);
|
||||
});
|
||||
});
|
||||
|
||||
it("should preserve attendee name when reassigning round robin host manually with rescheduleReason required", async () => {
|
||||
const bookingBody: CreateBookingInput_2024_08_13 = {
|
||||
start: new Date(Date.UTC(2050, 0, 10, 13, 0, 0)).toISOString(),
|
||||
eventTypeId: teamRoundRobinWithRescheduleReasonEventTypeId,
|
||||
attendee: {
|
||||
name: "David",
|
||||
email: "david@gmail.com",
|
||||
timeZone: "Europe/Rome",
|
||||
language: "en",
|
||||
},
|
||||
meetingUrl: "https://meet.google.com/abc-def-ghi",
|
||||
};
|
||||
|
||||
const createResponse = await request(app.getHttpServer())
|
||||
.post("/v2/bookings")
|
||||
.send(bookingBody)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
|
||||
.expect(201);
|
||||
|
||||
const bookingUid = createResponse.body.data.uid;
|
||||
rescheduleReasonBookingUid = bookingUid;
|
||||
const booking = await bookingsRepositoryFixture.getByUid(bookingUid);
|
||||
|
||||
expect(booking).toBeDefined();
|
||||
expect(booking?.userId).toBeDefined();
|
||||
const initialHostId = booking!.userId!;
|
||||
rescheduleReasonBookingInitialHostId = initialHostId;
|
||||
|
||||
const expectedInitialTitle = `${teamRoundRobinWithRescheduleReasonEventTypeTitle} between ${
|
||||
booking?.userId === teamUser1.id ? teamUser1.name : teamUser2.name
|
||||
} and David`;
|
||||
expect(booking?.title).toEqual(expectedInitialTitle);
|
||||
expect(booking?.title).not.toContain("Nameless");
|
||||
|
||||
const reassignToHostId = initialHostId === teamUser1.id ? teamUser2.id : teamUser1.id;
|
||||
const reassignToHostName = reassignToHostId === teamUser1.id ? teamUser1.name : teamUser2.name;
|
||||
|
||||
return request(app.getHttpServer())
|
||||
.post(`/v2/bookings/${bookingUid}/reassign/${reassignToHostId}`)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
|
||||
.expect(200)
|
||||
.then(async (response) => {
|
||||
const responseBody: ReassignBookingOutput_2024_08_13 = response.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
expect(responseBody.data).toBeDefined();
|
||||
|
||||
const data: ReassignBookingOutput_2024_08_13["data"] = responseBody.data;
|
||||
expect(data.bookingUid).toEqual(bookingUid);
|
||||
expect(data.reassignedTo.id).toEqual(reassignToHostId);
|
||||
|
||||
const reassigned = await bookingsRepositoryFixture.getByUid(bookingUid);
|
||||
expect(reassigned?.userId).toEqual(reassignToHostId);
|
||||
|
||||
const expectedReassignedTitle = `${teamRoundRobinWithRescheduleReasonEventTypeTitle} between ${reassignToHostName} and David`;
|
||||
expect(reassigned?.title).toEqual(expectedReassignedTitle);
|
||||
expect(reassigned?.title).not.toContain("Nameless");
|
||||
});
|
||||
});
|
||||
|
||||
it("should preserve attendee name when reassigning round robin host automatically with rescheduleReason required", async () => {
|
||||
const bookingUid = rescheduleReasonBookingUid;
|
||||
const booking = await bookingsRepositoryFixture.getByUid(bookingUid);
|
||||
|
||||
expect(booking).toBeDefined();
|
||||
expect(booking?.userId).toBeDefined();
|
||||
|
||||
const currentHostId = booking!.userId!;
|
||||
const initialHostId = rescheduleReasonBookingInitialHostId;
|
||||
const initialHostName = initialHostId === teamUser1.id ? teamUser1.name : teamUser2.name;
|
||||
|
||||
expect(currentHostId).not.toEqual(initialHostId);
|
||||
|
||||
return request(app.getHttpServer())
|
||||
.post(`/v2/bookings/${bookingUid}/reassign`)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
|
||||
.expect(200)
|
||||
.then(async (response) => {
|
||||
const responseBody: ReassignBookingOutput_2024_08_13 = response.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
expect(responseBody.data).toBeDefined();
|
||||
|
||||
const data: ReassignBookingOutput_2024_08_13["data"] = responseBody.data;
|
||||
expect(data.bookingUid).toEqual(bookingUid);
|
||||
expect(data.reassignedTo.id).toEqual(initialHostId);
|
||||
|
||||
const autoReassigned = await bookingsRepositoryFixture.getByUid(bookingUid);
|
||||
expect(autoReassigned?.userId).toEqual(initialHostId);
|
||||
|
||||
const expectedAutoReassignedTitle = `${teamRoundRobinWithRescheduleReasonEventTypeTitle} between ${initialHostName} and David`;
|
||||
expect(autoReassigned?.title).toEqual(expectedAutoReassignedTitle);
|
||||
expect(autoReassigned?.title).not.toContain("Nameless");
|
||||
});
|
||||
});
|
||||
|
||||
async function createOAuthClient(organizationId: number) {
|
||||
const data = {
|
||||
logo: "logo-url",
|
||||
@@ -325,9 +742,11 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
||||
await teamRepositoryFixture.delete(organization.id);
|
||||
await userRepositoryFixture.deleteByEmail(teamUser1.email);
|
||||
await userRepositoryFixture.deleteByEmail(teamUserEmail2);
|
||||
await userRepositoryFixture.deleteByEmail(teamUserEmail3);
|
||||
await bookingsRepositoryFixture.deleteAllBookings(teamUser1.id, teamUser1.email);
|
||||
await bookingsRepositoryFixture.deleteAllBookings(teamUser2.id, teamUser2.email);
|
||||
await bookingsRepositoryFixture.deleteAllBookings(teamUser3.id, teamUser3.email);
|
||||
await app.close();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,3 @@
|
||||
import { GetBookingOutput_2024_08_13 } from "@calcom/platform-types";
|
||||
|
||||
export class AddGuestsOutput_2024_08_13 extends GetBookingOutput_2024_08_13 {}
|
||||
+14
@@ -50,6 +50,20 @@ export class BookingsRepository_2024_08_13 {
|
||||
});
|
||||
}
|
||||
|
||||
async getByUidWithUserIdAndSeatsReferencesCount(bookingUid: string) {
|
||||
return this.dbRead.prisma.booking.findUnique({
|
||||
where: {
|
||||
uid: bookingUid,
|
||||
},
|
||||
select: {
|
||||
userId: true,
|
||||
seatsReferences: {
|
||||
take: 1,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async getByUid(bookingUid: string) {
|
||||
return this.dbRead.prisma.booking.findUnique({
|
||||
where: {
|
||||
@@ -0,0 +1,46 @@
|
||||
import { BookingsRepository_2024_08_13 } from "@/ee/bookings/2024-08-13/repositories/bookings.repository";
|
||||
import { BookingsService_2024_08_13 } from "@/ee/bookings/2024-08-13/services/bookings.service";
|
||||
import { PlatformBookingsService } from "@/ee/bookings/shared/platform-bookings.service";
|
||||
import { ApiAuthGuardUser } from "@/modules/auth/strategies/api-auth/api-auth.strategy";
|
||||
import { Injectable, Logger, HttpException, NotFoundException } from "@nestjs/common";
|
||||
|
||||
import { addGuestsHandler } from "@calcom/platform-libraries/bookings";
|
||||
import type { AddGuestsInput_2024_08_13 } from "@calcom/platform-types";
|
||||
|
||||
@Injectable()
|
||||
export class BookingGuestsService_2024_08_13 {
|
||||
private readonly logger = new Logger("BookingGuestsService_2024_08_13");
|
||||
|
||||
constructor(
|
||||
private readonly bookingsRepository: BookingsRepository_2024_08_13,
|
||||
private readonly bookingsService: BookingsService_2024_08_13,
|
||||
private readonly platformBookingsService: PlatformBookingsService
|
||||
) {}
|
||||
|
||||
async addGuests(bookingUid: string, input: AddGuestsInput_2024_08_13, user: ApiAuthGuardUser) {
|
||||
const booking = await this.bookingsRepository.getByUidWithAttendeesAndUserAndEvent(bookingUid);
|
||||
if (!booking) {
|
||||
throw new NotFoundException(`Booking with uid ${bookingUid} not found`);
|
||||
}
|
||||
|
||||
const platformClientParams = booking.eventTypeId
|
||||
? await this.platformBookingsService.getOAuthClientParams(booking.eventTypeId)
|
||||
: undefined;
|
||||
|
||||
const emailsEnabled = platformClientParams ? platformClientParams.arePlatformEmailsEnabled : true;
|
||||
|
||||
const res = await addGuestsHandler({
|
||||
ctx: { user },
|
||||
input: { bookingId: booking.id, guests: input.guests },
|
||||
emailsEnabled,
|
||||
});
|
||||
if (res.message === "Guests added") {
|
||||
return await this.bookingsService.getBooking(bookingUid, user);
|
||||
} else {
|
||||
throw new HttpException(
|
||||
"Failed to add guests to the booking",
|
||||
500
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { BookingReferencesRepository_2024_08_13 } from "@/ee/bookings/2024-08-13/booking-references.repository";
|
||||
import { BookingsRepository_2024_08_13 } from "@/ee/bookings/2024-08-13/bookings.repository";
|
||||
import { BookingReferencesRepository_2024_08_13 } from "@/ee/bookings/2024-08-13/repositories/booking-references.repository";
|
||||
import { BookingsRepository_2024_08_13 } from "@/ee/bookings/2024-08-13/repositories/bookings.repository";
|
||||
import { BookingReferencesFilterInput_2024_08_13 } from "@/ee/bookings/2024-08-13/inputs/booking-references-filter.input";
|
||||
import { OutputBookingReferencesService_2024_08_13 } from "@/ee/bookings/2024-08-13/services/output-booking-references.service";
|
||||
import { Injectable, NotFoundException, BadRequestException } from "@nestjs/common";
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { BookingsRepository_2024_08_13 } from "@/ee/bookings/2024-08-13/bookings.repository";
|
||||
import { BookingsRepository_2024_08_13 } from "@/ee/bookings/2024-08-13/repositories/bookings.repository";
|
||||
import { CalendarLink } from "@/ee/bookings/2024-08-13/outputs/calendar-links.output";
|
||||
import { ErrorsBookingsService_2024_08_13 } from "@/ee/bookings/2024-08-13/services/errors.service";
|
||||
import { InputBookingsService_2024_08_13 } from "@/ee/bookings/2024-08-13/services/input.service";
|
||||
@@ -49,6 +49,7 @@ import {
|
||||
confirmBookingHandler,
|
||||
getCalendarLinks,
|
||||
} from "@calcom/platform-libraries";
|
||||
import { PrismaOrgMembershipRepository } from "@calcom/platform-libraries/bookings";
|
||||
import {
|
||||
CreateBookingInput_2024_08_13,
|
||||
CreateBookingInput,
|
||||
@@ -64,6 +65,7 @@ import {
|
||||
RescheduleBookingInput,
|
||||
CancelBookingInput,
|
||||
} from "@calcom/platform-types";
|
||||
import type { RescheduleSeatedBookingInput_2024_08_13 } from "@calcom/platform-types";
|
||||
import type { PrismaClient } from "@calcom/prisma";
|
||||
import type { EventType, User, Team } from "@calcom/prisma/client";
|
||||
|
||||
@@ -762,16 +764,26 @@ export class BookingsService_2024_08_13 {
|
||||
authUser: AuthOptionalUser
|
||||
) {
|
||||
try {
|
||||
await this.canRescheduleBooking(bookingUid);
|
||||
const isIndividualSeatRequest = this.isRescheduleSeatedBody(body);
|
||||
const isIndividualSeatReschedule = await this.shouldRescheduleIndividualSeat(
|
||||
bookingUid,
|
||||
isIndividualSeatRequest,
|
||||
authUser
|
||||
);
|
||||
|
||||
const bookingRequest = await this.inputService.createRescheduleBookingRequest(
|
||||
request,
|
||||
bookingUid,
|
||||
body
|
||||
body,
|
||||
isIndividualSeatReschedule
|
||||
);
|
||||
|
||||
await this.canRescheduleBooking(bookingUid);
|
||||
|
||||
const booking = await this.regularBookingService.createBooking({
|
||||
bookingData: bookingRequest.body,
|
||||
bookingMeta: {
|
||||
userId: bookingRequest.userId,
|
||||
userId: bookingRequest.userId ?? authUser?.id,
|
||||
hostname: bookingRequest.headers?.host || "",
|
||||
platformClientId: bookingRequest.platformClientId,
|
||||
platformRescheduleUrl: bookingRequest.platformRescheduleUrl,
|
||||
@@ -840,6 +852,57 @@ export class BookingsService_2024_08_13 {
|
||||
return booking;
|
||||
}
|
||||
|
||||
async shouldRescheduleIndividualSeat(
|
||||
bookingUid: string,
|
||||
isIndividualSeatReschedule: boolean,
|
||||
authUser: AuthOptionalUser
|
||||
) {
|
||||
const booking = await this.bookingsRepository.getByUidWithUserIdAndSeatsReferencesCount(bookingUid);
|
||||
|
||||
if (!booking) {
|
||||
throw new NotFoundException(`Booking with uid=${bookingUid} was not found in the database`);
|
||||
}
|
||||
|
||||
const hasSeatsPresent = booking.seatsReferences.length > 0;
|
||||
|
||||
if (!hasSeatsPresent) return false;
|
||||
|
||||
return await this.isIndividualSeatOrOrgAdminReschedule(
|
||||
isIndividualSeatReschedule,
|
||||
booking.userId,
|
||||
authUser?.id
|
||||
);
|
||||
}
|
||||
|
||||
async isIndividualSeatOrOrgAdminReschedule(
|
||||
isIndividualSeatReschedule: boolean,
|
||||
bookingUserId: number | null,
|
||||
authUserId?: number | null
|
||||
) {
|
||||
if (isIndividualSeatReschedule) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!authUserId) {
|
||||
throw new Error(`No auth user found`);
|
||||
}
|
||||
|
||||
if (!bookingUserId) {
|
||||
throw new Error(`No user found for booking`);
|
||||
}
|
||||
|
||||
const isOrgAdmin = await PrismaOrgMembershipRepository.isLoggedInUserOrgAdminOfBookingHost(
|
||||
authUserId,
|
||||
bookingUserId
|
||||
);
|
||||
|
||||
return isOrgAdmin;
|
||||
}
|
||||
|
||||
isRescheduleSeatedBody(body: RescheduleBookingInput): body is RescheduleSeatedBookingInput_2024_08_13 {
|
||||
return "seatUid" in body;
|
||||
}
|
||||
|
||||
async cancelBooking(
|
||||
request: Request,
|
||||
bookingUid: string,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { BookingsRepository_2024_08_13 } from "@/ee/bookings/2024-08-13/bookings.repository";
|
||||
import { BookingsRepository_2024_08_13 } from "@/ee/bookings/2024-08-13/repositories/bookings.repository";
|
||||
import { OutputBookingsService_2024_08_13 } from "@/ee/bookings/2024-08-13/services/output.service";
|
||||
import { Injectable, Logger, NotFoundException } from "@nestjs/common";
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { BookingsRepository_2024_08_13 } from "@/ee/bookings/2024-08-13/bookings.repository";
|
||||
import { BookingsRepository_2024_08_13 } from "@/ee/bookings/2024-08-13/repositories/bookings.repository";
|
||||
import {
|
||||
eventTypeBookingFieldsSchema,
|
||||
EventTypeWithOwnerAndTeam,
|
||||
@@ -507,11 +507,13 @@ export class InputBookingsService_2024_08_13 {
|
||||
async createRescheduleBookingRequest(
|
||||
request: Request,
|
||||
bookingUid: string,
|
||||
body: RescheduleBookingInput
|
||||
body: RescheduleBookingInput,
|
||||
isIndividualSeatReschedule: boolean
|
||||
): Promise<BookingRequest> {
|
||||
const bodyTransformed = this.isRescheduleSeatedBody(body)
|
||||
? await this.transformInputRescheduleSeatedBooking(bookingUid, body)
|
||||
: await this.transformInputRescheduleBooking(bookingUid, body);
|
||||
const bodyTransformed =
|
||||
isIndividualSeatReschedule && "seatUid" in body
|
||||
? await this.transformInputRescheduleSeatedBooking(bookingUid, body)
|
||||
: await this.transformInputRescheduleBooking(bookingUid, body, isIndividualSeatReschedule);
|
||||
|
||||
const oAuthClientParams = await this.platformBookingsService.getOAuthClientParams(
|
||||
bodyTransformed.eventTypeId
|
||||
@@ -554,7 +556,7 @@ export class InputBookingsService_2024_08_13 {
|
||||
}
|
||||
|
||||
isRescheduleSeatedBody(body: RescheduleBookingInput): body is RescheduleSeatedBookingInput_2024_08_13 {
|
||||
return body.hasOwnProperty("seatUid");
|
||||
return Object.prototype.hasOwnProperty.call(body, "seatUid");
|
||||
}
|
||||
|
||||
async transformInputRescheduleSeatedBooking(
|
||||
@@ -606,7 +608,11 @@ export class InputBookingsService_2024_08_13 {
|
||||
};
|
||||
}
|
||||
|
||||
async transformInputRescheduleBooking(bookingUid: string, inputBooking: RescheduleBookingInput_2024_08_13) {
|
||||
async transformInputRescheduleBooking(
|
||||
bookingUid: string,
|
||||
inputBooking: RescheduleBookingInput_2024_08_13,
|
||||
isIndividualSeatReschedule: boolean
|
||||
) {
|
||||
const booking = await this.bookingsRepository.getByUidWithAttendeesAndUserAndEvent(bookingUid);
|
||||
if (!booking) {
|
||||
throw new NotFoundException(`Booking with uid=${bookingUid} not found`);
|
||||
@@ -618,7 +624,7 @@ export class InputBookingsService_2024_08_13 {
|
||||
if (!eventType) {
|
||||
throw new NotFoundException(`Event type with id=${booking.eventTypeId} not found`);
|
||||
}
|
||||
if (eventType.seatsPerTimeSlot) {
|
||||
if (eventType.seatsPerTimeSlot && !isIndividualSeatReschedule) {
|
||||
throw new BadRequestException(
|
||||
`Booking with uid=${bookingUid} is a seated booking which means you have to provide seatUid in the request body to specify which seat specifically you want to reschedule. First, fetch the booking using https://cal.com/docs/api-reference/v2/bookings/get-a-booking and then within the attendees array you will find the seatUid of the booking you want to reschedule. Second, provide the seatUid in the request body to specify which seat specifically you want to reschedule using the reschedule endpoint https://cal.com/docs/api-reference/v2/bookings/reschedule-a-booking#option-2`
|
||||
);
|
||||
@@ -652,7 +658,6 @@ export class InputBookingsService_2024_08_13 {
|
||||
|
||||
const startTime = DateTime.fromISO(inputBooking.start, { zone: "utc" }).setZone(attendee.timeZone);
|
||||
const endTime = startTime.plus({ minutes: eventType.length });
|
||||
|
||||
return {
|
||||
start: startTime.toISO(),
|
||||
end: endTime.toISO(),
|
||||
@@ -770,7 +775,7 @@ export class InputBookingsService_2024_08_13 {
|
||||
}
|
||||
|
||||
isCancelSeatedBody(body: CancelBookingInput): body is CancelSeatedBookingInput_2024_08_13 {
|
||||
return body.hasOwnProperty("seatUid");
|
||||
return Object.prototype.hasOwnProperty.call(body, "seatUid");
|
||||
}
|
||||
|
||||
async transformInputCancelBooking(bookingUid: string, inputBooking: CancelBookingInput_2024_08_13) {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { BookingsRepository_2024_08_13 } from "@/ee/bookings/2024-08-13/bookings.repository";
|
||||
import { BookingsRepository_2024_08_13 } from "@/ee/bookings/2024-08-13/repositories/bookings.repository";
|
||||
import {
|
||||
defaultBookingMetadata,
|
||||
defaultBookingResponses,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { BookingReferencesRepository_2024_08_13 } from "@/ee/bookings/2024-08-13/booking-references.repository";
|
||||
import { BookingsRepository_2024_08_13 } from "@/ee/bookings/2024-08-13/bookings.repository";
|
||||
import { BookingReferencesRepository_2024_08_13 } from "@/ee/bookings/2024-08-13/repositories/booking-references.repository";
|
||||
import { BookingsRepository_2024_08_13 } from "@/ee/bookings/2024-08-13/repositories/bookings.repository";
|
||||
import { CalendarsRepository } from "@/ee/calendars/calendars.repository";
|
||||
import { CalendarsController } from "@/ee/calendars/controllers/calendars.controller";
|
||||
import { AppleCalendarService } from "@/ee/calendars/services/apple-calendar.service";
|
||||
|
||||
+197
-18
@@ -22,29 +22,17 @@ import { UserRepositoryFixture } from "test/fixtures/repository/users.repository
|
||||
import { randomString } from "test/utils/randomString";
|
||||
import { withApiAuth } from "test/utils/withApiAuth";
|
||||
|
||||
|
||||
|
||||
import { CAL_API_VERSION_HEADER, SUCCESS_STATUS, VERSION_2024_06_14 } from "@calcom/platform-constants";
|
||||
import {
|
||||
BookingWindowPeriodInputTypeEnum_2024_06_14,
|
||||
BookerLayoutsInputEnum_2024_06_14,
|
||||
ConfirmationPolicyEnum,
|
||||
NoticeThresholdUnitEnum,
|
||||
FrequencyInput,
|
||||
} from "@calcom/platform-enums";
|
||||
import { BookingWindowPeriodInputTypeEnum_2024_06_14, BookerLayoutsInputEnum_2024_06_14, ConfirmationPolicyEnum, NoticeThresholdUnitEnum, FrequencyInput } from "@calcom/platform-enums";
|
||||
import { SchedulingType } from "@calcom/platform-libraries";
|
||||
import {
|
||||
type ApiSuccessResponse,
|
||||
type CreateEventTypeInput_2024_06_14,
|
||||
type EventTypeOutput_2024_06_14,
|
||||
type GuestsDefaultFieldOutput_2024_06_14,
|
||||
type NameDefaultFieldInput_2024_06_14,
|
||||
type NotesDefaultFieldInput_2024_06_14,
|
||||
type SplitNameDefaultFieldOutput_2024_06_14,
|
||||
type UpdateEventTypeInput_2024_06_14,
|
||||
} from "@calcom/platform-types";
|
||||
import { type ApiSuccessResponse, type CreateEventTypeInput_2024_06_14, type EventTypeOutput_2024_06_14, type GuestsDefaultFieldOutput_2024_06_14, type NameDefaultFieldInput_2024_06_14, type NotesDefaultFieldInput_2024_06_14, type SplitNameDefaultFieldOutput_2024_06_14, type UpdateEventTypeInput_2024_06_14 } from "@calcom/platform-types";
|
||||
import { FAILED_RECURRING_EVENT_TYPE_WITH_BOOKER_LIMITS_ERROR_MESSAGE } from "@calcom/platform-types/event-types/event-types_2024_06_14/inputs/validators/CantHaveRecurrenceAndBookerActiveBookingsLimit";
|
||||
import { REQUIRES_AT_LEAST_ONE_PROPERTY_ERROR } from "@calcom/platform-types/utils/RequiresOneOfPropertiesWhenNotDisabled";
|
||||
import type { PlatformOAuthClient, Team, User, Schedule, EventType } from "@calcom/prisma/client";
|
||||
|
||||
|
||||
const orderBySlug = (a: { slug: string }, b: { slug: string }) => {
|
||||
if (a.slug < b.slug) return -1;
|
||||
if (a.slug > b.slug) return 1;
|
||||
@@ -1641,6 +1629,197 @@ describe("Event types Endpoints", () => {
|
||||
}
|
||||
await app.close();
|
||||
});
|
||||
|
||||
describe("confirmationPolicy", () => {
|
||||
describe("negative tests", () => {
|
||||
it("should not create an event type with type 'time' without noticeThreshold", async () => {
|
||||
const body: CreateEventTypeInput_2024_06_14 = {
|
||||
title: "Event requiring confirmation with time",
|
||||
slug: "event-confirmation-time-missing-threshold",
|
||||
description: "This event requires confirmation based on time.",
|
||||
lengthInMinutes: 60,
|
||||
confirmationPolicy: {
|
||||
type: ConfirmationPolicyEnum.TIME,
|
||||
blockUnconfirmedBookingsInBooker: true,
|
||||
},
|
||||
};
|
||||
|
||||
const response = await request(app.getHttpServer())
|
||||
.post("/api/v2/event-types")
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_06_14)
|
||||
.set("Authorization", `Bearer ${apiKeyString}`)
|
||||
.send(body)
|
||||
.expect(400);
|
||||
|
||||
expect(response.body.error).toBeDefined();
|
||||
expect(response.status).toBe(400);
|
||||
});
|
||||
|
||||
it("should not update an event type to type 'time' without noticeThreshold", async () => {
|
||||
// First create an event type with type 'always'
|
||||
const createBody: CreateEventTypeInput_2024_06_14 = {
|
||||
title: "Event requiring confirmation always",
|
||||
slug: "event-confirmation-always-to-time",
|
||||
description: "This event always requires confirmation.",
|
||||
lengthInMinutes: 60,
|
||||
confirmationPolicy: {
|
||||
type: ConfirmationPolicyEnum.ALWAYS,
|
||||
blockUnconfirmedBookingsInBooker: true,
|
||||
},
|
||||
};
|
||||
|
||||
const createResponse = await request(app.getHttpServer())
|
||||
.post("/api/v2/event-types")
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_06_14)
|
||||
.set("Authorization", `Bearer ${apiKeyString}`)
|
||||
.send(createBody)
|
||||
.expect(201);
|
||||
|
||||
const createdEventType = createResponse.body.data;
|
||||
|
||||
// Try to update to type 'time' without noticeThreshold
|
||||
const updateBody: UpdateEventTypeInput_2024_06_14 = {
|
||||
confirmationPolicy: {
|
||||
type: ConfirmationPolicyEnum.TIME,
|
||||
blockUnconfirmedBookingsInBooker: true,
|
||||
},
|
||||
};
|
||||
|
||||
const updateResponse = await request(app.getHttpServer())
|
||||
.patch(`/api/v2/event-types/${createdEventType.id}`)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_06_14)
|
||||
.set("Authorization", `Bearer ${apiKeyString}`)
|
||||
.send(updateBody)
|
||||
.expect(400);
|
||||
|
||||
expect(updateResponse.body.error).toBeDefined();
|
||||
expect(updateResponse.status).toBe(400);
|
||||
|
||||
// Cleanup
|
||||
await eventTypesRepositoryFixture.delete(createdEventType.id);
|
||||
});
|
||||
});
|
||||
|
||||
describe("positive tests", () => {
|
||||
let eventTypeWithConfirmationTimeId: number;
|
||||
|
||||
it("should create an event type with type 'always'", async () => {
|
||||
const body: CreateEventTypeInput_2024_06_14 = {
|
||||
title: "Event requiring confirmation always",
|
||||
slug: "event-confirmation-always",
|
||||
description: "This event always requires confirmation.",
|
||||
lengthInMinutes: 60,
|
||||
confirmationPolicy: {
|
||||
type: ConfirmationPolicyEnum.ALWAYS,
|
||||
blockUnconfirmedBookingsInBooker: true,
|
||||
},
|
||||
};
|
||||
|
||||
return request(app.getHttpServer())
|
||||
.post("/api/v2/event-types")
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_06_14)
|
||||
.set("Authorization", `Bearer ${apiKeyString}`)
|
||||
.send(body)
|
||||
.expect(201)
|
||||
.then(async (response) => {
|
||||
const responseBody: ApiSuccessResponse<EventTypeOutput_2024_06_14> = response.body;
|
||||
const createdEventType = responseBody.data;
|
||||
expect(createdEventType).toHaveProperty("id");
|
||||
expect(createdEventType.title).toEqual(body.title);
|
||||
expect(createdEventType.confirmationPolicy).toEqual(body.confirmationPolicy);
|
||||
await eventTypesRepositoryFixture.delete(createdEventType.id);
|
||||
});
|
||||
});
|
||||
|
||||
it("should create an event type with type 'time' and noticeThreshold", async () => {
|
||||
const body: CreateEventTypeInput_2024_06_14 = {
|
||||
title: "Event requiring confirmation with time",
|
||||
slug: "event-confirmation-time-with-threshold",
|
||||
description: "This event requires confirmation based on time.",
|
||||
lengthInMinutes: 60,
|
||||
confirmationPolicy: {
|
||||
type: ConfirmationPolicyEnum.TIME,
|
||||
noticeThreshold: {
|
||||
unit: NoticeThresholdUnitEnum.HOURS,
|
||||
count: 24,
|
||||
},
|
||||
blockUnconfirmedBookingsInBooker: true,
|
||||
},
|
||||
};
|
||||
|
||||
return request(app.getHttpServer())
|
||||
.post("/api/v2/event-types")
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_06_14)
|
||||
.set("Authorization", `Bearer ${apiKeyString}`)
|
||||
.send(body)
|
||||
.expect(201)
|
||||
.then(async (response) => {
|
||||
const responseBody: ApiSuccessResponse<EventTypeOutput_2024_06_14> = response.body;
|
||||
const createdEventType = responseBody.data;
|
||||
expect(createdEventType).toHaveProperty("id");
|
||||
expect(createdEventType.title).toEqual(body.title);
|
||||
expect(createdEventType.confirmationPolicy).toEqual(body.confirmationPolicy);
|
||||
eventTypeWithConfirmationTimeId = createdEventType.id;
|
||||
});
|
||||
});
|
||||
|
||||
it("should update an event type from type 'time' to 'always'", async () => {
|
||||
const body: UpdateEventTypeInput_2024_06_14 = {
|
||||
confirmationPolicy: {
|
||||
type: ConfirmationPolicyEnum.ALWAYS,
|
||||
blockUnconfirmedBookingsInBooker: false,
|
||||
},
|
||||
};
|
||||
|
||||
return request(app.getHttpServer())
|
||||
.patch(`/api/v2/event-types/${eventTypeWithConfirmationTimeId}`)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_06_14)
|
||||
.set("Authorization", `Bearer ${apiKeyString}`)
|
||||
.send(body)
|
||||
.expect(200)
|
||||
.then(async (response) => {
|
||||
const responseBody: ApiSuccessResponse<EventTypeOutput_2024_06_14> = response.body;
|
||||
const updatedEventType = responseBody.data;
|
||||
const policy = updatedEventType.confirmationPolicy as any;
|
||||
expect(policy?.type).toEqual(ConfirmationPolicyEnum.ALWAYS);
|
||||
expect(policy?.blockUnconfirmedBookingsInBooker).toEqual(false);
|
||||
expect(policy?.noticeThreshold).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
it("should update an event type from type 'always' to 'time' with noticeThreshold", async () => {
|
||||
const body: UpdateEventTypeInput_2024_06_14 = {
|
||||
confirmationPolicy: {
|
||||
type: ConfirmationPolicyEnum.TIME,
|
||||
noticeThreshold: {
|
||||
unit: NoticeThresholdUnitEnum.MINUTES,
|
||||
count: 120,
|
||||
},
|
||||
blockUnconfirmedBookingsInBooker: true,
|
||||
},
|
||||
};
|
||||
|
||||
return request(app.getHttpServer())
|
||||
.patch(`/api/v2/event-types/${eventTypeWithConfirmationTimeId}`)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_06_14)
|
||||
.set("Authorization", `Bearer ${apiKeyString}`)
|
||||
.send(body)
|
||||
.expect(200)
|
||||
.then(async (response) => {
|
||||
const responseBody: ApiSuccessResponse<EventTypeOutput_2024_06_14> = response.body;
|
||||
const updatedEventType = responseBody.data;
|
||||
const policy = updatedEventType.confirmationPolicy as any;
|
||||
expect(policy?.type).toEqual(ConfirmationPolicyEnum.TIME);
|
||||
expect(policy?.noticeThreshold).toEqual({
|
||||
unit: NoticeThresholdUnitEnum.MINUTES,
|
||||
count: 120,
|
||||
});
|
||||
expect(policy?.blockUnconfirmedBookingsInBooker).toEqual(true);
|
||||
await eventTypesRepositoryFixture.delete(eventTypeWithConfirmationTimeId);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Handle event-types booking fields", () => {
|
||||
@@ -2614,4 +2793,4 @@ describe("Event types Endpoints", () => {
|
||||
await app.close();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -286,10 +286,9 @@ export class EventTypesAtomService {
|
||||
.filter(({ ...app }) => app.slug === slug)
|
||||
.map(
|
||||
async ({
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
credentials: _,
|
||||
credential,
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
|
||||
key: _2 /* don't leak to frontend */,
|
||||
...app
|
||||
}: EnabledAppType) => {
|
||||
@@ -368,7 +367,7 @@ export class EventTypesAtomService {
|
||||
endTime: endTime.toString(),
|
||||
};
|
||||
if (!eventType) throw new NotFoundException(`Event type with uid ${uid} not found`);
|
||||
if (eventType.users.length === 0 && !!!eventType.team)
|
||||
if (eventType.users.length === 0 && !eventType.team)
|
||||
throw new NotFoundException(`No users found or no team present for event type with uid ${uid}`);
|
||||
const [user] = eventType?.users.length
|
||||
? eventType.users
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { BookingsRepository_2024_08_13 } from "@/ee/bookings/2024-08-13/bookings.repository";
|
||||
import { BookingsRepository_2024_08_13 } from "@/ee/bookings/2024-08-13/repositories/bookings.repository";
|
||||
import { BillingProcessor } from "@/modules/billing/billing.processor";
|
||||
import { BillingRepository } from "@/modules/billing/billing.repository";
|
||||
import { BillingController } from "@/modules/billing/controllers/billing.controller";
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { AppConfig } from "@/config/type";
|
||||
import { BookingsRepository_2024_08_13 } from "@/ee/bookings/2024-08-13/bookings.repository";
|
||||
import { BookingsRepository_2024_08_13 } from "@/ee/bookings/2024-08-13/repositories/bookings.repository";
|
||||
import { BILLING_QUEUE, INCREMENT_JOB, IncrementJobDataType } from "@/modules/billing/billing.processor";
|
||||
import { BillingRepository } from "@/modules/billing/billing.repository";
|
||||
import { IBillingService } from "@/modules/billing/interfaces/billing-service.interface";
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { BookingReferencesRepository_2024_08_13 } from "@/ee/bookings/2024-08-13/booking-references.repository";
|
||||
import { BookingReferencesRepository_2024_08_13 } from "@/ee/bookings/2024-08-13/repositories/booking-references.repository";
|
||||
import { CalendarsRepository } from "@/ee/calendars/calendars.repository";
|
||||
import { CalendarsCacheService } from "@/ee/calendars/services/calendars-cache.service";
|
||||
import { CalendarsService } from "@/ee/calendars/services/calendars.service";
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { BookingReferencesRepository_2024_08_13 } from "@/ee/bookings/2024-08-13/booking-references.repository";
|
||||
import { BookingReferencesRepository_2024_08_13 } from "@/ee/bookings/2024-08-13/repositories/booking-references.repository";
|
||||
import { GoogleCalendarService as GCalService } from "@/ee/calendars/services/gcal.service";
|
||||
import { GoogleCalendarEventResponse } from "@/modules/cal-unified-calendars/pipes/get-calendar-event-details-output-pipe";
|
||||
import { calendar_v3 } from "@googleapis/calendar";
|
||||
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
import { BookingReferencesRepository_2024_08_13 } from "@/ee/bookings/2024-08-13/booking-references.repository";
|
||||
import { BookingReferencesRepository_2024_08_13 } from "@/ee/bookings/2024-08-13/repositories/booking-references.repository";
|
||||
import { BookingsModule_2024_08_13 } from "@/ee/bookings/2024-08-13/bookings.module";
|
||||
import { BookingsRepository_2024_08_13 } from "@/ee/bookings/2024-08-13/bookings.repository";
|
||||
import { BookingsRepository_2024_08_13 } from "@/ee/bookings/2024-08-13/repositories/bookings.repository";
|
||||
import { BookingReferencesService_2024_08_13 } from "@/ee/bookings/2024-08-13/services/booking-references.service";
|
||||
import { OutputBookingReferencesService_2024_08_13 } from "@/ee/bookings/2024-08-13/services/output-booking-references.service";
|
||||
import { MembershipsModule } from "@/modules/memberships/memberships.module";
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
.git
|
||||
.github
|
||||
.env.example
|
||||
@@ -6,7 +6,9 @@ import { redirect } from "next/navigation";
|
||||
import { z } from "zod";
|
||||
|
||||
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
|
||||
import { FeaturesRepository } from "@calcom/features/flags/features.repository";
|
||||
import { PermissionCheckService } from "@calcom/features/pbac/services/permission-check.service";
|
||||
import { prisma } from "@calcom/prisma";
|
||||
import { MembershipRole } from "@calcom/prisma/enums";
|
||||
|
||||
import { buildLegacyRequest } from "@lib/buildLegacyCtx";
|
||||
@@ -52,12 +54,18 @@ const Page = async ({ params }: PageProps) => {
|
||||
canReadOthersBookings = teamIdsWithPermission.length > 0;
|
||||
}
|
||||
|
||||
const featuresRepository = new FeaturesRepository(prisma);
|
||||
const isCalendarViewEnabled = await featuresRepository.checkIfFeatureIsEnabledGlobally(
|
||||
"booking-calendar-view"
|
||||
);
|
||||
|
||||
return (
|
||||
<ShellMainAppDir heading={t("bookings")} subtitle={t("bookings_description")}>
|
||||
<BookingsList
|
||||
status={parsed.data.status}
|
||||
userId={session?.user?.id}
|
||||
permissions={{ canReadOthersBookings }}
|
||||
isCalendarViewEnabled={isCalendarViewEnabled}
|
||||
/>
|
||||
</ShellMainAppDir>
|
||||
);
|
||||
|
||||
@@ -6,6 +6,7 @@ import { unstable_cache } from "next/cache";
|
||||
import { cookies, headers } from "next/headers";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
import { checkOnboardingRedirect } from "@calcom/features/auth/lib/onboardingUtils";
|
||||
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
|
||||
import { getTeamsFiltersFromQuery } from "@calcom/features/filters/lib/getTeamsFiltersFromQuery";
|
||||
import { eventTypesRouter } from "@calcom/trpc/server/routers/viewer/eventTypes/_router";
|
||||
@@ -53,6 +54,17 @@ const Page = async ({ searchParams }: PageProps) => {
|
||||
return redirect("/auth/login");
|
||||
}
|
||||
|
||||
// Check if user needs onboarding and redirect before fetching event types data
|
||||
// Use organizationId from session profile if available to avoid extra query
|
||||
const organizationId = session.user.profile?.organizationId ?? null;
|
||||
const onboardingPath = await checkOnboardingRedirect(session.user.id, {
|
||||
checkEmailVerification: true,
|
||||
organizationId,
|
||||
});
|
||||
if (onboardingPath) {
|
||||
return redirect(onboardingPath);
|
||||
}
|
||||
|
||||
const t = await getTranslate();
|
||||
const filters = getTeamsFiltersFromQuery(_searchParams);
|
||||
const userEventGroupsData = await getCachedEventGroups(_headers, _cookies, filters);
|
||||
|
||||
@@ -17,21 +17,21 @@ export const generateMetadata = async ({ params }: _PageProps) => {
|
||||
if (!p.success) {
|
||||
return notFound();
|
||||
}
|
||||
|
||||
const props = await getStaticProps(p.data.slug);
|
||||
const slugFromUrl = p.data.slug;
|
||||
const props = await getStaticProps(slugFromUrl);
|
||||
|
||||
if (!props) {
|
||||
notFound();
|
||||
}
|
||||
const { name, logo, description } = props.data;
|
||||
const { name, logo, dirName: appStoreDirSlug, slug: appSlug, description } = props.data;
|
||||
|
||||
return await generateAppMetadata(
|
||||
{ slug: logo, name, description },
|
||||
{ slug: appStoreDirSlug ?? appSlug, logoUrl: logo, name, description },
|
||||
() => name,
|
||||
() => description,
|
||||
undefined,
|
||||
undefined,
|
||||
`/apps/${p.data.slug}`
|
||||
`/apps/${appSlug}`
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import { cookies, headers } from "next/headers";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
|
||||
import { isCompanyEmail } from "@calcom/features/ee/organizations/lib/utils";
|
||||
import { OnboardingPathService } from "@calcom/features/onboarding/lib/onboarding-path.service";
|
||||
import { UserRepository } from "@calcom/features/users/repositories/UserRepository";
|
||||
import { prisma } from "@calcom/prisma";
|
||||
|
||||
import { buildLegacyRequest } from "@lib/buildLegacyCtx";
|
||||
|
||||
export default async function OrganizationOnboardingLayout({ children }: { children: React.ReactNode }) {
|
||||
const session = await getServerSession({ req: buildLegacyRequest(await headers(), await cookies()) });
|
||||
|
||||
if (!session?.user?.id) {
|
||||
return redirect("/auth/login");
|
||||
}
|
||||
|
||||
const userEmail = session.user.email || "";
|
||||
const userId = session.user.id;
|
||||
|
||||
const gettingStartedPath = await OnboardingPathService.getGettingStartedPath(prisma);
|
||||
|
||||
if (!isCompanyEmail(userEmail)) {
|
||||
return redirect(gettingStartedPath);
|
||||
}
|
||||
|
||||
const userRepository = new UserRepository(prisma);
|
||||
const { organizations } = await userRepository.findOrganizations({ userId });
|
||||
|
||||
if (organizations.length > 0) {
|
||||
return redirect(gettingStartedPath);
|
||||
}
|
||||
|
||||
return children;
|
||||
}
|
||||
@@ -66,10 +66,10 @@ export const _generateMetadata = async (
|
||||
);
|
||||
const image =
|
||||
SEO_IMG_OGIMG +
|
||||
constructGenericImage({
|
||||
(await constructGenericImage({
|
||||
title: metadata.title,
|
||||
description: metadata.description,
|
||||
});
|
||||
}));
|
||||
|
||||
return {
|
||||
...metadata,
|
||||
@@ -80,7 +80,7 @@ export const _generateMetadata = async (
|
||||
};
|
||||
};
|
||||
|
||||
export const _generateMetadataForStaticPage = (
|
||||
export const _generateMetadataForStaticPage = async (
|
||||
title: string,
|
||||
description: string,
|
||||
hideBranding?: boolean,
|
||||
@@ -108,10 +108,10 @@ export const _generateMetadataForStaticPage = (
|
||||
};
|
||||
const image =
|
||||
SEO_IMG_OGIMG +
|
||||
constructGenericImage({
|
||||
(await constructGenericImage({
|
||||
title: metadata.title,
|
||||
description: metadata.description,
|
||||
});
|
||||
}));
|
||||
|
||||
return {
|
||||
...metadata,
|
||||
@@ -137,7 +137,7 @@ export const generateMeetingMetadata = async (
|
||||
origin,
|
||||
pathname
|
||||
);
|
||||
const image = SEO_IMG_OGIMG + constructMeetingImage(meeting);
|
||||
const image = SEO_IMG_OGIMG + (await constructMeetingImage(meeting));
|
||||
|
||||
return {
|
||||
...metadata,
|
||||
@@ -164,7 +164,7 @@ export const generateAppMetadata = async (
|
||||
pathname
|
||||
);
|
||||
|
||||
const image = SEO_IMG_OGIMG + constructAppImage({ ...app, description: metadata.description });
|
||||
const image = SEO_IMG_OGIMG + (await constructAppImage({ ...app, description: metadata.description }));
|
||||
|
||||
return {
|
||||
...metadata,
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { describe, expect, test, vi, beforeEach } from "vitest";
|
||||
|
||||
import { getOGImageVersion } from "@calcom/lib/OgImages";
|
||||
|
||||
import { GET } from "../route";
|
||||
|
||||
vi.mock("next/og", () => ({
|
||||
@@ -14,14 +16,19 @@ vi.mock("next/og", () => ({
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock("@calcom/lib/OgImages", () => ({
|
||||
Meeting: vi.fn(() => null),
|
||||
App: vi.fn(() => null),
|
||||
Generic: vi.fn(() => null),
|
||||
}));
|
||||
vi.mock("@calcom/lib/OgImages", async (importOriginal) => {
|
||||
return await importOriginal();
|
||||
});
|
||||
|
||||
vi.mock("@calcom/lib/constants", () => ({
|
||||
WEBAPP_URL: "http://localhost:3000",
|
||||
vi.mock(import("@calcom/lib/constants"), async (importOriginal) => {
|
||||
return await importOriginal();
|
||||
});
|
||||
|
||||
vi.mock("@calcom/web/public/app-store/svg-hashes.json", () => ({
|
||||
default: {
|
||||
huddle01video: "81a0653b",
|
||||
zoomvideo: "d1c78abf",
|
||||
},
|
||||
}));
|
||||
|
||||
global.fetch = vi.fn();
|
||||
@@ -82,7 +89,7 @@ describe("GET /api/social/og/image", () => {
|
||||
const response = await GET(request);
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
expect(await response.text()).toBe("What you're looking for is not here..");
|
||||
expect(await response.text()).toBe("Wrong image type");
|
||||
});
|
||||
|
||||
test("returns 404 when invalid type parameter is provided", async () => {
|
||||
@@ -90,7 +97,7 @@ describe("GET /api/social/og/image", () => {
|
||||
const response = await GET(request);
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
expect(await response.text()).toBe("What you're looking for is not here..");
|
||||
expect(await response.text()).toBe("Wrong image type");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -107,4 +114,33 @@ describe("GET /api/social/og/image", () => {
|
||||
expect(await response.text()).toBe("Internal server error");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getOGImageVersion with SVG hash", () => {
|
||||
test("app type: ETag changes when SVG hash is provided", async () => {
|
||||
const etagWithoutHash = await getOGImageVersion("app");
|
||||
const etagWithHash = await getOGImageVersion("app", {
|
||||
slug: "huddle01video",
|
||||
svgHash: "81a0653b",
|
||||
});
|
||||
|
||||
expect(etagWithoutHash).toBeTruthy();
|
||||
expect(etagWithHash).toBeTruthy();
|
||||
expect(etagWithHash).not.toBe(etagWithoutHash);
|
||||
});
|
||||
|
||||
test("app type: different SVG hashes produce different ETags", async () => {
|
||||
const etagHash1 = await getOGImageVersion("app", {
|
||||
slug: "huddle01video",
|
||||
svgHash: "81a0653b",
|
||||
});
|
||||
const etagHash2 = await getOGImageVersion("app", {
|
||||
slug: "zoomvideo",
|
||||
svgHash: "d1c78abf",
|
||||
});
|
||||
|
||||
expect(etagHash1).toBeTruthy();
|
||||
expect(etagHash2).toBeTruthy();
|
||||
expect(etagHash1).not.toBe(etagHash2);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,7 +3,7 @@ import type { NextRequest } from "next/server";
|
||||
import type { SatoriOptions } from "satori";
|
||||
import { z, ZodError } from "zod";
|
||||
|
||||
import { Meeting, App, Generic } from "@calcom/lib/OgImages";
|
||||
import { Meeting, App, Generic, getOGImageVersion } from "@calcom/lib/OgImages";
|
||||
import { WEBAPP_URL } from "@calcom/lib/constants";
|
||||
|
||||
export const runtime = "edge";
|
||||
@@ -22,6 +22,7 @@ const appSchema = z.object({
|
||||
name: z.string(),
|
||||
description: z.string(),
|
||||
slug: z.string(),
|
||||
logoUrl: z.string(),
|
||||
});
|
||||
|
||||
const genericSchema = z.object({
|
||||
@@ -74,6 +75,7 @@ async function handler(req: NextRequest) {
|
||||
imageType,
|
||||
});
|
||||
|
||||
const etag = await getOGImageVersion("meeting");
|
||||
const img = new ImageResponse(
|
||||
(
|
||||
<Meeting
|
||||
@@ -89,7 +91,9 @@ async function handler(req: NextRequest) {
|
||||
status: 200,
|
||||
headers: {
|
||||
"Content-Type": "image/png",
|
||||
"Cache-Control": "public, max-age=3600, stale-while-revalidate=86400",
|
||||
"Cache-Control":
|
||||
"public, max-age=31536000, immutable, s-maxage=31536000, stale-while-revalidate=31536000",
|
||||
ETag: `"${etag}"`,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
@@ -111,19 +115,32 @@ async function handler(req: NextRequest) {
|
||||
}
|
||||
case "app": {
|
||||
try {
|
||||
const { name, description, slug } = appSchema.parse({
|
||||
const { name, description, slug, logoUrl } = appSchema.parse({
|
||||
name: searchParams.get("name"),
|
||||
description: searchParams.get("description"),
|
||||
slug: searchParams.get("slug"),
|
||||
logoUrl: searchParams.get("logoUrl"),
|
||||
imageType,
|
||||
});
|
||||
const img = new ImageResponse(<App name={name} description={description} slug={slug} />, ogConfig);
|
||||
|
||||
// Get SVG hash for the app
|
||||
const svgHashesModule = await import("@calcom/web/public/app-store/svg-hashes.json");
|
||||
const SVG_HASHES = svgHashesModule.default ?? {};
|
||||
const svgHash = SVG_HASHES[slug] ?? undefined;
|
||||
|
||||
const etag = await getOGImageVersion("app", { svgHash });
|
||||
const img = new ImageResponse(
|
||||
<App name={name} description={description} slug={slug} logoUrl={logoUrl} />,
|
||||
ogConfig
|
||||
);
|
||||
|
||||
return new Response(img.body, {
|
||||
status: 200,
|
||||
headers: {
|
||||
"Content-Type": "image/png",
|
||||
"Cache-Control": "public, max-age=3600, stale-while-revalidate=86400",
|
||||
"Cache-Control":
|
||||
"public, max-age=31536000, immutable, s-maxage=31536000, stale-while-revalidate=31536000",
|
||||
ETag: `"${etag}"`,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
@@ -151,13 +168,16 @@ async function handler(req: NextRequest) {
|
||||
imageType,
|
||||
});
|
||||
|
||||
const etag = await getOGImageVersion("generic");
|
||||
const img = new ImageResponse(<Generic title={title} description={description} />, ogConfig);
|
||||
|
||||
return new Response(img.body, {
|
||||
status: 200,
|
||||
headers: {
|
||||
"Content-Type": "image/png",
|
||||
"Cache-Control": "public, max-age=3600, stale-while-revalidate=86400",
|
||||
"Cache-Control":
|
||||
"public, max-age=31536000, immutable, s-maxage=31536000, stale-while-revalidate=31536000",
|
||||
ETag: `"${etag}"`,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
@@ -178,9 +198,9 @@ async function handler(req: NextRequest) {
|
||||
}
|
||||
|
||||
default:
|
||||
return new Response("What you're looking for is not here..", { status: 404 });
|
||||
return new Response("Wrong image type", { status: 404 });
|
||||
}
|
||||
} catch (error) {
|
||||
} catch {
|
||||
return new Response("Internal server error", { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
declare module "@calcom/web/public/app-store/svg-hashes.json" {
|
||||
const value: Record<string, string>;
|
||||
export default value;
|
||||
}
|
||||
@@ -10,13 +10,10 @@ import { lucideIconList } from "../../../../packages/ui/components/icon/icon-lis
|
||||
import { IconGrid } from "./IconGrid";
|
||||
|
||||
export const dynamic = "force-static";
|
||||
export const metadata: Metadata = _generateMetadataForStaticPage(
|
||||
"Icons Showcase",
|
||||
"",
|
||||
undefined,
|
||||
undefined,
|
||||
"/icons"
|
||||
);
|
||||
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
return await _generateMetadataForStaticPage("Icons Showcase", "", undefined, undefined, "/icons");
|
||||
}
|
||||
|
||||
const interFont = Inter({ subsets: ["latin"], variable: "--font-inter", preload: true, display: "swap" });
|
||||
const calFont = localFont({
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { cookies, headers } from "next/headers";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
import { checkOnboardingRedirect } from "@calcom/features/auth/lib/onboardingUtils";
|
||||
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
|
||||
|
||||
import { buildLegacyRequest } from "@lib/buildLegacyCtx";
|
||||
@@ -11,6 +12,17 @@ const RedirectPage = async () => {
|
||||
if (!session?.user?.id) {
|
||||
redirect("/auth/login");
|
||||
}
|
||||
|
||||
// Check if user needs onboarding and redirect before going to event-types
|
||||
const organizationId = session.user.profile?.organizationId ?? null;
|
||||
const onboardingPath = await checkOnboardingRedirect(session.user.id, {
|
||||
checkEmailVerification: true,
|
||||
organizationId,
|
||||
});
|
||||
if (onboardingPath) {
|
||||
redirect(onboardingPath);
|
||||
}
|
||||
|
||||
redirect("/event-types");
|
||||
};
|
||||
|
||||
|
||||
@@ -1214,8 +1214,8 @@ const NoShowAttendeesDialog = ({
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={() => setIsOpen(false)}>
|
||||
<DialogContent title={t("mark_as_no_show_title")} description={t("no_show_description")}>
|
||||
{noShowAttendees.map((attendee) => (
|
||||
<DialogContent title={t("mark_as_no_show_title")} description={t("no_show_description")} enableOverflow>
|
||||
{noShowAttendees.map((attendee, index) => (
|
||||
<form
|
||||
key={attendee.id}
|
||||
onSubmit={(e) => {
|
||||
@@ -1225,7 +1225,10 @@ const NoShowAttendeesDialog = ({
|
||||
attendees: [{ email: attendee.email, noShow: !attendee.noShow }],
|
||||
});
|
||||
}}>
|
||||
<div className="bg-muted flex items-center justify-between rounded-md px-4 py-2">
|
||||
<div
|
||||
className={`bg-muted flex items-center justify-between px-4 py-2 ${
|
||||
index === 0 ? "rounded-t-md" : ""
|
||||
} ${index === noShowAttendees.length - 1 ? "rounded-b-md" : "border-subtle border-b"}`}>
|
||||
<span className="text-emphasis flex flex-col text-sm">
|
||||
{attendee.name}
|
||||
{attendee.email && <span className="text-muted">({attendee.email})</span>}
|
||||
|
||||
@@ -128,7 +128,9 @@ export function getEditEventActions(context: BookingActionContext): ActionType[]
|
||||
iconClassName: "rotate-45 w-[16px] -translate-x-0.5 ",
|
||||
label: t("send_reschedule_request"),
|
||||
disabled:
|
||||
(isBookingInPast && !booking.eventType.allowReschedulingPastBookings) || isDisabledRescheduling,
|
||||
(isBookingInPast && !booking.eventType.allowReschedulingPastBookings) ||
|
||||
isDisabledRescheduling ||
|
||||
booking.seatsReferences.length > 0,
|
||||
},
|
||||
isBookingFromRoutingForm
|
||||
? {
|
||||
|
||||
@@ -47,7 +47,8 @@ export const AddGuestsDialog = (props: IAddGuestsDialog) => {
|
||||
}
|
||||
const validationResult = ZAddGuestsInputSchema.safeParse(multiEmailValue);
|
||||
if (validationResult.success) {
|
||||
addGuestsMutation.mutate({ bookingId, guests: multiEmailValue });
|
||||
const guests = multiEmailValue.map((email) => ({ email }));
|
||||
addGuestsMutation.mutate({ bookingId, guests });
|
||||
} else {
|
||||
setIsInvalidEmail(true);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { ErrorMessage } from "@hookform/error-message";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { isValidPhoneNumber } from "libphonenumber-js";
|
||||
import { isValidPhoneNumber } from "libphonenumber-js/max";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Controller, useForm, useWatch, useFormContext } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
@@ -202,7 +202,7 @@ export const EditLocationDialog = (props: ISetLocationDialog) => {
|
||||
id="locationInput"
|
||||
placeholder={t(eventLocationType.organizerInputPlaceholder || "")}
|
||||
required
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
|
||||
defaultValue={
|
||||
defaultLocation ? defaultLocation[eventLocationType.defaultValueVariable] : undefined
|
||||
}
|
||||
|
||||
@@ -50,7 +50,11 @@ export function onRouterTransitionStart(url: string, navigationType: "push" | "r
|
||||
}
|
||||
}
|
||||
|
||||
process.env.NEXT_PUBLIC_VERCEL_USE_BOTID_IN_BOOKER === "1" &&
|
||||
if (
|
||||
process.env.NEXT_PUBLIC_VERCEL_USE_BOTID_IN_BOOKER === "1" &&
|
||||
typeof window !== "undefined" &&
|
||||
typeof window.crypto?.getRandomValues === "function"
|
||||
) {
|
||||
initBotId({
|
||||
protect: [
|
||||
{
|
||||
@@ -59,3 +63,4 @@ process.env.NEXT_PUBLIC_VERCEL_USE_BOTID_IN_BOOKER === "1" &&
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { z } from "zod";
|
||||
import dayjs from "@calcom/dayjs";
|
||||
import { OrganizationRepository } from "@calcom/features/ee/organizations/repositories/OrganizationRepository";
|
||||
import { StripeBillingService } from "@calcom/features/ee/billing/stripe-billing-service";
|
||||
import { OnboardingPathService } from "@calcom/features/onboarding/lib/onboarding-path.service";
|
||||
import { WEBAPP_URL } from "@calcom/lib/constants";
|
||||
import { IS_STRIPE_ENABLED } from "@calcom/lib/constants";
|
||||
import { prisma } from "@calcom/prisma";
|
||||
@@ -172,7 +173,9 @@ export async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
|
||||
await moveUserToMatchingOrg({ email: user.email });
|
||||
|
||||
return res.redirect(`${WEBAPP_URL}/${hasCompletedOnboarding ? "/event-types" : "/getting-started"}`);
|
||||
const gettingStartedPath = await OnboardingPathService.getGettingStartedPath(prisma);
|
||||
|
||||
return res.redirect(`${WEBAPP_URL}${hasCompletedOnboarding ? "/event-types" : gettingStartedPath}`);
|
||||
}
|
||||
|
||||
export async function cleanUpVerificationTokens(id: number) {
|
||||
|
||||
@@ -11,6 +11,7 @@ import useEmailVerifyCheck from "@calcom/trpc/react/hooks/useEmailVerifyCheck";
|
||||
import { showToast } from "@calcom/ui/components/toast";
|
||||
import { Button } from "@calcom/ui/components/button";
|
||||
import { EmptyScreen } from "@calcom/ui/components/empty-screen";
|
||||
import { useFlagMap } from "@calcom/features/flags/context/provider";
|
||||
|
||||
function VerifyEmailPage() {
|
||||
const { data } = useEmailVerifyCheck();
|
||||
@@ -18,13 +19,15 @@ function VerifyEmailPage() {
|
||||
const router = useRouter();
|
||||
const { t, isLocaleReady } = useLocale();
|
||||
const mutation = trpc.viewer.auth.resendVerifyEmail.useMutation();
|
||||
const flags = useFlagMap();
|
||||
|
||||
useEffect(() => {
|
||||
if (data?.isVerified) {
|
||||
router.replace("/getting-started");
|
||||
const gettingStartedPath = flags["onboarding-v3"] ? "/onboarding/getting-started" : "/getting-started";
|
||||
router.replace(gettingStartedPath);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [data?.isVerified]);
|
||||
}, [data?.isVerified, flags]);
|
||||
if (!isLocaleReady) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -1,23 +1,96 @@
|
||||
"use client";
|
||||
|
||||
import type { Table as ReactTable } from "@tanstack/react-table";
|
||||
import { createParser, useQueryState } from "nuqs";
|
||||
import { useMemo, useCallback } from "react";
|
||||
|
||||
import { DataTableFilters, DataTableSegment } from "@calcom/features/data-table";
|
||||
import { EmptyScreen } from "@calcom/ui/components/empty-screen";
|
||||
import dayjs from "@calcom/dayjs";
|
||||
import {
|
||||
DataTableFilters,
|
||||
DataTableSegment,
|
||||
useDataTable,
|
||||
useFilterValue,
|
||||
ZDateRangeFilterValue,
|
||||
ColumnFilterType,
|
||||
} from "@calcom/features/data-table";
|
||||
import { CUSTOM_PRESET } from "@calcom/features/data-table/lib/dateRange";
|
||||
|
||||
import type { RowData, BookingListingStatus } from "../types";
|
||||
import { BookingsCalendarView } from "./BookingsCalendarView";
|
||||
|
||||
type BookingsCalendarViewProps = {
|
||||
status: BookingListingStatus;
|
||||
table: ReactTable<RowData>;
|
||||
};
|
||||
|
||||
const COLUMN_IDS_TO_HIDE = ["dateRange"];
|
||||
|
||||
const weekStartParser = createParser({
|
||||
parse: (value: string) => {
|
||||
const parsed = dayjs(value);
|
||||
return parsed.isValid() ? parsed.startOf("week") : dayjs().startOf("week");
|
||||
},
|
||||
serialize: (value: dayjs.Dayjs) => value.format("YYYY-MM-DD"),
|
||||
});
|
||||
|
||||
export function BookingsCalendar({ table }: BookingsCalendarViewProps) {
|
||||
const { rows } = table.getRowModel();
|
||||
const { updateFilter } = useDataTable();
|
||||
const dateRange = useFilterValue("dateRange", ZDateRangeFilterValue)?.data;
|
||||
|
||||
const [currentWeekStart, setCurrentWeekStart] = useQueryState(
|
||||
"weekStart",
|
||||
weekStartParser.withDefault(dayjs().startOf("week"))
|
||||
);
|
||||
|
||||
const bookings = useMemo(() => {
|
||||
return rows
|
||||
.filter((row) => row.original.type === "data")
|
||||
.map((row) => (row.original.type === "data" ? row.original.booking : null))
|
||||
.filter((booking): booking is NonNullable<typeof booking> => booking !== null);
|
||||
}, [rows]);
|
||||
|
||||
const handleWeekStartChange = useCallback(
|
||||
(newWeekStart: dayjs.Dayjs) => {
|
||||
setCurrentWeekStart(newWeekStart);
|
||||
|
||||
const startDate = newWeekStart.toDate();
|
||||
const endDate = newWeekStart.add(6, "day").toDate();
|
||||
|
||||
if (!dateRange) {
|
||||
return;
|
||||
}
|
||||
|
||||
const rangeStart = dateRange.startDate ? new Date(dateRange.startDate) : null;
|
||||
const rangeEnd = dateRange.endDate ? new Date(dateRange.endDate) : null;
|
||||
|
||||
const needsStartUpdate = !rangeStart || startDate < rangeStart;
|
||||
const needsEndUpdate = !rangeEnd || endDate > rangeEnd;
|
||||
|
||||
if (!needsStartUpdate && !needsEndUpdate) {
|
||||
return;
|
||||
}
|
||||
|
||||
const newStartDate = needsStartUpdate ? startDate : rangeStart;
|
||||
const newEndDate = needsEndUpdate ? endDate : rangeEnd;
|
||||
|
||||
updateFilter("dateRange", {
|
||||
type: ColumnFilterType.DATE_RANGE,
|
||||
data: {
|
||||
startDate: newStartDate.toISOString(),
|
||||
endDate: newEndDate.toISOString(),
|
||||
preset: CUSTOM_PRESET.value,
|
||||
},
|
||||
});
|
||||
},
|
||||
[dateRange, updateFilter, setCurrentWeekStart]
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<DataTableFilters.FilterBar table={table} />
|
||||
<DataTableFilters.FilterBar table={table} columnIdsToHide={COLUMN_IDS_TO_HIDE} />
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -26,9 +99,11 @@ export function BookingsCalendar({ table }: BookingsCalendarViewProps) {
|
||||
<DataTableSegment.Select />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-center pt-2 xl:pt-0">
|
||||
<EmptyScreen Icon="calendar" headline="Calendar view" description="Calendar view is coming soon." />
|
||||
</div>
|
||||
<BookingsCalendarView
|
||||
bookings={bookings}
|
||||
currentWeekStart={currentWeekStart}
|
||||
onWeekStartChange={handleWeekStartChange}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useEffect } from "react";
|
||||
|
||||
import dayjs from "@calcom/dayjs";
|
||||
import { useTimePreferences } from "@calcom/features/bookings/lib";
|
||||
import { Calendar } from "@calcom/features/calendars/weeklyview";
|
||||
import type { CalendarEvent } from "@calcom/features/calendars/weeklyview/types/events";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { useGetTheme } from "@calcom/lib/hooks/useTheme";
|
||||
import { Button } from "@calcom/ui/components/button";
|
||||
import { ButtonGroup } from "@calcom/ui/components/buttonGroup";
|
||||
import { Icon } from "@calcom/ui/components/icon";
|
||||
|
||||
import type { BookingOutput } from "../types";
|
||||
|
||||
type BookingsCalendarViewProps = {
|
||||
bookings: BookingOutput[];
|
||||
currentWeekStart: dayjs.Dayjs;
|
||||
onWeekStartChange: (weekStart: dayjs.Dayjs) => void;
|
||||
};
|
||||
|
||||
export function BookingsCalendarView({
|
||||
bookings,
|
||||
currentWeekStart,
|
||||
onWeekStartChange,
|
||||
}: BookingsCalendarViewProps) {
|
||||
const { t } = useLocale();
|
||||
const { timezone } = useTimePreferences();
|
||||
const { resolvedTheme, forcedTheme } = useGetTheme();
|
||||
|
||||
const goToPreviousWeek = () => {
|
||||
onWeekStartChange(currentWeekStart.subtract(1, "week"));
|
||||
};
|
||||
|
||||
const goToNextWeek = () => {
|
||||
onWeekStartChange(currentWeekStart.add(1, "week"));
|
||||
};
|
||||
|
||||
const goToToday = () => {
|
||||
onWeekStartChange(dayjs().startOf("week"));
|
||||
};
|
||||
|
||||
const startDate = useMemo(() => currentWeekStart.toDate(), [currentWeekStart]);
|
||||
const endDate = useMemo(() => currentWeekStart.add(6, "day").toDate(), [currentWeekStart]);
|
||||
|
||||
useEffect(() => {
|
||||
onWeekStartChange(currentWeekStart);
|
||||
}, []);
|
||||
|
||||
const events = useMemo<CalendarEvent[]>(() => {
|
||||
const hasDarkTheme = !forcedTheme && resolvedTheme === "dark";
|
||||
|
||||
return bookings
|
||||
.filter((booking) => {
|
||||
const bookingStart = dayjs(booking.startTime);
|
||||
return (
|
||||
(bookingStart.isAfter(currentWeekStart) || bookingStart.isSame(currentWeekStart)) &&
|
||||
bookingStart.isBefore(currentWeekStart.add(7, "day"))
|
||||
);
|
||||
})
|
||||
.sort((a, b) => {
|
||||
const startDiff = new Date(a.startTime).getTime() - new Date(b.startTime).getTime();
|
||||
if (startDiff !== 0) return startDiff;
|
||||
return new Date(a.endTime).getTime() - new Date(b.endTime).getTime();
|
||||
})
|
||||
.map((booking, idx) => {
|
||||
// Parse eventTypeColor and extract the appropriate color based on theme
|
||||
const eventTypeColor =
|
||||
booking.eventType?.eventTypeColor &&
|
||||
booking.eventType.eventTypeColor[hasDarkTheme ? "darkEventTypeColor" : "lightEventTypeColor"];
|
||||
|
||||
return {
|
||||
id: idx,
|
||||
title: booking.title,
|
||||
start: new Date(booking.startTime),
|
||||
end: new Date(booking.endTime),
|
||||
options: {
|
||||
status: booking.status,
|
||||
...(eventTypeColor && { color: eventTypeColor }),
|
||||
},
|
||||
};
|
||||
});
|
||||
}, [bookings, currentWeekStart, resolvedTheme, forcedTheme]);
|
||||
|
||||
const weekStart = currentWeekStart;
|
||||
const weekEnd = currentWeekStart.add(6, "day");
|
||||
const startMonth = weekStart.format("MMM");
|
||||
const endMonth = weekEnd.format("MMM");
|
||||
const year = weekEnd.format("YYYY");
|
||||
|
||||
const weekRange =
|
||||
startMonth === endMonth ? (
|
||||
<>
|
||||
<span className="text-emphasis">{`${startMonth} ${weekStart.format("D")} - ${weekEnd.format(
|
||||
"D"
|
||||
)}`}</span>
|
||||
<span className="text-muted">, {year}</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span className="text-emphasis">{`${weekStart.format("MMM D")} - ${weekEnd.format("MMM D")}`}</span>
|
||||
<span className="text-muted">, {year}</span>
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="border-subtle flex h-[calc(100vh-260px)] min-h-[600px] flex-col rounded-2xl border">
|
||||
<div className="mx-4 mt-4 flex items-center justify-between py-1.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<h2 className="text-xl font-semibold">{weekRange}</h2>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button color="secondary" onClick={goToToday} className="capitalize leading-4">
|
||||
{t("today")}
|
||||
</Button>
|
||||
<ButtonGroup combined>
|
||||
<Button color="secondary" onClick={goToPreviousWeek}>
|
||||
<span className="sr-only">{t("view_previous_week")}</span>
|
||||
<Icon name="chevron-left" className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button color="secondary" onClick={goToNextWeek}>
|
||||
<span className="sr-only">{t("view_next_week")}</span>
|
||||
<Icon name="chevron-right" className="h-4 w-4" />
|
||||
</Button>
|
||||
</ButtonGroup>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto overflow-x-hidden rounded-2xl">
|
||||
<Calendar
|
||||
timezone={timezone}
|
||||
sortEvents
|
||||
startHour={0}
|
||||
endHour={23}
|
||||
events={events}
|
||||
startDate={startDate}
|
||||
endDate={endDate}
|
||||
gridCellsPerHour={4}
|
||||
hoverEventDuration={0}
|
||||
showBackgroundPattern={false}
|
||||
showBorder={false}
|
||||
borderColor="subtle"
|
||||
onEventClick={(_event) => {}}
|
||||
hideHeader
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import { orgDomainConfig } from "@calcom/ee/organizations/lib/orgDomains";
|
||||
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
|
||||
import getBookingInfo from "@calcom/features/bookings/lib/getBookingInfo";
|
||||
import { BookingRepository } from "@calcom/features/bookings/repositories/BookingRepository";
|
||||
import { isTeamMember } from "@calcom/features/ee/teams/lib/queries";
|
||||
import { getDefaultEvent } from "@calcom/features/eventtypes/lib/defaultEvents";
|
||||
import { getBrandingForEventType } from "@calcom/features/profile/lib/getBranding";
|
||||
import { shouldHideBrandingForEvent } from "@calcom/features/profile/lib/hideBranding";
|
||||
@@ -183,6 +184,10 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
|
||||
};
|
||||
|
||||
const isLoggedInUserHost = checkIfUserIsHost(userId);
|
||||
const eventTeamId = eventType.team?.id ?? eventType.parent?.teamId;
|
||||
const isLoggedInUserTeamMember = !!(userId && eventTeamId && (await isTeamMember(userId, eventTeamId)));
|
||||
|
||||
const canViewHiddenData = isLoggedInUserHost || isLoggedInUserTeamMember;
|
||||
|
||||
if (bookingInfo !== null && eventType.seatsPerTimeSlot) {
|
||||
await handleSeatsEventTypeOnBooking(eventType, bookingInfo, seatReferenceUid, isLoggedInUserHost);
|
||||
@@ -202,8 +207,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
|
||||
},
|
||||
});
|
||||
|
||||
if (!isLoggedInUserHost) {
|
||||
// Removing hidden fields from responses
|
||||
if (!canViewHiddenData) {
|
||||
for (const key in bookingInfo.responses) {
|
||||
const field = eventTypeRaw.bookingFields.find((field) => field.name === key);
|
||||
if (field && !!field.hidden) {
|
||||
@@ -215,7 +219,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
|
||||
const { currentOrgDomain } = orgDomainConfig(context.req);
|
||||
|
||||
async function getInternalNotePresets(teamId: number | null) {
|
||||
if (!teamId || !isLoggedInUserHost) return [];
|
||||
if (!teamId || !canViewHiddenData) return [];
|
||||
return await prisma.internalNotePreset.findMany({
|
||||
where: {
|
||||
teamId,
|
||||
@@ -233,8 +237,8 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
|
||||
// Filter out organizer information if hideOrganizerEmail is true
|
||||
const sanitizedPreviousBooking =
|
||||
eventType.hideOrganizerEmail &&
|
||||
previousBooking &&
|
||||
previousBooking.rescheduledBy === bookingInfo.user?.email
|
||||
previousBooking &&
|
||||
previousBooking.rescheduledBy === bookingInfo.user?.email
|
||||
? { ...previousBooking, rescheduledBy: bookingInfo.user?.name }
|
||||
: previousBooking;
|
||||
|
||||
@@ -247,11 +251,11 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
|
||||
hideBranding: isPlatformBooking
|
||||
? true
|
||||
: await shouldHideBrandingForEvent({
|
||||
eventTypeId: eventType.id,
|
||||
team: eventType?.parent?.team ?? eventType?.team,
|
||||
owner: eventType.users[0] ?? null,
|
||||
organizationId: session?.user?.profile?.organizationId ?? session?.user?.org?.id ?? null,
|
||||
}),
|
||||
eventTypeId: eventType.id,
|
||||
team: eventType?.parent?.team ?? eventType?.team,
|
||||
owner: eventType.users[0] ?? null,
|
||||
organizationId: session?.user?.profile?.organizationId ?? session?.user?.org?.id ?? null,
|
||||
}),
|
||||
profile,
|
||||
eventType,
|
||||
recurringBookings: await getRecurringBookings(bookingInfo.recurringEventId),
|
||||
@@ -264,6 +268,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
|
||||
requiresLoginToUpdate,
|
||||
rescheduledToUid,
|
||||
isLoggedInUserHost,
|
||||
canViewHiddenData,
|
||||
internalNotePresets: internalNotes,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -100,7 +100,14 @@ export default function Success(props: PageProps) {
|
||||
const pathname = usePathname();
|
||||
const searchParams = useCompatSearchParams();
|
||||
|
||||
const { eventType, bookingInfo, previousBooking, requiresLoginToUpdate, rescheduledToUid } = props;
|
||||
const {
|
||||
eventType,
|
||||
bookingInfo,
|
||||
previousBooking,
|
||||
requiresLoginToUpdate,
|
||||
rescheduledToUid,
|
||||
canViewHiddenData,
|
||||
} = props;
|
||||
|
||||
const {
|
||||
allRemainingBookings,
|
||||
@@ -139,6 +146,7 @@ export default function Success(props: PageProps) {
|
||||
const status = bookingInfo?.status;
|
||||
const reschedule = bookingInfo.status === BookingStatus.ACCEPTED;
|
||||
const cancellationReason = bookingInfo.cancellationReason || bookingInfo.rejectionReason;
|
||||
const isAwaitingPayment = props.paymentStatus && !props.paymentStatus.success;
|
||||
|
||||
const attendees = bookingInfo?.attendees;
|
||||
|
||||
@@ -296,6 +304,12 @@ export default function Success(props: PageProps) {
|
||||
if (isCancelled) {
|
||||
return "";
|
||||
}
|
||||
if (isAwaitingPayment && !isCancelled) {
|
||||
return t("complete_your_booking_subject", {
|
||||
title: eventName,
|
||||
date: formatToLocalizedDate(date, undefined, "long", tz),
|
||||
});
|
||||
}
|
||||
if (needsConfirmation) {
|
||||
if (props.profile.name !== null) {
|
||||
return t(`user_needs_to_confirm_or_reject_booking${titleSuffix}`, {
|
||||
@@ -374,12 +388,17 @@ export default function Success(props: PageProps) {
|
||||
const isRescheduled = bookingInfo?.rescheduled;
|
||||
|
||||
const canCancelOrReschedule = !eventType?.disableCancelling || !eventType?.disableRescheduling;
|
||||
const canCancelAndReschedule = !eventType?.disableCancelling && !eventType?.disableRescheduling;
|
||||
|
||||
const canCancel = !eventType?.disableCancelling;
|
||||
const canReschedule = !eventType?.disableRescheduling;
|
||||
|
||||
const successPageHeadline = (() => {
|
||||
if (isAwaitingPayment && !isCancelled) {
|
||||
return props.paymentStatus?.paymentOption === "HOLD"
|
||||
? t("meeting_awaiting_payment_method")
|
||||
: t("meeting_awaiting_payment");
|
||||
}
|
||||
|
||||
if (needsConfirmationAndReschedulable) {
|
||||
return isRecurringBooking ? t("booking_submitted_recurring") : t("booking_submitted");
|
||||
}
|
||||
@@ -478,14 +497,18 @@ export default function Success(props: PageProps) {
|
||||
"mx-auto flex h-12 w-12 items-center justify-center rounded-full",
|
||||
isRoundRobin &&
|
||||
"border-cal-bg dark:border-cal-bg-muted absolute bottom-0 right-0 z-10 h-12 w-12 border-8",
|
||||
!giphyImage && isReschedulable && !needsConfirmation ? "bg-success" : "",
|
||||
!giphyImage && isReschedulable && needsConfirmation ? "bg-subtle" : "",
|
||||
!giphyImage && isReschedulable && !needsConfirmation && !isAwaitingPayment
|
||||
? "bg-success"
|
||||
: "",
|
||||
!giphyImage && isReschedulable && (needsConfirmation || isAwaitingPayment)
|
||||
? "bg-subtle"
|
||||
: "",
|
||||
isCancelled ? "bg-error" : ""
|
||||
)}>
|
||||
{!giphyImage && !needsConfirmation && isReschedulable && (
|
||||
{!giphyImage && !needsConfirmation && !isAwaitingPayment && isReschedulable && (
|
||||
<Icon name="check" className="h-5 w-5 text-green-600 dark:text-green-400" />
|
||||
)}
|
||||
{needsConfirmation && isReschedulable && (
|
||||
{(needsConfirmation || isAwaitingPayment) && isReschedulable && (
|
||||
<Icon name="calendar" className="text-emphasis h-5 w-5" />
|
||||
)}
|
||||
{isCancelled && <Icon name="x" className="h-5 w-5 text-red-600 dark:text-red-200" />}
|
||||
@@ -698,7 +721,7 @@ export default function Success(props: PageProps) {
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{!!utmParams && isHost && (
|
||||
{!!utmParams && canViewHiddenData && (
|
||||
<>
|
||||
<div className="mt-9 pr-2 font-medium sm:pr-0">{t("utm_params")}</div>
|
||||
<div className="col-span-2 mb-2 ml-3 mt-9 sm:ml-0">
|
||||
@@ -720,7 +743,7 @@ export default function Success(props: PageProps) {
|
||||
{showUtmParams && (
|
||||
<div className="col-span-2 mb-2 mt-2">
|
||||
{Object.entries(utmParams).filter(([_, value]) => Boolean(value)).length >
|
||||
0 ? (
|
||||
0 ? (
|
||||
<ul className="list-disc space-y-1 p-1 pl-5 sm:w-80">
|
||||
{Object.entries(utmParams)
|
||||
.filter(([_, value]) => Boolean(value))
|
||||
@@ -828,11 +851,10 @@ export default function Success(props: PageProps) {
|
||||
<span className="text-default inline">
|
||||
<span className="underline" data-testid="reschedule-link">
|
||||
<Link
|
||||
href={`/reschedule/${seatReferenceUid || bookingInfo?.uid}${
|
||||
currentUserEmail
|
||||
? `?rescheduledBy=${encodeURIComponent(currentUserEmail)}`
|
||||
: ""
|
||||
}`}
|
||||
href={`/reschedule/${seatReferenceUid || bookingInfo?.uid}${currentUserEmail
|
||||
? `?rescheduledBy=${encodeURIComponent(currentUserEmail)}`
|
||||
: ""
|
||||
}`}
|
||||
legacyBehavior>
|
||||
{t("reschedule")}
|
||||
</Link>
|
||||
|
||||
@@ -23,7 +23,6 @@ import useMeQuery from "@calcom/trpc/react/hooks/useMeQuery";
|
||||
import { Alert } from "@calcom/ui/components/alert";
|
||||
import type { HorizontalTabItemProps } from "@calcom/ui/components/navigation";
|
||||
import { HorizontalTabs } from "@calcom/ui/components/navigation";
|
||||
import type { VerticalTabItemProps } from "@calcom/ui/components/navigation";
|
||||
import { WipeMyCalActionButton } from "@calcom/web/components/apps/wipemycalother/wipeMyCalActionButton";
|
||||
|
||||
import BookingListItem from "@components/booking/BookingListItem";
|
||||
@@ -41,6 +40,7 @@ type BookingsProps = {
|
||||
permissions: {
|
||||
canReadOthersBookings: boolean;
|
||||
};
|
||||
isCalendarViewEnabled: boolean;
|
||||
};
|
||||
|
||||
function useSystemSegments(userId?: number) {
|
||||
@@ -90,14 +90,15 @@ const viewParser = createParser({
|
||||
serialize: (value: "list" | "calendar") => value,
|
||||
});
|
||||
|
||||
function BookingsContent({ status, permissions }: BookingsProps) {
|
||||
const [view] = useQueryState("view", viewParser.withDefault("list"));
|
||||
function BookingsContent({ status, permissions, isCalendarViewEnabled }: BookingsProps) {
|
||||
const [_view] = useQueryState("view", viewParser.withDefault("list"));
|
||||
// Force view to be "list" if calendar view is disabled
|
||||
const view = isCalendarViewEnabled ? _view : "list";
|
||||
const { t } = useLocale();
|
||||
const user = useMeQuery().data;
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
// Generate dynamic tabs that preserve query parameters
|
||||
const tabs: (VerticalTabItemProps | HorizontalTabItemProps)[] = useMemo(() => {
|
||||
const tabs: HorizontalTabItemProps[] = useMemo(() => {
|
||||
const queryString = searchParams?.toString() || "";
|
||||
|
||||
const baseTabConfigs = [
|
||||
@@ -392,7 +393,7 @@ function BookingsContent({ status, permissions }: BookingsProps) {
|
||||
|
||||
return (
|
||||
<div className="flex flex-col">
|
||||
<div className="flex flex-row flex-wrap justify-between">
|
||||
<div className="flex flex-row flex-wrap justify-between lg:hidden">
|
||||
<HorizontalTabs
|
||||
tabs={tabs.map((tab) => ({
|
||||
...tab,
|
||||
|
||||
@@ -22,6 +22,7 @@ import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { trpc } from "@calcom/trpc";
|
||||
import type { RouterOutputs } from "@calcom/trpc/react";
|
||||
import { Badge } from "@calcom/ui/components/badge";
|
||||
import { EmptyScreen } from "@calcom/ui/components/empty-screen";
|
||||
|
||||
type CallHistoryRow = {
|
||||
id: string;
|
||||
@@ -79,7 +80,7 @@ function CallHistoryContent({ org: _org }: CallHistoryProps) {
|
||||
const [rowSelection, setRowSelection] = useState({});
|
||||
const [state, dispatch] = useReducer(reducer, initialState);
|
||||
|
||||
const { limit, offset, searchTerm: _searchTerm } = useDataTable();
|
||||
const { limit, offset, searchTerm } = useDataTable();
|
||||
|
||||
const {
|
||||
data: callsData,
|
||||
@@ -114,7 +115,7 @@ function CallHistoryContent({ org: _org }: CallHistoryProps) {
|
||||
callCreated: call.call_analysis?.call_successful ?? true,
|
||||
inVoicemail: call.call_analysis?.in_voicemail ?? false,
|
||||
}));
|
||||
}, [callsData?.calls]);
|
||||
}, [callsData?.calls, t]);
|
||||
|
||||
const columns = useMemo<ColumnDef<CallHistoryRow>[]>(
|
||||
() => [
|
||||
@@ -310,6 +311,16 @@ function CallHistoryContent({ org: _org }: CallHistoryProps) {
|
||||
<DataTableFilters.ClearFiltersButton />
|
||||
</>
|
||||
}
|
||||
EmptyView={
|
||||
<div className="px-6 py-8">
|
||||
<EmptyScreen
|
||||
Icon="phone"
|
||||
headline={searchTerm ? t("no_result_found_for", { searchTerm }) : t("no_call_history")}
|
||||
description={t("no_call_history_description")}
|
||||
className="mb-16"
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
{state.callDetailsSheet.showModal && <CallDetailsSheet state={state} dispatch={dispatch} />}
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
AverageEventDurationChart,
|
||||
BookingKPICards,
|
||||
BookingsByHourChart,
|
||||
CSATOverTimeChart,
|
||||
EventTrendsChart,
|
||||
HighestNoShowHostTable,
|
||||
HighestRatedMembersTable,
|
||||
@@ -25,6 +26,7 @@ import {
|
||||
MostCancelledBookingsTables,
|
||||
MostCompletedTeamMembersTable,
|
||||
LeastCompletedTeamMembersTable,
|
||||
NoShowHostsOverTimeChart,
|
||||
PopularEventsTable,
|
||||
RecentNoShowGuestsChart,
|
||||
RecentFeedbackTable,
|
||||
@@ -84,9 +86,7 @@ function InsightsPageContent() {
|
||||
className="flex flex-wrap items-center gap-2"
|
||||
data-testid={`insights-filters-${isAll}-${teamId}-${userId}`}>
|
||||
<OrgTeamsFilter />
|
||||
<DataTableFilters.AddFilterButton table={table} hideWhenFilterApplied />
|
||||
<DataTableFilters.ActiveFilters table={table} />
|
||||
<DataTableFilters.AddFilterButton table={table} variant="sm" showWhenFilterApplied />
|
||||
<DataTableFilters.FilterBar table={table} />
|
||||
<DataTableFilters.ClearFiltersButton exclude={["startTime", "createdAt"]} />
|
||||
<div className="grow" />
|
||||
<Download />
|
||||
@@ -105,6 +105,11 @@ function InsightsPageContent() {
|
||||
|
||||
<EventTrendsChart />
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<NoShowHostsOverTimeChart />
|
||||
<CSATOverTimeChart />
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 md:grid-cols-4">
|
||||
<div className="sm:col-span-2">
|
||||
<BookingsByHourChart />
|
||||
|
||||
@@ -8,11 +8,11 @@ import classNames from "@calcom/ui/classNames";
|
||||
import { Badge } from "@calcom/ui/components/badge";
|
||||
import { Button } from "@calcom/ui/components/button";
|
||||
import { type IconName } from "@calcom/ui/components/icon";
|
||||
import { Logo } from "@calcom/ui/components/logo";
|
||||
import { RadioAreaGroup } from "@calcom/ui/components/radio";
|
||||
|
||||
import { OnboardingContinuationPrompt } from "../components/onboarding-continuation-prompt";
|
||||
import { PlanIcon } from "../components/plan-icon";
|
||||
import { OnboardingLayout } from "../personal/_components/OnboardingLayout";
|
||||
import { useOnboardingStore, type PlanType } from "../store/onboarding-store";
|
||||
|
||||
type OnboardingViewProps = {
|
||||
@@ -77,28 +77,10 @@ export const OnboardingView = ({ userName, userEmail }: OnboardingViewProps) =>
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="bg-default flex min-h-screen w-full flex-col items-start overflow-clip">
|
||||
<>
|
||||
<OnboardingContinuationPrompt />
|
||||
{/* Header */}
|
||||
<div className="flex w-full items-center justify-between px-6 py-4">
|
||||
<Logo className="h-5 w-auto" />
|
||||
|
||||
{/* Progress dots - centered */}
|
||||
<div className="absolute left-1/2 flex -translate-x-1/2 items-center justify-center gap-1">
|
||||
<div className="bg-emphasis h-1.5 w-1.5 rounded-full" />
|
||||
<div className="bg-subtle h-1 w-1 rounded-full" />
|
||||
<div className="bg-subtle h-1 w-1 rounded-full" />
|
||||
<div className="bg-subtle h-1 w-1 rounded-full" />
|
||||
</div>
|
||||
|
||||
<div className="bg-muted flex items-center gap-2 rounded-full px-3 py-2">
|
||||
<p className="text-emphasis font-mediumu text-sm leading-none">{userEmail}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main content */}
|
||||
<div className="flex h-full w-full items-start justify-center px-6 py-8">
|
||||
<div className="flex w-full max-w-[600px] flex-col gap-6">
|
||||
<OnboardingLayout userEmail={userEmail} currentStep={1}>
|
||||
<div className="flex w-full flex-col gap-6">
|
||||
{/* Card */}
|
||||
<div className="bg-muted border-muted relative rounded-xl border p-1">
|
||||
<div className="rounded-inherit flex w-full flex-col items-start overflow-clip">
|
||||
@@ -173,7 +155,7 @@ export const OnboardingView = ({ userName, userEmail }: OnboardingViewProps) =>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</OnboardingLayout>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
|
||||
import { useFlagMap } from "@calcom/features/flags/context/provider";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
|
||||
import type { OnboardingState } from "../store/onboarding-store";
|
||||
@@ -8,6 +9,7 @@ import type { OnboardingState } from "../store/onboarding-store";
|
||||
export function useCreateTeam() {
|
||||
const router = useRouter();
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const flags = useFlagMap();
|
||||
|
||||
const createTeamMutation = trpc.viewer.teams.create.useMutation();
|
||||
|
||||
@@ -31,7 +33,11 @@ export function useCreateTeam() {
|
||||
}
|
||||
|
||||
if (result.team) {
|
||||
router.push("/getting-started");
|
||||
// Not sure we need this flag check - keeping it here for safe keeping as this is called only from v3 onboarding flow
|
||||
const gettingStartedPath = flags["onboarding-v3"]
|
||||
? "/onboarding/getting-started"
|
||||
: "/getting-started";
|
||||
router.push(gettingStartedPath);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to create team:", error);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
|
||||
import { useFlagMap } from "@calcom/features/flags/context/provider";
|
||||
import { CreationSource } from "@calcom/prisma/enums";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import { showToast } from "@calcom/ui/components/toast";
|
||||
@@ -11,10 +12,15 @@ export const useSubmitOnboarding = () => {
|
||||
const router = useRouter();
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const flags = useFlagMap();
|
||||
|
||||
const intentToCreateOrg = trpc.viewer.organizations.intentToCreateOrg.useMutation();
|
||||
|
||||
const submitOnboarding = async (store: OnboardingState, userEmail: string) => {
|
||||
const submitOnboarding = async (
|
||||
store: OnboardingState,
|
||||
userEmail: string,
|
||||
invitesToSubmit: OnboardingState["invites"]
|
||||
) => {
|
||||
setIsSubmitting(true);
|
||||
setError(null);
|
||||
|
||||
@@ -24,7 +30,6 @@ export const useSubmitOnboarding = () => {
|
||||
organizationDetails,
|
||||
organizationBrand,
|
||||
teams,
|
||||
invites,
|
||||
inviteRole,
|
||||
resetOnboarding,
|
||||
} = store;
|
||||
@@ -44,7 +49,7 @@ export const useSubmitOnboarding = () => {
|
||||
}));
|
||||
|
||||
// Prepare invites data
|
||||
const invitedMembersData = invites
|
||||
const invitedMembersData = invitesToSubmit
|
||||
.filter((invite) => invite.email.trim().length > 0)
|
||||
.map((invite) => ({
|
||||
email: invite.email,
|
||||
@@ -79,8 +84,7 @@ export const useSubmitOnboarding = () => {
|
||||
// Organization has already been created by the backend
|
||||
showToast("Organization created successfully!", "success");
|
||||
// TODO: after this redirect we need to hard refresh the page to see org
|
||||
resetOnboarding();
|
||||
router.push("/getting-started");
|
||||
skipToPersonal(resetOnboarding);
|
||||
} catch (err) {
|
||||
const errorMessage = err instanceof Error ? err.message : "Failed to create organization";
|
||||
setError(errorMessage);
|
||||
@@ -91,8 +95,15 @@ export const useSubmitOnboarding = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const skipToPersonal = (resetOnboarding: () => void) => {
|
||||
resetOnboarding();
|
||||
const gettingStartedPath = flags["onboarding-v3"] ? "/onboarding/personal/settings" : "/getting-started";
|
||||
router.push(gettingStartedPath);
|
||||
};
|
||||
|
||||
return {
|
||||
submitOnboarding,
|
||||
skipToPersonal,
|
||||
isSubmitting,
|
||||
error,
|
||||
};
|
||||
|
||||
@@ -7,8 +7,9 @@ import { HexColorPicker } from "react-colorful";
|
||||
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { Button } from "@calcom/ui/components/button";
|
||||
import { Logo } from "@calcom/ui/components/logo";
|
||||
|
||||
import { OnboardingCard } from "../../personal/_components/OnboardingCard";
|
||||
import { OnboardingLayout } from "../../personal/_components/OnboardingLayout";
|
||||
import { useOnboardingStore } from "../../store/onboarding-store";
|
||||
|
||||
type OrganizationBrandViewProps = {
|
||||
@@ -116,70 +117,137 @@ export const OrganizationBrandView = ({ userEmail }: OrganizationBrandViewProps)
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-default flex min-h-screen w-full flex-col items-start overflow-clip rounded-xl">
|
||||
{/* Header */}
|
||||
<div className="flex w-full items-center justify-between px-6 py-4">
|
||||
<Logo className="h-5 w-auto" />
|
||||
<OnboardingLayout userEmail={userEmail} currentStep={2}>
|
||||
<OnboardingCard
|
||||
title={t("onboarding_org_brand_title")}
|
||||
subtitle={t("onboarding_org_brand_subtitle")}
|
||||
footer={
|
||||
<>
|
||||
<Button color="minimal" className="rounded-[10px]" onClick={handleSkip}>
|
||||
{t("ill_do_this_later")}
|
||||
</Button>
|
||||
<Button color="primary" className="rounded-[10px]" onClick={handleContinue}>
|
||||
{t("continue")}
|
||||
</Button>
|
||||
</>
|
||||
}>
|
||||
{/* Form */}
|
||||
<div className="bg-default border-muted w-full rounded-[10px] border">
|
||||
<div className="rounded-inherit flex w-full flex-col items-start overflow-clip">
|
||||
<div className="flex w-full flex-col items-start">
|
||||
<div className="flex w-full gap-6 px-5 py-4">
|
||||
{/* Left side - Form */}
|
||||
<div className="flex w-full flex-col gap-6">
|
||||
{/* Brand Color */}
|
||||
<div className="flex w-full flex-col gap-6">
|
||||
<p className="text-emphasis text-sm font-medium leading-4">{t("brand_color")}</p>
|
||||
<div className="flex w-full items-center gap-2">
|
||||
<p className="text-subtle w-[98px] overflow-hidden text-ellipsis whitespace-nowrap text-sm font-medium leading-4">
|
||||
{t("onboarding_primary_color_label")}
|
||||
</p>
|
||||
<BrandColorPicker
|
||||
value={brandColor}
|
||||
onChange={(value) => {
|
||||
setBrandColor(value);
|
||||
setOrganizationBrand({ color: value });
|
||||
}}
|
||||
t={t}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Progress dots - centered */}
|
||||
<div className="absolute left-1/2 flex -translate-x-1/2 items-center justify-center gap-1">
|
||||
<div className="bg-emphasis h-1 w-1 rounded-full" />
|
||||
<div className="bg-emphasis h-1.5 w-1.5 rounded-full" />
|
||||
<div className="bg-subtle h-1 w-1 rounded-full" />
|
||||
<div className="bg-subtle h-1 w-1 rounded-full" />
|
||||
</div>
|
||||
{/* Logo Upload */}
|
||||
<div className="flex w-full flex-col gap-2">
|
||||
<p className="text-emphasis text-sm font-medium leading-4">{t("logo")}</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="bg-muted border-muted relative h-16 w-16 shrink-0 overflow-hidden rounded-md border">
|
||||
{logoPreview && (
|
||||
<img
|
||||
src={logoPreview}
|
||||
alt={t("onboarding_logo_preview_alt")}
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Button
|
||||
color="secondary"
|
||||
size="sm"
|
||||
onClick={() => document.getElementById("logo-upload")?.click()}>
|
||||
{t("upload")}
|
||||
</Button>
|
||||
<input
|
||||
id="logo-upload"
|
||||
type="file"
|
||||
accept="image/*"
|
||||
className="hidden"
|
||||
onChange={(e) => handleLogoChange(e.target.files?.[0] || null)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-subtle text-xs font-normal leading-3">{t("onboarding_logo_size_hint")}</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-muted flex items-center gap-2 rounded-full px-3 py-2">
|
||||
<p className="text-emphasis text-sm font-medium leading-none">{userEmail}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main content */}
|
||||
<div className="flex h-full w-full items-start justify-center px-6 py-8">
|
||||
<div className="relative flex w-full max-w-[600px] flex-col gap-6">
|
||||
{/* Card */}
|
||||
<div className="bg-muted border-muted relative rounded-xl border p-1">
|
||||
<div className="rounded-inherit flex w-full flex-col items-start overflow-clip">
|
||||
{/* Card Header */}
|
||||
<div className="flex w-full gap-1.5 px-5 py-4">
|
||||
<div className="flex w-full flex-col gap-1">
|
||||
<h1 className="font-cal text-xl font-semibold leading-6">{t("onboarding_org_brand_title")}</h1>
|
||||
<p className="text-subtle text-sm font-medium leading-tight">
|
||||
{t("onboarding_org_brand_subtitle")}
|
||||
</p>
|
||||
{/* Banner Upload */}
|
||||
<div className="flex w-full flex-col gap-2">
|
||||
<p className="text-emphasis text-sm font-medium leading-4">
|
||||
{t("onboarding_banner_label")}
|
||||
</p>
|
||||
<div className="flex w-full flex-col gap-2">
|
||||
<div className="bg-muted border-muted relative h-[92px] w-full overflow-hidden rounded-md border">
|
||||
{bannerPreview && (
|
||||
<img
|
||||
src={bannerPreview}
|
||||
alt={t("onboarding_banner_preview_alt")}
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Button
|
||||
color="secondary"
|
||||
size="sm"
|
||||
className="w-fit"
|
||||
onClick={() => document.getElementById("banner-upload")?.click()}>
|
||||
{t("upload")}
|
||||
</Button>
|
||||
<input
|
||||
id="banner-upload"
|
||||
type="file"
|
||||
accept="image/*"
|
||||
className="hidden"
|
||||
onChange={(e) => handleBannerChange(e.target.files?.[0] || null)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-subtle text-xs font-normal leading-3">
|
||||
{t("onboarding_banner_size_hint")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Form */}
|
||||
<div className="bg-default border-muted w-full rounded-[10px] border">
|
||||
<div className="rounded-inherit flex w-full flex-col items-start overflow-clip">
|
||||
<div className="flex w-full flex-col items-start">
|
||||
<div className="flex w-full gap-6 px-5 py-4">
|
||||
{/* Left side - Form */}
|
||||
<div className="flex w-full flex-col gap-6">
|
||||
{/* Brand Color */}
|
||||
<div className="flex w-full flex-col gap-6">
|
||||
<p className="text-emphasis text-sm font-medium leading-4">{t("brand_color")}</p>
|
||||
<div className="flex w-full items-center gap-2">
|
||||
<p className="text-subtle w-[98px] overflow-hidden text-ellipsis whitespace-nowrap text-sm font-medium leading-4">
|
||||
{t("onboarding_primary_color_label")}
|
||||
</p>
|
||||
<BrandColorPicker
|
||||
value={brandColor}
|
||||
onChange={(value) => {
|
||||
setBrandColor(value);
|
||||
setOrganizationBrand({ color: value });
|
||||
}}
|
||||
t={t}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{/* Right side - Preview */}
|
||||
<div className="bg-muted border-muted flex hidden h-[328px] w-full grow overflow-hidden rounded-[10px] border p-5 md:block">
|
||||
<div className="flex flex-col gap-2.5">
|
||||
<p className="text-subtle text-sm font-medium leading-4">{t("preview")}</p>
|
||||
<div className="border-subtle bg-default relative flex w-[110%] flex-col gap-2.5 rounded-md border px-5 pb-5 pt-[74px]">
|
||||
{/* Banner preview */}
|
||||
<div className="bg-muted border-muted absolute left-1 top-1 h-[92px] w-[272px] overflow-hidden rounded-[4px] border">
|
||||
{bannerPreview && (
|
||||
<img
|
||||
src={bannerPreview}
|
||||
alt={t("onboarding_banner_preview_alt")}
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Logo Upload */}
|
||||
<div className="flex w-full flex-col gap-2">
|
||||
<p className="text-emphasis text-sm font-medium leading-4">{t("logo")}</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="bg-muted border-muted relative h-16 w-16 shrink-0 overflow-hidden rounded-md border">
|
||||
{/* Content */}
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-3 p-1">
|
||||
<div className="flex flex-col gap-3">
|
||||
{/* Logo preview */}
|
||||
<div className="bg-muted z-20 h-9 w-9 shrink-0 overflow-hidden rounded-md border-2 border-[var(--cal-bg)]">
|
||||
{logoPreview && (
|
||||
<img
|
||||
src={logoPreview}
|
||||
@@ -188,141 +256,38 @@ export const OrganizationBrandView = ({ userEmail }: OrganizationBrandViewProps)
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Button
|
||||
color="secondary"
|
||||
size="sm"
|
||||
onClick={() => document.getElementById("logo-upload")?.click()}>
|
||||
{t("upload")}
|
||||
</Button>
|
||||
<input
|
||||
id="logo-upload"
|
||||
type="file"
|
||||
accept="image/*"
|
||||
className="hidden"
|
||||
onChange={(e) => handleLogoChange(e.target.files?.[0] || null)}
|
||||
/>
|
||||
<p className="text-subtle text-sm font-medium capitalize leading-4 ">
|
||||
{organizationDetails.name || t("onboarding_preview_nameless")}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex flex-col gap-3">
|
||||
<p className="font-cal text-xl leading-5 tracking-[0.2px]">
|
||||
{t("onboarding_preview_example_title")}
|
||||
</p>
|
||||
<p className="text-subtle text-sm font-medium leading-5">
|
||||
{t("onboarding_preview_example_description")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-subtle text-xs font-normal leading-3">
|
||||
{t("onboarding_logo_size_hint")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Banner Upload */}
|
||||
<div className="flex w-full flex-col gap-2">
|
||||
<p className="text-emphasis text-sm font-medium leading-4">{t("onboarding_banner_label")}</p>
|
||||
<div className="flex w-full flex-col gap-2">
|
||||
<div className="bg-muted border-muted relative h-[92px] w-full overflow-hidden rounded-md border">
|
||||
{bannerPreview && (
|
||||
<img
|
||||
src={bannerPreview}
|
||||
alt={t("onboarding_banner_preview_alt")}
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
)}
|
||||
<div className="flex flex-col gap-1">
|
||||
{[134, 104, 84, 104].map((width, i) => (
|
||||
<div key={i} className="flex items-center gap-2 p-1">
|
||||
<div className="bg-subtle h-5 w-5 shrink-0 rounded-full" />
|
||||
<div className="bg-subtle h-2.5 rounded-full" style={{ width: `${width}px` }} />
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Button
|
||||
color="secondary"
|
||||
size="sm"
|
||||
className="w-fit"
|
||||
onClick={() => document.getElementById("banner-upload")?.click()}>
|
||||
{t("upload")}
|
||||
</Button>
|
||||
<input
|
||||
id="banner-upload"
|
||||
type="file"
|
||||
accept="image/*"
|
||||
className="hidden"
|
||||
onChange={(e) => handleBannerChange(e.target.files?.[0] || null)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-subtle text-xs font-normal leading-3">
|
||||
{t("onboarding_banner_size_hint")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right side - Preview */}
|
||||
<div className="bg-muted border-muted flex hidden h-[328px] w-full grow overflow-hidden rounded-[10px] border p-5 md:block">
|
||||
<div className="flex flex-col gap-2.5">
|
||||
<p className="text-subtle text-sm font-medium leading-4">{t("preview")}</p>
|
||||
<div className="border-subtle bg-default relative flex w-[110%] flex-col gap-2.5 rounded-md border px-5 pb-5 pt-[74px]">
|
||||
{/* Banner preview */}
|
||||
<div className="bg-muted border-muted absolute left-1 top-1 h-[92px] w-[272px] overflow-hidden rounded-[4px] border">
|
||||
{bannerPreview && (
|
||||
<img
|
||||
src={bannerPreview}
|
||||
alt={t("onboarding_banner_preview_alt")}
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-3 p-1">
|
||||
<div className="flex flex-col gap-3">
|
||||
{/* Logo preview */}
|
||||
<div className="bg-muted z-20 h-9 w-9 shrink-0 overflow-hidden rounded-md border-2 border-[var(--cal-bg)]">
|
||||
{logoPreview && (
|
||||
<img
|
||||
src={logoPreview}
|
||||
alt={t("onboarding_logo_preview_alt")}
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-subtle text-sm font-medium capitalize leading-4 ">
|
||||
{organizationDetails.name || t("onboarding_preview_nameless")}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex flex-col gap-3">
|
||||
<p className="font-cal text-xl leading-5 tracking-[0.2px]">
|
||||
{t("onboarding_preview_example_title")}
|
||||
</p>
|
||||
<p className="text-subtle text-sm font-medium leading-5">
|
||||
{t("onboarding_preview_example_description")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
{[134, 104, 84, 104].map((width, i) => (
|
||||
<div key={i} className="flex items-center gap-2 p-1">
|
||||
<div className="bg-subtle h-5 w-5 shrink-0 rounded-full" />
|
||||
<div
|
||||
className="bg-subtle h-2.5 rounded-full"
|
||||
style={{ width: `${width}px` }}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex w-full items-center justify-end gap-1 px-5 py-4">
|
||||
<Button color="minimal" className="rounded-[10px]" onClick={handleSkip}>
|
||||
{t("ill_do_this_later")}
|
||||
</Button>
|
||||
<Button color="primary" className="rounded-[10px]" onClick={handleContinue}>
|
||||
{t("continue")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</OnboardingCard>
|
||||
</OnboardingLayout>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -6,9 +6,9 @@ import { useEffect, useState } from "react";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { Button } from "@calcom/ui/components/button";
|
||||
import { Label, TextField, TextArea } from "@calcom/ui/components/form";
|
||||
import { Logo } from "@calcom/ui/components/logo";
|
||||
|
||||
import { OnboardingContinuationPrompt } from "../../components/onboarding-continuation-prompt";
|
||||
import { OnboardingCard } from "../../personal/_components/OnboardingCard";
|
||||
import { OnboardingLayout } from "../../personal/_components/OnboardingLayout";
|
||||
import { useOnboardingStore } from "../../store/onboarding-store";
|
||||
import { ValidatedOrganizationSlug } from "./validated-organization-slug";
|
||||
|
||||
@@ -76,101 +76,64 @@ export const OrganizationDetailsView = ({ userEmail }: OrganizationDetailsViewPr
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-default flex min-h-screen w-full flex-col items-start overflow-clip rounded-xl">
|
||||
{/* Header */}
|
||||
<div className="flex w-full items-center justify-between px-6 py-4">
|
||||
<Logo className="h-5 w-auto" />
|
||||
<OnboardingLayout userEmail={userEmail} currentStep={2}>
|
||||
<OnboardingCard
|
||||
title={t("onboarding_org_details_title")}
|
||||
subtitle={t("onboarding_org_details_subtitle")}
|
||||
footer={
|
||||
<Button
|
||||
color="primary"
|
||||
className="rounded-[10px]"
|
||||
onClick={handleContinue}
|
||||
disabled={!isSlugValid || !organizationName || !organizationLink}>
|
||||
{t("continue")}
|
||||
</Button>
|
||||
}>
|
||||
{/* Form */}
|
||||
<div className="bg-default border-muted w-full rounded-[10px] border">
|
||||
<div className="rounded-inherit flex w-full flex-col items-start overflow-clip">
|
||||
<div className="flex w-full flex-col items-start">
|
||||
<div className="flex w-full gap-6 px-5 py-5">
|
||||
<div className="flex w-full flex-col gap-4 rounded-xl">
|
||||
{/* Organization Name */}
|
||||
<div className="flex w-full flex-col gap-1.5">
|
||||
<Label className="text-emphasis text-sm font-medium leading-4">
|
||||
{t("organization_name")}
|
||||
</Label>
|
||||
<TextField
|
||||
value={organizationName}
|
||||
onChange={(e) => setOrganizationName(e.target.value)}
|
||||
placeholder={t("organization_name")}
|
||||
className="border-default h-7 rounded-[10px] border px-2 py-1.5 text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Progress dots - centered */}
|
||||
<div className="absolute left-1/2 flex -translate-x-1/2 items-center justify-center gap-1">
|
||||
<div className="bg-emphasis h-1 w-1 rounded-full" />
|
||||
<div className="bg-emphasis h-1.5 w-1.5 rounded-full" />
|
||||
<div className="bg-subtle h-1 w-1 rounded-full" />
|
||||
<div className="bg-subtle h-1 w-1 rounded-full" />
|
||||
</div>
|
||||
{/* Organization Link */}
|
||||
<ValidatedOrganizationSlug
|
||||
value={organizationLink}
|
||||
onChange={handleSlugChange}
|
||||
onValidationChange={setIsSlugValid}
|
||||
/>
|
||||
|
||||
<div className="bg-muted flex items-center gap-2 rounded-full px-3 py-2">
|
||||
<p className="text-emphasis text-sm font-medium leading-none">{userEmail}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main content */}
|
||||
<div className="flex h-full w-full items-start justify-center px-6 py-8">
|
||||
<div className="flex w-full max-w-[600px] flex-col gap-6">
|
||||
{/* Card */}
|
||||
<div className="bg-muted border-muted relative rounded-xl border p-1">
|
||||
<div className="rounded-inherit flex w-full flex-col items-start overflow-clip">
|
||||
{/* Card Header */}
|
||||
<div className="flex w-full gap-1.5 px-5 py-4">
|
||||
<div className="flex w-full flex-col gap-1">
|
||||
<h1 className="font-cal text-xl font-semibold leading-6">
|
||||
{t("onboarding_org_details_title")}
|
||||
</h1>
|
||||
<p className="text-subtle text-sm font-medium leading-tight">
|
||||
{t("onboarding_org_details_subtitle")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Form */}
|
||||
<div className="bg-default border-muted w-full rounded-[10px] border">
|
||||
<div className="rounded-inherit flex w-full flex-col items-start overflow-clip">
|
||||
<div className="flex w-full flex-col items-start">
|
||||
<div className="flex w-full gap-6 px-5 py-5">
|
||||
<div className="flex w-full flex-col gap-4 rounded-xl">
|
||||
{/* Organization Name */}
|
||||
<div className="flex w-full flex-col gap-1.5">
|
||||
<Label className="text-emphasis text-sm font-medium leading-4">
|
||||
{t("organization_name")}
|
||||
</Label>
|
||||
<TextField
|
||||
value={organizationName}
|
||||
onChange={(e) => setOrganizationName(e.target.value)}
|
||||
placeholder={t("organization_name")}
|
||||
className="border-default h-7 rounded-[10px] border px-2 py-1.5 text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Organization Link */}
|
||||
<ValidatedOrganizationSlug
|
||||
value={organizationLink}
|
||||
onChange={handleSlugChange}
|
||||
onValidationChange={setIsSlugValid}
|
||||
/>
|
||||
|
||||
{/* Organization Bio */}
|
||||
<div className="flex w-full flex-col gap-1.5">
|
||||
<Label className="text-emphasis text-sm font-medium leading-4">
|
||||
{t("onboarding_org_bio_label")}
|
||||
</Label>
|
||||
<TextArea
|
||||
value={organizationBio}
|
||||
onChange={(e) => setOrganizationBio(e.target.value)}
|
||||
placeholder={t("onboarding_org_bio_placeholder")}
|
||||
rows={4}
|
||||
className="border-default rounded-lg border px-2 py-2 text-sm leading-tight"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* Organization Bio */}
|
||||
<div className="flex w-full flex-col gap-1.5">
|
||||
<Label className="text-emphasis text-sm font-medium leading-4">
|
||||
{t("onboarding_org_bio_label")}
|
||||
</Label>
|
||||
<TextArea
|
||||
value={organizationBio}
|
||||
onChange={(e) => setOrganizationBio(e.target.value)}
|
||||
placeholder={t("onboarding_org_bio_placeholder")}
|
||||
rows={4}
|
||||
className="border-default rounded-lg border px-2 py-2 text-sm leading-tight"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex w-full items-center justify-end gap-1 px-5 py-4">
|
||||
<Button
|
||||
color="primary"
|
||||
className="rounded-[10px]"
|
||||
onClick={handleContinue}
|
||||
disabled={!isSlugValid || !organizationName || !organizationLink}>
|
||||
{t("continue")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</OnboardingCard>
|
||||
</OnboardingLayout>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -63,7 +63,7 @@ export const OrganizationInviteView = ({ userEmail }: OrganizationInviteViewProp
|
||||
const handleSubmitOnboarding = async (invitesData: FormValues["invites"]) => {
|
||||
// Save invites to store before submitting
|
||||
setInvites(invitesData);
|
||||
await submitOnboarding(store, userEmail);
|
||||
await submitOnboarding(store, userEmail, invitesData);
|
||||
};
|
||||
|
||||
const handleContinue = async (data?: FormValues) => {
|
||||
@@ -188,7 +188,7 @@ export const OrganizationInviteView = ({ userEmail }: OrganizationInviteViewProp
|
||||
<Form form={form} handleSubmit={handleContinue} className="w-full">
|
||||
<div className="flex w-full flex-col gap-4">
|
||||
{/* Email and Team inputs */}
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex flex-col">
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<Label className="text-emphasis text-sm font-medium">{t("email")}</Label>
|
||||
<Label className="text-emphasis text-sm font-medium">{t("team")}</Label>
|
||||
@@ -235,7 +235,7 @@ export const OrganizationInviteView = ({ userEmail }: OrganizationInviteViewProp
|
||||
color="secondary"
|
||||
size="sm"
|
||||
StartIcon="plus"
|
||||
className="w-fit"
|
||||
className="mt-2 w-fit"
|
||||
onClick={() => append({ email: "", team: "", role: "MEMBER" })}>
|
||||
{t("add")}
|
||||
</Button>
|
||||
|
||||
@@ -9,8 +9,9 @@ import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { Button } from "@calcom/ui/components/button";
|
||||
import { Form, TextField } from "@calcom/ui/components/form";
|
||||
import { Icon } from "@calcom/ui/components/icon";
|
||||
import { Logo } from "@calcom/ui/components/logo";
|
||||
|
||||
import { OnboardingCard } from "../../personal/_components/OnboardingCard";
|
||||
import { OnboardingLayout } from "../../personal/_components/OnboardingLayout";
|
||||
import { useOnboardingStore } from "../../store/onboarding-store";
|
||||
|
||||
type OrganizationTeamsViewProps = {
|
||||
@@ -63,107 +64,74 @@ export const OrganizationTeamsView = ({ userEmail }: OrganizationTeamsViewProps)
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="bg-default flex min-h-screen w-full flex-col items-start overflow-clip rounded-xl">
|
||||
{/* Header */}
|
||||
<div className="flex w-full items-center justify-between px-6 py-4">
|
||||
<Logo className="h-5 w-auto" />
|
||||
|
||||
{/* Progress dots - centered */}
|
||||
<div className="absolute left-1/2 flex -translate-x-1/2 items-center justify-center gap-1">
|
||||
<div className="bg-emphasis h-1 w-1 rounded-full" />
|
||||
<div className="bg-emphasis h-1 w-1 rounded-full" />
|
||||
<div className="bg-emphasis h-1.5 w-1.5 rounded-full" />
|
||||
<div className="bg-subtle h-1 w-1 rounded-full" />
|
||||
</div>
|
||||
|
||||
<div className="bg-muted flex items-center gap-2 rounded-full px-3 py-2">
|
||||
<p className="text-emphasis text-sm font-medium leading-none">{userEmail}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main content */}
|
||||
<div className="flex h-full w-full items-start justify-center px-6 py-8">
|
||||
<div className="flex w-full max-w-[600px] flex-col gap-4">
|
||||
{/* Card */}
|
||||
<div className="bg-muted border-muted relative rounded-xl border p-1">
|
||||
<div className="rounded-inherit flex w-full flex-col items-start overflow-clip">
|
||||
{/* Card Header */}
|
||||
<div className="flex w-full gap-1.5 px-5 py-4">
|
||||
<div className="flex w-full flex-col gap-1">
|
||||
<h1 className="font-cal text-xl font-semibold leading-6">{t("onboarding_org_teams_title")}</h1>
|
||||
<p className="text-subtle text-sm font-medium leading-tight">
|
||||
{t("onboarding_org_teams_subtitle")}
|
||||
</p>
|
||||
<OnboardingLayout userEmail={userEmail} currentStep={3}>
|
||||
<OnboardingCard
|
||||
title={t("onboarding_org_teams_title")}
|
||||
subtitle={t("onboarding_org_teams_subtitle")}
|
||||
footer={
|
||||
<>
|
||||
<Button type="button" color="minimal" className="rounded-[10px]" onClick={handleSkip}>
|
||||
{t("onboarding_skip_for_now")}
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
form="teams-form"
|
||||
color="primary"
|
||||
className="rounded-[10px]"
|
||||
disabled={!hasValidTeams}>
|
||||
{t("continue")}
|
||||
</Button>
|
||||
</>
|
||||
}>
|
||||
<Form id="teams-form" form={form} handleSubmit={handleContinue} className="w-full">
|
||||
<div className="bg-default border-subtle w-full rounded-md border">
|
||||
<div className="flex w-full flex-col gap-8 px-5 py-5">
|
||||
<div className="flex w-full flex-col gap-2">
|
||||
<div className="flex flex-col gap-1">
|
||||
<p className="text-emphasis text-sm font-medium leading-4">{t("team")}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Form */}
|
||||
<Form form={form} handleSubmit={handleContinue} className="w-full">
|
||||
<div className="bg-default border-subtle w-full rounded-md border">
|
||||
<div className="flex w-full flex-col gap-8 px-5 py-5">
|
||||
<div className="flex w-full flex-col gap-2">
|
||||
<div className="flex flex-col gap-1">
|
||||
<p className="text-emphasis text-sm font-medium leading-4">{t("team")}</p>
|
||||
{/* Team fields */}
|
||||
<div className="flex flex-col gap-2">
|
||||
{fields.map((field, index) => (
|
||||
<div key={field.id} className="flex w-full items-end gap-0.5">
|
||||
<div className="flex-1">
|
||||
<TextField
|
||||
labelSrOnly
|
||||
{...form.register(`teams.${index}.name`)}
|
||||
placeholder={t("team")}
|
||||
className="h-7 w-full rounded-[10px] text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Team fields */}
|
||||
<div className="flex flex-col gap-2">
|
||||
{fields.map((field, index) => (
|
||||
<div key={field.id} className="flex w-full items-end gap-0.5">
|
||||
<div className="flex-1">
|
||||
<TextField
|
||||
labelSrOnly
|
||||
{...form.register(`teams.${index}.name`)}
|
||||
placeholder={t("team")}
|
||||
className="h-7 w-full rounded-[10px] text-sm"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
color="minimal"
|
||||
variant="icon"
|
||||
size="sm"
|
||||
className="h-7 w-7"
|
||||
disabled={fields.length === 1}
|
||||
onClick={() => remove(index)}>
|
||||
<Icon name="x" className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Add button */}
|
||||
<Button
|
||||
type="button"
|
||||
color="secondary"
|
||||
color="minimal"
|
||||
variant="icon"
|
||||
size="sm"
|
||||
StartIcon="plus"
|
||||
className="w-fit"
|
||||
onClick={() => append({ name: "" })}>
|
||||
{t("add")}
|
||||
className="h-7 w-7"
|
||||
disabled={fields.length === 1}
|
||||
onClick={() => remove(index)}>
|
||||
<Icon name="x" className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="relative flex w-full items-center justify-end gap-1 px-5 py-4">
|
||||
<Button
|
||||
type="button"
|
||||
color="minimal"
|
||||
className="absolute left-[368px] rounded-[10px]"
|
||||
onClick={handleSkip}>
|
||||
{t("onboarding_skip_for_now")}
|
||||
</Button>
|
||||
<Button type="submit" color="primary" className="rounded-[10px]" disabled={!hasValidTeams}>
|
||||
{t("continue")}
|
||||
</Button>
|
||||
</div>
|
||||
</Form>
|
||||
{/* Add button */}
|
||||
<Button
|
||||
type="button"
|
||||
color="secondary"
|
||||
size="sm"
|
||||
StartIcon="plus"
|
||||
className="w-fit"
|
||||
onClick={() => append({ name: "" })}>
|
||||
{t("add")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Form>
|
||||
</OnboardingCard>
|
||||
</OnboardingLayout>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -23,7 +23,7 @@ export const OnboardingCard = ({ title, subtitle, children, footer, isLoading }:
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex w-full flex-col gap-4 px-5 py-5">
|
||||
<div className="flex w-full flex-col gap-4">
|
||||
{isLoading ? (
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
<SkeletonText className="h-40 w-full" />
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import { signOut } from "next-auth/react";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { Button } from "@calcom/ui/components/button";
|
||||
import { Logo } from "@calcom/ui/components/logo";
|
||||
|
||||
type OnboardingLayoutProps = {
|
||||
@@ -9,6 +14,12 @@ type OnboardingLayoutProps = {
|
||||
};
|
||||
|
||||
export const OnboardingLayout = ({ userEmail, currentStep, children }: OnboardingLayoutProps) => {
|
||||
const { t } = useLocale();
|
||||
|
||||
const handleSignOut = () => {
|
||||
signOut({ callbackUrl: "/auth/logout" });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-default flex min-h-screen w-full flex-col items-start overflow-clip rounded-xl">
|
||||
{/* Header */}
|
||||
@@ -33,9 +44,16 @@ export const OnboardingLayout = ({ userEmail, currentStep, children }: Onboardin
|
||||
</div>
|
||||
|
||||
{/* Main content */}
|
||||
<div className="flex h-full w-full items-start justify-center px-6 py-8">
|
||||
<div className="flex w-full flex-1 items-start justify-center px-6 py-8">
|
||||
<div className="flex w-full max-w-[600px] flex-col gap-4">{children}</div>
|
||||
</div>
|
||||
|
||||
{/* Footer with signout button */}
|
||||
<div className="flex w-full items-center justify-center px-6 py-6">
|
||||
<Button onClick={handleSignOut} color="minimal">
|
||||
{t("sign_out")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -13,11 +13,12 @@ import { Button } from "@calcom/ui/components/button";
|
||||
import { Editor } from "@calcom/ui/components/editor";
|
||||
import { Label } from "@calcom/ui/components/form";
|
||||
import { ImageUploader } from "@calcom/ui/components/image-uploader";
|
||||
import { Logo } from "@calcom/ui/components/logo";
|
||||
import { showToast } from "@calcom/ui/components/toast";
|
||||
|
||||
import { UsernameAvailabilityField } from "@components/ui/UsernameAvailability";
|
||||
|
||||
import { OnboardingCard } from "../_components/OnboardingCard";
|
||||
import { OnboardingLayout } from "../_components/OnboardingLayout";
|
||||
import { useOnboardingStore } from "../../store/onboarding-store";
|
||||
|
||||
type PersonalProfileViewProps = {
|
||||
@@ -93,128 +94,89 @@ export const PersonalProfileView = ({ userEmail }: PersonalProfileViewProps) =>
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
return null; // or a loading spinner
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-default flex min-h-screen w-full flex-col items-start overflow-clip rounded-xl">
|
||||
{/* Header */}
|
||||
<div className="flex w-full items-center justify-between px-6 py-4">
|
||||
<Logo className="h-5 w-auto" />
|
||||
<OnboardingLayout userEmail={userEmail} currentStep={2}>
|
||||
<OnboardingCard
|
||||
title={t("complete_your_profile")}
|
||||
subtitle={t("personal_profile_subtitle")}
|
||||
footer={
|
||||
<Button
|
||||
color="primary"
|
||||
className="rounded-[10px]"
|
||||
onClick={onSubmit}
|
||||
loading={mutation.isPending}
|
||||
disabled={mutation.isPending}>
|
||||
{t("continue")}
|
||||
</Button>
|
||||
}>
|
||||
{/* Form */}
|
||||
<div className="bg-default border-muted w-full rounded-[10px] border">
|
||||
<div className="rounded-inherit flex w-full flex-col items-start overflow-clip">
|
||||
<div className="flex w-full flex-col items-start">
|
||||
<form onSubmit={onSubmit} className="flex w-full gap-6 px-5 py-5">
|
||||
<div className="flex w-full flex-col gap-6 rounded-xl">
|
||||
{/* Avatar */}
|
||||
<div className="flex w-full flex-col gap-2">
|
||||
<Label className="text-emphasis text-sm font-medium leading-4">{t("profile_photo")}</Label>
|
||||
<div className="flex flex-row items-center justify-start gap-4 rtl:justify-end">
|
||||
{user && <UserAvatar size="lg" user={user} previewSrc={imageSrc} />}
|
||||
<input
|
||||
ref={avatarRef}
|
||||
type="hidden"
|
||||
name="avatar"
|
||||
id="avatar"
|
||||
defaultValue={imageSrc}
|
||||
/>
|
||||
<ImageUploader
|
||||
target="avatar"
|
||||
id="avatar-upload"
|
||||
buttonMsg={t("add_profile_photo")}
|
||||
handleAvatarChange={(newAvatar) => {
|
||||
if (avatarRef.current) {
|
||||
avatarRef.current.value = newAvatar;
|
||||
}
|
||||
const nativeInputValueSetter = Object.getOwnPropertyDescriptor(
|
||||
window.HTMLInputElement.prototype,
|
||||
"value"
|
||||
)?.set;
|
||||
nativeInputValueSetter?.call(avatarRef.current, newAvatar);
|
||||
const ev2 = new Event("input", { bubbles: true });
|
||||
avatarRef.current?.dispatchEvent(ev2);
|
||||
updateProfileHandler(newAvatar);
|
||||
}}
|
||||
imageSrc={imageSrc}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Progress dots - centered */}
|
||||
<div className="absolute left-1/2 flex -translate-x-1/2 items-center justify-center gap-1">
|
||||
<div className="bg-emphasis h-1 w-1 rounded-full" />
|
||||
<div className="bg-emphasis h-1.5 w-1.5 rounded-full" />
|
||||
<div className="bg-subtle h-1 w-1 rounded-full" />
|
||||
<div className="bg-subtle h-1 w-1 rounded-full" />
|
||||
</div>
|
||||
{/* Username */}
|
||||
<div className="flex w-full flex-col gap-1.5">
|
||||
<UsernameAvailabilityField />
|
||||
</div>
|
||||
|
||||
<div className="bg-muted flex items-center gap-2 rounded-full px-3 py-2">
|
||||
<p className="text-emphasis text-sm font-medium leading-none">{userEmail}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main content */}
|
||||
<div className="flex h-full w-full items-start justify-center px-6 py-8">
|
||||
<div className="flex w-full max-w-[600px] flex-col gap-6">
|
||||
{/* Card */}
|
||||
<div className="bg-muted border-muted relative rounded-xl border p-1">
|
||||
<div className="rounded-inherit flex w-full flex-col items-start overflow-clip">
|
||||
{/* Card Header */}
|
||||
<div className="flex w-full gap-1.5 px-5 py-4">
|
||||
<div className="flex w-full flex-col gap-1">
|
||||
<h1 className="font-cal text-xl font-semibold leading-6">{t("complete_your_profile")}</h1>
|
||||
<p className="text-subtle text-sm font-medium leading-tight">
|
||||
{t("personal_profile_subtitle")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Form */}
|
||||
<div className="bg-default border-muted w-full rounded-[10px] border">
|
||||
<div className="rounded-inherit flex w-full flex-col items-start overflow-clip">
|
||||
<div className="flex w-full flex-col items-start">
|
||||
<form onSubmit={onSubmit} className="flex w-full gap-6 px-5 py-5">
|
||||
<div className="flex w-full flex-col gap-6 rounded-xl">
|
||||
{/* Avatar */}
|
||||
<div className="flex w-full flex-col gap-2">
|
||||
<Label className="text-emphasis text-sm font-medium leading-4">
|
||||
{t("profile_photo")}
|
||||
</Label>
|
||||
<div className="flex flex-row items-center justify-start gap-4 rtl:justify-end">
|
||||
{user && <UserAvatar size="lg" user={user} previewSrc={imageSrc} />}
|
||||
<input
|
||||
ref={avatarRef}
|
||||
type="hidden"
|
||||
name="avatar"
|
||||
id="avatar"
|
||||
defaultValue={imageSrc}
|
||||
/>
|
||||
<ImageUploader
|
||||
target="avatar"
|
||||
id="avatar-upload"
|
||||
buttonMsg={t("add_profile_photo")}
|
||||
handleAvatarChange={(newAvatar) => {
|
||||
if (avatarRef.current) {
|
||||
avatarRef.current.value = newAvatar;
|
||||
}
|
||||
const nativeInputValueSetter = Object.getOwnPropertyDescriptor(
|
||||
window.HTMLInputElement.prototype,
|
||||
"value"
|
||||
)?.set;
|
||||
nativeInputValueSetter?.call(avatarRef.current, newAvatar);
|
||||
const ev2 = new Event("input", { bubbles: true });
|
||||
avatarRef.current?.dispatchEvent(ev2);
|
||||
updateProfileHandler(newAvatar);
|
||||
}}
|
||||
imageSrc={imageSrc}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Username */}
|
||||
<div className="flex w-full flex-col gap-1.5">
|
||||
<UsernameAvailabilityField />
|
||||
</div>
|
||||
|
||||
{/* Bio */}
|
||||
<div className="flex w-full flex-col gap-1.5">
|
||||
<Label className="text-emphasis mb-1 text-sm font-medium leading-4">
|
||||
{t("about")}
|
||||
</Label>
|
||||
<Editor
|
||||
getText={() => md.render(getValues("bio") || user?.bio || "")}
|
||||
setText={(value: string) => setValue("bio", turndown(value))}
|
||||
excludedToolbarItems={["blockType"]}
|
||||
firstRender={firstRender}
|
||||
setFirstRender={setFirstRender}
|
||||
/>
|
||||
<p className="text-subtle text-xs font-normal leading-3">
|
||||
{t("few_sentences_about_yourself")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
{/* Bio */}
|
||||
<div className="flex w-full flex-col gap-1.5">
|
||||
<Label className="text-emphasis mb-1 text-sm font-medium leading-4">{t("about")}</Label>
|
||||
<Editor
|
||||
getText={() => md.render(getValues("bio") || user?.bio || "")}
|
||||
setText={(value: string) => setValue("bio", turndown(value))}
|
||||
excludedToolbarItems={["blockType"]}
|
||||
firstRender={firstRender}
|
||||
setFirstRender={setFirstRender}
|
||||
/>
|
||||
<p className="text-subtle text-xs font-normal leading-3">
|
||||
{t("few_sentences_about_yourself")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex w-full items-center justify-end gap-1 px-5 py-4">
|
||||
<Button
|
||||
color="primary"
|
||||
className="rounded-[10px]"
|
||||
onClick={onSubmit}
|
||||
loading={mutation.isPending}
|
||||
disabled={mutation.isPending}>
|
||||
{t("continue")}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</OnboardingCard>
|
||||
</OnboardingLayout>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -14,10 +14,11 @@ import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import { Button } from "@calcom/ui/components/button";
|
||||
import { Label, TextField } from "@calcom/ui/components/form";
|
||||
import { Logo } from "@calcom/ui/components/logo";
|
||||
|
||||
import { OnboardingContinuationPrompt } from "../../components/onboarding-continuation-prompt";
|
||||
import { useOnboardingStore } from "../../store/onboarding-store";
|
||||
import { OnboardingCard } from "../_components/OnboardingCard";
|
||||
import { OnboardingLayout } from "../_components/OnboardingLayout";
|
||||
|
||||
type PersonalSettingsViewProps = {
|
||||
userEmail: string;
|
||||
@@ -79,101 +80,68 @@ export const PersonalSettingsView = ({ userEmail, userName }: PersonalSettingsVi
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="bg-default flex min-h-screen w-full flex-col items-start rounded-xl">
|
||||
<>
|
||||
<OnboardingContinuationPrompt />
|
||||
{/* Header */}
|
||||
<div className="flex w-full items-center justify-between px-6 py-4">
|
||||
<Logo className="h-5 w-auto" />
|
||||
<OnboardingLayout userEmail={userEmail} currentStep={1}>
|
||||
<OnboardingCard
|
||||
title={t("welcome_to_cal_com")}
|
||||
subtitle={t("personal_settings_subtitle")}
|
||||
footer={
|
||||
<Button
|
||||
color="primary"
|
||||
className="rounded-[10px]"
|
||||
onClick={handleContinue}
|
||||
loading={mutation.isPending}
|
||||
disabled={mutation.isPending || !form.formState.isValid}>
|
||||
{t("continue")}
|
||||
</Button>
|
||||
}>
|
||||
{/* Form */}
|
||||
<div className="bg-default border-muted w-full rounded-[10px] border">
|
||||
<div className="rounded-inherit flex w-full flex-col items-start">
|
||||
<div className="flex w-full flex-col items-start">
|
||||
<div className="flex w-full gap-6 px-5 py-5">
|
||||
<div className="flex w-full flex-col gap-4 rounded-xl">
|
||||
{/* Name */}
|
||||
<div className="flex w-full flex-col gap-1.5">
|
||||
<TextField
|
||||
label={t("full_name")}
|
||||
{...form.register("name")}
|
||||
value={name}
|
||||
onChange={(e) => {
|
||||
setName(e.target.value);
|
||||
form.setValue("name", e.target.value);
|
||||
}}
|
||||
placeholder="John Doe"
|
||||
className="border-default h-7 rounded-[10px] border px-2 py-1.5 text-sm"
|
||||
/>
|
||||
{form.formState.errors.name && (
|
||||
<p className="text-error text-sm">{form.formState.errors.name.message}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Progress dots - centered */}
|
||||
<div className="absolute left-1/2 flex -translate-x-1/2 items-center justify-center gap-1">
|
||||
<div className="bg-emphasis h-1.5 w-1.5 rounded-full" />
|
||||
<div className="bg-subtle h-1 w-1 rounded-full" />
|
||||
<div className="bg-subtle h-1 w-1 rounded-full" />
|
||||
<div className="bg-subtle h-1 w-1 rounded-full" />
|
||||
</div>
|
||||
|
||||
<div className="bg-muted flex items-center gap-2 rounded-full px-3 py-2">
|
||||
<p className="text-emphasis text-sm font-medium leading-none">{userEmail}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main content */}
|
||||
<div className="flex h-full w-full items-start justify-center px-6 py-8">
|
||||
<div className="flex w-full max-w-[600px] flex-col gap-6">
|
||||
{/* Card */}
|
||||
<div className="bg-muted border-muted relative rounded-xl border p-1">
|
||||
<div className="rounded-inherit flex w-full flex-col items-start ">
|
||||
{/* Card Header */}
|
||||
<div className="flex w-full gap-1.5 px-5 py-4">
|
||||
<div className="flex w-full flex-col gap-1">
|
||||
<h1 className="font-cal text-xl font-semibold leading-6">{t("welcome_to_cal_com")}</h1>
|
||||
<p className="text-subtle text-sm font-medium leading-tight">
|
||||
{t("personal_settings_subtitle")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Form */}
|
||||
<div className="bg-default border-muted w-full rounded-[10px] border">
|
||||
<div className="rounded-inherit flex w-full flex-col items-start">
|
||||
<div className="flex w-full flex-col items-start">
|
||||
<div className="flex w-full gap-6 px-5 py-5">
|
||||
<div className="flex w-full flex-col gap-4 rounded-xl">
|
||||
{/* Name */}
|
||||
<div className="flex w-full flex-col gap-1.5">
|
||||
<TextField
|
||||
label={t("full_name")}
|
||||
{...form.register("name")}
|
||||
value={name}
|
||||
onChange={(e) => {
|
||||
setName(e.target.value);
|
||||
form.setValue("name", e.target.value);
|
||||
}}
|
||||
placeholder="John Doe"
|
||||
className="border-default h-7 rounded-[10px] border px-2 py-1.5 text-sm"
|
||||
/>
|
||||
{form.formState.errors.name && (
|
||||
<p className="text-error text-sm">{form.formState.errors.name.message}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Timezone */}
|
||||
<div className="flex w-full flex-col gap-1.5">
|
||||
<Label className="text-emphasis mb-0 text-sm font-medium leading-4">
|
||||
{t("timezone")}
|
||||
</Label>
|
||||
<TimezoneSelect
|
||||
value={selectedTimeZone}
|
||||
onChange={({ value }) => setSelectedTimeZone(value)}
|
||||
className="rounded-[10px] text-sm"
|
||||
/>
|
||||
<p className="text-subtle text-xs font-normal leading-3">
|
||||
{t("current_time")}{" "}
|
||||
{dayjs().tz(selectedTimeZone).format("LT").toString().toLowerCase()}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{/* Timezone */}
|
||||
<div className="flex w-full flex-col gap-1.5">
|
||||
<Label className="text-emphasis mb-0 text-sm font-medium leading-4">
|
||||
{t("timezone")}
|
||||
</Label>
|
||||
<TimezoneSelect
|
||||
value={selectedTimeZone}
|
||||
onChange={({ value }) => setSelectedTimeZone(value)}
|
||||
size="sm"
|
||||
/>
|
||||
<p className="text-subtle text-xs font-normal leading-3">
|
||||
{t("current_time")}{" "}
|
||||
{dayjs().tz(selectedTimeZone).format("LT").toString().toLowerCase()}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex w-full items-center justify-end gap-1 px-5 py-4">
|
||||
<Button
|
||||
color="primary"
|
||||
className="rounded-[10px]"
|
||||
onClick={handleContinue}
|
||||
loading={mutation.isPending}
|
||||
disabled={mutation.isPending || !form.formState.isValid}>
|
||||
{t("continue")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</OnboardingCard>
|
||||
</OnboardingLayout>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
import { createJSONStorage, type StateStorage } from "zustand/middleware";
|
||||
|
||||
/**
|
||||
* IndexedDB storage adapter for onboarding store.
|
||||
* Supports much larger storage limits than localStorage (typically 50% of available disk space),
|
||||
* making it suitable for storing large base64-encoded images (logos, banners, etc.).
|
||||
* Falls back to localStorage if IndexedDB is not available.
|
||||
*/
|
||||
const DB_NAME = "cal-onboarding-idb";
|
||||
const DB_VERSION = 1;
|
||||
const STORE_NAME = "keyval";
|
||||
|
||||
// Cache database connection to avoid opening/closing repeatedly
|
||||
let dbPromise: Promise<IDBDatabase> | null = null;
|
||||
|
||||
function getDB(): Promise<IDBDatabase> {
|
||||
if (dbPromise) {
|
||||
return dbPromise;
|
||||
}
|
||||
|
||||
dbPromise = new Promise((resolve, reject) => {
|
||||
if (typeof window === "undefined" || !window.indexedDB) {
|
||||
reject(new Error("IndexedDB is not available"));
|
||||
return;
|
||||
}
|
||||
|
||||
const request = window.indexedDB.open(DB_NAME, DB_VERSION);
|
||||
|
||||
request.onerror = () => {
|
||||
dbPromise = null;
|
||||
reject(request.error);
|
||||
};
|
||||
|
||||
request.onupgradeneeded = (event) => {
|
||||
const db = (event.target as IDBOpenDBRequest).result;
|
||||
if (!db.objectStoreNames.contains(STORE_NAME)) {
|
||||
db.createObjectStore(STORE_NAME);
|
||||
}
|
||||
};
|
||||
|
||||
request.onsuccess = () => {
|
||||
const db = request.result;
|
||||
// Reset promise on close so we can reconnect if needed
|
||||
db.onclose = () => {
|
||||
dbPromise = null;
|
||||
};
|
||||
resolve(db);
|
||||
};
|
||||
});
|
||||
|
||||
return dbPromise;
|
||||
}
|
||||
|
||||
async function getItem(key: string): Promise<string | null> {
|
||||
try {
|
||||
const db = await getDB();
|
||||
return new Promise((resolve, reject) => {
|
||||
const transaction = db.transaction([STORE_NAME], "readonly");
|
||||
const store = transaction.objectStore(STORE_NAME);
|
||||
const request = store.get(key);
|
||||
|
||||
request.onerror = () => reject(request.error);
|
||||
request.onsuccess = () => resolve(request.result ?? null);
|
||||
});
|
||||
} catch {
|
||||
// Fallback to localStorage if IndexedDB fails
|
||||
try {
|
||||
return window.localStorage.getItem(key);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function setItem(key: string, value: string): Promise<void> {
|
||||
try {
|
||||
const db = await getDB();
|
||||
return new Promise((resolve, reject) => {
|
||||
const transaction = db.transaction([STORE_NAME], "readwrite");
|
||||
const store = transaction.objectStore(STORE_NAME);
|
||||
const request = store.put(value, key);
|
||||
|
||||
request.onerror = () => reject(request.error);
|
||||
request.onsuccess = () => resolve();
|
||||
});
|
||||
} catch {
|
||||
// Fallback to localStorage if IndexedDB fails (will throw if quota exceeded)
|
||||
try {
|
||||
window.localStorage.setItem(key, value);
|
||||
} catch {
|
||||
// Storage quota exceeded even in fallback - not much we can do
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function removeItem(key: string): Promise<void> {
|
||||
try {
|
||||
const db = await getDB();
|
||||
return new Promise((resolve, reject) => {
|
||||
const transaction = db.transaction([STORE_NAME], "readwrite");
|
||||
const store = transaction.objectStore(STORE_NAME);
|
||||
const request = store.delete(key);
|
||||
|
||||
request.onerror = () => reject(request.error);
|
||||
request.onsuccess = () => resolve();
|
||||
});
|
||||
} catch {
|
||||
// Fallback to localStorage if IndexedDB fails
|
||||
try {
|
||||
window.localStorage.removeItem(key);
|
||||
} catch {
|
||||
// Ignore errors on remove
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* IndexedDB storage adapter that implements StateStorage interface.
|
||||
* This provides much larger storage capacity than localStorage, making it suitable for
|
||||
* storing large base64-encoded images (logos, banners, etc.).
|
||||
*/
|
||||
const indexedDBStateStorage: StateStorage = {
|
||||
getItem,
|
||||
setItem,
|
||||
removeItem,
|
||||
};
|
||||
|
||||
/**
|
||||
* IndexedDB storage adapter that implements the PersistStorage interface for zustand persist.
|
||||
* Uses createJSONStorage to handle JSON serialization/deserialization automatically.
|
||||
*/
|
||||
export const onboardingIndexedDBStorage = createJSONStorage(() => indexedDBStateStorage);
|
||||
@@ -1,6 +1,8 @@
|
||||
import { create } from "zustand";
|
||||
import { persist } from "zustand/middleware";
|
||||
|
||||
import { onboardingIndexedDBStorage } from "./onboarding-storage";
|
||||
|
||||
export type PlanType = "personal" | "team" | "organization";
|
||||
export type InviteRole = "MEMBER" | "ADMIN";
|
||||
|
||||
@@ -160,7 +162,8 @@ export const useOnboardingStore = create<OnboardingState>()(
|
||||
resetOnboarding: () => set(initialState),
|
||||
}),
|
||||
{
|
||||
name: "cal-onboarding-storage", // localStorage key
|
||||
name: "cal-onboarding-storage", // Storage key
|
||||
storage: onboardingIndexedDBStorage, // Use IndexedDB instead of localStorage for larger capacity
|
||||
// Optional: Only persist certain fields
|
||||
partialize: (state) => ({
|
||||
selectedPlan: state.selectedPlan,
|
||||
|
||||
@@ -313,7 +313,7 @@ export default function Signup({
|
||||
id="gtm-init-script"
|
||||
// It is strictly not necessary to disable, but in a future update of react/no-danger this will error.
|
||||
// And we don't want it to error here anyways
|
||||
// eslint-disable-next-line react/no-danger
|
||||
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: `(function (w, d, s, l, i) {
|
||||
w[l] = w[l] || []; w[l].push({ 'gtm.start': new Date().getTime(), event: 'gtm.js' });
|
||||
@@ -323,7 +323,7 @@ export default function Signup({
|
||||
}}
|
||||
/>
|
||||
<noscript
|
||||
// eslint-disable-next-line react/no-danger
|
||||
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: `<iframe src="https://www.googletagmanager.com/ns.html?id=${process.env.NEXT_PUBLIC_GTM_ID}" height="0" width="0" style="display:none;visibility:hidden"></iframe>`,
|
||||
}}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@calcom/web",
|
||||
"version": "5.8.6",
|
||||
"version": "5.8.7",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"analyze": "ANALYZE=true next build",
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { expect } from "@playwright/test";
|
||||
|
||||
import { applySelectFilter } from "./filter-helpers";
|
||||
import { test } from "./lib/fixtures";
|
||||
|
||||
test.describe.configure({ mode: "parallel" });
|
||||
@@ -15,10 +16,9 @@ test.describe("Booking Filters", () => {
|
||||
teammates: [{ name: teamMateName }],
|
||||
});
|
||||
|
||||
const allUsers = await users.get();
|
||||
const allUsers = users.get();
|
||||
const memberUser = allUsers.find((user) => user.name === teamMateName);
|
||||
|
||||
// eslint-disable-next-line playwright/no-conditional-in-test
|
||||
if (!memberUser) {
|
||||
throw new Error("user should exist");
|
||||
}
|
||||
@@ -48,4 +48,184 @@ test.describe("Booking Filters", () => {
|
||||
await page.locator('[data-testid="add-filter-button"]').click();
|
||||
await expect(page.locator('[data-testid="add-filter-item-userId"]')).toBeVisible();
|
||||
});
|
||||
|
||||
test("Query params should be preserved when switching between bookings tabs", async ({ page, users }) => {
|
||||
const owner = await users.create(
|
||||
{ name: "Owner User" },
|
||||
{
|
||||
hasTeam: true,
|
||||
isOrg: true,
|
||||
teammates: [{ name: "Team Member 1" }, { name: "Team Member 2" }],
|
||||
}
|
||||
);
|
||||
|
||||
await owner.apiLogin();
|
||||
|
||||
const bookingsGetResponse = page.waitForResponse((response) =>
|
||||
/\/api\/trpc\/bookings\/get.*/.test(response.url())
|
||||
);
|
||||
await page.goto(`/bookings/upcoming`, { waitUntil: "domcontentloaded" });
|
||||
await bookingsGetResponse;
|
||||
|
||||
await applySelectFilter(page, "userId", "Owner User");
|
||||
|
||||
await expect(page).toHaveURL(/.*userId.*/);
|
||||
const urlWithFilters = page.url();
|
||||
expect(urlWithFilters).toContain("userId");
|
||||
|
||||
const searchParams = new URL(urlWithFilters).searchParams;
|
||||
const activeFilters = searchParams.get("activeFilters");
|
||||
expect(activeFilters).toBeTruthy();
|
||||
|
||||
const pastBookingsGetResponse = page.waitForResponse((response) =>
|
||||
/\/api\/trpc\/bookings\/get.*/.test(response.url())
|
||||
);
|
||||
await page.getByTestId("past-test").click();
|
||||
await expect(page).toHaveURL(/\/bookings\/past/);
|
||||
await pastBookingsGetResponse;
|
||||
|
||||
const pastUrl = page.url();
|
||||
expect(pastUrl).toContain("/bookings/past");
|
||||
expect(pastUrl).toContain("userId");
|
||||
|
||||
const pastSearchParams = new URL(pastUrl).searchParams;
|
||||
expect(pastSearchParams.get("activeFilters")).toBe(activeFilters);
|
||||
|
||||
const cancelledBookingsGetResponse = page.waitForResponse((response) =>
|
||||
/\/api\/trpc\/bookings\/get.*/.test(response.url())
|
||||
);
|
||||
await page.getByTestId("cancelled-test").click();
|
||||
await expect(page).toHaveURL(/\/bookings\/cancelled/);
|
||||
await cancelledBookingsGetResponse;
|
||||
|
||||
const cancelledUrl = page.url();
|
||||
expect(cancelledUrl).toContain("/bookings/cancelled");
|
||||
expect(cancelledUrl).toContain("userId");
|
||||
|
||||
const cancelledSearchParams = new URL(cancelledUrl).searchParams;
|
||||
expect(cancelledSearchParams.get("activeFilters")).toBe(activeFilters);
|
||||
});
|
||||
|
||||
test("Query params should be preserved when switching between bookings tabs in calendar view", async ({
|
||||
page,
|
||||
users,
|
||||
}) => {
|
||||
const owner = await users.create(
|
||||
{ name: "Owner User" },
|
||||
{
|
||||
hasTeam: true,
|
||||
isOrg: true,
|
||||
teammates: [{ name: "Team Member 1" }, { name: "Team Member 2" }],
|
||||
}
|
||||
);
|
||||
|
||||
await owner.apiLogin();
|
||||
|
||||
const bookingsGetResponse = page.waitForResponse((response) =>
|
||||
/\/api\/trpc\/bookings\/get.*/.test(response.url())
|
||||
);
|
||||
await page.goto(`/bookings/upcoming?view=calendar`, { waitUntil: "domcontentloaded" });
|
||||
await bookingsGetResponse;
|
||||
|
||||
await applySelectFilter(page, "userId", "Owner User");
|
||||
|
||||
await expect(page).toHaveURL(/.*userId.*/);
|
||||
const urlWithFilters = page.url();
|
||||
expect(urlWithFilters).toContain("userId");
|
||||
|
||||
const searchParams = new URL(urlWithFilters).searchParams;
|
||||
const activeFilters = searchParams.get("activeFilters");
|
||||
expect(activeFilters).toBeTruthy();
|
||||
expect(searchParams.get("view")).toBe("calendar");
|
||||
|
||||
const pastBookingsGetResponse = page.waitForResponse((response) =>
|
||||
/\/api\/trpc\/bookings\/get.*/.test(response.url())
|
||||
);
|
||||
await page.getByTestId("past-test").click();
|
||||
await expect(page).toHaveURL(/\/bookings\/past/);
|
||||
await pastBookingsGetResponse;
|
||||
|
||||
const pastUrl = page.url();
|
||||
expect(pastUrl).toContain("/bookings/past");
|
||||
expect(pastUrl).toContain("userId");
|
||||
|
||||
const pastSearchParams = new URL(pastUrl).searchParams;
|
||||
expect(pastSearchParams.get("activeFilters")).toBe(activeFilters);
|
||||
expect(pastSearchParams.get("view")).toBe("calendar");
|
||||
|
||||
const cancelledBookingsGetResponse = page.waitForResponse((response) =>
|
||||
/\/api\/trpc\/bookings\/get.*/.test(response.url())
|
||||
);
|
||||
await page.getByTestId("cancelled-test").click();
|
||||
await expect(page).toHaveURL(/\/bookings\/cancelled/);
|
||||
await cancelledBookingsGetResponse;
|
||||
|
||||
const cancelledUrl = page.url();
|
||||
expect(cancelledUrl).toContain("/bookings/cancelled");
|
||||
expect(cancelledUrl).toContain("userId");
|
||||
|
||||
const cancelledSearchParams = new URL(cancelledUrl).searchParams;
|
||||
expect(cancelledSearchParams.get("activeFilters")).toBe(activeFilters);
|
||||
expect(cancelledSearchParams.get("view")).toBe("calendar");
|
||||
});
|
||||
|
||||
test("Query params should NOT be preserved when navigating from a non-bookings page", async ({
|
||||
page,
|
||||
users,
|
||||
}) => {
|
||||
const owner = await users.create(undefined, {
|
||||
hasTeam: true,
|
||||
isOrg: true,
|
||||
});
|
||||
|
||||
await owner.apiLogin();
|
||||
await page.goto(`/event-types?someParam=value`);
|
||||
await expect(page).toHaveURL(/.*someParam=value.*/);
|
||||
|
||||
const upcomingBookingsGetResponse = page.waitForResponse((response) =>
|
||||
/\/api\/trpc\/bookings\/get.*/.test(response.url())
|
||||
);
|
||||
await page.getByTestId("bookings-test").click();
|
||||
await page.getByTestId("upcoming-test").click();
|
||||
await expect(page).toHaveURL(/\/bookings\/upcoming/);
|
||||
await upcomingBookingsGetResponse;
|
||||
|
||||
const upcomingUrl = page.url();
|
||||
expect(upcomingUrl).toContain("/bookings/upcoming");
|
||||
expect(upcomingUrl).not.toContain("someParam=value");
|
||||
});
|
||||
|
||||
test("Query params should NOT be preserved when navigating from a bookings page to a non-bookings page", async ({
|
||||
page,
|
||||
users,
|
||||
}) => {
|
||||
const owner = await users.create(
|
||||
{ name: "Owner User" },
|
||||
{
|
||||
hasTeam: true,
|
||||
isOrg: true,
|
||||
teammates: [{ name: "Team Member 1" }, { name: "Team Member 2" }],
|
||||
}
|
||||
);
|
||||
|
||||
await owner.apiLogin();
|
||||
|
||||
const bookingsGetResponse = page.waitForResponse((response) =>
|
||||
/\/api\/trpc\/bookings\/get.*/.test(response.url())
|
||||
);
|
||||
await page.goto(`/bookings/upcoming`, { waitUntil: "domcontentloaded" });
|
||||
await bookingsGetResponse;
|
||||
|
||||
await applySelectFilter(page, "userId", "Owner User");
|
||||
|
||||
await expect(page).toHaveURL(/.*userId.*/);
|
||||
const urlWithFilters = page.url();
|
||||
expect(urlWithFilters).toContain("userId");
|
||||
|
||||
await page.getByTestId("event_types_page_title-test").click();
|
||||
await expect(page).toHaveURL(/\/event-types/);
|
||||
|
||||
const eventTypeSearchParams = new URL(page.url()).searchParams;
|
||||
expect(eventTypeSearchParams.get("activeFilters")).toBeFalsy();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -193,9 +193,9 @@ test.describe("Organization:", () => {
|
||||
});
|
||||
await expect(page.getByTestId("success-page")).toBeVisible();
|
||||
// All the teammates should be in the booking
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
|
||||
await expect(page.getByText(user1.name!, { exact: true })).toBeVisible();
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
|
||||
await expect(page.getByText(user2.name!, { exact: true })).toBeVisible();
|
||||
}
|
||||
);
|
||||
|
||||
@@ -4,7 +4,7 @@ import { createHash } from "crypto";
|
||||
import EventEmitter from "events";
|
||||
import type { IncomingMessage, ServerResponse } from "http";
|
||||
import { createServer } from "http";
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
|
||||
import type { Messages } from "mailhog";
|
||||
import { totp } from "otplib";
|
||||
import { v4 as uuid } from "uuid";
|
||||
@@ -213,7 +213,7 @@ export async function setupManagedEvent({
|
||||
addManagedEventToTeamMates: true,
|
||||
managedEventUnlockedFields: unlockedFields,
|
||||
});
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
|
||||
const memberUser = users.get().find((u) => u.name === teamMateName)!;
|
||||
const { team } = await adminUser.getFirstTeamMembership();
|
||||
const managedEvent = await adminUser.getFirstTeamEvent(team.id, SchedulingType.MANAGED);
|
||||
@@ -275,11 +275,17 @@ export async function getInviteLink(page: Page) {
|
||||
export async function getEmailsReceivedByUser({
|
||||
emails,
|
||||
userEmail,
|
||||
waitForEmailMs = 5000,
|
||||
}: {
|
||||
emails?: ReturnType<typeof createEmailsFixture>;
|
||||
userEmail: string;
|
||||
waitForEmailMs?: number;
|
||||
}): Promise<Messages | null> {
|
||||
if (!emails) return null;
|
||||
|
||||
// Wait for email to be sent/received
|
||||
await new Promise((resolve) => setTimeout(resolve, waitForEmailMs));
|
||||
|
||||
const matchingEmails = await emails.search(userEmail, "to");
|
||||
if (!matchingEmails?.total) {
|
||||
console.log(
|
||||
@@ -365,7 +371,7 @@ async function createUserWithSeatedEvent(users: Fixtures["users"]) {
|
||||
},
|
||||
],
|
||||
});
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
|
||||
const eventType = user.eventTypes.find((e) => e.slug === slug)!;
|
||||
return { user, eventType };
|
||||
}
|
||||
|
||||
@@ -184,7 +184,7 @@ test.describe("Bookings", () => {
|
||||
// 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
|
||||
);
|
||||
@@ -248,7 +248,7 @@ test.describe("Bookings", () => {
|
||||
// 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
|
||||
);
|
||||
|
||||
@@ -15,9 +15,6 @@ export async function expectInvitationEmailToBeReceived(
|
||||
returnLink?: string
|
||||
) {
|
||||
if (!emails) return null;
|
||||
// We need to wait for the email to go through, otherwise it will fail
|
||||
// eslint-disable-next-line playwright/no-wait-for-timeout
|
||||
await page.waitForTimeout(2000);
|
||||
const receivedEmails = await getEmailsReceivedByUser({ emails, userEmail });
|
||||
expect(receivedEmails?.total).toBe(1);
|
||||
const [firstReceivedEmail] = (receivedEmails as Messages).items;
|
||||
|
||||
@@ -90,7 +90,7 @@ test.describe("Organization", () => {
|
||||
|
||||
// This test is already covered by booking.e2e.ts where existing user is invited and his booking links are tested.
|
||||
// We can re-test here when we want to test some more scenarios.
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-function
|
||||
|
||||
test("existing user invited to an organization", () => {});
|
||||
|
||||
test("nonexisting user invited to a Team inside organization", async ({
|
||||
@@ -313,7 +313,7 @@ test.describe("Organization", () => {
|
||||
await page.locator('[data-testid="continue-with-email-button"]').click();
|
||||
await expect(page.locator('[data-testid="signup-submit-button"]')).toBeVisible();
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
|
||||
await page.locator('input[name="username"]').fill(existingUser.username!);
|
||||
await page
|
||||
.locator('input[name="email"]')
|
||||
|
||||
@@ -295,10 +295,6 @@ test.describe("Email Signup Flow Test", async () => {
|
||||
userEmail: userToCreate.email,
|
||||
});
|
||||
|
||||
// We need to wait for emails to be sent
|
||||
// eslint-disable-next-line playwright/no-wait-for-timeout
|
||||
await page.waitForTimeout(5000);
|
||||
|
||||
expect(receivedEmails?.total).toBe(1);
|
||||
|
||||
const verifyEmail = receivedEmails?.items[0];
|
||||
|
||||
@@ -15,7 +15,6 @@ export async function expectInvitationEmailToBeReceived(
|
||||
) {
|
||||
if (!emails) return null;
|
||||
|
||||
await page.waitForTimeout(2000);
|
||||
const receivedEmails = await getEmailsReceivedByUser({ emails, userEmail });
|
||||
expect(receivedEmails?.total).toBe(1);
|
||||
|
||||
|
||||
@@ -367,8 +367,8 @@
|
||||
"available_apps_lower_case": "Available apps",
|
||||
"available_apps_desc": "View popular apps below and explore more in our <0>App Store</0>",
|
||||
"fixed_host_helper": "Add anyone who needs to attend the event. <0>Learn more</0>",
|
||||
"round_robin_groups_helper": "People in a group take turns, one person of each group will show up for the event.",
|
||||
"round_robin_helper": "People in the group take turns and only one person will show up for the event.",
|
||||
"round_robin_groups_helper": "People in a group take turns, one person of each group will show up for the event. <0>Learn more</0>",
|
||||
"round_robin_helper": "People in the group take turns and only one person will show up for the event. <0>Learn more</0>",
|
||||
"check_email_reset_password": "Check your email. We sent you a link to reset your password.",
|
||||
"finish": "Finish",
|
||||
"organization_general_description": "Manage settings for your team language and timezone",
|
||||
@@ -943,6 +943,8 @@
|
||||
"new_event_type_heading": "Create your first event type",
|
||||
"new_event_type_description": "Event types enable you to share links that show available times on your calendar and allow people to make bookings with you.",
|
||||
"event_type_created_successfully": "{{eventTypeTitle}} event type created successfully",
|
||||
"no_call_history": "No call history",
|
||||
"no_call_history_description": "No matching call history found for the particular account/search query.",
|
||||
"event_type_updated_successfully": "{{eventTypeTitle}} event type updated successfully",
|
||||
"event_type_deleted_successfully": "Event type deleted successfully",
|
||||
"hours": "Hours",
|
||||
@@ -1054,11 +1056,11 @@
|
||||
"edit": "Edit",
|
||||
"add_input": "Add an Input",
|
||||
"disable_notes": "Hide notes in calendar",
|
||||
"disable_notes_description": "For privacy reasons, additional inputs and notes will be hidden in the calendar entry. They will still be sent to your email.",
|
||||
"requires_confirmation_description": "The booking needs to be manually confirmed before it is pushed to your calendar and a confirmation is sent.",
|
||||
"disable_notes_description": "For privacy reasons, additional inputs and notes will be hidden in the calendar entry. They will still be sent to your email. <0>Learn more</0>",
|
||||
"requires_confirmation_description": "The booking needs to be manually confirmed before it is pushed to your calendar and a confirmation is sent. <0>Learn more</0>",
|
||||
"requires_confirmation_will_block_slot_description": "Unconfirmed bookings still block calendar slots.",
|
||||
"recurring_event": "Recurring Event",
|
||||
"recurring_event_description": "People can subscribe for recurring events",
|
||||
"recurring_event_description": "People can subscribe for recurring events. <0>Learn more</0>",
|
||||
"connect_and_join": "Connect and Join",
|
||||
"cannot_be_used_with_paid_event_types": "It cannot be used with paid event types",
|
||||
"warning_payment_instant_meeting_event": "Instant Meetings are not supported with recurring events and payment apps yet",
|
||||
@@ -1074,7 +1076,7 @@
|
||||
"copy_private_link_to_event": "Copy private link to event",
|
||||
"private_link_description": "Generate a private URL to share without exposing your {{appName}} username",
|
||||
"multiple_private_links_title": "Private Links",
|
||||
"multiple_private_links_description": "Generate private URLs without exposing the username, with configurable expiry and usage limits",
|
||||
"multiple_private_links_description": "Generate private URLs without exposing the username, with configurable expiry and usage limits. <0>Learn more</0>",
|
||||
"add_a_multiple_private_link": "Add new link",
|
||||
"multiple_private_link_copied": "Private link copied",
|
||||
"invitees_can_schedule": "Invitees can schedule",
|
||||
@@ -1278,7 +1280,7 @@
|
||||
"redirect_url_description": "Redirect to a custom URL after a successful booking",
|
||||
"duplicate": "Duplicate",
|
||||
"offer_seats": "Offer seats",
|
||||
"offer_seats_description": "Offer seats for booking. This automatically disables guest & opt-in bookings.",
|
||||
"offer_seats_description": "Offer seats for booking. This automatically disables guest & opt-in bookings. <0>Learn more</0>",
|
||||
"seats_available_one": "Seat available",
|
||||
"seats_available_other": "Seats available",
|
||||
"seats_nearly_full": "Seats almost full",
|
||||
@@ -1313,11 +1315,11 @@
|
||||
"enabled_after_update_description": "The private link will work after saving",
|
||||
"confirm_delete_api_key": "Revoke this API key",
|
||||
"disable_rescheduling": "Disable Rescheduling",
|
||||
"description_disable_rescheduling": "Guests can no longer reschedule the event with calendar invite or email",
|
||||
"description_disable_rescheduling": "Guests can no longer reschedule the event with calendar invite or email. <0>Learn more</0>",
|
||||
"allow_rescheduling_cancelled_bookings": "Allow booking through reschedule link",
|
||||
"description_allow_rescheduling_cancelled_bookings": "When enabled, users will be able to create a new booking when trying to reschedule a cancelled booking",
|
||||
"disable_cancelling": "Disable Cancelling",
|
||||
"description_disable_cancelling": "Guests can no longer cancel the event with calendar invite or email",
|
||||
"description_disable_cancelling": "Guests can no longer cancel the event with calendar invite or email. <0>Learn more</0>",
|
||||
"revoke_api_key": "Revoke API key",
|
||||
"api_key_copied": "API key copied!",
|
||||
"api_key_expires_on": "The API key will expire on",
|
||||
@@ -1899,13 +1901,13 @@
|
||||
"every_app_published": "Every app published on the {{appName}} App Store is open source and thoroughly tested via peer reviews. Nevertheless, {{companyName}} does not endorse or certify these apps unless they are published by {{appName}}. If you encounter inappropriate content or behaviour please report it.",
|
||||
"report_app": "Report app",
|
||||
"limit_booking_frequency": "Limit booking frequency",
|
||||
"limit_booking_frequency_description": "Limit how many times this event can be booked",
|
||||
"limit_booking_frequency_description": "Limit how many times this event can be booked. <0>Learn more</0>",
|
||||
"only_show_first_available_slot": "Only show the first slot of each day as available",
|
||||
"only_show_first_available_slot_description": "This will limit your availability for this event type to one slot per day, scheduled at the earliest available time.",
|
||||
"limit_total_booking_duration": "Limit total booking duration",
|
||||
"limit_total_booking_duration_description": "Limit total amount of time that this event can be booked",
|
||||
"booker_upcoming_limit": "Limit number of upcoming bookings per booker",
|
||||
"booker_booking_limit_description": "Limit the number of active bookings a booker can make for this event type",
|
||||
"booker_booking_limit_description": "Limit the number of active bookings a booker can make for this event type. <0>Learn more</0>",
|
||||
"recurring_event_doesnt_support_booker_booking_limit": "Recurring events don't support booker booking limit",
|
||||
"add_limit": "Add Limit",
|
||||
"team_name_required": "Team name required",
|
||||
@@ -1973,7 +1975,7 @@
|
||||
"saml_sp_entity_id_copied": "SP Entity ID copied!",
|
||||
"add_calendar": "Add Calendar",
|
||||
"limit_future_bookings": "Limit future bookings",
|
||||
"limit_future_bookings_description": "Limit how far in the future this event can be booked",
|
||||
"limit_future_bookings_description": "Limit how far in the future this event can be booked. <0>Learn more</0>",
|
||||
"no_event_types": "No event types setup",
|
||||
"no_event_types_description": "{{name}} has not setup any event types for you to book.",
|
||||
"billing_frequency": "Billing Frequency",
|
||||
@@ -2229,7 +2231,7 @@
|
||||
"under_maintenance_description": "The {{appName}} team are performing scheduled maintenance. If you have any questions, please contact support.",
|
||||
"event_type_seats": "{{numberOfSeats}} seats",
|
||||
"booking_questions_title": "Booking questions",
|
||||
"booking_questions_description": "Customize the questions asked on the booking page",
|
||||
"booking_questions_description": "Customize the questions asked on the booking page. <0>Learn more</0>",
|
||||
"add_a_booking_question": "Add a question",
|
||||
"identifier": "Identifier",
|
||||
"duplicate_email": "Email is duplicate",
|
||||
@@ -2406,6 +2408,10 @@
|
||||
"insights": "Insights",
|
||||
"routing_forms": "Routing Forms",
|
||||
"insights_no_data_found_for_filter": "No data found for the selected filter or selected dates.",
|
||||
"no_show_hosts_over_time": "No-Show Hosts Over Time",
|
||||
"csat_over_time": "CSAT Over Time",
|
||||
"no_show_hosts": "No-Show Hosts",
|
||||
"csat": "CSAT",
|
||||
"acknowledge_booking_no_show_fee": "I acknowledge that if I do not attend this event that a {{amount, currency}} no show fee will be applied to my card.",
|
||||
"days": "days",
|
||||
"card_details": "Card details",
|
||||
@@ -2709,7 +2715,7 @@
|
||||
"locked": "Locked",
|
||||
"unlocked": "Unlocked",
|
||||
"lock_timezone_toggle_on_booking_page": "Lock timezone on booking page",
|
||||
"description_lock_timezone_toggle_on_booking_page": "To lock the timezone on booking page, useful for in-person events.",
|
||||
"description_lock_timezone_toggle_on_booking_page": "To lock the timezone on booking page, useful for in-person events. <0>Learn more</0>",
|
||||
"event_setup_multiple_payment_apps_error": "You can only have one payment app enabled per event type.",
|
||||
"number_in_international_format": "Please enter number in international format.",
|
||||
"install_calendar": "Install Calendar",
|
||||
@@ -3226,7 +3232,7 @@
|
||||
"routed": "Routed",
|
||||
"reassigned": "Reassigned",
|
||||
"allow_rescheduling_past_events": "Allow rescheduling past events",
|
||||
"allow_rescheduling_past_events_description": "Enabling this option allows for past events to be rescheduled",
|
||||
"allow_rescheduling_past_events_description": "Enabling this option allows for past events to be rescheduled. <0>Learn more</0>",
|
||||
"rerouted": "Rerouted",
|
||||
"salesforce_assigned": "Salesforce assignment",
|
||||
"router_position": "Router Position",
|
||||
@@ -3453,7 +3459,7 @@
|
||||
"hide_organizer_email": "Hide organizer's email",
|
||||
"platform_customer_cant_create_organization": "You are already part of a platform organization which is why you can't create a new organization. Each user can only belong to one organization. Please, create a new cal.com account with a different email and use that account to subscribe to the organizations plan.",
|
||||
"organization_customer_cant_create_platform": "You are already part of an organization which is why you can't create a platform organization. Each user can only belong to one organization. Please, create a new cal.com account with a different email and use that account to subscribe to the platform plan.",
|
||||
"hide_organizer_email_description": "Hide organizer's email address from the booking screen, email notifications, and calendar events",
|
||||
"hide_organizer_email_description": "Hide organizer's email address from the booking screen, email notifications, and calendar events. <0>Learn more</0>",
|
||||
"you_and_conjunction": "You &",
|
||||
"select_verified_email": "Select a verified email",
|
||||
"add_verified_email": "Add verified email",
|
||||
@@ -3688,7 +3694,7 @@
|
||||
"license_key_saved": "License key saved successfully",
|
||||
"timezone_mismatch_tooltip": "You are viewing the report based on your profile timezone ({{userTimezone}}), while your browser is set to timezone ({{browserTimezone}})",
|
||||
"show_optimized_slots": "Optimized slots",
|
||||
"show_optimized_slots_description": "Arrange time slots to optimize availability.",
|
||||
"show_optimized_slots_description": "Arrange time slots to optimize availability. <0>Learn more</0>",
|
||||
"failed_bookings_by_field": "Failed Bookings By Field",
|
||||
"failed_bookings": "Failed bookings",
|
||||
"event_type_no_hosts": "No hosts are assigned to event type",
|
||||
@@ -3897,5 +3903,16 @@
|
||||
"onboarding_plan_team_badge": "$15/user/mo",
|
||||
"onboarding_plan_organization_badge": "$37/user/mo",
|
||||
"onboarding_plan_organization_description": "Robust scheduling for larger teams looking to have more control, privacy, and security.",
|
||||
"view_previous_week": "View previous week",
|
||||
"view_next_week": "View next week",
|
||||
"invite_teammates": "Invite Teammates",
|
||||
"organization_brand": "Organization Brand",
|
||||
"organization_details": "Organization Details",
|
||||
"organization_teams": "Organization Teams",
|
||||
"personal_profile": "Personal Profile",
|
||||
"personal_settings": "Personal Settings",
|
||||
"team_brand": "Team Brand",
|
||||
"team_details": "Team Details",
|
||||
"team_invite": "Team Invite",
|
||||
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Add your new strings above here ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
|
||||
}
|
||||
|
||||
@@ -1,21 +1,24 @@
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const glob = require("glob");
|
||||
const crypto = require("crypto");
|
||||
|
||||
const copyAppStoreStatic = () => {
|
||||
// Get all static files from app-store packages
|
||||
const staticFiles = glob.sync("../../packages/app-store/**/static/**/*", { nodir: true });
|
||||
|
||||
// Object to store icon SVG hashes
|
||||
const SVG_HASHES = {};
|
||||
|
||||
staticFiles.forEach((file) => {
|
||||
// Extract app name from path
|
||||
const appNameMatch = file.match(/app-store\/(.*?)\/static/);
|
||||
if (!appNameMatch) return;
|
||||
|
||||
const appName = appNameMatch[1];
|
||||
const appDirName = appNameMatch[1];
|
||||
const fileName = path.basename(file);
|
||||
|
||||
// Create destination directory if it doesn't exist
|
||||
const destDir = path.join(process.cwd(), "public", "app-store", appName);
|
||||
const destDir = path.join(process.cwd(), "public", "app-store", appDirName);
|
||||
if (!fs.existsSync(destDir)) {
|
||||
fs.mkdirSync(destDir, { recursive: true });
|
||||
}
|
||||
@@ -23,8 +26,20 @@ const copyAppStoreStatic = () => {
|
||||
// Copy file to destination (Turborepo caching handles change detection)
|
||||
const destPath = path.join(destDir, fileName);
|
||||
fs.copyFileSync(file, destPath);
|
||||
|
||||
// If it's an icon SVG file, compute hash
|
||||
if (fileName.includes("icon") && fileName.endsWith(".svg")) {
|
||||
const content = fs.readFileSync(file, "utf8");
|
||||
const hash = crypto.createHash("md5").update(content).digest("hex").slice(0, 8);
|
||||
SVG_HASHES[appDirName] = hash;
|
||||
}
|
||||
|
||||
console.log(`Copied ${file} to ${destPath}`);
|
||||
});
|
||||
|
||||
// Write SVG hashes to a JSON file
|
||||
const hashFilePath = path.join(process.cwd(), "public", "app-store", "svg-hashes.json");
|
||||
fs.writeFileSync(hashFilePath, JSON.stringify(SVG_HASHES, null, 2));
|
||||
};
|
||||
|
||||
// Run the copy function
|
||||
|
||||
@@ -7,6 +7,7 @@ import stripe from "@calcom/features/ee/payments/server/stripe";
|
||||
import { hostedCal, isSAMLLoginEnabled, samlProductID, samlTenantID } from "@calcom/features/ee/sso/lib/saml";
|
||||
import { ssoTenantProduct } from "@calcom/features/ee/sso/lib/sso";
|
||||
import { checkUsername } from "@calcom/features/profile/lib/checkUsername";
|
||||
import { OnboardingPathService } from "@calcom/features/onboarding/lib/onboarding-path.service";
|
||||
import { IS_PREMIUM_USERNAME_ENABLED } from "@calcom/lib/constants";
|
||||
import prisma from "@calcom/prisma";
|
||||
|
||||
@@ -18,7 +19,12 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
|
||||
const providerParam = asStringOrNull(context.query.provider);
|
||||
const emailParam = asStringOrNull(context.query.email);
|
||||
const usernameParam = asStringOrNull(context.query.username);
|
||||
const successDestination = `/getting-started${usernameParam ? `?username=${usernameParam}` : ""}`;
|
||||
|
||||
const successDestination = await OnboardingPathService.getGettingStartedPathWithParams(
|
||||
prisma,
|
||||
usernameParam ? { username: usernameParam } : undefined
|
||||
);
|
||||
|
||||
if (!providerParam) {
|
||||
throw new Error(`File is not named sso/[provider]`);
|
||||
}
|
||||
|
||||
@@ -126,7 +126,6 @@ describe("handleChildrenEventTypes", () => {
|
||||
autoTranslateDescriptionEnabled,
|
||||
includeNoShowInRRCalculation,
|
||||
instantMeetingScheduleId,
|
||||
showOptimizedSlots,
|
||||
...evType
|
||||
} = mockFindFirstEventType({
|
||||
id: 123,
|
||||
@@ -189,7 +188,6 @@ describe("handleChildrenEventTypes", () => {
|
||||
assignRRMembersUsingSegment,
|
||||
includeNoShowInRRCalculation,
|
||||
instantMeetingScheduleId,
|
||||
showOptimizedSlots,
|
||||
...evType
|
||||
} = mockFindFirstEventType({
|
||||
metadata: { managedEventConfig: {} },
|
||||
@@ -300,7 +298,6 @@ describe("handleChildrenEventTypes", () => {
|
||||
includeNoShowInRRCalculation,
|
||||
instantMeetingScheduleId,
|
||||
assignRRMembersUsingSegment,
|
||||
showOptimizedSlots,
|
||||
...evType
|
||||
} = mockFindFirstEventType({
|
||||
id: 123,
|
||||
@@ -366,7 +363,6 @@ describe("handleChildrenEventTypes", () => {
|
||||
assignRRMembersUsingSegment,
|
||||
rrSegmentQueryValue,
|
||||
useEventLevelSelectedCalendars,
|
||||
showOptimizedSlots,
|
||||
...evType
|
||||
} = mockFindFirstEventType({
|
||||
metadata: { managedEventConfig: {} },
|
||||
@@ -432,7 +428,6 @@ describe("handleChildrenEventTypes", () => {
|
||||
includeNoShowInRRCalculation,
|
||||
instantMeetingScheduleId,
|
||||
assignRRMembersUsingSegment,
|
||||
showOptimizedSlots,
|
||||
...evType
|
||||
} = mockFindFirstEventType({
|
||||
metadata: { managedEventConfig: {} },
|
||||
@@ -456,7 +451,6 @@ describe("handleChildrenEventTypes", () => {
|
||||
requiresBookerEmailVerification: false,
|
||||
lockTimeZoneToggleOnBookingPage: false,
|
||||
useEventTypeDestinationCalendarEmail: false,
|
||||
showOptimizedSlots: false,
|
||||
workflows: [],
|
||||
parentId: 1,
|
||||
locations: [],
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
# Use postgres/example user/password credentials
|
||||
|
||||
volumes:
|
||||
database-data:
|
||||
redis-data:
|
||||
|
||||
networks:
|
||||
stack:
|
||||
name: stack
|
||||
external: false
|
||||
|
||||
services:
|
||||
database:
|
||||
container_name: database
|
||||
image: postgres
|
||||
restart: always
|
||||
volumes:
|
||||
- database-data:/var/lib/postgresql
|
||||
environment:
|
||||
- POSTGRES_USER=unicorn_user
|
||||
- POSTGRES_PASSWORD=magical_password
|
||||
- POSTGRES_DB=calendso
|
||||
networks:
|
||||
- stack
|
||||
|
||||
redis:
|
||||
container_name: redis
|
||||
image: redis:latest
|
||||
restart: always
|
||||
volumes:
|
||||
- redis-data:/data
|
||||
networks:
|
||||
- stack
|
||||
ports:
|
||||
- "${REDIS_PORT:-6379}:6379"
|
||||
|
||||
calcom:
|
||||
image: calcom.docker.scarf.sh/calcom/cal.com
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
args:
|
||||
NEXT_PUBLIC_WEBAPP_URL: ${NEXT_PUBLIC_WEBAPP_URL}
|
||||
NEXT_PUBLIC_API_V2_URL: ${NEXT_PUBLIC_API_V2_URL}
|
||||
NEXT_PUBLIC_LICENSE_CONSENT: ${NEXT_PUBLIC_LICENSE_CONSENT}
|
||||
NEXT_PUBLIC_WEBSITE_TERMS_URL: ${NEXT_PUBLIC_WEBSITE_TERMS_URL}
|
||||
NEXT_PUBLIC_WEBSITE_PRIVACY_POLICY_URL: ${NEXT_PUBLIC_WEBSITE_PRIVACY_POLICY_URL}
|
||||
NEXT_PUBLIC_SINGLE_ORG_SLUG: ${NEXT_PUBLIC_SINGLE_ORG_SLUG}
|
||||
ORGANIZATIONS_ENABLED: ${ORGANIZATIONS_ENABLED}
|
||||
CALCOM_TELEMETRY_DISABLED: ${CALCOM_TELEMETRY_DISABLED}
|
||||
NEXTAUTH_SECRET: ${NEXTAUTH_SECRET}
|
||||
CALENDSO_ENCRYPTION_KEY: ${CALENDSO_ENCRYPTION_KEY}
|
||||
DATABASE_URL: ${DATABASE_URL}
|
||||
DATABASE_DIRECT_URL: ${DATABASE_URL}
|
||||
CSP_POLICY: ${CSP_POLICY}
|
||||
restart: always
|
||||
networks:
|
||||
- stack
|
||||
ports:
|
||||
- 3000:3000
|
||||
env_file: .env
|
||||
environment:
|
||||
- DATABASE_URL=postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@${DATABASE_HOST}/${POSTGRES_DB}
|
||||
- DATABASE_DIRECT_URL=${DATABASE_URL}
|
||||
depends_on:
|
||||
- database
|
||||
|
||||
calcom-api:
|
||||
container_name: calcom-api
|
||||
build:
|
||||
context: ./calcom
|
||||
dockerfile: apps/api/v2/Dockerfile
|
||||
args:
|
||||
DATABASE_URL: ${DATABASE_URL}
|
||||
DATABASE_DIRECT_URL: ${DATABASE_URL}
|
||||
restart: always
|
||||
networks:
|
||||
- stack
|
||||
ports:
|
||||
- "${API_PORT:-80}:${API_PORT:-80}"
|
||||
env_file: .env
|
||||
environment:
|
||||
- NODE_ENV=${NODE_ENV}
|
||||
- API_PORT=${API_PORT:-80}
|
||||
- API_URL=${API_URL}
|
||||
- DATABASE_URL=postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@${DATABASE_HOST}/${POSTGRES_DB}
|
||||
- DATABASE_READ_URL=postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@${DATABASE_HOST}/${POSTGRES_DB}
|
||||
- DATABASE_WRITE_URL=postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@${DATABASE_HOST}/${POSTGRES_DB}
|
||||
- DATABASE_DIRECT_URL=${DATABASE_URL}
|
||||
- NEXTAUTH_SECRET=${NEXTAUTH_SECRET}
|
||||
- JWT_SECRET=${JWT_SECRET}
|
||||
- REDIS_URL=${REDIS_URL}
|
||||
- LOG_LEVEL=${LOG_LEVEL}
|
||||
- API_KEY_PREFIX=${API_KEY_PREFIX}
|
||||
- WEB_APP_URL=${WEB_APP_URL}
|
||||
- IS_E2E=${IS_E2E:-false}
|
||||
- REWRITE_API_V2_PREFIX=${REWRITE_API_V2_PREFIX:-true}
|
||||
- CALCOM_LICENSE_KEY=${CALCOM_LICENSE_KEY}
|
||||
- NEXT_PUBLIC_VAPID_PUBLIC_KEY=${NEXT_PUBLIC_VAPID_PUBLIC_KEY}
|
||||
- VAPID_PRIVATE_KEY=${VAPID_PRIVATE_KEY}
|
||||
- STRIPE_API_KEY=${STRIPE_API_KEY}
|
||||
- STRIPE_WEBHOOK_SECRET=${STRIPE_WEBHOOK_SECRET}
|
||||
- STRIPE_PRICE_ID_STARTER=${STRIPE_PRICE_ID_STARTER}
|
||||
- STRIPE_PRICE_ID_STARTER_OVERAGE=${STRIPE_PRICE_ID_STARTER_OVERAGE}
|
||||
- STRIPE_PRICE_ID_ESSENTIALS=${STRIPE_PRICE_ID_ESSENTIALS}
|
||||
- STRIPE_PRICE_ID_ESSENTIALS_OVERAGE=${STRIPE_PRICE_ID_ESSENTIALS_OVERAGE}
|
||||
- STRIPE_PRICE_ID_ENTERPRISE=${STRIPE_PRICE_ID_ENTERPRISE}
|
||||
- STRIPE_PRICE_ID_ENTERPRISE_OVERAGE=${STRIPE_PRICE_ID_ENTERPRISE_OVERAGE}
|
||||
- STRIPE_TEAM_MONTHLY_PRICE_ID=${STRIPE_TEAM_MONTHLY_PRICE_ID}
|
||||
- IS_TEAM_BILLING_ENABLED=${IS_TEAM_BILLING_ENABLED:-false}
|
||||
- AXIOM_DATASET=${AXIOM_DATASET}
|
||||
- AXIOM_TOKEN=${AXIOM_TOKEN}
|
||||
- LOGGER_BRIDGE_LOG_LEVEL=${LOGGER_BRIDGE_LOG_LEVEL}
|
||||
- DOCS_URL=${DOCS_URL}
|
||||
- GET_LICENSE_KEY_URL=${GET_LICENSE_KEY_URL}
|
||||
depends_on:
|
||||
- database
|
||||
- redis
|
||||
|
||||
# Optional use of Prisma Studio. In production, comment out or remove the section below to prevent unwanted access to your database.
|
||||
studio:
|
||||
image: calcom.docker.scarf.sh/calcom/cal.com
|
||||
restart: always
|
||||
networks:
|
||||
- stack
|
||||
ports:
|
||||
- 5555:5555
|
||||
env_file: .env
|
||||
environment:
|
||||
- DATABASE_URL=postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@${DATABASE_HOST}/${POSTGRES_DB}
|
||||
- DATABASE_DIRECT_URL=${DATABASE_URL}
|
||||
depends_on:
|
||||
- database
|
||||
command:
|
||||
- npx
|
||||
- prisma
|
||||
- studio
|
||||
# END SECTION: Optional use of Prisma Studio.
|
||||
@@ -7957,7 +7957,7 @@
|
||||
"post": {
|
||||
"operationId": "BookingsController_2024_08_13_createBooking",
|
||||
"summary": "Create a booking",
|
||||
"description": "\n POST /v2/bookings is used to create regular bookings, recurring bookings and instant bookings. The request bodies for all 3 are almost the same except:\n If eventTypeId in the request body is id of a regular event, then regular booking is created.\n\n If it is an id of a recurring event type, then recurring booking is created.\n\n Meaning that the request bodies are equal but the outcome depends on what kind of event type it is with the goal of making it as seamless for developers as possible.\n\n For team event types it is possible to create instant meeting. To do that just pass `\"instant\": true` to the request body.\n\n The start needs to be in UTC aka if the timezone is GMT+2 in Rome and meeting should start at 11, then UTC time should have hours 09:00 aka without time zone.\n\n Finally, there are 2 ways to book an event type belonging to an individual user:\n 1. Provide `eventTypeId` in the request body.\n 2. Provide `eventTypeSlug` and `username` and optionally `organizationSlug` if the user with the username is within an organization.\n\n And 2 ways to book and event type belonging to a team:\n 1. Provide `eventTypeId` in the request body.\n 2. Provide `eventTypeSlug` and `teamSlug` and optionally `organizationSlug` if the team with the teamSlug is within an organization.\n\n If you are creating a seated booking for an event type with 'show attendees' disabled, then to retrieve attendees in the response either set 'show attendees' to true on event type level or\n you have to provide an authentication method of event type owner, host, team admin or owner or org admin or owner.\n\n <Note>Please make sure to pass in the cal-api-version header value as mentioned in the Headers section. Not passing the correct value will default to an older version of this endpoint.</Note>\n ",
|
||||
"description": "\n POST /v2/bookings is used to create regular bookings, recurring bookings and instant bookings. The request bodies for all 3 are almost the same except:\n If eventTypeId in the request body is id of a regular event, then regular booking is created.\n\n If it is an id of a recurring event type, then recurring booking is created.\n\n Meaning that the request bodies are equal but the outcome depends on what kind of event type it is with the goal of making it as seamless for developers as possible.\n\n For team event types it is possible to create instant meeting. To do that just pass `\"instant\": true` to the request body.\n\n The start needs to be in UTC aka if the timezone is GMT+2 in Rome and meeting should start at 11, then UTC time should have hours 09:00 aka without time zone.\n\n Finally, there are 2 ways to book an event type belonging to an individual user:\n 1. Provide `eventTypeId` in the request body.\n 2. Provide `eventTypeSlug` and `username` and optionally `organizationSlug` if the user with the username is within an organization.\n\n And 2 ways to book and event type belonging to a team:\n 1. Provide `eventTypeId` in the request body.\n 2. Provide `eventTypeSlug` and `teamSlug` and optionally `organizationSlug` if the team with the teamSlug is within an organization.\n\n If you are creating a seated booking for an event type with 'show attendees' disabled, then to retrieve attendees in the response either set 'show attendees' to true on event type level or\n you have to provide an authentication method of event type owner, host, team admin or owner or org admin or owner.\n\n For event types that have SMS reminders workflow, you need to pass the attendee's phone number in the request body via `attendee.phoneNumber` (e.g., \"+19876543210\" in international format). This is an optional field, but becomes required when SMS reminders are enabled for the event type. For the complete attendee object structure, see the [attendee object](https://cal.com/docs/api-reference/v2/bookings/create-a-booking#body-attendee) documentation.\n\n <Note>Please make sure to pass in the cal-api-version header value as mentioned in the Headers section. Not passing the correct value will default to an older version of this endpoint.</Note>\n ",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "cal-api-version",
|
||||
@@ -8483,7 +8483,7 @@
|
||||
],
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"description": "Accepts different types of reschedule booking input: Reschedule Booking (Option 1) or Reschedule Seated Booking (Option 2).\n\n If you are rescheduling a seated booking for an event type with 'show attendees' disabled, then to retrieve attendees in the response either set 'show attendees' to true on event type level or\n you have to provide an authentication method of event type owner, host, team admin or owner or org admin or owner.",
|
||||
"description": "Accepts different types of reschedule booking input: Reschedule Booking (Option 1) or Reschedule Seated Booking (Option 2). If you're rescheduling a seated booking as org admin of booking host, pass booking input for Reschedule Booking (Option 1) along with your access token in the request header.\n\n If you are rescheduling a seated booking for an event type with 'show attendees' disabled, then to retrieve attendees in the response either set 'show attendees' to true on event type level or\n you have to provide an authentication method of event type owner, host, team admin or owner or org admin or owner.",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
@@ -8518,7 +8518,7 @@
|
||||
"post": {
|
||||
"operationId": "BookingsController_2024_08_13_cancelBooking",
|
||||
"summary": "Cancel a booking",
|
||||
"description": ":bookingUid can be :bookingUid of an usual booking, individual recurrence or recurring booking to cancel all recurrences.\n \n \nCancelling normal bookings:\n If the booking is not seated and not recurring simply pass :bookingUid in the request URL `/bookings/:bookingUid/cancel` and optionally cancellationReason in the request body `{\"cancellationReason\": \"Will travel\"}`.\n\n \nCancelling seated bookings:\n It is possible to cancel specific seat within a booking as an attendee or all of the seats as the host.\n \n1. As an attendee - provide :bookingUid in the request URL `/bookings/:bookingUid/cancel` and seatUid in the request body `{\"seatUid\": \"123-123-123\"}` . This will remove this particular attendance from the booking.\n \n2. As the host - host can cancel booking for all attendees aka for every seat. Provide :bookingUid in the request URL `/bookings/:bookingUid/cancel` and cancellationReason in the request body `{\"cancellationReason\": \"Will travel\"}` and `Authorization: Bearer token` request header where token is event type owner (host) credential. This will cancel the booking for all attendees.\n \n \nCancelling recurring seated bookings:\n For recurring seated bookings it is not possible to cancel all of them with 1 call\n like with non-seated recurring bookings by providing recurring bookind uid - you have to cancel each recurrence booking by its bookingUid + seatUid.\n \n If you are cancelling a seated booking for an event type with 'show attendees' disabled, then to retrieve attendees in the response either set 'show attendees' to true on event type level or\n you have to provide an authentication method of event type owner, host, team admin or owner or org admin or owner.\n\n <Note>Please make sure to pass in the cal-api-version header value as mentioned in the Headers section. Not passing the correct value will default to an older version of this endpoint.</Note>\n ",
|
||||
"description": ":bookingUid can be :bookingUid of an usual booking, individual recurrence or recurring booking to cancel all recurrences.\n \n \nCancelling normal bookings:\n If the booking is not seated and not recurring, simply pass :bookingUid in the request URL `/bookings/:bookingUid/cancel` and optionally cancellationReason in the request body `{\"cancellationReason\": \"Will travel\"}`.\n\n \nCancelling seated bookings:\n It is possible to cancel specific seat within a booking as an attendee or all of the seats as the host.\n \n1. As an attendee - provide :bookingUid in the request URL `/bookings/:bookingUid/cancel` and seatUid in the request body `{\"seatUid\": \"123-123-123\"}` . This will remove this particular attendance from the booking.\n \n2. As the host or org admin of host - host can cancel booking for all attendees aka for every seat, this also applies to org admins. Provide :bookingUid in the request URL `/bookings/:bookingUid/cancel` and cancellationReason in the request body `{\"cancellationReason\": \"Will travel\"}` and `Authorization: Bearer token` request header where token is event type owner (host) credential. This will cancel the booking for all attendees.\n \n \nCancelling recurring seated bookings:\n For recurring seated bookings it is not possible to cancel all of them with 1 call\n like with non-seated recurring bookings by providing recurring bookind uid - you have to cancel each recurrence booking by its bookingUid + seatUid.\n \n If you are cancelling a seated booking for an event type with 'show attendees' disabled, then to retrieve attendees in the response either set 'show attendees' to true on event type level or\n you have to provide an authentication method of event type owner, host, team admin or owner or org admin or owner.\n\n <Note>Please make sure to pass in the cal-api-version header value as mentioned in the Headers section. Not passing the correct value will default to an older version of this endpoint.</Note>\n ",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "cal-api-version",
|
||||
@@ -8998,6 +8998,64 @@
|
||||
"tags": ["Bookings"]
|
||||
}
|
||||
},
|
||||
"/v2/bookings/{bookingUid}/guests": {
|
||||
"post": {
|
||||
"operationId": "BookingGuestsController_2024_08_13_addGuests",
|
||||
"summary": "Add guests to an existing booking",
|
||||
"description": "Add one or more guests to an existing booking.\n \n **Email Notifications:**\n When guests are added, the following notifications are sent (unless disabled by event type settings):\n \n - **Organizer & Team Members:** Receive an \"Add Guests\" notification email informing them that new guests have been added to the booking.\n \n - **New Guests:** Receive a \"Scheduled Event\" email with full booking details and calendar invite. If they have a phone number, they also receive an SMS notification.\n \n - **Existing Guests:** Receive an \"Add Guests\" notification email informing them that additional guests have been added to the booking.\n \n <Note>The cal-api-version header is required for this endpoint. Without it, the request will fail with a 404 error.</Note>\n ",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "cal-api-version",
|
||||
"in": "header",
|
||||
"description": "Must be set to 2024-08-13. This header is required as this endpoint does not exist in older API versions.",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "bookingUid",
|
||||
"required": true,
|
||||
"in": "path",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "Authorization",
|
||||
"in": "header",
|
||||
"description": "value must be `Bearer <token>` where `<token>` is api key prefixed with cal_ or managed user access token",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/AddGuestsInput_2024_08_13"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/AddGuestsOutput_2024_08_13"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"tags": ["Bookings / Guests"]
|
||||
}
|
||||
},
|
||||
"/v2/calendars/{calendar}/event/{eventUid}": {
|
||||
"get": {
|
||||
"operationId": "CalUnifiedCalendarsController_getCalendarEventDetails",
|
||||
@@ -27285,6 +27343,147 @@
|
||||
},
|
||||
"required": ["status", "data"]
|
||||
},
|
||||
"Guest": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"email": {
|
||||
"type": "string",
|
||||
"description": "The email of the guest.",
|
||||
"example": "john.doe@example.com"
|
||||
},
|
||||
"name": {
|
||||
"type": "string",
|
||||
"description": "The name of the guest.",
|
||||
"example": "John Doe"
|
||||
},
|
||||
"timeZone": {
|
||||
"type": "string",
|
||||
"description": "The time zone of the guest.",
|
||||
"example": "America/New_York"
|
||||
},
|
||||
"phoneNumber": {
|
||||
"type": "string",
|
||||
"description": "The phone number of the guest in international format.",
|
||||
"example": "+919876543210"
|
||||
},
|
||||
"language": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"ar",
|
||||
"ca",
|
||||
"de",
|
||||
"es",
|
||||
"eu",
|
||||
"he",
|
||||
"id",
|
||||
"ja",
|
||||
"lv",
|
||||
"pl",
|
||||
"ro",
|
||||
"sr",
|
||||
"th",
|
||||
"vi",
|
||||
"az",
|
||||
"cs",
|
||||
"el",
|
||||
"es-419",
|
||||
"fi",
|
||||
"hr",
|
||||
"it",
|
||||
"km",
|
||||
"nl",
|
||||
"pt",
|
||||
"ru",
|
||||
"sv",
|
||||
"tr",
|
||||
"zh-CN",
|
||||
"bg",
|
||||
"da",
|
||||
"en",
|
||||
"et",
|
||||
"fr",
|
||||
"hu",
|
||||
"iw",
|
||||
"ko",
|
||||
"no",
|
||||
"pt-BR",
|
||||
"sk",
|
||||
"ta",
|
||||
"uk",
|
||||
"zh-TW",
|
||||
"bn"
|
||||
],
|
||||
"description": "The preferred language of the guest. Used for booking confirmation.",
|
||||
"example": "it",
|
||||
"default": "en"
|
||||
}
|
||||
},
|
||||
"required": ["email"]
|
||||
},
|
||||
"AddGuestsInput_2024_08_13": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"guests": {
|
||||
"description": "Array of guests to add to the booking",
|
||||
"example": [
|
||||
{
|
||||
"email": "john.doe@example.com",
|
||||
"name": "John Doe",
|
||||
"timeZone": "America/New_York"
|
||||
},
|
||||
{
|
||||
"email": "jane.smith@example.com",
|
||||
"name": "Jane Smith"
|
||||
}
|
||||
],
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/Guest"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": ["guests"]
|
||||
},
|
||||
"AddGuestsOutput_2024_08_13": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"status": {
|
||||
"type": "string",
|
||||
"example": "success",
|
||||
"enum": ["success", "error"]
|
||||
},
|
||||
"data": {
|
||||
"oneOf": [
|
||||
{
|
||||
"$ref": "#/components/schemas/BookingOutput_2024_08_13"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/RecurringBookingOutput_2024_08_13"
|
||||
},
|
||||
{
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/RecurringBookingOutput_2024_08_13"
|
||||
}
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/GetSeatedBookingOutput_2024_08_13"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/GetRecurringSeatedBookingOutput_2024_08_13"
|
||||
},
|
||||
{
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/GetRecurringSeatedBookingOutput_2024_08_13"
|
||||
}
|
||||
}
|
||||
],
|
||||
"description": "Booking data, which can be either a BookingOutput object, a RecurringBookingOutput object, or an array of RecurringBookingOutput objects"
|
||||
}
|
||||
},
|
||||
"required": ["status", "data"]
|
||||
},
|
||||
"CreateTeamMembershipInput": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
|
||||
+136
-75
@@ -77,65 +77,67 @@ For a demonstration of the booker atom along with calendar overlay, please refer
|
||||
|
||||
<p></p>
|
||||
|
||||
We offer all kinds of customizations to the booker atom via props and customClassNames.
|
||||
We offer all kinds of customizations to the booker atom via props and customClassNames.
|
||||
|
||||
Below is a list of props that can be passed to the booker atom.
|
||||
|
||||
<p></p>
|
||||
|
||||
| Name | Required | Description |
|
||||
|:----------------------------|:----------|:-------------------------------------------------------------------------------------------------------|
|
||||
| username | Yes | Username of the person whose schedule is to be displayed |
|
||||
| eventSlug | Yes | Unique slug created for a particular event |
|
||||
| orgBannerUrl | No | URL of the user's current organization |
|
||||
| customClassNames | No | To pass in custom classnames from outside for styling the atom |
|
||||
| month | No | The exact month for displaying a user's availability; defaults to the current month |
|
||||
| selectedDate | No | Default selected date for which the slot picker opens |
|
||||
| startTime | No | Custom start time for the Booker that allows users to decide the first available date. Accepts JavaScript Date object or date string in format `YYYY-MM-DD` (e.g., `"2025-08-20"` or `new Date("2025-08-20")`) |
|
||||
| hideBranding | No | For hiding any branding on the booker |
|
||||
| isAway | No | Sets the booker component to the away state |
|
||||
| allowsDynamicBooking | No | Boolean indicating if the booking is a dynamic booking |
|
||||
| bookingData | No | Data for rescheduling a booking passed in via this prop |
|
||||
| defaultFormValues | No | Prefilled values for booking form fields like name, email, guests, notes, reschedule reason, etc. |
|
||||
| isTeamEvent | No | Boolean indicating if it is a team event |
|
||||
| duration | No | Refers to a multiple-duration event type; selects default if not passed |
|
||||
| durationConfig | No | Configures selectable options for a multi-duration event type |
|
||||
| hashedLink | No | Refers to the private link from event types page |
|
||||
| isInstantMeeting | No | Boolean indicating if the booking is an instant meeting |
|
||||
| rescheduleUid | No | Unique ID generated during rescheduling |
|
||||
| bookingUid | No | Unique ID generated during booking |
|
||||
| locationUrl | No | Custom meeting link URL instead of a Cal.com link |
|
||||
| firstName | No | First name of the attendee |
|
||||
| lastName | No | Last name of the attendee |
|
||||
| guests | No | Invite a guest to join a meeting |
|
||||
| name | No | Host name |
|
||||
| onCreateBookingSuccess | No | Callback function for successful booking creation |
|
||||
| onCreateBookingError | No | Callback function triggered on booking creation failure |
|
||||
| onCreateRecurringBookingSuccess | No | Callback function for successful recurring booking creation |
|
||||
| onCreateRecurringBookingError | No | Callback function triggered on recurring booking creation failure |
|
||||
| onCreateInstantBookingSuccess | No | Callback function for successful instant booking creation |
|
||||
| onCreateInstantBookingError | No | Callback function triggered on instant booking creation failure |
|
||||
| onReserveSlotSuccess | No | Callback function for successful slot reservation |
|
||||
| onReserveSlotError | No | Callback function triggered on slot reservation failure |
|
||||
| onDeleteSlotSuccess | No | Callback function for successful slot deletion |
|
||||
| onDeleteSlotError | No | Callback function triggered on slot deletion failure
|
||||
| view | No | Specifies the layout of the booker atom into column, week, or month view |
|
||||
| metadata | No | Used to pass custom metadata values into the booker. Metadata should be an object eg: `{ bookingSource: "website", userRole: "admin" }` |
|
||||
| bannerUrl | No | Adds custom banner to the booker atom |
|
||||
| onBookerStateChange | No | Callback function that is triggered when the state of the booker atom changes. |
|
||||
| allowUpdatingUrlParams | No | Boolean indicating if the URL parameters should be updated, defaults to false. |
|
||||
| confirmButtonDisabled | No | Boolean indicating if the submit button should be disabled, defaults to false. |
|
||||
| timeZones | No | Array of valid IANA timezones to be used in the booker. Eg. ["Asia/Kolkata", "Europe/London"] |
|
||||
| onTimeslotsLoaded | No | Callback function triggered once the available timeslots have been fetched. |
|
||||
| roundRobinHideOrgAndTeam | No | Boolean indicating if the organization and team should be hidden in the booker atom sidebar for round robin scheduling type, defaults to false. |
|
||||
| showNoAvailabilityDialog | No | Boolean indicating if the no availability dialog should be shown, defaults to true. |
|
||||
| silentlyHandleCalendarFailures | No | Boolean when true the booker still displays slots when the third party calendars credentials are invalid or expired, Booker may show stale availability when enabled |
|
||||
| hideEventMetadata | No | Boolean that controls the visibility of the event metadata sidebar. When `true`, hides the left sidebar containing event details like title, description, duration, and host information. Defaults to `false`. |
|
||||
| Name | Required | Description |
|
||||
| :------------------------------ | :------- | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| username | Yes | Username of the person whose schedule is to be displayed |
|
||||
| eventSlug | Yes | Unique slug created for a particular event |
|
||||
| orgBannerUrl | No | URL of the user's current organization |
|
||||
| customClassNames | No | To pass in custom classnames from outside for styling the atom |
|
||||
| month | No | The exact month for displaying a user's availability; defaults to the current month |
|
||||
| selectedDate | No | Default selected date for which the slot picker opens |
|
||||
| startTime | No | Custom start time for the Booker that allows users to decide the first available date. Accepts JavaScript Date object or date string in format `YYYY-MM-DD` (e.g., `"2025-08-20"` or `new Date("2025-08-20")`) |
|
||||
| hideBranding | No | For hiding any branding on the booker |
|
||||
| isAway | No | Sets the booker component to the away state |
|
||||
| allowsDynamicBooking | No | Boolean indicating if the booking is a dynamic booking |
|
||||
| bookingData | No | Data for rescheduling a booking passed in via this prop |
|
||||
| defaultFormValues | No | Prefilled values for booking form fields like name, email, guests, notes, reschedule reason, etc. |
|
||||
| isTeamEvent | No | Boolean indicating if it is a team event |
|
||||
| duration | No | Refers to a multiple-duration event type; selects default if not passed |
|
||||
| durationConfig | No | Configures selectable options for a multi-duration event type |
|
||||
| hashedLink | No | Refers to the private link from event types page |
|
||||
| isInstantMeeting | No | Boolean indicating if the booking is an instant meeting |
|
||||
| bookingUid | No | Unique ID generated during booking creation |
|
||||
| rescheduleUid | No | Unique ID generated during booking creation, same as bookingUid |
|
||||
| locationUrl | No | Custom meeting link URL instead of a Cal.com link |
|
||||
| firstName | No | First name of the attendee |
|
||||
| lastName | No | Last name of the attendee |
|
||||
| guests | No | Invite a guest to join a meeting |
|
||||
| name | No | Host name |
|
||||
| onCreateBookingSuccess | No | Callback function for successful booking creation |
|
||||
| onCreateBookingError | No | Callback function triggered on booking creation failure |
|
||||
| onCreateRecurringBookingSuccess | No | Callback function for successful recurring booking creation |
|
||||
| onCreateRecurringBookingError | No | Callback function triggered on recurring booking creation failure |
|
||||
| onCreateInstantBookingSuccess | No | Callback function for successful instant booking creation |
|
||||
| onCreateInstantBookingError | No | Callback function triggered on instant booking creation failure |
|
||||
| onReserveSlotSuccess | No | Callback function for successful slot reservation |
|
||||
| onReserveSlotError | No | Callback function triggered on slot reservation failure |
|
||||
| onDeleteSlotSuccess | No | Callback function for successful slot deletion |
|
||||
| onDeleteSlotError | No | Callback function triggered on slot deletion failure |
|
||||
| view | No | Specifies the layout of the booker atom into column, week, or month view |
|
||||
| metadata | No | Used to pass custom metadata values into the booker. Metadata should be an object eg: `{ bookingSource: "website", userRole: "admin" }` |
|
||||
| bannerUrl | No | Adds custom banner to the booker atom |
|
||||
| onBookerStateChange | No | Callback function that is triggered when the state of the booker atom changes. |
|
||||
| allowUpdatingUrlParams | No | Boolean indicating if the URL parameters should be updated, defaults to false. |
|
||||
| confirmButtonDisabled | No | Boolean indicating if the submit button should be disabled, defaults to false. |
|
||||
| timeZones | No | Array of valid IANA timezones to be used in the booker. Eg. ["Asia/Kolkata", "Europe/London"] |
|
||||
| onTimeslotsLoaded | No | Callback function triggered once the available timeslots have been fetched. |
|
||||
| roundRobinHideOrgAndTeam | No | Boolean indicating if the organization and team should be hidden in the booker atom sidebar for round robin scheduling type, defaults to false. |
|
||||
| showNoAvailabilityDialog | No | Boolean indicating if the no availability dialog should be shown, defaults to true. |
|
||||
| silentlyHandleCalendarFailures | No | Boolean when true the booker still displays slots when the third party calendars credentials are invalid or expired, Booker may show stale availability when enabled |
|
||||
| hideEventMetadata | No | Boolean that controls the visibility of the event metadata sidebar. When `true`, hides the left sidebar containing event details like title, description, duration, and host information. Defaults to `false`. |
|
||||
|
||||
## Styling
|
||||
|
||||
Booker atom accepts custom styles via the `customClassNames` prop. This prop is an object that contains root level styles and nested objects for styling different parts of the booker component. Each nested object groups related styles for a specific section of the booker (e.g., eventMeta, datePicker, availableTimeSlots, etc).
|
||||
|
||||
Here is an example booker with root level style `bookerContainer` and nested object `datePickerCustomClassNames` with `datePickerDatesActive` style:
|
||||
|
||||
```js
|
||||
import { Booker } from "@calcom/atoms";
|
||||
|
||||
@@ -159,43 +161,49 @@ export default function Booker( props : BookerProps ) {
|
||||
Below is a list of **customClassNames** properties grouped by their parent objects:
|
||||
|
||||
### Root Level Styles
|
||||
| Property | Description |
|
||||
|:---------|:-------------|
|
||||
|
||||
| Property | Description |
|
||||
| :-------------- | :------------------------------------------- |
|
||||
| bookerContainer | Adds styling to the whole of the booker atom |
|
||||
|
||||
### Event Meta Styles (`eventMetaCustomClassNames`)
|
||||
| Property | Description |
|
||||
|:---------|:-------------|
|
||||
| eventMetaContainer | Styles the event meta component containing details about an event |
|
||||
| eventMetaTitle | Adds styles to the event meta title |
|
||||
| eventMetaTimezoneSelect | Adds styles to the event meta timezone selector |
|
||||
|
||||
| Property | Description |
|
||||
| :---------------------- | :---------------------------------------------------------------- |
|
||||
| eventMetaContainer | Styles the event meta component containing details about an event |
|
||||
| eventMetaTitle | Adds styles to the event meta title |
|
||||
| eventMetaTimezoneSelect | Adds styles to the event meta timezone selector |
|
||||
|
||||
### Date Picker Styles (`datePickerCustomClassNames`)
|
||||
| Property | Description |
|
||||
|:---------|:-------------|
|
||||
| datePickerContainer | Adds styling to the date picker |
|
||||
| datePickerTitle | Styles the date picker title |
|
||||
| datePickerDays | Adds styling to all days in the date picker |
|
||||
| datePickerDate | Adds styling to all dates in the date picker |
|
||||
| datePickerDatesActive | Styles only the dates with available slots |
|
||||
| datePickerToggle | Styles the left and right toggle buttons |
|
||||
|
||||
| Property | Description |
|
||||
| :-------------------- | :------------------------------------------- |
|
||||
| datePickerContainer | Adds styling to the date picker |
|
||||
| datePickerTitle | Styles the date picker title |
|
||||
| datePickerDays | Adds styling to all days in the date picker |
|
||||
| datePickerDate | Adds styling to all dates in the date picker |
|
||||
| datePickerDatesActive | Styles only the dates with available slots |
|
||||
| datePickerToggle | Styles the left and right toggle buttons |
|
||||
|
||||
### Available Time Slots Styles (`availableTimeSlotsCustomClassNames`)
|
||||
| Property | Description |
|
||||
|:---------|:-------------|
|
||||
| availableTimeSlotsContainer | Adds styling to the available time slots component |
|
||||
| availableTimeSlotsHeaderContainer | Styles only the header container |
|
||||
| availableTimeSlotsTitle | Adds styles to the title |
|
||||
| availableTimeSlotsTimeFormatToggle | Adds styles to the format toggle buttons |
|
||||
| availableTimes | Styles all the available times container |
|
||||
|
||||
| Property | Description |
|
||||
| :--------------------------------- | :------------------------------------------------- |
|
||||
| availableTimeSlotsContainer | Adds styling to the available time slots component |
|
||||
| availableTimeSlotsHeaderContainer | Styles only the header container |
|
||||
| availableTimeSlotsTitle | Adds styles to the title |
|
||||
| availableTimeSlotsTimeFormatToggle | Adds styles to the format toggle buttons |
|
||||
| availableTimes | Styles all the available times container |
|
||||
|
||||
### Confirmation Step Styles (`confirmStep`)
|
||||
| Property | Description |
|
||||
|:---------|:-------------|
|
||||
|
||||
| Property | Description |
|
||||
| :------------ | :-------------------------------------------- |
|
||||
| confirmButton | Styles the confirm button in the booking form |
|
||||
| backButton | Styles the back button in the booking form |
|
||||
| backButton | Styles the back button in the booking form |
|
||||
|
||||
Here is an example with more custom styles:
|
||||
|
||||
```js
|
||||
import { Booker } from "@calcom/atoms";
|
||||
|
||||
@@ -225,4 +233,57 @@ export default function Booker( props : BookerProps ) {
|
||||
</>
|
||||
)
|
||||
}
|
||||
```
|
||||
```
|
||||
|
||||
## Rescheduling a booking
|
||||
|
||||
The booker atom also supports rescheduling a booking. To reschedule a booking, you need to pass in the `rescheduleUid` prop to the booker atom along with the other necessary props. This will allow the booker to display the reschedule form and handle the rescheduling process. The `rescheduleUid` is the booking uid you get when you create a booking.
|
||||
|
||||
Here is an example of how to use the booker atom for rescheduling an individual event:
|
||||
|
||||
```js
|
||||
import { Booker } from "@calcom/atoms";
|
||||
|
||||
export default function Booker( props : BookerProps ) {
|
||||
return (
|
||||
<>
|
||||
<Booker
|
||||
eventSlug={props.eventTypeSlug}
|
||||
username={props.calUsername}
|
||||
// rescheduleUid is the booking uid you get when you create a booking
|
||||
// it is only required when rescheduling a booking
|
||||
rescheduleUid={props.rescheduleUid}
|
||||
onCreateBookingSuccess={() => {
|
||||
console.log("Booking rescheduled successfully!");
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
For team events, you need to pass isTeamEvent and teamId prop to the booker along with the other necessary props. This will allow the booker to display the reschedule form and handle the rescheduling process. The `teamId` is the team id you get when you create a team event.
|
||||
|
||||
Here is an example of how to use the booker atom for rescheduling a team event:
|
||||
|
||||
```js
|
||||
import { Booker } from "@calcom/atoms";
|
||||
|
||||
export default function Booker( props : BookerProps ) {
|
||||
return (
|
||||
<>
|
||||
<Booker
|
||||
eventSlug={props.eventTypeSlug}
|
||||
// rescheduleUid is the booking uid you get when you create a booking
|
||||
// it is only required when rescheduling a booking
|
||||
rescheduleUid={props.rescheduleUid}
|
||||
isTeamEvent={true}
|
||||
teamId={props.teamId}
|
||||
onCreateBookingSuccess={() => {
|
||||
console.log("Booking rescheduled successfully!");
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
@@ -13,7 +13,8 @@ import { CalendarServiceMap } from "../calendar.services.generated";
|
||||
const log = logger.getSubLogger({ prefix: ["CalendarManager"] });
|
||||
|
||||
export const getCalendar = async (
|
||||
credential: CredentialForCalendarService | null
|
||||
credential: CredentialForCalendarService | null,
|
||||
shouldServeCache?: boolean,
|
||||
): Promise<Calendar | null> => {
|
||||
if (!credential || !credential.key) return null;
|
||||
let { type: calendarType } = credential;
|
||||
@@ -41,12 +42,8 @@ export const getCalendar = async (
|
||||
log.warn(`calendar of type ${calendarType} is not implemented`);
|
||||
return null;
|
||||
}
|
||||
|
||||
// check if Calendar Cache is supported and enabled
|
||||
if (CalendarCacheEventService.isCalendarTypeSupported(calendarType)) {
|
||||
log.debug(
|
||||
`Using regular CalendarService for credential ${credential.id} (not Google or Office365 Calendar)`
|
||||
);
|
||||
// if shouldServeCache is not supplied, determine on the fly.
|
||||
if (typeof shouldServeCache === "undefined") {
|
||||
const featuresRepository = new FeaturesRepository(prisma);
|
||||
const [isCalendarSubscriptionCacheEnabled, isCalendarSubscriptionCacheEnabledForUser] = await Promise.all(
|
||||
[
|
||||
@@ -59,19 +56,19 @@ export const getCalendar = async (
|
||||
),
|
||||
]
|
||||
);
|
||||
|
||||
if (isCalendarSubscriptionCacheEnabled && isCalendarSubscriptionCacheEnabledForUser) {
|
||||
log.debug(`Calendar Cache is enabled, using CalendarCacheService for credential ${credential.id}`);
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const originalCalendar = new CalendarService(credential as any);
|
||||
if (originalCalendar) {
|
||||
// return cacheable calendar
|
||||
const calendarCacheEventRepository = new CalendarCacheEventRepository(prisma);
|
||||
return new CalendarCacheWrapper({
|
||||
originalCalendar,
|
||||
calendarCacheEventRepository,
|
||||
});
|
||||
}
|
||||
shouldServeCache = isCalendarSubscriptionCacheEnabled && isCalendarSubscriptionCacheEnabledForUser;
|
||||
}
|
||||
if (CalendarCacheEventService.isCalendarTypeSupported(calendarType) && shouldServeCache) {
|
||||
log.debug(`Calendar Cache is enabled, using CalendarCacheService for credential ${credential.id}`);
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const originalCalendar = new CalendarService(credential as any);
|
||||
if (originalCalendar) {
|
||||
// return cacheable calendar
|
||||
const calendarCacheEventRepository = new CalendarCacheEventRepository(prisma);
|
||||
return new CalendarCacheWrapper({
|
||||
originalCalendar,
|
||||
calendarCacheEventRepository,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -653,7 +653,7 @@ export async function getCredentialForCalendarCache({ credentialId }: { credenti
|
||||
/**
|
||||
* It includes in-memory DelegationCredential credentials as well.
|
||||
*/
|
||||
export async function getUsersCredentialsIncludeServiceAccountKey(user: User) {
|
||||
export async function getUsersCredentialsIncludeServiceAccountKey(user: Pick<User, "id" | "email">) {
|
||||
const credentials = await prisma.credential.findMany({
|
||||
where: {
|
||||
userId: user.id,
|
||||
|
||||
@@ -32,10 +32,10 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
|
||||
throw new Error("Unable to create user credential for Alby");
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
if (error instanceof Error) {
|
||||
return res.status(500).json({ message: error.message });
|
||||
}
|
||||
return res.status(500);
|
||||
const message =
|
||||
error instanceof Error ? error.message : typeof error === "string" ? error : JSON.stringify(error);
|
||||
|
||||
return res.status(500).json({ message });
|
||||
}
|
||||
|
||||
return res.status(200).json({ url: "/apps/hitpay/setup" });
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* TODO: Consolidate this file with BookingLocationService and add tests
|
||||
*/
|
||||
import type { TFunction } from "i18next";
|
||||
import { isValidPhoneNumber } from "libphonenumber-js";
|
||||
import { isValidPhoneNumber } from "libphonenumber-js/max";
|
||||
import { z } from "zod";
|
||||
|
||||
import { appStoreMetadata } from "@calcom/app-store/bookerAppsMetaData";
|
||||
@@ -312,7 +312,7 @@ export const privacyFilteredLocations = (locations: LocationObject[]): PrivacyFi
|
||||
if (location.displayLocationPublicly || !eventLocationType) {
|
||||
return location;
|
||||
} else {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
|
||||
const { address: _1, link: _2, hostPhoneNumber: _3, ...privacyFilteredLocation } = location;
|
||||
logger.debug("Applied Privacy Filter", location, privacyFilteredLocation);
|
||||
return privacyFilteredLocation;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { Calendar as OfficeCalendar, User, Event } from "@microsoft/microsoft-graph-types-beta";
|
||||
import type { DefaultBodyType } from "msw";
|
||||
import { findIana } from "windows-iana";
|
||||
|
||||
import { MSTeamsLocationType } from "@calcom/app-store/constants";
|
||||
import dayjs from "@calcom/dayjs";
|
||||
@@ -112,7 +113,7 @@ export default class Office365CalendarService implements Calendar {
|
||||
invalidateTokenObject: () => oAuthManagerHelper.invalidateCredential(credential.id),
|
||||
expireAccessToken: () => oAuthManagerHelper.markTokenAsExpired(credential),
|
||||
updateTokenObject: (tokenObject) => {
|
||||
if (!Boolean(credential.delegatedTo)) {
|
||||
if (!credential.delegatedTo) {
|
||||
return oAuthManagerHelper.updateTokenObject({ tokenObject, credentialId: credential.id });
|
||||
}
|
||||
return Promise.resolve();
|
||||
@@ -688,20 +689,58 @@ export default class Office365CalendarService implements Calendar {
|
||||
return response.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the main timezone from Office365 mailbox settings.
|
||||
* Handles both legacy string format and new Graph API object format due to API evolution.
|
||||
* Legacy format: returns string timezone as-is (preserves existing behavior)
|
||||
* New format: converts Windows timezone from {"value": "Windows Timezone"} to IANA format
|
||||
*/
|
||||
async getMainTimeZone(): Promise<string> {
|
||||
try {
|
||||
const response = await this.fetcher(`${await this.getUserEndpoint()}/mailboxSettings/timeZone`);
|
||||
const timezone = await handleErrorsJson<string>(response);
|
||||
const timezoneResponse = await handleErrorsJson<string | { value: string }>(response);
|
||||
|
||||
if (!timezone || typeof timezone !== "string") {
|
||||
if (typeof timezoneResponse === "object" && timezoneResponse !== null && "value" in timezoneResponse) {
|
||||
const windowsTimezoneName = timezoneResponse.value;
|
||||
this.log.info("timezone found in outlook mailbox settings (new format)", {
|
||||
windowsTimezoneName,
|
||||
});
|
||||
|
||||
try {
|
||||
const ianaTimezone = findIana(windowsTimezoneName);
|
||||
if (ianaTimezone && ianaTimezone.length > 0) {
|
||||
const convertedTimezone = ianaTimezone[0];
|
||||
this.log.info("Successfully converted Windows timezone to IANA", {
|
||||
windowsTimezoneName,
|
||||
convertedTimezone,
|
||||
});
|
||||
return convertedTimezone;
|
||||
} else {
|
||||
this.log.warn("Could not convert Windows timezone to IANA, using original value", {
|
||||
windowsTimezoneName,
|
||||
});
|
||||
return windowsTimezoneName;
|
||||
}
|
||||
} catch (conversionError) {
|
||||
this.log.warn("Error converting Windows timezone to IANA, using original value", {
|
||||
windowsTimezoneName,
|
||||
conversionError,
|
||||
});
|
||||
return windowsTimezoneName;
|
||||
}
|
||||
}
|
||||
|
||||
if (!timezoneResponse || typeof timezoneResponse !== "string") {
|
||||
this.log.warn("No timezone found in outlook mailbox settings, defaulting to Europe/London", {
|
||||
timezone,
|
||||
timezoneResponse,
|
||||
});
|
||||
return "Europe/London";
|
||||
}
|
||||
this.log.info("timezone found in outlook mailbox settings", { timezone });
|
||||
|
||||
return timezone;
|
||||
this.log.info("timezone found in outlook mailbox settings (legacy format)", {
|
||||
timezone: timezoneResponse,
|
||||
});
|
||||
return timezoneResponse;
|
||||
} catch (error) {
|
||||
this.log.error("Error getting main timezone from Office365 Calendar", { error });
|
||||
throw error;
|
||||
|
||||
@@ -8,7 +8,8 @@
|
||||
"dependencies": {
|
||||
"@calcom/lib": "workspace:*",
|
||||
"@calcom/prisma": "workspace:*",
|
||||
"msw": "^2.7.0"
|
||||
"msw": "^2.7.0",
|
||||
"windows-iana": "^5.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@calcom/types": "workspace:*"
|
||||
|
||||
@@ -947,8 +947,6 @@ test.describe("Routing Forms", () => {
|
||||
const newUser = await addNewUserToTeam({ users, teamId });
|
||||
await goToRoutingLinkAndSubmit({ page, formId });
|
||||
|
||||
// eslint-disable-next-line playwright/no-wait-for-timeout
|
||||
await page.waitForTimeout(2000);
|
||||
const receivedEmailsOwner = await getEmailsReceivedByUser({ emails, userEmail: owner.email });
|
||||
expect(receivedEmailsOwner?.total).toBe(1);
|
||||
const receivedEmailsNewUser = await getEmailsReceivedByUser({ emails, userEmail: newUser.email });
|
||||
@@ -965,8 +963,6 @@ test.describe("Routing Forms", () => {
|
||||
const newUser = await addNewUserToTeam({ users, teamId });
|
||||
await goToRoutingLinkAndSubmit({ page, formId });
|
||||
|
||||
// eslint-disable-next-line playwright/no-wait-for-timeout
|
||||
await page.waitForTimeout(2000);
|
||||
const receivedEmailsOwner = await getEmailsReceivedByUser({ emails, userEmail: owner.email });
|
||||
expect(receivedEmailsOwner?.total).toBe(1);
|
||||
const receivedEmailsNewUser = await getEmailsReceivedByUser({ emails, userEmail: newUser.email });
|
||||
|
||||
@@ -6,6 +6,8 @@ import { WEBAPP_URL } from "@calcom/lib/constants";
|
||||
import { getSafeRedirectUrl } from "@calcom/lib/getSafeRedirectUrl";
|
||||
import logger from "@calcom/lib/logger";
|
||||
import prisma from "@calcom/prisma";
|
||||
import type { Prisma } from "@calcom/prisma/client";
|
||||
import { teamMetadataSchema } from "@calcom/prisma/zod-utils";
|
||||
|
||||
import stripe from "../../server";
|
||||
|
||||
@@ -62,6 +64,23 @@ export abstract class BillingPortalService {
|
||||
return safeRedirectUrl || defaultUrl;
|
||||
}
|
||||
|
||||
protected getValidatedTeamSubscriptionId(metadata: Prisma.JsonValue) {
|
||||
const teamMetadataParsed = teamMetadataSchema.safeParse(metadata);
|
||||
|
||||
if (!teamMetadataParsed.success || !teamMetadataParsed.data?.subscriptionId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return teamMetadataParsed.data.subscriptionId;
|
||||
}
|
||||
|
||||
protected getValidatedTeamSubscriptionIdForPlatform(subscriptionId?: string | null) {
|
||||
if (!subscriptionId) {
|
||||
return null;
|
||||
}
|
||||
return subscriptionId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Abstract method to check permissions - implemented by subclasses
|
||||
*/
|
||||
|
||||
+2
-22
@@ -1,7 +1,5 @@
|
||||
import logger from "@calcom/lib/logger";
|
||||
import type { Prisma } from "@calcom/prisma/client";
|
||||
import { MembershipRole } from "@calcom/prisma/enums";
|
||||
import { teamMetadataSchema } from "@calcom/prisma/zod-utils";
|
||||
|
||||
import { getSubscriptionFromId } from "../../subscriptions";
|
||||
import { BillingPortalService } from "../base/BillingPortalService";
|
||||
@@ -24,24 +22,6 @@ export class OrganizationBillingPortalService extends BillingPortalService {
|
||||
});
|
||||
}
|
||||
|
||||
async getValidatedTeamSubscriptionId(metadata: Prisma.JsonValue) {
|
||||
const teamMetadataParsed = teamMetadataSchema.safeParse(metadata);
|
||||
|
||||
if (!teamMetadataParsed.success || !teamMetadataParsed.data?.subscriptionId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return teamMetadataParsed.data.subscriptionId;
|
||||
}
|
||||
|
||||
async getValidatedTeamSubscriptionIdForPlatform(subscriptionId?: string | null) {
|
||||
if (!subscriptionId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return subscriptionId;
|
||||
}
|
||||
|
||||
async getCustomerId(teamId: number): Promise<string | null> {
|
||||
const log = logger.getSubLogger({ prefix: ["OrganizationBillingPortalService", "getCustomerId"] });
|
||||
|
||||
@@ -51,14 +31,14 @@ export class OrganizationBillingPortalService extends BillingPortalService {
|
||||
let teamSubscriptionId = "";
|
||||
|
||||
if (team.isPlatform) {
|
||||
const subscriptionId = await this.getValidatedTeamSubscriptionIdForPlatform(
|
||||
const subscriptionId = this.getValidatedTeamSubscriptionIdForPlatform(
|
||||
team.platformBilling?.subscriptionId
|
||||
);
|
||||
|
||||
if (!subscriptionId) return null;
|
||||
teamSubscriptionId = subscriptionId;
|
||||
} else {
|
||||
const subscriptionId = await this.getValidatedTeamSubscriptionId(team.metadata);
|
||||
const subscriptionId = this.getValidatedTeamSubscriptionId(team.metadata);
|
||||
|
||||
if (!subscriptionId) return null;
|
||||
teamSubscriptionId = subscriptionId;
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import logger from "@calcom/lib/logger";
|
||||
import type { Prisma } from "@calcom/prisma/client";
|
||||
import { MembershipRole } from "@calcom/prisma/enums";
|
||||
import { teamMetadataSchema } from "@calcom/prisma/zod-utils";
|
||||
|
||||
import { getSubscriptionFromId } from "../../subscriptions";
|
||||
import { BillingPortalService } from "../base/BillingPortalService";
|
||||
@@ -19,24 +17,6 @@ export class TeamBillingPortalService extends BillingPortalService {
|
||||
});
|
||||
}
|
||||
|
||||
async getValidatedTeamSubscriptionId(metadata: Prisma.JsonValue) {
|
||||
const teamMetadataParsed = teamMetadataSchema.safeParse(metadata);
|
||||
|
||||
if (!teamMetadataParsed.success || !teamMetadataParsed.data?.subscriptionId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return teamMetadataParsed.data.subscriptionId;
|
||||
}
|
||||
|
||||
async getValidatedTeamSubscriptionIdForPlatform(subscriptionId?: string | null) {
|
||||
if (!subscriptionId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return subscriptionId;
|
||||
}
|
||||
|
||||
async getCustomerId(teamId: number): Promise<string | null> {
|
||||
const log = logger.getSubLogger({ prefix: ["TeamBillingPortalService", "getCustomerId"] });
|
||||
|
||||
@@ -46,14 +26,14 @@ export class TeamBillingPortalService extends BillingPortalService {
|
||||
let teamSubscriptionId = "";
|
||||
|
||||
if (team.isPlatform) {
|
||||
const subscriptionId = await this.getValidatedTeamSubscriptionIdForPlatform(
|
||||
const subscriptionId = this.getValidatedTeamSubscriptionIdForPlatform(
|
||||
team.platformBilling?.subscriptionId
|
||||
);
|
||||
|
||||
if (!subscriptionId) return null;
|
||||
teamSubscriptionId = subscriptionId;
|
||||
} else {
|
||||
const subscriptionId = await this.getValidatedTeamSubscriptionId(team.metadata);
|
||||
const subscriptionId = this.getValidatedTeamSubscriptionId(team.metadata);
|
||||
|
||||
if (!subscriptionId) return null;
|
||||
teamSubscriptionId = subscriptionId;
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user