Compare commits

...
21 Commits
Author SHA1 Message Date
Alex van Andel fbff4db2a6 chore: Release v4.3.3 (#15924)
release.yml / release (push) Canceled after 0s
2024-07-26 09:00:00 +01:00
Udit Takkar ba61731689 fix: make max_participants optional (#15920) 2024-07-26 07:08:05 +00:00
sean-brydonandOmar López e16662e655 feat: usage base billed - license key check - move to private api (#15222)
Co-authored-by: Omar López <zomars@me.com>
2024-07-25 13:20:24 -07:00
sean-brydon 53c37a9ddf feat: refactor drawer component (#15892)
* Add sheet component

* refactor user edit sheet

* refactor availbility sheet edit

* Remove tremor raw sheet text

* remove accidently file commit

* fix overflow issue
2024-07-25 12:41:23 +01:00
Morgan 64ef2703a5 feat: org members can fetch their teams apiv2 (#15910)
* feat: org members can fetch their teams apiv2

* fixup! feat: org members can fetch their teams apiv2

* feat: org members can fetch their teams apiv2

* fixup! feat: org members can fetch their teams apiv2

* fixup! fixup! feat: org members can fetch their teams apiv2

* fixup! fixup! fixup! feat: org members can fetch their teams apiv2

* fixup! fixup! fixup! fixup! feat: org members can fetch their teams apiv2
2024-07-25 10:39:43 +00:00
Rajiv SahalandMorgan Vernay a997edd8db chore: update dtos for v2 calendars endpoint (#15904)
* init calendars input

* update request body input type

* fix incorrect naming

* update imprts

* fixup! update imprts

* fixup! fixup! update imprts

---------

Co-authored-by: Morgan Vernay <morgan@cal.com>
2024-07-25 08:49:18 +00:00
Peer Richelsen bd73eba528 chore: Update config.yml (#15900) 2024-07-25 09:35:33 +02:00
0d53c2069e feat: team owner/admin, be able to confirm/reject "requires confirmation" team events (#15326)
* feat: eam owner/admin, be able to confirm/reject requir
es confirmation team events

* update

* addressed requested changes

* remove console.log

---------

Co-authored-by: Keith Williams <keithwillcode@gmail.com>
Co-authored-by: Somay Chauhan <somaychauhan98@gmail.com>
Co-authored-by: Amit Sharma <74371312+Amit91848@users.noreply.github.com>
2024-07-25 09:35:03 +02:00
Souptik Datta 3b559525a8 fix: Unable to invite a user to a team via username (#15868)
* fix: Fix invalid existing username DB query

Signed-off-by: Souptik Datta <souptikdatta2001@gmail.com>

* fix: Support adding org team member using username

Signed-off-by: Souptik Datta <souptikdatta2001@gmail.com>

* fix: Remove invite by username logic and fix labe to display only email

Signed-off-by: Souptik Datta <souptikdatta2001@gmail.com>

* refactor: Remove username support completely from invitation-modal component

Signed-off-by: Souptik Datta <souptikdatta2001@gmail.com>

---------

Signed-off-by: Souptik Datta <souptikdatta2001@gmail.com>
2024-07-25 07:04:44 +00:00
Peer Richelsen 33e1abf4a7 chore: fixed bug_report.yml (#15901) 2024-07-24 22:55:48 +00:00
059c6c37ba fix google calendar event not deleted on default calendar of cal event change (#14994)
Co-authored-by: Shaik-Sirajuddin <sirajudddinshaik30@gmail.com>
Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com>
Co-authored-by: Keith Williams <keithwillcode@gmail.com>
Co-authored-by: Amit Sharma <74371312+Amit91848@users.noreply.github.com>
Co-authored-by: Peer Richelsen <peeroke@gmail.com>
2024-07-24 15:34:41 +00:00
Peer Richelsen 14bc489d70 chore: Update bug_report.md (#15894)
* Update bug_report.md

* Apply suggestions from code review
2024-07-24 15:22:45 +00:00
Morgan e8b36d5b0b chore: platform api usage based billing using queue (#15085)
* chore: platform api usage based billing using queue

* fixup! chore: platform api usage based billing using queue

* fixup! fixup! chore: platform api usage based billing using queue

* fixup! Merge branch 'chore-platformapi-billing-queue' of github.com:calcom/cal.com into chore-platformapi-billing-queue

* fixup! fixup! Merge branch 'chore-platformapi-billing-queue' of github.com:calcom/cal.com into chore-platformapi-billing-queue

* fixup! Merge branch 'main' into chore-platformapi-billing-queue

* chore: platform billing by userId

* fix: fix tests requirement team subscirption

* fixup! Merge branch 'main' into chore-platformapi-billing-queue

* fixup! fixup! Merge branch 'main' into chore-platformapi-billing-queue

* fixup! Merge branch 'main' into chore-platformapi-billing-queue

* improve tests logs

* fix: silently close redis and queue

* fixup! Merge branch 'main' into chore-platformapi-billing-queue

* fixup! Merge branch 'main' into chore-platformapi-billing-queue

* fixup! fixup! Merge branch 'main' into chore-platformapi-billing-queue

* fix: upgrade libraries version
2024-07-24 17:10:22 +02:00
Saket kumar singh 5983c960a6 Fix: #15888 (#15890) 2024-07-24 14:03:31 +00:00
Souptik Dattaandunknown 40305b9c5a fix: Change conferencing app-store URL on event types and bookings pages (#15878)
* fix: Change conferencing app-store URL on event types and bookings pages

Signed-off-by: Souptik Datta <souptikdatta2001@gmail.com>

* chore: Change conference app store help text

Signed-off-by: Souptik Datta <souptikdatta2001@gmail.com>

* chore: Revert app install helper text key for all locales except en

Signed-off-by: Souptik Datta <souptikdatta2001@gmail.com>

* delete key and text

---------

Signed-off-by: Souptik Datta <souptikdatta2001@gmail.com>
Co-authored-by: unknown <adhabal2002@gmail.com>
2024-07-24 14:02:13 +00:00
Lauris Skraucis dcc62c6db0 fix: CreateAvailabilityInput_2024_04_15 Cannot read properties of undefined (#15885) 2024-07-24 12:52:42 +00:00
Lucas Víctor Ferreira de AraújoandAnik Dhabal Babu 07f14e4e14 test: test cases for POST selected-calendars (#15873)
* test-selected_calendars

* Update _post.test.ts

---------

Co-authored-by: Anik Dhabal Babu <81948346+anikdhabal@users.noreply.github.com>
2024-07-24 14:29:09 +02:00
Keith Williams f940f6bd87 chore: Remove unit tests from running after crowd in (#15880)
* chore: Remove unit tests from running after crowd in

* Update permissions on all workflows

* Updated pr permissions

* Added actions:write to top-level pr

* Update .github/workflows/unit-tests.yml

* Removed the linting annotation
2024-07-24 15:18:11 +03:00
Keith Williams ca5dcfd1e8 chore: Add v2 API test suite (#15865)
* chore: Add v2 API test suite

* Changed oauth credentials guard to e2e spec

* Uploading e2e artifacts

* Removed commented line
2024-07-24 11:02:03 +02:00
Lauris Skraucis 324728d4ec refactor: v2 organizations event-types e2e tests (#15886) 2024-07-24 07:15:26 +00:00
Trillium S 2f7cc05cad fix: Update component order, props for Link in user-public-view (#15882) 2024-07-24 12:28:52 +05:30
131 changed files with 1976 additions and 847 deletions
+3 -3
View File
@@ -15,10 +15,10 @@
# - You can not repackage or sell the codebase
# - Acquire a commercial license to remove these terms by visiting: cal.com/sales
#
# To enable enterprise-only features, as an admin, go to /auth/setup to select your license and follow
# instructions. This environment variable is deprecated although still supported for backward compatibility.
# @see https://console.cal.com
# To enable enterprise-only features please add your envrionment variable to the .env file then make your way to /auth/setup to select your license and follow instructions.
CALCOM_LICENSE_KEY=
CALCOM_PRIVATE_API_ROUTE="https://goblin.cal.com"
# ***********************************************************************************************************
# - DATABASE ************************************************************************************************
@@ -1,7 +1,7 @@
name: Bug report
description: "Found a bug? Please fill out the sections below. \U0001F44D"
title: ""
labels: 🐛 bug
description: "Please make sure you have checked for any duplicate issues."
title: "(bug)"
labels: ["🐛 bug"]
assignees: []
body:
- type: textarea
+2 -2
View File
@@ -1,5 +1,5 @@
blank_issues_enabled: false
contact_links:
- name: Questions
url: https://go.cal.com/discord
about: Ask a general question about the project on our discord community
url: https://github.com/calcom/cal.com/discussions
about: Need help selfhosting or ask a general question about the project? Open a discussion
@@ -3,6 +3,9 @@ name: Production Builds
on:
workflow_call:
permissions:
contents: read
env:
ALLOWED_HOSTNAMES: ${{ vars.CI_ALLOWED_HOSTNAMES }}
CALENDSO_ENCRYPTION_KEY: ${{ secrets.CI_CALENDSO_ENCRYPTION_KEY }}
@@ -10,6 +10,8 @@ env:
jobs:
build:
name: Build API v2
permissions:
contents: read
runs-on: buildjet-4vcpu-ubuntu-2204
timeout-minutes: 30
services:
+2
View File
@@ -3,6 +3,8 @@ on:
workflow_call:
env:
NODE_OPTIONS: --max-old-space-size=4096
permissions:
contents: read
jobs:
check-types:
runs-on: buildjet-4vcpu-ubuntu-2204
+79
View File
@@ -0,0 +1,79 @@
name: E2E
on:
workflow_call:
env:
ALLOWED_HOSTNAMES: ${{ vars.CI_ALLOWED_HOSTNAMES }}
API_KEY_PREFIX: ${{ secrets.CI_API_KEY_PREFIX }}
API_PORT: ${{ vars.CI_API_V2_PORT }}
CALCOM_LICENSE_KEY: ${{ secrets.CI_CALCOM_LICENSE_KEY }}
DAILY_API_KEY: ${{ secrets.CI_DAILY_API_KEY }}
DATABASE_URL: ${{ secrets.CI_DATABASE_URL }}
DATABASE_READ_URL: ${{ secrets.CI_DATABASE_URL }}
DATABASE_WRITE_URL: ${{ secrets.CI_DATABASE_URL }}
DATABASE_DIRECT_URL: ${{ secrets.CI_DATABASE_URL }}
GOOGLE_API_CREDENTIALS: ${{ secrets.CI_GOOGLE_API_CREDENTIALS }}
IS_E2E: true
NEXTAUTH_SECRET: ${{ secrets.CI_NEXTAUTH_SECRET }}
NEXTAUTH_URL: ${{ secrets.CI_NEXTAUTH_URL }}
NODE_OPTIONS: --max-old-space-size=4096
REDIS_URL: "redis://localhost:6379"
STRIPE_PRIVATE_KEY: ${{ secrets.CI_STRIPE_PRIVATE_KEY }}
STRIPE_API_KEY: ${{ secrets.CI_STRIPE_PRIVATE_KEY }}
STRIPE_CLIENT_ID: ${{ secrets.CI_STRIPE_CLIENT_ID }}
STRIPE_WEBHOOK_SECRET: ${{ secrets.CI_STRIPE_WEBHOOK_SECRET }}
jobs:
e2e:
timeout-minutes: 20
name: E2E API v2
runs-on: buildjet-8vcpu-ubuntu-2204
services:
postgres:
image: postgres:13
credentials:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
env:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_DB: calendso
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
ports:
- 5432:5432
redis:
image: redis:latest
ports:
- 6379:6379
options: >-
--health-cmd "redis-cli ping"
--health-interval 10s
--health-timeout 5s
--health-retries 5
strategy:
fail-fast: false
steps:
- uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- uses: actions/checkout@v4
- uses: ./.github/actions/dangerous-git-checkout
- uses: ./.github/actions/yarn-install
- uses: ./.github/actions/yarn-playwright-install
- uses: ./.github/actions/cache-db
- name: Run Tests
working-directory: apps/api/v2
run: |
yarn test:e2e
EXIT_CODE=$?
echo "yarn test:e2e command exit code: $EXIT_CODE"
exit $EXIT_CODE
- name: Upload Test Results
if: ${{ always() }}
uses: actions/upload-artifact@v4
with:
name: test-results-api-v2
path: test-results
+3
View File
@@ -1,6 +1,9 @@
name: E2E App Store Tests
on:
workflow_call:
permissions:
actions: write
contents: read
env:
NODE_OPTIONS: --max-old-space-size=4096
ALLOWED_HOSTNAMES: ${{ vars.CI_ALLOWED_HOSTNAMES }}
+3
View File
@@ -1,6 +1,9 @@
name: E2E Embed React tests and booking flow (for non-embed as well)
on:
workflow_call:
permissions:
actions: write
contents: read
env:
NODE_OPTIONS: --max-old-space-size=4096
ALLOWED_HOSTNAMES: ${{ vars.CI_ALLOWED_HOSTNAMES }}
+3
View File
@@ -1,6 +1,9 @@
name: E2E Embed Core tests and booking flow (for non-embed as well)
on:
workflow_call:
permissions:
actions: write
contents: read
env:
NODE_OPTIONS: --max-old-space-size=4096
ALLOWED_HOSTNAMES: ${{ vars.CI_ALLOWED_HOSTNAMES }}
+3
View File
@@ -1,6 +1,9 @@
name: E2E
on:
workflow_call:
permissions:
actions: write
contents: read
env:
NODE_OPTIONS: --max-old-space-size=4096
ALLOWED_HOSTNAMES: ${{ vars.CI_ALLOWED_HOSTNAMES }}
+2
View File
@@ -1,6 +1,8 @@
name: Integration
on:
workflow_call:
permissions:
contents: read
env:
NODE_OPTIONS: --max-old-space-size=4096
ALLOWED_HOSTNAMES: ${{ vars.CI_ALLOWED_HOSTNAMES }}
+3 -7
View File
@@ -1,6 +1,9 @@
name: Lint
on:
workflow_call:
permissions:
actions: write
contents: read
jobs:
lint:
runs-on: buildjet-4vcpu-ubuntu-2204
@@ -16,13 +19,6 @@ jobs:
- name: Merge lint reports
run: jq -s '[.[]]|flatten' lint-results/*.json &> lint-results/eslint_report.json
- name: Annotate Code Linting Results
uses: ataylorme/eslint-annotate-action@v2
with:
repo-token: ${{ secrets.GITHUB_TOKEN }}
report-json: "lint-results/eslint_report.json"
only-pr-files: false
- name: Upload ESLint report
if: ${{ always() }}
uses: actions/upload-artifact@v4
@@ -7,6 +7,10 @@ on:
branches:
- main
permissions:
actions: write
contents: read
env:
ALLOWED_HOSTNAMES: ${{ vars.CI_ALLOWED_HOSTNAMES }}
CALENDSO_ENCRYPTION_KEY: ${{ secrets.CI_CALENDSO_ENCRYPTION_KEY }}
+14 -1
View File
@@ -8,6 +8,10 @@ on:
- gh-actions-test-branch
workflow_dispatch:
permissions:
actions: write
contents: read
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
@@ -39,6 +43,8 @@ jobs:
needs: [changes]
runs-on: buildjet-2vcpu-ubuntu-2204
name: Check for E2E label
permissions:
pull-requests: read
outputs:
run-e2e: ${{ steps.check-if-pr-has-label.outputs.run-e2e == 'true' && (github.event.action != 'labeled' || (github.event.action == 'labeled' && github.event.label.name == 'ready-for-e2e')) }}
steps:
@@ -141,6 +147,13 @@ jobs:
uses: ./.github/workflows/e2e.yml
secrets: inherit
e2e-api-v2:
name: Tests
needs: [changes, check-label, build, build-api-v1, build-api-v2]
if: ${{ needs.check-label.outputs.run-e2e == 'true' && needs.changes.outputs.has-files-requiring-all-checks == 'true' }}
uses: ./.github/workflows/e2e-api-v2.yml
secrets: inherit
e2e-app-store:
name: Tests
needs: [changes, check-label, build, build-api-v1, build-api-v2]
@@ -169,7 +182,7 @@ jobs:
secrets: inherit
required:
needs: [changes, lint, type-check, unit-test, integration-test, check-label, build, build-api-v1, build-api-v2, e2e, e2e-embed, e2e-embed-react, e2e-app-store]
needs: [changes, lint, type-check, unit-test, integration-test, check-label, build, build-api-v1, build-api-v2, e2e, e2e-api-v2, e2e-embed, e2e-embed-react, e2e-app-store]
if: always()
runs-on: buildjet-2vcpu-ubuntu-2204
steps:
-77
View File
@@ -1,77 +0,0 @@
name: Pre-release checks
on:
workflow_dispatch:
jobs:
changes:
name: Detect changes
runs-on: buildjet-4vcpu-ubuntu-2204
permissions:
pull-requests: read
outputs:
app-store: ${{ steps.filter.outputs.app-store }}
embed: ${{ steps.filter.outputs.embed }}
embed-react: ${{ steps.filter.outputs.embed-react }}
steps:
- uses: actions/checkout@v4
- uses: ./.github/actions/dangerous-git-checkout
- uses: dorny/paths-filter@v3
id: filter
with:
filters: |
app-store:
- 'apps/web/**'
- 'packages/app-store/**'
- 'playwright.config.ts'
embed:
- 'apps/web/**'
- 'packages/embeds/**'
- 'playwright.config.ts'
embed-react:
- 'apps/web/**'
- 'packages/embeds/**'
- 'playwright.config.ts'
lint:
name: Linters
uses: ./.github/workflows/lint.yml
secrets: inherit
build:
name: Production builds
uses: ./.github/workflows/production-build-without-database.yml
secrets: inherit
e2e:
name: E2E tests
needs: [changes, lint, build]
uses: ./.github/workflows/e2e.yml
secrets: inherit
e2e-app-store:
name: E2E App Store tests
needs: [changes, lint, build]
uses: ./.github/workflows/e2e-app-store.yml
secrets: inherit
e2e-embed:
name: E2E embeds tests
needs: [changes, lint, build]
uses: ./.github/workflows/e2e-embed.yml
secrets: inherit
e2e-embed-react:
name: E2E React embeds tests
needs: [changes, lint, build]
uses: ./.github/workflows/e2e-embed-react.yml
secrets: inherit
required:
needs: [changes, lint, build, e2e, e2e-app-store, e2e-embed, e2e-embed-react]
if: always()
runs-on: buildjet-2vcpu-ubuntu-2204
steps:
- name: fail if conditional jobs failed
if: contains(needs.*.result, 'failure') || contains(needs.*.result, 'skipped') || contains(needs.*.result, 'cancelled')
run: exit 1
@@ -2,6 +2,9 @@ name: Production Builds
on:
workflow_call:
permissions:
contents: read
env:
ALLOWED_HOSTNAMES: ${{ vars.CI_ALLOWED_HOSTNAMES }}
CALENDSO_ENCRYPTION_KEY: ${{ secrets.CI_CALENDSO_ENCRYPTION_KEY }}
+7 -4
View File
@@ -1,9 +1,8 @@
name: Unit
on:
workflow_call:
workflow_run:
workflows: [Crowdin Action]
types: [completed]
permissions:
contents: read
jobs:
test:
name: Unit
@@ -13,7 +12,11 @@ jobs:
- uses: actions/checkout@v4
- uses: ./.github/actions/dangerous-git-checkout
- uses: ./.github/actions/yarn-install
# Should be an 8GB machine as per https://docs.github.com/en/actions/using-github-hosted-runners/about-github-hosted-runners
- run: yarn test
# We could add different timezones here that we need to run our tests in
- run: TZ=America/Los_Angeles yarn test -- --timeZoneDependentTestsOnly
- name: Run API v2 tests
working-directory: apps/api/v2
run: |
export NODE_OPTIONS="--max_old_space_size=8192"
yarn test
+3
View File
@@ -3,6 +3,9 @@ name: Yarn install
on:
workflow_call:
permissions:
contents: read
jobs:
setup:
name: Yarn install & cache
+6 -3
View File
@@ -1,7 +1,7 @@
import type { NextMiddleware } from "next-api-middleware";
import LicenseKeyService from "@calcom/ee/common/server/LicenseKeyService";
import { hashAPIKey } from "@calcom/features/ee/api-keys/lib/apiKeys";
import checkLicense from "@calcom/features/ee/common/server/checkLicense";
import { IS_PRODUCTION } from "@calcom/lib/constants";
import prisma from "@calcom/prisma";
@@ -18,9 +18,12 @@ export const dateNotInPast = function (date: Date) {
// This verifies the apiKey and sets the user if it is valid.
export const verifyApiKey: NextMiddleware = async (req, res, next) => {
const hasValidLicense = await checkLicense(prisma);
if (!hasValidLicense && IS_PRODUCTION)
const licenseKeyService = await LicenseKeyService.create();
const hasValidLicense = await licenseKeyService.checkLicense();
if (!hasValidLicense && IS_PRODUCTION) {
return res.status(401).json({ error: "Invalid or missing CALCOM_LICENSE_KEY environment variable" });
}
// Check if the apiKey query param is provided.
if (!req.query.apiKey) return res.status(401).json({ message: "No apiKey provided" });
// remove the prefix from the user provided api_key. If no env set default to "cal_"
@@ -3,9 +3,9 @@ import prismaMock from "../../../../../../tests/libs/__mocks__/prismaMock";
import type { Request, Response } from "express";
import type { NextApiRequest, NextApiResponse } from "next";
import { createMocks } from "node-mocks-http";
import { describe, vi, it, expect, afterEach } from "vitest";
import { describe, vi, it, expect, afterEach, beforeEach } from "vitest";
import checkLicense from "@calcom/features/ee/common/server/checkLicense";
import LicenseKeyService from "@calcom/ee/common/server/LicenseKeyService";
import prisma from "@calcom/prisma";
import { isAdminGuard } from "~/lib/utils/isAdmin";
@@ -20,20 +20,21 @@ afterEach(() => {
vi.resetAllMocks();
});
vi.mock("@calcom/features/ee/common/server/checkLicense", () => {
return {
default: vi.fn(),
};
});
vi.mock("~/lib/utils/isAdmin", () => {
return {
isAdminGuard: vi.fn(),
};
});
vi.mock("@calcom/prisma");
vi.mock("~/lib/utils/isAdmin", () => ({
isAdminGuard: vi.fn(),
}));
describe("Verify API key", () => {
it("It should throw an error if the api key is not valid", async () => {
let service: LicenseKeyService;
beforeEach(async () => {
service = await LicenseKeyService.create();
vi.spyOn(service, "checkLicense");
});
it("should throw an error if the api key is not valid", async () => {
const { req, res } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
method: "POST",
body: {},
@@ -43,7 +44,7 @@ describe("Verify API key", () => {
fn: verifyApiKey,
};
vi.mocked(checkLicense).mockResolvedValue(false);
vi.mocked(service.checkLicense).mockResolvedValue(false);
vi.mocked(isAdminGuard).mockResolvedValue({ isAdmin: false, scope: null });
const serverNext = vi.fn((next: void) => Promise.resolve(next));
@@ -53,9 +54,11 @@ describe("Verify API key", () => {
await middleware.fn(req, res, serverNext);
expect(middlewareSpy).toBeCalled();
expect(res.statusCode).toBe(401);
});
it("It should throw an error if no api key is provided", async () => {
it("should throw an error if no api key is provided", async () => {
const { req, res } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
method: "POST",
body: {},
@@ -65,7 +68,7 @@ describe("Verify API key", () => {
fn: verifyApiKey,
};
vi.mocked(checkLicense).mockResolvedValue(true);
vi.mocked(service.checkLicense).mockResolvedValue(true);
vi.mocked(isAdminGuard).mockResolvedValue({ isAdmin: false, scope: null });
const serverNext = vi.fn((next: void) => Promise.resolve(next));
@@ -75,10 +78,11 @@ describe("Verify API key", () => {
await middleware.fn(req, res, serverNext);
expect(middlewareSpy).toBeCalled();
expect(res.statusCode).toBe(401);
});
it("It should set correct permissions for system-wide admin", async () => {
it("should set correct permissions for system-wide admin", async () => {
const { req, res } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
method: "POST",
body: {},
@@ -97,7 +101,7 @@ describe("Verify API key", () => {
fn: verifyApiKey,
};
vi.mocked(checkLicense).mockResolvedValue(true);
vi.mocked(service.checkLicense).mockResolvedValue(true);
vi.mocked(isAdminGuard).mockResolvedValue({ isAdmin: true, scope: ScopeOfAdmin.SystemWide });
const serverNext = vi.fn((next: void) => Promise.resolve(next));
@@ -107,11 +111,12 @@ describe("Verify API key", () => {
await middleware.fn(req, res, serverNext);
expect(middlewareSpy).toBeCalled();
expect(req.isSystemWideAdmin).toBe(true);
expect(req.isOrganizationOwnerOrAdmin).toBe(false);
});
it("It should set correct permissions for org-level admin", async () => {
it("should set correct permissions for org-level admin", async () => {
const { req, res } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
method: "POST",
body: {},
@@ -130,7 +135,7 @@ describe("Verify API key", () => {
fn: verifyApiKey,
};
vi.mocked(checkLicense).mockResolvedValue(true);
vi.mocked(service.checkLicense).mockResolvedValue(true);
vi.mocked(isAdminGuard).mockResolvedValue({ isAdmin: true, scope: ScopeOfAdmin.OrgOwnerOrAdmin });
const serverNext = vi.fn((next: void) => Promise.resolve(next));
@@ -140,6 +145,7 @@ describe("Verify API key", () => {
await middleware.fn(req, res, serverNext);
expect(middlewareSpy).toBeCalled();
expect(req.isSystemWideAdmin).toBe(false);
expect(req.isOrganizationOwnerOrAdmin).toBe(true);
});
@@ -0,0 +1,121 @@
import prismaMock from "../../../../../../tests/libs/__mocks__/prismaMock";
import type { Request, Response } from "express";
import type { NextApiRequest, NextApiResponse } from "next";
import { createMocks } from "node-mocks-http";
import { describe, expect, test } from "vitest";
import { HttpError } from "@calcom/lib/http-error";
import handler from "../../../pages/api/selected-calendars/_post";
type CustomNextApiRequest = NextApiRequest & Request;
type CustomNextApiResponse = NextApiResponse & Response;
describe("POST /api/selected-calendars", () => {
describe("Errors", () => {
test("Returns 403 if non-admin user tries to set userId in body", async () => {
const { req, res } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
method: "POST",
body: {
integration: "google",
externalId: "ext123",
userId: 444444,
},
});
req.userId = 333333;
try {
await handler(req, res);
} catch (e) {
expect(e).toBeInstanceOf(HttpError);
expect((e as HttpError).statusCode).toBe(403);
expect((e as HttpError).message).toBe("ADMIN required for userId");
}
});
test("Returns 400 if request body is invalid", async () => {
const { req, res } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
method: "POST",
body: {
integration: "google",
},
});
req.userId = 333333;
req.isSystemWideAdmin = true;
await handler(req, res);
expect(res.statusCode).toBe(400);
expect(JSON.parse(res._getData()).message).toBe("invalid_type in 'externalId': Required");
});
});
describe("Success", () => {
test("Creates selected calendar if user is admin and sets bodyUserId", async () => {
const { req, res } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
method: "POST",
query: {
apiKey: "validApiKey",
},
body: {
integration: "google",
externalId: "ext123",
userId: 444444,
},
});
req.userId = 333333;
req.isSystemWideAdmin = true;
prismaMock.user.findFirstOrThrow.mockResolvedValue({
id: 444444,
} as any);
prismaMock.selectedCalendar.create.mockResolvedValue({
credentialId: 1,
integration: "google",
externalId: "ext123",
userId: 444444,
});
await handler(req, res);
expect(res.statusCode).toBe(200);
const responseData = JSON.parse(res._getData());
expect(responseData.selected_calendar.credentialId).toBe(1);
expect(responseData.message).toBe("Selected Calendar created successfully");
});
test("Creates selected calendar if user is non-admin and does not set bodyUserId", async () => {
const { req, res } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
method: "POST",
query: {
apiKey: "validApiKey",
},
body: {
integration: "google",
externalId: "ext123",
},
});
req.userId = 333333;
prismaMock.selectedCalendar.create.mockResolvedValue({
credentialId: 1,
integration: "google",
externalId: "ext123",
userId: 333333,
});
await handler(req, res);
expect(res.statusCode).toBe(200);
const responseData = JSON.parse(res._getData());
expect(responseData.selected_calendar.credentialId).toBe(1);
expect(responseData.message).toBe("Selected Calendar created successfully");
});
});
});
+3
View File
@@ -14,8 +14,11 @@ NEXT_PUBLIC_SENTRY_DSN=
# Stripe Billing
STRIPE_PRICE_ID_STARTER=
STRIPE_PRICE_ID_STARTER_OVERAGE=
STRIPE_PRICE_ID_ESSENTIALS=
STRIPE_PRICE_ID_ESSENTIALS_OVERAGE=
STRIPE_PRICE_ID_ENTERPRISE=
STRIPE_PRICE_ID_ENTERPRISE_OVERAGE=
STRIPE_API_KEY=
STRIPE_WEBHOOK_SECRET=
+2 -1
View File
@@ -10,5 +10,6 @@
"transform": {
"^.+\\.(t|j)s$": "ts-jest"
},
"setupFiles": ["<rootDir>/test/setEnvVars.ts"]
"setupFiles": ["<rootDir>/test/setEnvVars.ts"],
"reporters": ["default", "jest-summarizing-reporter"]
}
+4 -2
View File
@@ -18,7 +18,7 @@
"test:watch": "yarn dev:build && jest --watch",
"test:cov": "yarn dev:build && jest --coverage",
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
"test:e2e": "yarn dev:build && jest --runInBand --config ./jest-e2e.json",
"test:e2e": "yarn dev:build && jest --runInBand --detectOpenHandles --forceExit --config ./jest-e2e.json",
"prisma": "yarn workspace @calcom/prisma prisma",
"generate-schemas": "yarn prisma generate && yarn prisma format",
"copy-swagger-module": "ts-node -r tsconfig-paths/register swagger/copy-swagger-module.ts"
@@ -26,12 +26,13 @@
"dependencies": {
"@calcom/platform-constants": "*",
"@calcom/platform-libraries-0.0.2": "npm:@calcom/platform-libraries@0.0.2",
"@calcom/platform-libraries-0.0.20": "npm:@calcom/platform-libraries@0.0.20",
"@calcom/platform-libraries-0.0.21": "npm:@calcom/platform-libraries@0.0.21",
"@calcom/platform-types": "*",
"@calcom/platform-utils": "*",
"@calcom/prisma": "*",
"@golevelup/ts-jest": "^0.4.0",
"@microsoft/microsoft-graph-types-beta": "^0.42.0-preview",
"@nestjs/bull": "^10.1.1",
"@nestjs/common": "^10.0.0",
"@nestjs/config": "^3.1.1",
"@nestjs/core": "^10.0.0",
@@ -42,6 +43,7 @@
"@nestjs/throttler": "^5.1.2",
"@sentry/node": "^8.8.0",
"body-parser": "^1.20.2",
"bull": "^4.12.4",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.0",
"cookie-parser": "^1.4.6",
+12
View File
@@ -11,6 +11,7 @@ import { JwtModule } from "@/modules/jwt/jwt.module";
import { PrismaModule } from "@/modules/prisma/prisma.module";
import { RedisModule } from "@/modules/redis/redis.module";
import { RedisService } from "@/modules/redis/redis.service";
import { BullModule } from "@nestjs/bull";
import { MiddlewareConsumer, Module, NestModule, RequestMethod } from "@nestjs/common";
import { ConfigModule } from "@nestjs/config";
import { APP_INTERCEPTOR, RouterModule } from "@nestjs/core";
@@ -26,7 +27,18 @@ import { AppController } from "./app.controller";
isGlobal: true,
load: [appConfig],
}),
RedisModule,
BullModule.forRootAsync({
imports: [RedisModule],
useFactory: async (redisService: RedisService) => ({
redis: {
host: redisService.redis.options.host,
port: redisService.redis.options.port,
},
}),
inject: [RedisService],
}),
ThrottlerModule.forRootAsync({
imports: [RedisModule],
inject: [RedisService],
@@ -19,7 +19,7 @@ import { UserRepositoryFixture } from "test/fixtures/repository/users.repository
import { withApiAuth } from "test/utils/withApiAuth";
import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants";
import { handleNewBooking } from "@calcom/platform-libraries-0.0.20";
import { handleNewBooking } from "@calcom/platform-libraries-0.0.21";
import { ApiSuccessResponse, ApiResponse } from "@calcom/platform-types";
describe("Bookings Endpoints", () => {
@@ -46,7 +46,7 @@ import {
getBookingInfo,
handleCancelBooking,
getBookingForReschedule,
} from "@calcom/platform-libraries-0.0.20";
} from "@calcom/platform-libraries-0.0.21";
import { GetBookingsInput, CancelBookingInput, Status } from "@calcom/platform-types";
import { ApiResponse } from "@calcom/platform-types";
import { PrismaClient } from "@calcom/prisma";
@@ -150,17 +150,21 @@ export class BookingsController {
@Req() req: BookingRequest,
@Body() body: CreateBookingInput,
@Headers(X_CAL_CLIENT_ID) clientId?: string
): Promise<ApiResponse<unknown>> {
): Promise<ApiResponse<Partial<BookingResponse>>> {
const oAuthClientId = clientId?.toString();
const { orgSlug, locationUrl } = body;
req.headers["x-cal-force-slug"] = orgSlug;
try {
const booking = await handleNewBooking(
await this.createNextApiBookingRequest(req, oAuthClientId, locationUrl)
);
void (await this.billingService.increaseUsageByClientId(oAuthClientId!));
if (booking.userId && booking.uid && booking.startTime) {
void (await this.billingService.increaseUsageByUserId(booking.userId, {
uid: booking.uid,
startTime: booking.startTime,
fromReschedule: booking.fromReschedule,
}));
}
return {
status: SUCCESS_STATUS,
data: booking,
@@ -177,14 +181,22 @@ export class BookingsController {
@Param("bookingId") bookingId: string,
@Body() _: CancelBookingInput,
@Headers(X_CAL_CLIENT_ID) clientId?: string
): Promise<ApiResponse> {
): Promise<ApiResponse<{ bookingId: number; bookingUid: string; onlyRemovedAttendee: boolean }>> {
const oAuthClientId = clientId?.toString();
if (bookingId) {
try {
req.body.id = parseInt(bookingId);
await handleCancelBooking(await this.createNextApiBookingRequest(req, oAuthClientId));
const res = await handleCancelBooking(await this.createNextApiBookingRequest(req, oAuthClientId));
if (!res.onlyRemovedAttendee) {
void (await this.billingService.cancelUsageByBookingUid(res.bookingUid));
}
return {
status: SUCCESS_STATUS,
data: {
bookingId: res.bookingId,
bookingUid: res.bookingUid,
onlyRemovedAttendee: res.onlyRemovedAttendee,
},
};
} catch (err) {
this.handleBookingErrors(err);
@@ -228,7 +240,14 @@ export class BookingsController {
await this.createNextApiBookingRequest(req, oAuthClientId)
);
void (await this.billingService.increaseUsageByClientId(oAuthClientId!));
createdBookings.forEach(async (booking) => {
if (booking.userId && booking.uid && booking.startTime) {
void (await this.billingService.increaseUsageByUserId(booking.userId, {
uid: booking.uid,
startTime: booking.startTime,
}));
}
});
return {
status: SUCCESS_STATUS,
@@ -253,7 +272,15 @@ export class BookingsController {
await this.createNextApiBookingRequest(req, oAuthClientId)
);
void (await this.billingService.increaseUsageByClientId(oAuthClientId!));
if (instantMeeting.userId && instantMeeting.bookingUid) {
const now = new Date();
// add a 10 secondes delay to the usage incrementation to give some time to cancel the booking if needed
now.setSeconds(now.getSeconds() + 10);
void (await this.billingService.increaseUsageByUserId(instantMeeting.userId, {
uid: instantMeeting.bookingUid,
startTime: now,
}));
}
return {
status: SUCCESS_STATUS,
@@ -1,7 +1,7 @@
import { CreateBookingInput } from "@/ee/bookings/inputs/create-booking.input";
import { IsBoolean, IsNumber, IsOptional } from "class-validator";
import type { AppsStatus } from "@calcom/platform-libraries-0.0.20";
import type { AppsStatus } from "@calcom/platform-libraries-0.0.21";
export class CreateRecurringBookingInput extends CreateBookingInput {
@IsBoolean()
@@ -107,15 +107,16 @@ describe("Platform Calendars Endpoints", () => {
.expect(401);
});
it(`/GET/v2/calendars/${OFFICE_365_CALENDAR}/connect: it should redirect to auth-url for office 365 calendar oauth with valid access token `, async () => {
const response = await request(app.getHttpServer())
.get(`/v2/calendars/${OFFICE_365_CALENDAR}/connect`)
.set("Authorization", `Bearer ${accessTokenSecret}`)
.set("Origin", CLIENT_REDIRECT_URI)
.expect(200);
const data = response.body.data;
expect(data.authUrl).toBeDefined();
});
// TODO: Uncomment this once CI is ready to run proper Office365 tests
// it(`/GET/v2/calendars/${OFFICE_365_CALENDAR}/connect: it should redirect to auth-url for office 365 calendar oauth with valid access token `, async () => {
// const response = await request(app.getHttpServer())
// .get(`/v2/calendars/${OFFICE_365_CALENDAR}/connect`)
// .set("Authorization", `Bearer ${accessTokenSecret}`)
// .set("Origin", CLIENT_REDIRECT_URI)
// .expect(200);
// const data = response.body.data;
// expect(data.authUrl).toBeDefined();
// });
it(`/GET/v2/calendars/${GOOGLE_CALENDAR}/connect: it should redirect to auth-url for google calendar oauth with valid access token `, async () => {
const response = await request(app.getHttpServer())
@@ -1,4 +1,5 @@
import { CalendarsRepository } from "@/ee/calendars/calendars.repository";
import { DeleteCalendarCredentialsInputBodyDto } from "@/ee/calendars/input/delete-calendar-credentials.input";
import { GetBusyTimesOutput } from "@/ee/calendars/outputs/busy-times.output";
import { ConnectedCalendarsOutput } from "@/ee/calendars/outputs/connected-calendars.output";
import {
@@ -196,7 +197,7 @@ export class CalendarsController {
@HttpCode(HttpStatus.OK)
async deleteCalendarCredentials(
@Param("calendar") calendar: string,
@Body() body: { id: number },
@Body() body: DeleteCalendarCredentialsInputBodyDto,
@GetUser() user: UserWithProfile
): Promise<DeletedCalendarCredentialsOutputResponseDto> {
const { id: credentialId } = body;
@@ -0,0 +1,15 @@
import { ApiProperty } from "@nestjs/swagger";
import { Expose } from "class-transformer";
import { IsInt } from "class-validator";
export class DeleteCalendarCredentialsInputBodyDto {
@IsInt()
@Expose()
@ApiProperty({
example: 10,
description: "Credential ID of the calendar to delete, as returned by the /calendars endpoint",
type: "integer",
required: true,
})
readonly id!: number;
}
@@ -5,7 +5,7 @@ import { BadRequestException, UnauthorizedException } from "@nestjs/common";
import { Injectable } from "@nestjs/common";
import { SUCCESS_STATUS, APPLE_CALENDAR_TYPE, APPLE_CALENDAR_ID } from "@calcom/platform-constants";
import { symmetricEncrypt, CalendarService } from "@calcom/platform-libraries-0.0.20";
import { symmetricEncrypt, CalendarService } from "@calcom/platform-libraries-0.0.21";
@Injectable()
export class AppleCalendarService implements CredentialSyncCalendarApp {
@@ -18,7 +18,7 @@ import { User } from "@prisma/client";
import { DateTime } from "luxon";
import { z } from "zod";
import { getConnectedDestinationCalendars, getBusyCalendarTimes } from "@calcom/platform-libraries-0.0.20";
import { getConnectedDestinationCalendars, getBusyCalendarTimes } from "@calcom/platform-libraries-0.0.21";
import { Calendar } from "@calcom/platform-types";
import { PrismaClient } from "@calcom/prisma";
@@ -35,7 +35,7 @@ import {
EventTypesPublic,
eventTypeBookingFields,
eventTypeLocations,
} from "@calcom/platform-libraries-0.0.20";
} from "@calcom/platform-libraries-0.0.21";
import { ApiSuccessResponse } from "@calcom/platform-types";
describe("Event types Endpoints", () => {
@@ -4,7 +4,7 @@ import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
import { UserWithProfile } from "@/modules/users/users.repository";
import { Injectable } from "@nestjs/common";
import { getEventTypeById } from "@calcom/platform-libraries-0.0.20";
import { getEventTypeById } from "@calcom/platform-libraries-0.0.21";
import type { PrismaClient } from "@calcom/prisma";
@Injectable()
@@ -14,7 +14,7 @@ import {
updateEventType,
EventTypesPublic,
getEventTypesPublic,
} from "@calcom/platform-libraries-0.0.20";
} from "@calcom/platform-libraries-0.0.21";
import { EventType } from "@calcom/prisma/client";
@Injectable()
@@ -7,7 +7,7 @@ import {
getEventTypeById,
transformApiEventTypeBookingFields,
transformApiEventTypeLocations,
} from "@calcom/platform-libraries-0.0.20";
} from "@calcom/platform-libraries-0.0.21";
import { CreateEventTypeInput_2024_06_14 } from "@calcom/platform-types";
import type { PrismaClient } from "@calcom/prisma";
@@ -10,9 +10,9 @@ import { UsersService } from "@/modules/users/services/users.service";
import { UserWithProfile, UsersRepository } from "@/modules/users/users.repository";
import { BadRequestException, ForbiddenException, Injectable, NotFoundException } from "@nestjs/common";
import { createEventType, updateEventType } from "@calcom/platform-libraries-0.0.20";
import { getEventTypesPublic, EventTypesPublic } from "@calcom/platform-libraries-0.0.20";
import { dynamicEvent } from "@calcom/platform-libraries-0.0.20";
import { createEventType, updateEventType } from "@calcom/platform-libraries-0.0.21";
import { getEventTypesPublic, EventTypesPublic } from "@calcom/platform-libraries-0.0.21";
import { dynamicEvent } from "@calcom/platform-libraries-0.0.21";
import {
CreateEventTypeInput_2024_06_14,
UpdateEventTypeInput_2024_06_14,
@@ -3,7 +3,7 @@ import { Injectable } from "@nestjs/common";
import {
transformApiEventTypeBookingFields,
transformApiEventTypeLocations,
} from "@calcom/platform-libraries-0.0.20";
} from "@calcom/platform-libraries-0.0.21";
import { CreateEventTypeInput_2024_06_14, UpdateEventTypeInput_2024_06_14 } from "@calcom/platform-types";
@Injectable()
@@ -9,7 +9,7 @@ import {
parseRecurringEvent,
TransformedLocationsSchema,
BookingFieldsSchema,
} from "@calcom/platform-libraries-0.0.20";
} from "@calcom/platform-libraries-0.0.21";
type EventTypeRelations = { users: User[]; schedule: Schedule | null };
type DatabaseEventType = EventType & EventTypeRelations;
@@ -66,6 +66,37 @@ describe("Schedules Endpoints", () => {
expect(user).toBeDefined();
});
it("should not create an invalid schedule", async () => {
const scheduleName = "schedule-name";
const scheduleTimeZone = "Europe/Rome";
const isDefault = true;
const body = {
name: scheduleName,
timeZone: scheduleTimeZone,
isDefault,
availabilities: [
{
days: ["Monday"],
endTime: "11:15",
startTime: "10:00",
},
],
};
return request(app.getHttpServer())
.post("/api/v2/schedules")
.set(CAL_API_VERSION_HEADER, VERSION_2024_04_15)
.send(body)
.expect(400)
.then(async (response) => {
expect(response.body.status).toEqual("error");
expect(response.body.error.message).toEqual(
"Invalid datestring format. Expected format(ISO8061): 2025-04-12T13:17:56.324Z. Received: 11:15"
);
});
});
it("should create a default schedule", async () => {
const scheduleName = "schedule-name";
const scheduleTimeZone = "Europe/Rome";
@@ -19,8 +19,18 @@ export class CreateAvailabilityInput_2024_04_15 {
}
function transformStringToDate(value: string, key: string): Date {
// note(Lauris): incoming value is ISO8061 e.g. 2025-0412T13:17:56.324Z
if (!value) {
throw new BadRequestException(
`Missing ${key}. Expected value is in ISO8061 format e.g. 2025-0412T13:17:56.324Z`
);
}
const dateTimeParts = value.split("T");
if (dateTimeParts.length !== 2) {
throw new BadRequestException(
`Invalid datestring format. Expected format(ISO8061): 2025-04-12T13:17:56.324Z. Received: ${value}`
);
}
const timePart = dateTimeParts[1].split(".")[0]; // Removes milliseconds
const parts = timePart.split(":");
@@ -3,7 +3,7 @@ import { Injectable } from "@nestjs/common";
import {
transformApiScheduleOverrides,
transformApiScheduleAvailability,
} from "@calcom/platform-libraries-0.0.20";
} from "@calcom/platform-libraries-0.0.21";
import { CreateScheduleInput_2024_06_11, ScheduleAvailabilityInput_2024_06_11 } from "@calcom/platform-types";
import { ScheduleOverrideInput_2024_06_11 } from "@calcom/platform-types";
@@ -2,7 +2,7 @@ import { ArgumentsHost, Catch, ExceptionFilter, Logger } from "@nestjs/common";
import { Request } from "express";
import { ERROR_STATUS } from "@calcom/platform-constants";
import { TRPCError } from "@calcom/platform-libraries-0.0.20";
import { TRPCError } from "@calcom/platform-libraries-0.0.21";
import { Response } from "@calcom/platform-types";
@Catch(TRPCError)
@@ -1,3 +1,4 @@
import { BillingProcessor } from "@/modules/billing/billing.processor";
import { BillingRepository } from "@/modules/billing/billing.repository";
import { BillingController } from "@/modules/billing/controllers/billing.controller";
import { BillingConfigService } from "@/modules/billing/services/billing.config.service";
@@ -6,11 +7,24 @@ import { MembershipsModule } from "@/modules/memberships/memberships.module";
import { OrganizationsModule } from "@/modules/organizations/organizations.module";
import { PrismaModule } from "@/modules/prisma/prisma.module";
import { StripeModule } from "@/modules/stripe/stripe.module";
import { BullModule } from "@nestjs/bull";
import { Module } from "@nestjs/common";
@Module({
imports: [PrismaModule, StripeModule, MembershipsModule, OrganizationsModule],
providers: [BillingConfigService, BillingService, BillingRepository],
imports: [
PrismaModule,
StripeModule,
MembershipsModule,
OrganizationsModule,
BullModule.registerQueue({
name: "billing",
limiter: {
max: 1,
duration: 1000,
},
}),
],
providers: [BillingConfigService, BillingService, BillingRepository, BillingProcessor],
exports: [BillingService, BillingRepository],
controllers: [BillingController],
})
@@ -0,0 +1,91 @@
import { BillingRepository } from "@/modules/billing/billing.repository";
import { OrganizationsRepository } from "@/modules/organizations/organizations.repository";
import { StripeService } from "@/modules/stripe/stripe.service";
import { Process, Processor } from "@nestjs/bull";
import { Logger } from "@nestjs/common";
import { Job } from "bull";
export const INCREMENT_JOB = "increment";
export const BILLING_QUEUE = "billing";
export type IncrementJobDataType = {
userId: number;
};
export type DecrementJobDataType = IncrementJobDataType;
@Processor(BILLING_QUEUE)
export class BillingProcessor {
private readonly logger = new Logger(BillingProcessor.name);
constructor(
public readonly stripeService: StripeService,
private readonly billingRepository: BillingRepository,
private readonly teamsRepository: OrganizationsRepository
) {}
@Process(INCREMENT_JOB)
async handleIncrement(job: Job<IncrementJobDataType>) {
const { userId } = job.data;
try {
// get the platform organization of the managed user
const team = await this.teamsRepository.findPlatformOrgFromUserId(userId);
const teamId = team.id;
if (!team.id) {
this.logger.error(`User (${userId}) is not part of the platform organization (${teamId}) `, {
teamId,
userId,
});
return;
}
const billingSubscription = await this.billingRepository.getBillingForTeam(teamId);
if (!billingSubscription || !billingSubscription?.subscriptionId) {
this.logger.error(`Team ${teamId} did not have stripe subscription associated to it`, {
teamId,
});
return;
}
const stripeSubscription = await this.stripeService.stripe.subscriptions.retrieve(
billingSubscription.subscriptionId
);
if (!stripeSubscription?.id) {
this.logger.error(`Failed to retrieve stripe subscription (${billingSubscription.subscriptionId})`, {
teamId,
subscriptionId: billingSubscription.subscriptionId,
});
return;
}
const meteredItem = stripeSubscription.items.data.find(
(item) => item.price?.recurring?.usage_type === "metered"
);
// no metered item found to increase usage, return early
if (!meteredItem) {
this.logger.error(`Stripe subscription (${stripeSubscription.id} is not usage based`, {
teamId,
subscriptionId: stripeSubscription.id,
});
return;
}
await this.stripeService.stripe.subscriptionItems.createUsageRecord(meteredItem.id, {
action: "increment",
quantity: 1,
timestamp: "now",
});
this.logger.log("Increased organization usage for subscription", {
subscriptionId: billingSubscription.subscriptionId,
teamId,
userId,
itemId: meteredItem.id,
});
} catch (err) {
this.logger.error("Failed to increase usage for Organization", {
userId,
err,
});
}
return;
}
}
@@ -3,21 +3,38 @@ import { Injectable } from "@nestjs/common";
@Injectable()
export class BillingConfigService {
private readonly config: Map<PlatformPlan, string>;
private readonly config: Map<
PlatformPlan,
{
base: string;
overage: string;
}
>;
constructor() {
this.config = new Map<PlatformPlan, string>();
this.config = new Map<
PlatformPlan,
{
base: string;
overage: string;
}
>();
const planKeys = Object.keys(PlatformPlan).filter((key) => isNaN(Number(key)));
for (const key of planKeys) {
this.config.set(
PlatformPlan[key.toUpperCase() as keyof typeof PlatformPlan],
process.env[`STRIPE_PRICE_ID_${key}`] ?? ""
);
this.config.set(PlatformPlan[key.toUpperCase() as keyof typeof PlatformPlan], {
base: process.env[`STRIPE_PRICE_ID_${key}`] ?? "",
overage: process.env[`STRIPE_PRICE_ID_${key}_OVERAGE`] ?? "",
});
}
}
get(plan: PlatformPlan): string | undefined {
get(plan: PlatformPlan):
| {
base: string;
overage: string;
}
| undefined {
return this.config.get(plan);
}
}
@@ -1,16 +1,19 @@
import { AppConfig } from "@/config/type";
import { BILLING_QUEUE, INCREMENT_JOB, IncrementJobDataType } from "@/modules/billing/billing.processor";
import { BillingRepository } from "@/modules/billing/billing.repository";
import { BillingConfigService } from "@/modules/billing/services/billing.config.service";
import { PlatformPlan } from "@/modules/billing/types";
import { OrganizationsRepository } from "@/modules/organizations/organizations.repository";
import { StripeService } from "@/modules/stripe/stripe.service";
import { Injectable, InternalServerErrorException, Logger } from "@nestjs/common";
import { InjectQueue } from "@nestjs/bull";
import { Injectable, InternalServerErrorException, Logger, OnModuleDestroy } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { Queue } from "bull";
import { DateTime } from "luxon";
import Stripe from "stripe";
@Injectable()
export class BillingService {
export class BillingService implements OnModuleDestroy {
private logger = new Logger("BillingService");
private readonly webAppUrl: string;
@@ -19,9 +22,10 @@ export class BillingService {
public readonly stripeService: StripeService,
private readonly billingRepository: BillingRepository,
private readonly configService: ConfigService<AppConfig>,
private readonly billingConfigService: BillingConfigService
private readonly billingConfigService: BillingConfigService,
@InjectQueue(BILLING_QUEUE) private readonly billingQueue: Queue
) {
this.webAppUrl = configService.get("app.baseUrl", { infer: true }) ?? "https://app.cal.com";
this.webAppUrl = this.configService.get("app.baseUrl", { infer: true }) ?? "https://app.cal.com";
}
async getBillingData(teamId: number) {
@@ -58,7 +62,10 @@ export class BillingService {
customer: customerId,
line_items: [
{
price: this.billingConfigService.get(plan),
price: this.billingConfigService.get(plan)?.overage,
},
{
price: this.billingConfigService.get(plan)?.base,
quantity: 1,
},
],
@@ -99,46 +106,56 @@ export class BillingService {
);
}
async increaseUsageForTeam(teamId: number) {
try {
const billingSubscription = await this.billingRepository.getBillingForTeam(teamId);
if (!billingSubscription || !billingSubscription?.subscriptionId) {
this.logger.error("Team did not have stripe subscription associated to it", {
teamId,
});
return void 0;
}
/**
*
* Adds a job to the queue to increment usage of a stripe subscription.
* we delay the job until the booking starts.
* the delay ensure we can adapt to cancel / reschedule.
*/
async increaseUsageByUserId(
userId: number,
booking: {
uid: string;
startTime: Date;
fromReschedule?: string | null;
}
) {
const { uid, startTime, fromReschedule } = booking;
const stripeSubscription = await this.stripeService.stripe.subscriptions.retrieve(
billingSubscription.subscriptionId
);
const item = stripeSubscription.items.data[0];
// legacy plans are licensed, we cannot create usage records against them
if (item.price?.recurring?.usage_type === "licensed") {
return void 0;
}
const delay = startTime.getTime() - Date.now();
if (fromReschedule) {
// cancel the usage increment job for the booking that is being rescheduled
await this.cancelUsageByBookingUid(fromReschedule);
this.logger.log(`Cancelled usage increment job for rescheduled booking uid: ${fromReschedule}`);
}
await this.billingQueue.add(
INCREMENT_JOB,
{
userId,
} satisfies IncrementJobDataType,
{ delay: delay > 0 ? delay : 0, jobId: `increment-${uid}`, removeOnComplete: true }
);
this.logger.log(`Added stripe usage increment job for booking ${uid} and user ${userId}`);
}
await this.stripeService.stripe.subscriptionItems.createUsageRecord(item.id, {
action: "increment",
quantity: 1,
timestamp: "now",
});
} catch (error) {
// don't fail the request, log it.
this.logger.error("Failed to increase usage for team", {
teamId: teamId,
error,
});
/**
*
* Cancels the usage increment job for a booking when it is cancelled.
* Removing an attendee from a booking does not cancel the usage increment job.
*/
async cancelUsageByBookingUid(bookingUid: string) {
const job = await this.billingQueue.getJob(`increment-${bookingUid}`);
if (job) {
await job.remove();
this.logger.log(`Removed increment job for cancelled booking ${bookingUid}`);
}
}
async increaseUsageByClientId(clientId: string) {
if (this.configService.get("e2e")) {
return void 0;
async onModuleDestroy() {
try {
await this.billingQueue.close();
} catch (err) {
this.logger.error(err);
}
const team = await this.teamsRepository.findTeamIdFromClientId(clientId);
if (!team.id) return Promise.resolve(); // noop resolution.
return this.increaseUsageForTeam(team?.id);
}
}
@@ -1,6 +1,6 @@
import { Injectable } from "@nestjs/common";
import { sendSignupToOrganizationEmail, getTranslation } from "@calcom/platform-libraries-0.0.20";
import { sendSignupToOrganizationEmail, getTranslation } from "@calcom/platform-libraries-0.0.21";
@Injectable()
export class EmailService {
@@ -163,7 +163,7 @@ describe("OAuth Clients Endpoints", () => {
await app.init();
});
describe("User is not in an organization", () => {
describe("User is not part of an organization", () => {
it(`/GET`, () => {
return request(app.getHttpServer()).get("/api/v2/oauth-clients").expect(403);
});
@@ -371,6 +371,7 @@ describe("OAuth Clients Endpoints", () => {
afterAll(async () => {
await teamFixtures.delete(org.id);
await usersFixtures.delete(user.id);
await platformBillingRepositoryFixture.deleteSubscriptionForTeam(org.id);
await app.close();
});
});
@@ -7,7 +7,7 @@ import { UsersRepository } from "@/modules/users/users.repository";
import { BadRequestException, Injectable } from "@nestjs/common";
import { User } from "@prisma/client";
import { createNewUsersConnectToOrgIfExists, slugify } from "@calcom/platform-libraries-0.0.20";
import { createNewUsersConnectToOrgIfExists, slugify } from "@calcom/platform-libraries-0.0.21";
@Injectable()
export class OAuthClientUsersService {
@@ -489,22 +489,27 @@ describe("Organizations Event Types Endpoints", () => {
);
expect(teammate1EventTypes.length).toEqual(1);
expect(teammate1EventTypes[0].title).toEqual(newTitle);
expect(teammate2EventTypes.length).toEqual(0);
expect(managedTeamEventTypes.length).toEqual(1);
expect(managedTeamEventTypes[0].assignAllTeamMembers).toEqual(false);
const responseTeamEvent = responseBody.data[0];
expect(responseTeamEvent.title).toEqual(newTitle);
expect(responseTeamEvent.assignAllTeamMembers).toEqual(false);
const responseTeammate1Event = responseBody.data[1];
expect(responseTeammate1Event.title).toEqual(newTitle);
expect(teammate1EventTypes[0].title).toEqual(newTitle);
expect(
teamEventTypes.filter((eventType) => eventType.schedulingType === "MANAGED")?.[0]?.title
).toEqual(newTitle);
const responseTeamEvent = responseBody.data.find(
(eventType) => eventType.schedulingType === "MANAGED"
);
expect(responseTeamEvent).toBeDefined();
expect(responseTeamEvent?.title).toEqual(newTitle);
expect(responseTeamEvent?.assignAllTeamMembers).toEqual(false);
const responseTeammate1Event = responseBody.data.find(
(eventType) => eventType.ownerId === teammate1.id
);
expect(responseTeammate1Event).toBeDefined();
expect(responseTeammate1Event?.title).toEqual(newTitle);
managedEventType = responseBody.data[0];
});
});
@@ -537,19 +542,28 @@ describe("Organizations Event Types Endpoints", () => {
expect(managedTeamEventTypes.length).toEqual(1);
expect(managedTeamEventTypes[0].assignAllTeamMembers).toEqual(true);
const responseTeamEvent = responseBody.data[0];
const responseTeamEvent = responseBody.data.find(
(eventType) => eventType.schedulingType === "MANAGED"
);
expect(responseTeamEvent).toBeDefined();
expect(responseTeamEvent?.teamId).toEqual(team.id);
expect(responseTeamEvent.assignAllTeamMembers).toEqual(true);
expect(responseTeamEvent?.assignAllTeamMembers).toEqual(true);
const responseTeammate1Event = responseBody.data[1];
expect(responseTeammate1Event?.ownerId).toEqual(teammate1.id);
const responseTeammate1Event = responseBody.data.find(
(eventType) => eventType.ownerId === teammate1.id
);
expect(responseTeammate1Event).toBeDefined();
expect(responseTeammate1Event?.parentEventTypeId).toEqual(responseTeamEvent?.id);
const responseTeammate2Event = responseBody.data[2];
expect(responseTeammate2Event?.ownerId).toEqual(teammate2.id);
const responseTeammate2Event = responseBody.data.find(
(eventType) => eventType.ownerId === teammate2.id
);
expect(responseTeammate1Event).toBeDefined();
expect(responseTeammate2Event?.parentEventTypeId).toEqual(responseTeamEvent?.id);
managedEventType = responseTeamEvent;
if (responseTeamEvent) {
managedEventType = responseTeamEvent;
}
});
});
@@ -1,6 +1,7 @@
import { bootstrap } from "@/app";
import { AppModule } from "@/app.module";
import { CreateOrgTeamDto } from "@/modules/organizations/inputs/create-organization-team.input";
import { OrgMeTeamOutputDto } from "@/modules/organizations/outputs/organization-team.output";
import { PrismaModule } from "@/modules/prisma/prisma.module";
import { TokensModule } from "@/modules/tokens/tokens.module";
import { UsersModule } from "@/modules/users/users.module";
@@ -149,6 +150,17 @@ describe("Organizations Team Endpoints", () => {
});
});
it("should get all the teams of the authenticated org member", async () => {
return request(app.getHttpServer())
.get(`/v2/organizations/${org.id}/teams/me`)
.expect(200)
.then((response) => {
const responseBody: ApiSuccessResponse<OrgMeTeamOutputDto[]> = response.body;
expect(responseBody.data.find((t) => t.id === teamCreatedViaApi.id)).toBeDefined();
expect(responseBody.data.some((t) => t.accepted)).toBeTruthy();
});
});
it("should update the team of the org", async () => {
return request(app.getHttpServer())
.patch(`/v2/organizations/${org.id}/teams/${teamCreatedViaApi.id}`)
@@ -8,6 +8,8 @@ import { RolesGuard } from "@/modules/auth/guards/roles/roles.guard";
import { IsTeamInOrg } from "@/modules/auth/guards/teams/is-team-in-org.guard";
import { CreateOrgTeamDto } from "@/modules/organizations/inputs/create-organization-team.input";
import {
OrgMeTeamOutputDto,
OrgMeTeamsOutputResponseDto,
OrgTeamOutputDto,
OrgTeamOutputResponseDto,
OrgTeamsOutputResponseDto,
@@ -44,7 +46,6 @@ export class OrganizationsTeamsController {
@Get()
@ApiOperation({ summary: "Get all the teams of an organization." })
@UseGuards()
@Roles("ORG_ADMIN")
async getAllTeams(
@Param("orgId", ParseIntPipe) orgId: number,
@@ -58,6 +59,33 @@ export class OrganizationsTeamsController {
};
}
@Get("/me")
@ApiOperation({ summary: "Get the organization's teams user is a member of" })
@Roles("ORG_MEMBER")
async getMyTeams(
@Param("orgId", ParseIntPipe) orgId: number,
@Query() queryParams: SkipTakePagination,
@GetUser() user: UserWithProfile
): Promise<OrgMeTeamsOutputResponseDto> {
const { skip, take } = queryParams;
const teams = await this.organizationsTeamsService.getPaginatedOrgUserTeams(
orgId,
user.id,
skip ?? 0,
take ?? 250
);
return {
status: SUCCESS_STATUS,
data: teams.map((team) =>
plainToClass(
OrgMeTeamOutputDto,
{ ...team, accepted: team.members.find((member) => member.userId === user.id)?.accepted ?? false },
{ strategy: "excludeAll" }
)
),
};
}
@UseGuards(IsTeamInOrg)
@Roles("TEAM_ADMIN")
@Get("/:teamId")
@@ -70,6 +70,26 @@ export class OrganizationsRepository {
},
});
}
async findPlatformOrgFromUserId(userId: number) {
return this.dbRead.prisma.team.findFirstOrThrow({
where: {
orgProfiles: {
some: {
userId: userId,
},
},
isPlatform: true,
isOrganization: true,
},
select: {
id: true,
isPlatform: true,
isOrganization: true,
},
});
}
async findOrgUser(organizationId: number, userId: number) {
return this.dbRead.prisma.user.findUnique({
where: {
@@ -118,6 +118,12 @@ export class OrgTeamOutputDto {
readonly weekStart?: string = "Sunday";
}
export class OrgMeTeamOutputDto extends OrgTeamOutputDto {
@IsString()
@Expose()
readonly accepted!: boolean;
}
export class OrgTeamsOutputResponseDto {
@ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] })
@IsEnum([SUCCESS_STATUS, ERROR_STATUS])
@@ -129,6 +135,17 @@ export class OrgTeamsOutputResponseDto {
data!: OrgTeamOutputDto[];
}
export class OrgMeTeamsOutputResponseDto {
@ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] })
@IsEnum([SUCCESS_STATUS, ERROR_STATUS])
status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS;
@Expose()
@ValidateNested()
@Type(() => OrgTeamOutputDto)
data!: OrgTeamOutputDto[];
}
export class OrgTeamOutputResponseDto {
@ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] })
@IsEnum([SUCCESS_STATUS, ERROR_STATUS])
@@ -49,6 +49,24 @@ export class OrganizationsTeamsRepository {
});
}
async findOrgUserTeamsPaginated(organizationId: number, userId: number, skip: number, take: number) {
return this.dbRead.prisma.team.findMany({
where: {
parentId: organizationId,
members: {
some: {
userId,
},
},
},
include: {
members: { select: { accepted: true, userId: true } },
},
skip,
take,
});
}
async getTeamMembersIds(teamId: number) {
const team = await this.dbRead.prisma.team.findUnique({
where: {
@@ -9,7 +9,7 @@ import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
import { UserWithProfile } from "@/modules/users/users.repository";
import { Injectable, NotFoundException } from "@nestjs/common";
import { createEventType, updateEventType } from "@calcom/platform-libraries-0.0.20";
import { createEventType, updateEventType } from "@calcom/platform-libraries-0.0.21";
import {
CreateTeamEventTypeInput_2024_06_14,
UpdateTeamEventTypeInput_2024_06_14,
@@ -11,6 +11,16 @@ export class OrganizationsTeamsService {
private readonly membershipsRepository: MembershipsRepository
) {}
async getPaginatedOrgUserTeams(organizationId: number, userId: number, skip = 0, take = 250) {
const teams = await this.organizationsTeamRepository.findOrgUserTeamsPaginated(
organizationId,
userId,
skip,
take
);
return teams;
}
async getPaginatedOrgTeams(organizationId: number, skip = 0, take = 250) {
const teams = await this.organizationsTeamRepository.findOrgTeamsPaginated(organizationId, skip, take);
return teams;
@@ -6,7 +6,7 @@ import { CreateUserInput } from "@/modules/users/inputs/create-user.input";
import { Injectable, ConflictException } from "@nestjs/common";
import { plainToInstance } from "class-transformer";
import { createNewUsersConnectToOrgIfExists } from "@calcom/platform-libraries-0.0.20";
import { createNewUsersConnectToOrgIfExists } from "@calcom/platform-libraries-0.0.21";
import { Team } from "@calcom/prisma/client";
@Injectable()
@@ -1,11 +1,12 @@
import { AppConfig } from "@/config/type";
import { Injectable, OnModuleDestroy } from "@nestjs/common";
import { Injectable, OnModuleDestroy, Logger } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { Redis } from "ioredis";
@Injectable()
export class RedisService implements OnModuleDestroy {
public redis: Redis;
private readonly logger = new Logger("RedisService");
constructor(readonly configService: ConfigService<AppConfig>) {
const dbUrl = configService.get<string>("db.redisUrl", { infer: true });
@@ -15,6 +16,10 @@ export class RedisService implements OnModuleDestroy {
}
async onModuleDestroy() {
await this.redis.disconnect();
try {
await this.redis.quit();
} catch (err) {
this.logger.error(err);
}
}
}
@@ -5,8 +5,8 @@ import { ApiTags as DocsTags } from "@nestjs/swagger";
import { Response as ExpressResponse, Request as ExpressRequest } from "express";
import { SUCCESS_STATUS } from "@calcom/platform-constants";
import { getAvailableSlots } from "@calcom/platform-libraries-0.0.20";
import type { AvailableSlotsType } from "@calcom/platform-libraries-0.0.20";
import { getAvailableSlots } from "@calcom/platform-libraries-0.0.21";
import type { AvailableSlotsType } from "@calcom/platform-libraries-0.0.21";
import { RemoveSelectedSlotInput, ReserveSlotInput } from "@calcom/platform-types";
import { ApiResponse, GetAvailableSlotsInput } from "@calcom/platform-types";
@@ -3,7 +3,7 @@ import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
import { Injectable } from "@nestjs/common";
import { DateTime } from "luxon";
import { MINUTES_TO_BOOK } from "@calcom/platform-libraries-0.0.20";
import { MINUTES_TO_BOOK } from "@calcom/platform-libraries-0.0.21";
import { ReserveSlotInput } from "@calcom/platform-types";
@Injectable()
@@ -4,7 +4,7 @@ import { Controller, Get } from "@nestjs/common";
import { ApiTags as DocsTags } from "@nestjs/swagger";
import { SUCCESS_STATUS } from "@calcom/platform-constants";
import type { CityTimezones } from "@calcom/platform-libraries-0.0.20";
import type { CityTimezones } from "@calcom/platform-libraries-0.0.21";
import { ApiResponse } from "@calcom/platform-types";
@Controller({
@@ -1,8 +1,8 @@
import { RedisService } from "@/modules/redis/redis.service";
import { Injectable } from "@nestjs/common";
import { cityTimezonesHandler } from "@calcom/platform-libraries-0.0.20";
import type { CityTimezones } from "@calcom/platform-libraries-0.0.20";
import { cityTimezonesHandler } from "@calcom/platform-libraries-0.0.21";
import type { CityTimezones } from "@calcom/platform-libraries-0.0.21";
@Injectable()
export class TimezonesService {
+246 -207
View File
@@ -741,6 +741,81 @@
]
}
},
"/v2/selected-calendars": {
"post": {
"operationId": "SelectedCalendarsController_addSelectedCalendar",
"parameters": [],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/SelectedCalendarsInputDto"
}
}
}
},
"responses": {
"201": {
"description": "",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/SelectedCalendarOutputResponseDto"
}
}
}
}
},
"tags": [
"Selected-Calendars"
]
},
"delete": {
"operationId": "SelectedCalendarsController_removeSelectedCalendar",
"parameters": [
{
"name": "integration",
"required": true,
"in": "query",
"schema": {
"type": "string"
}
},
{
"name": "externalId",
"required": true,
"in": "query",
"schema": {
"type": "string"
}
},
{
"name": "credentialId",
"required": true,
"in": "query",
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"description": "",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/SelectedCalendarOutputResponseDto"
}
}
}
}
},
"tags": [
"Selected-Calendars"
]
}
},
"/v2/event-types/{username}/{eventSlug}/public": {
"get": {
"operationId": "EventTypesController_2024_04_15_getPublicEventType",
@@ -1085,16 +1160,6 @@
}
}
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/CreateScheduleInput_2024_06_11"
}
}
}
},
"responses": {
"201": {
"description": "",
@@ -1197,16 +1262,6 @@
}
}
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UpdateScheduleInput_2024_06_11"
}
}
}
},
"responses": {
"200": {
"description": "",
@@ -2246,16 +2301,6 @@
}
}
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UpdateScheduleInput_2024_04_15"
}
}
}
},
"responses": {
"200": {
"description": "",
@@ -2672,6 +2717,46 @@
]
}
},
"/v2/calendars/{calendar}/disconnect": {
"post": {
"operationId": "CalendarsController_deleteCalendarCredentials",
"parameters": [
{
"name": "calendar",
"required": true,
"in": "path",
"schema": {
"type": "string"
}
}
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/DeleteCalendarCredentialsInputBodyDto"
}
}
}
},
"responses": {
"200": {
"description": "",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/DeletedCalendarCredentialsOutputResponseDto"
}
}
}
}
},
"tags": [
"Calendars"
]
}
},
"/v2/bookings": {
"get": {
"operationId": "BookingsController_getBookings",
@@ -3880,6 +3965,69 @@
"data"
]
},
"SelectedCalendarsInputDto": {
"type": "object",
"properties": {
"integration": {
"type": "string"
},
"externalId": {
"type": "string"
},
"credentialId": {
"type": "number"
}
},
"required": [
"integration",
"externalId",
"credentialId"
]
},
"SelectedCalendarOutputDto": {
"type": "object",
"properties": {
"userId": {
"type": "number"
},
"integration": {
"type": "string"
},
"externalId": {
"type": "string"
},
"credentialId": {
"type": "number",
"nullable": true
}
},
"required": [
"userId",
"integration",
"externalId",
"credentialId"
]
},
"SelectedCalendarOutputResponseDto": {
"type": "object",
"properties": {
"status": {
"type": "string",
"example": "success",
"enum": [
"success",
"error"
]
},
"data": {
"$ref": "#/components/schemas/SelectedCalendarOutputDto"
}
},
"required": [
"status",
"data"
]
},
"EventTypeLocation_2024_04_15": {
"type": "object",
"properties": {
@@ -5181,57 +5329,6 @@
"data"
]
},
"CreateScheduleInput_2024_06_11": {
"type": "object",
"properties": {
"name": {
"type": "string",
"example": "One-on-one coaching"
},
"timeZone": {
"type": "string",
"example": "Europe/Rome"
},
"availability": {
"example": [
{
"days": [
"Monday",
"Tuesday"
],
"startTime": "09:00",
"endTime": "10:00"
}
],
"type": "array",
"items": {
"$ref": "#/components/schemas/ScheduleAvailabilityInput_2024_06_11"
}
},
"isDefault": {
"type": "boolean",
"example": true
},
"overrides": {
"example": [
{
"date": "2024-05-20",
"startTime": "12:00",
"endTime": "14:00"
}
],
"type": "array",
"items": {
"$ref": "#/components/schemas/ScheduleOverrideInput_2024_06_11"
}
}
},
"required": [
"name",
"timeZone",
"isDefault"
]
},
"CreateScheduleOutput_2024_06_11": {
"type": "object",
"properties": {
@@ -5280,52 +5377,6 @@
"data"
]
},
"UpdateScheduleInput_2024_06_11": {
"type": "object",
"properties": {
"name": {
"type": "string",
"example": "One-on-one coaching"
},
"timeZone": {
"type": "string",
"example": "Europe/Rome"
},
"availability": {
"example": [
{
"days": [
"Monday",
"Tuesday"
],
"startTime": "09:00",
"endTime": "10:00"
}
],
"type": "array",
"items": {
"$ref": "#/components/schemas/ScheduleAvailabilityInput_2024_06_11"
}
},
"isDefault": {
"type": "boolean",
"example": true
},
"overrides": {
"example": [
{
"date": "2024-05-20",
"startTime": "12:00",
"endTime": "14:00"
}
],
"type": "array",
"items": {
"$ref": "#/components/schemas/ScheduleOverrideInput_2024_06_11"
}
}
}
},
"UpdateScheduleOutput_2024_06_11": {
"type": "object",
"properties": {
@@ -6416,26 +6467,6 @@
"userId"
]
},
"GetDefaultScheduleOutput_2024_06_11": {
"type": "object",
"properties": {
"status": {
"type": "string",
"example": "success",
"enum": [
"success",
"error"
]
},
"data": {
"$ref": "#/components/schemas/ScheduleOutput_2024_06_11"
}
},
"required": [
"status",
"data"
]
},
"CreateAvailabilityInput_2024_04_15": {
"type": "object",
"properties": {
@@ -6733,66 +6764,6 @@
"data"
]
},
"UpdateScheduleInput_2024_04_15": {
"type": "object",
"properties": {
"timeZone": {
"type": "string"
},
"name": {
"type": "string"
},
"isDefault": {
"type": "boolean"
},
"schedule": {
"example": [
[],
[
{
"start": "2022-01-01T00:00:00.000Z",
"end": "2022-01-02T00:00:00.000Z"
}
],
[],
[],
[],
[],
[]
],
"items": {
"type": "array"
},
"type": "array"
},
"dateOverrides": {
"example": [
[],
[
{
"start": "2022-01-01T00:00:00.000Z",
"end": "2022-01-02T00:00:00.000Z"
}
],
[],
[],
[],
[],
[]
],
"items": {
"type": "array"
},
"type": "array"
}
},
"required": [
"timeZone",
"name",
"isDefault",
"schedule"
]
},
"EventTypeModel_2024_04_15": {
"type": "object",
"properties": {
@@ -7422,6 +7393,74 @@
"data"
]
},
"DeleteCalendarCredentialsInputBodyDto": {
"type": "object",
"properties": {
"id": {
"type": "integer",
"example": 10,
"description": "Credential ID of the calendar to delete, as returned by the /calendars endpoint"
}
},
"required": [
"id"
]
},
"DeletedCalendarCredentialsOutputDto": {
"type": "object",
"properties": {
"id": {
"type": "number"
},
"type": {
"type": "string"
},
"userId": {
"type": "number",
"nullable": true
},
"teamId": {
"type": "number",
"nullable": true
},
"appId": {
"type": "string",
"nullable": true
},
"invalid": {
"type": "boolean",
"nullable": true
}
},
"required": [
"id",
"type",
"userId",
"teamId",
"appId",
"invalid"
]
},
"DeletedCalendarCredentialsOutputResponseDto": {
"type": "object",
"properties": {
"status": {
"type": "string",
"example": "success",
"enum": [
"success",
"error"
]
},
"data": {
"$ref": "#/components/schemas/DeletedCalendarCredentialsOutputDto"
}
},
"required": [
"status",
"data"
]
},
"Attendee": {
"type": "object",
"properties": {
@@ -22,4 +22,17 @@ export class PlatformBillingRepositoryFixture {
},
});
}
async deleteSubscriptionForTeam(teamId: number) {
// silently try to delete the subscription
try {
await this.prismaWriteClient.platformBilling.delete({
where: {
id: teamId,
},
});
} catch (err) {
console.error(err);
}
}
}
@@ -542,7 +542,9 @@ function BookingListItem(booking: BookingItemProps) {
<td className="flex w-full justify-end py-4 pl-4 text-right text-sm font-medium ltr:pr-4 rtl:pl-4 sm:pl-0">
{isUpcoming && !isCancelled ? (
<>
{isPending && userId === booking.user?.id && <TableActions actions={pendingActions} />}
{isPending && (userId === booking.user?.id || booking.isUserTeamAdminOrOwner) && (
<TableActions actions={pendingActions} />
)}
{isConfirmed && <TableActions actions={bookedActions} />}
{isRejected && <div className="text-subtle text-sm">{t("rejected")}</div>}
</>
@@ -244,9 +244,11 @@ export const EditLocationDialog = (props: ISetLocationDialog) => {
</h3>
{!booking && (
<p className="text-default text-sm">
<Trans i18nKey="cant_find_the_right_video_app_visit_our_app_store">
Can&apos;t find the right video app? Visit our
<Link className="cursor-pointer text-blue-500 underline" href="/apps/categories/video">
<Trans i18nKey="cant_find_the_right_conferencing_app_visit_our_app_store">
Can&apos;t find the right conferencing app? Visit our
<Link
className="cursor-pointer text-blue-500 underline"
href="/apps/categories/conferencing">
App Store
</Link>
.
+3 -3
View File
@@ -431,9 +431,9 @@ const Locations: React.FC<LocationsProps> = ({
</ul>
{props.showAppStoreLink && (
<p className="text-default mt-2 text-sm">
<Trans i18nKey="cant_find_the_right_video_app_visit_our_app_store">
Can&apos;t find the right video app? Visit our
<Link className="cursor-pointer text-blue-500 underline" href="/apps/categories/video">
<Trans i18nKey="cant_find_the_right_conferencing_app_visit_our_app_store">
Can&apos;t find the right conferencing app? Visit our
<Link className="cursor-pointer text-blue-500 underline" href="/apps/categories/conferencing">
App Store
</Link>
.
@@ -121,36 +121,34 @@ export function UserPage(props: InferGetServerSidePropsType<typeof getServerSide
className={classNames("rounded-md ", !isEventListEmpty && "border-subtle border")}
data-testid="event-types">
{eventTypes.map((type) => (
<div
<Link
key={type.id}
style={{ display: "flex", ...eventTypeListItemEmbedStyles }}
className="bg-default border-subtle dark:bg-muted dark:hover:bg-emphasis hover:bg-muted group relative border-b transition first:rounded-t-md last:rounded-b-md last:border-b-0">
prefetch={false}
href={{
pathname: `/${user.profile.username}/${type.slug}`,
query,
}}
passHref
onClick={async () => {
sdkActionManager?.fire("eventTypeSelected", {
eventType: type,
});
}}
className="bg-default border-subtle dark:bg-muted dark:hover:bg-emphasis hover:bg-muted group relative border-b transition first:rounded-t-md last:rounded-b-md last:border-b-0"
data-testid="event-type-link">
<Icon
name="arrow-right"
className="text-emphasis absolute right-4 top-4 h-4 w-4 opacity-0 transition-opacity group-hover:opacity-100"
/>
{/* Don't prefetch till the time we drop the amount of javascript in [user][type] page which is impacting score for [user] page */}
<div className="block w-full p-5">
<Link
prefetch={false}
href={{
pathname: `/${user.profile.username}/${type.slug}`,
query,
}}
passHref
onClick={async () => {
sdkActionManager?.fire("eventTypeSelected", {
eventType: type,
});
}}
data-testid="event-type-link">
<div className="flex flex-wrap items-center">
<h2 className="text-default pr-2 text-sm font-semibold">{type.title}</h2>
</div>
<EventTypeDescription eventType={type} isPublic={true} shortenDescription />
</Link>
<div className="flex flex-wrap items-center">
<h2 className="text-default pr-2 text-sm font-semibold">{type.title}</h2>
</div>
<EventTypeDescription eventType={type} isPublic={true} shortenDescription />
</div>
</div>
</Link>
))}
</div>
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@calcom/web",
"version": "4.3.2",
"version": "4.3.3",
"private": true,
"scripts": {
"analyze": "ANALYZE=true next build",
@@ -1,6 +1,5 @@
"use client";
import LicenseRequired from "@calcom/features/ee/common/components/LicenseRequired";
import { CreateANewLicenseKeyForm } from "@calcom/features/ee/deployment/licensekey/CreateLicenseKeyForm";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { WizardLayout, Meta } from "@calcom/ui";
@@ -12,10 +11,10 @@ import PageWrapper from "@components/PageWrapper";
const CreateNewLicenseKeyPage = () => {
const { t } = useLocale();
return (
<LicenseRequired>
<>
<Meta title={t("set_up_your_organization")} description={t("organizations_description")} />
<CreateANewLicenseKeyForm />
</LicenseRequired>
</>
);
};
const LayoutWrapper = (page: React.ReactElement) => {
@@ -30,4 +29,5 @@ CreateNewLicenseKeyPage.getLayout = LayoutWrapper;
CreateNewLicenseKeyPage.PageWrapper = PageWrapper;
export default CreateNewLicenseKeyPage;
export { getServerSideProps };
@@ -1562,7 +1562,6 @@
"how_long_after": "كم من الوقت بعد انتهاء الحدث؟",
"no_available_slots": "لا توجد فترات زمنية متاحة",
"time_available": "الوقت المتاح",
"cant_find_the_right_video_app_visit_our_app_store": "لا يمكن العثور على تطبيق الفيديو الصحيح؟ قم بزيارة متجر التطبيقات <1>App Store</1>.",
"install_new_calendar_app": "تثبيت تطبيق تقويم جديد",
"make_phone_number_required": "جعل رقم الهاتف مطلوب لحجز الحدث",
"new_event_type_availability": "توافر {{eventTypeTitle}}",
@@ -1496,7 +1496,6 @@
"how_long_after": "Jak dlouho po ukončení události?",
"no_available_slots": "Žádné dostupné sloty",
"time_available": "Dostupný čas",
"cant_find_the_right_video_app_visit_our_app_store": "Nemůžete najít správnou video aplikaci? Navštivte náš <1>App Store</1>.",
"install_new_calendar_app": "Nainstalovat novou aplikaci kalendáře",
"make_phone_number_required": "Pro rezervaci události vyžadovat telefonní číslo",
"new_event_type_availability": "Dostupnost pro {{eventTypeTitle}}",
@@ -1301,7 +1301,6 @@
"how_long_after": "Hvor længe efter begivenheden slutter?",
"no_available_slots": "Ingen ledige tidsrum",
"time_available": "Tid tilgængelig",
"cant_find_the_right_video_app_visit_our_app_store": "Kan du ikke finde den rette videoapp? Besøg vores <1>App Store</1>.",
"install_new_calendar_app": "Installér ny kalenderapp",
"make_phone_number_required": "Gør telefonnummer påkrævet for at booke begivenhed",
"new_event_type_availability": "{{eventTypeTitle}} Tilgængelighed",
@@ -1581,7 +1581,6 @@
"how_long_after": "Wie lange nach Ende des Termins?",
"no_available_slots": "Keine verfügbaren Plätze",
"time_available": "Zeit verfügbar",
"cant_find_the_right_video_app_visit_our_app_store": "Sie können die richtige Video-App nicht finden? Besuchen Sie unseren <1>App Store</1>.",
"install_new_calendar_app": "Neue Kalender-App installieren",
"make_phone_number_required": "Telefonnummer für die Buchung des Termins erforderlich machen",
"new_event_type_availability": "{{eventTypeTitle}} | Verfügbarkeit",
@@ -1597,7 +1597,7 @@
"how_long_after": "How long after event ends?",
"no_available_slots": "No Available slots",
"time_available": "Time available",
"cant_find_the_right_video_app_visit_our_app_store": "Can't find the right video app? Visit our <1>App Store</1>.",
"cant_find_the_right_conferencing_app_visit_our_app_store": "Can't find the right conferencing app? Visit our <1>App Store</1>.",
"install_new_calendar_app": "Install new calendar app",
"make_phone_number_required": "Make phone number required for booking event",
"new_event_type_availability": "{{eventTypeTitle}} Availability",
@@ -1495,7 +1495,6 @@
"how_long_after": "¿Cuánto tiempo después de que termine el evento?",
"no_available_slots": "No hay espacios disponibles",
"time_available": "Tiempo disponible",
"cant_find_the_right_video_app_visit_our_app_store": "¿No encuentra la aplicación de vídeo correcta? Visite nuestra <1>App Store</1>.",
"install_new_calendar_app": "Instalar nueva aplicación de calendario",
"make_phone_number_required": "Solicitar número de teléfono para reservar el evento",
"new_event_type_availability": "Disponibilidad para {{eventTypeTitle}}",
@@ -1586,7 +1586,6 @@
"how_long_after": "Kui kaua pärast ürituse lõppu?",
"no_available_slots": "Saadaolevaid teenindusaegu pole",
"time_available": "Aeg saadaval",
"cant_find_the_right_video_app_visit_our_app_store": "Kas te ei leia õiget videorakendust? Külastage meie <1>App Store'i</1>.",
"install_new_calendar_app": "Installi uus kalendrirakendus",
"make_phone_number_required": "Tee telefoninumber ürituse broneerimiseks vajalikuks",
"new_event_type_availability": "{{eventTypeTitle}} Saadavus",
@@ -1581,7 +1581,6 @@
"how_long_after": "Combien de temps après la fin de l'événement ?",
"no_available_slots": "Aucun créneau disponible",
"time_available": "Disponibilités",
"cant_find_the_right_video_app_visit_our_app_store": "Vous ne trouvez pas la bonne application de visioconférence ? Visitez notre <1>App Store</1>.",
"install_new_calendar_app": "Installer une nouvelle application de calendrier",
"make_phone_number_required": "Rendre le numéro de téléphone obligatoire pour la réservation de l'événement",
"new_event_type_availability": "Disponibilités de {{eventTypeTitle}}",
@@ -1578,7 +1578,6 @@
"how_long_after": "כמה זמן לאחר סיום האירוע?",
"no_available_slots": "אין חלונות זמן זמינים",
"time_available": "זמן זמין",
"cant_find_the_right_video_app_visit_our_app_store": "לא הצלחת למצוא את האפליקציה הנכונה לשיחות ועידה? כדאי לבקר ב-<1>App Store</1> שלנו.",
"install_new_calendar_app": "התקנת האפליקציה החדשה של לוחות שנה",
"make_phone_number_required": "הגדרת מספר טלפון כפרט נדרש עבור סוג האירוע הזה",
"new_event_type_availability": "זמינות {{eventTypeTitle}}",
@@ -1578,7 +1578,6 @@
"how_long_after": "Mennyi idővel az esemény vége után?",
"no_available_slots": "Nincsenek elérhető helyek",
"time_available": "Rendelkezésre álló idő",
"cant_find_the_right_video_app_visit_our_app_store": "Nem találja a megfelelő videóalkalmazást? Látogassa meg <1>Alkalmazás Bolt-unkat</1>.",
"install_new_calendar_app": "Telepítsen új naptáralkalmazást",
"make_phone_number_required": "Az esemény foglalásához telefonszám szükséges",
"new_event_type_availability": "{{eventTypeTitle}} Elérhetőség",
@@ -1581,7 +1581,6 @@
"how_long_after": "Quanto tempo dopo la fine dell'evento?",
"no_available_slots": "Nessuna fascia oraria disponibile",
"time_available": "Tempo disponibile",
"cant_find_the_right_video_app_visit_our_app_store": "Non riesci a trovare l'applicazione video necessaria? Visita il nostro <1>App Store</1>.",
"install_new_calendar_app": "Installa nuova app di calendario",
"make_phone_number_required": "Rendi obbligatorio il numero di telefono per prenotare un evento",
"new_event_type_availability": "Disponibilità {{eventTypeTitle}}",
@@ -1496,7 +1496,6 @@
"how_long_after": "イベント終了後のどのくらい後にしますか?",
"no_available_slots": "利用可能な時間帯がありません",
"time_available": "利用可能な時間帯",
"cant_find_the_right_video_app_visit_our_app_store": "適切なビデオアプリが見つかりませんか?<1>App Store</1>をご覧ください。",
"install_new_calendar_app": "新しいカレンダーアプリをインストールする",
"make_phone_number_required": "イベントの予約時に電話番号の入力を必須にする",
"new_event_type_availability": "{{eventTypeTitle}} 空き状況",
@@ -1496,7 +1496,6 @@
"how_long_after": "이벤트 종료 후 얼마나 경과해야 합니까?",
"no_available_slots": "사용 가능한 슬롯 없음",
"time_available": "시간 사용 가능",
"cant_find_the_right_video_app_visit_our_app_store": "올바른 비디오 앱을 찾을 수 없으신가요? <1>App Store</1>를 방문하세요.",
"install_new_calendar_app": "새 캘린더 앱 설치",
"make_phone_number_required": "예약 이벤트에 전화번호가 필요하도록 만들기",
"new_event_type_availability": "{{eventTypeTitle}} 가용성",
@@ -1496,7 +1496,6 @@
"how_long_after": "Hoelang na afloop van de gebeurtenis?",
"no_available_slots": "Geen beschikbare tijdvakken",
"time_available": "Tijd beschikbaar",
"cant_find_the_right_video_app_visit_our_app_store": "Kunt u de juiste video-app niet vinden? Bezoek onze <1>App Store</1>.",
"install_new_calendar_app": "Installeer een nieuwe agenda-app",
"make_phone_number_required": "Een telefoonnummer verplicht stellen voor het boeken van een gebeurtenis",
"new_event_type_availability": "Beschikbaarheid {{eventTypeTitle}}",
@@ -1279,7 +1279,6 @@
"how_long_after": "Hvor lenge etter at hendelsen er ferdig?",
"no_available_slots": "Ingen Tilgjengelige tidsluker",
"time_available": "Tilgjengelig tid",
"cant_find_the_right_video_app_visit_our_app_store": "Finner du ikke riktig video app? Besøk vår <1>App Butikk</1>.",
"install_new_calendar_app": "Installer ny kalender-app",
"make_phone_number_required": "Gjør telefonnummer pålagt for å booke hendelser",
"new_event_type_availability": "{{eventTypeTitle}} Tilgjengelighet",
@@ -1496,7 +1496,6 @@
"how_long_after": "Jak długo po zakończeniu wydarzenia?",
"no_available_slots": "Brak dostępnych przedziałów",
"time_available": "Dostępny czas",
"cant_find_the_right_video_app_visit_our_app_store": "Nie możesz znaleźć właściwej aplikacji wideo? Odwiedź nasz sklep <1>App Store</1>.",
"install_new_calendar_app": "Zainstaluj nową aplikację kalendarza",
"make_phone_number_required": "Ustaw wymóg podania numeru telefonu w celu rezerwacji wydarzenia",
"new_event_type_availability": "{{eventTypeTitle}} – dostępność",
@@ -1576,7 +1576,6 @@
"how_long_after": "Quanto tempo após o término do evento?",
"no_available_slots": "Não há espaços disponíveis",
"time_available": "Tempo disponível",
"cant_find_the_right_video_app_visit_our_app_store": "Não consegue achar o app de vídeo correto? Acesse a nossa <1>App Store</1>.",
"install_new_calendar_app": "Instale novo aplicativo de calendário",
"make_phone_number_required": "Torne o número de telefone necessário para o evento de reserva",
"new_event_type_availability": "Disponibilidade de {{eventTypeTitle}}",
@@ -1581,7 +1581,6 @@
"how_long_after": "Quanto tempo após o fim do evento?",
"no_available_slots": "Sem vagas disponíveis",
"time_available": "Tempo disponível",
"cant_find_the_right_video_app_visit_our_app_store": "Não conseguiu encontrar a aplicação de vídeo ideal? Visite a nossa <1>App Store</1>.",
"install_new_calendar_app": "Instalar a nova aplicação de calendário",
"make_phone_number_required": "Tornar o número de telefone obrigatório na reserva de um evento",
"new_event_type_availability": "Disponibilidade de {{eventTypeTitle}}",
@@ -1496,7 +1496,6 @@
"how_long_after": "La cât timp după finalul evenimentului?",
"no_available_slots": "Niciun interval disponibil",
"time_available": "Oră disponibilă",
"cant_find_the_right_video_app_visit_our_app_store": "Nu găsiți aplicația video corectă? Vizitați <1>App Store</1>.",
"install_new_calendar_app": "Instalați noua aplicație pentru calendar",
"make_phone_number_required": "Setați numărul de telefon ca fiind obligatoriu pentru rezervarea evenimentului",
"new_event_type_availability": "Disponibilitate {{eventTypeTitle}}",
@@ -1497,7 +1497,6 @@
"how_long_after": "Спустя какое время после окончания события?",
"no_available_slots": "Нет доступных интервалов",
"time_available": "Доступное время",
"cant_find_the_right_video_app_visit_our_app_store": "Не удается найти подходящее приложение для просмотра видео? Посетите наш <1>App Store</1>.",
"install_new_calendar_app": "Установить новое приложение календаря",
"make_phone_number_required": "Сделать номер телефона обязательным для бронирования",
"new_event_type_availability": "{{eventTypeTitle}} | Доступность",
@@ -1496,7 +1496,6 @@
"how_long_after": "Koliko vremena nakon što se događaj završi?",
"no_available_slots": "Nema dostupnih termina",
"time_available": "Dostupno vreme",
"cant_find_the_right_video_app_visit_our_app_store": "Ne možete da nađete pravu video aplikaciju? Posetite našu <1>App Store</1>.",
"install_new_calendar_app": "Instaliraj novu aplikaciju kalendara",
"make_phone_number_required": "Postaviti da je neophodan telefonski broj za rezervaciju događaja",
"new_event_type_availability": "{{eventTypeTitle}} Dostupnost",
@@ -1496,7 +1496,6 @@
"how_long_after": "Hur länge efter händelsens slut?",
"no_available_slots": "Inga tillgängliga tider",
"time_available": "Tid tillgänglig",
"cant_find_the_right_video_app_visit_our_app_store": "Kan du inte hitta rätt videoapp? Besök vår <1>App Store</1>.",
"install_new_calendar_app": "Installera ny kalenderapp",
"make_phone_number_required": "Gör telefonnummer obligatoriskt för att boka händelse",
"new_event_type_availability": "Tillgänglighet till {{eventTypeTitle}}",
@@ -1496,7 +1496,6 @@
"how_long_after": "Etkinlik sona erdikten ne kadar sonra?",
"no_available_slots": "Uygun yer yok",
"time_available": "Uygun zaman",
"cant_find_the_right_video_app_visit_our_app_store": "Doğru video uygulamasını bulamıyor musunuz? <1>Uygulama Mağazamızı</1> ziyaret edin.",
"install_new_calendar_app": "Yeni takvim uygulamasını yükleyin",
"make_phone_number_required": "Rezervasyon etkinliği için telefon numarasını gerekli hale getirin",
"new_event_type_availability": "{{eventTypeTitle}} Müsaitlik Durumu",
@@ -1528,7 +1528,6 @@
"how_long_after": "Через який час після завершення заходу?",
"no_available_slots": "Немає доступних вікон",
"time_available": "Доступний час",
"cant_find_the_right_video_app_visit_our_app_store": "Не можете знайти потрібний додаток для відео? Перейдіть у наш <1>магазин програм</1>.",
"install_new_calendar_app": "Установити новий додаток календаря",
"make_phone_number_required": "Вимагати введення номера телефону під час бронювання заходу",
"new_event_type_availability": "Доступність: {{eventTypeTitle}}",
@@ -1499,7 +1499,6 @@
"how_long_after": "Bao lâu sau khi sự kiện kết thúc?",
"no_available_slots": "Không còn khoảng thời gian trống",
"time_available": "Thời gian trống",
"cant_find_the_right_video_app_visit_our_app_store": "Không thể tìm thấy ứng dụng video phù hợp? Ghé <1>App Store</1> của chúng tôi.",
"install_new_calendar_app": "Cài ứng dụng lịch mới",
"make_phone_number_required": "Yêu cầu điện thoại khi đặt lịch hẹn sự kiện",
"new_event_type_availability": "Tình trạng trống lịch của {{eventTypeTitle}}",

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