Compare commits

...
27 Commits
Author SHA1 Message Date
Omar López dfbcf4742f v4.2.3 (#15604)
release.yml / release (push) Canceled after 0s
2024-06-27 22:52:12 +02:00
3a2a70d83a fix: getPublicEventType of NonOrgUser fetch OrgUser event-type of same username (#15587)
* fix: getPublicEventType of NonOrgUser fetch OrgUser event-type with same username

* Fix the test that was supposed to verify the scenario

---------

Co-authored-by: Hariom <hariombalhara@gmail.com>
Co-authored-by: Omar López <zomars@me.com>
2024-06-27 20:32:19 +00:00
3948b8d203 feat: booking no show webhook (#15502)
* WIP

Signed-off-by: zomars <zomars@me.com>

* WIP

Signed-off-by: zomars <zomars@me.com>

* WIP

Signed-off-by: zomars <zomars@me.com>

* Type fixes

* Update webhook.e2e.ts

* Update noShow.handler.ts

* Log failed results

* Updates tests

* Show generic error on frontend.

* test: add basic webhook service test

* fix: webhook end to end test

* fix: type error

* fix: test

---------

Signed-off-by: zomars <zomars@me.com>
Co-authored-by: sean-brydon <sean@cal.com>
Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com>
Co-authored-by: Udit Takkar <udit222001@gmail.com>
2024-06-27 20:12:09 +00:00
Keith Williams 6207afdf03 fix: PR update E2E tests problems (#15603) 2024-06-27 19:12:59 +00:00
Lauris Skraucis 52c7a89785 feat: organization schedules endpoints (#15584)
* feat: organization schedules endpoints

* refactor: add IsUserInOrg guard

* refactor: separate schedules controller folder

* tests

* chore: add roles guard and test it

* refactor: dont use Promise.all

* refactor: rely on profiles instead of user.organizationId

* feat: add pagination to getOrganizationSchedules

* refactor: default skip=0 and take=250

* chore: regenerate swagger doc
2024-06-27 19:04:26 +00:00
Hariom BalharaandAlex van Andel dda4ce5338 fix-moving-a-team-with-just-owner (#15595)
Co-authored-by: Alex van Andel <me@alexvanandel.com>
2024-06-27 17:57:48 +00:00
a02747fc52 fix: update z-index for intercom app launcher (#15516)
Co-authored-by: Morgan <33722304+ThyMinimalDev@users.noreply.github.com>
Co-authored-by: sean-brydon <55134778+sean-brydon@users.noreply.github.com>
2024-06-27 17:57:29 +00:00
Morgan 03311def07 fix: update version apiv2 platform libraries for createNewUsersConnectToOrgIfExists (#15601) 2024-06-27 17:14:00 +00:00
5e382ffd21 feat: apple calendar connect atom (#15510)
* init apple calendar service

* update apple calendar service logic

* update typing for CalendarApp interface

* add id and type for apple calendar

* fixup: logic for apple calendar service

* update logic for checkIfCalendarConnected function

* add apple calendar service to calendars module

* fixup

* update calendars to include cases for apple calendar

* fix imports

* init frontend for apple calendar connect

* add apple connect atom to connect atom

* fixup

* fixup

* add alias for latest version of platform library

* fix import paths

* custom hook for saving apple calendar credentials

* add apple calendar to examples app

* add interface for credentials syncing calendars

* bring calendars controller to orginal state

* refactor

* update custom hook to sync credentials

* update atom

* resolve merge conflicts

* add endpoint for syncing calendar credentials

* fixup

* update atom

* update useCheck to invalidate queries onSubmit

* fix typo

* fixup

* remove unused tokens respository

* rename controller

* cleanup

* fix: reset react queryClient on access token change

---------

Co-authored-by: Rajiv Sahal <rajivsahal@Rajivs-MacBook-Pro.local>
Co-authored-by: Morgan <33722304+ThyMinimalDev@users.noreply.github.com>
Co-authored-by: Morgan Vernay <morgan@cal.com>
2024-06-27 15:51:27 +00:00
Udit Takkar 0e0dfcdd88 fix: reduce cal recording bitrate (#15588)
* fix: reduce cal recording bitrate

* chore: bitrate

* chore: add enable recording ui

* fix: genereate meeting token

* chore: add wait for recording
2024-06-27 15:43:20 +00:00
Morgan e260809b08 feat: organizations teams endpoints apiv2 (#15586)
* feat: organizations teams endpoints apiv2

* fixup! Merge branch 'main' into feat-api-v2-org-teams-endpoints

* chore: add roles guard to endpoints

* add tests

* fixup! Merge branch 'main' into feat-api-v2-org-teams-endpoints

* fixup! fixup! Merge branch 'main' into feat-api-v2-org-teams-endpoints

* fixup! fixup! fixup! Merge branch 'main' into feat-api-v2-org-teams-endpoints
2024-06-27 18:04:37 +02:00
sean-brydonandHariom Balhara 835443a0d1 fix: cancel sub when movingTeamToOrg (#15594)
* fix: cancel sub when movingTeamToOrg

* Cleanup code a little

* Update packages/trpc/server/routers/viewer/organizations/createTeams.handler.ts

Co-authored-by: Hariom Balhara <hariombalhara@gmail.com>

* Update packages/trpc/server/routers/viewer/organizations/createTeams.handler.ts

Co-authored-by: Hariom Balhara <hariombalhara@gmail.com>

---------

Co-authored-by: Hariom Balhara <hariombalhara@gmail.com>
2024-06-27 10:38:42 +00:00
Crowdin Bot 48842e72a6 New Crowdin translations by Github Action 2024-06-27 09:58:07 +00:00
Morgan ff8931f4a0 fix: path /me endpoint should capitalize timezone apiv2 (#15593) 2024-06-27 09:41:00 +00:00
Hariom Balhara 6670bbc1d7 fix: Error in team members migration during org onboarding (#15349)
* fix: Error in team members migration during org onboarding

* Add invitationMemberHandler tests

* Add unit tests

* Improve tests and refactor

* Improve tests and refactor

* Fix type issue

* Fix createNewUsersConnectToOrgIfExists args
2024-06-27 00:53:55 +00:00
Alex van Andel 750676f91a chore: Add dynamic import and trigger captureException (#15583) 2024-06-26 22:32:26 +00:00
Sangram Bahadur db60f8faed setting background color to red for remove button in profile section (#15585) 2024-06-26 20:52:20 +00:00
Somay Chauhan 2b3cb20fdf fix: office365video expiry_date (#15561)
* fix: office#65Video expiry_date

* Revert "fix: office#65Video expiry_date"

This reverts commit 6142287805b5c6a40e746126735745a5617334c7.

* Change logger.debug to logger.error for better visibility of all log cases
2024-06-26 18:12:36 +00:00
Morgan 7f1d45bb5d chore: minimum required roles guard api-v2 (#15576)
* chore: minimum required roles guard api-v2

* fixup! chore: minimum required roles guard api-v2

* fixup! fixup! chore: minimum required roles guard api-v2

* fixup! Merge branch 'chore-roles-guard-api-v2' of github.com:calcom/cal.com into chore-roles-guard-api-v2

* fixup! Merge branch 'chore-roles-guard-api-v2' of github.com:calcom/cal.com into chore-roles-guard-api-v2
2024-06-26 18:00:48 +00:00
Lauris Skraucis 6632c26063 fix: exclude web wrappers from dist (#15572) 2024-06-26 14:41:37 +00:00
Keith Williams 97a3189c12 chore: Update PR template (#15581) 2024-06-26 14:29:26 +00:00
Udit Takkar d25595b546 feat: add instant meeting expiry input (#15555)
* feat: add instant meeting expiry input

* fix: type err

* chore: remove type

* chore: update label

* chore: update column name
2024-06-26 13:56:05 +00:00
Keith Williams a28eb63d61 chore: Removed unwanted CI changes that made it into main (#15580) 2024-06-26 13:24:38 +00:00
Omar Lópezandsean-brydon 4f944ec12d hotfix: team billing on batch operations (#15570)
Co-authored-by: sean-brydon <55134778+sean-brydon@users.noreply.github.com>
2024-06-26 12:06:07 +00:00
Keith Williams e33930e5c0 chore: Cache timezones by calcom version (#15556)
* chore: Cache timezones by calcom version

* Fix types
2024-06-26 11:43:02 +00:00
Lauris Skraucis 9613b9da6d chore: IsUserInOrd guard (#15579) 2024-06-26 11:21:39 +00:00
Morgan ec755b142c chore: IsTeamInOrg guard and decorator apiv2 (#15567) 2024-06-26 07:32:49 +00:00
112 changed files with 4761 additions and 1161 deletions
+3 -3
View File
@@ -11,9 +11,9 @@
## Mandatory Tasks (DO NOT REMOVE)
- [ ] I have self-reviewed the code (A decent size PR without self-review might be rejected)
- [ ] I have added a Docs issue [here](https://github.com/calcom/docs/issues/new) if this PR makes changes that would require a [documentation change](https://docs.cal.com)
- [ ] I have added or modified automated tests that prove my fix is effective or that my feature works (PRs might be rejected if logical changes are not properly tested)
- [ ] I have self-reviewed the code (A decent size PR without self-review might be rejected).
- [ ] I have added a Docs issue [here](https://github.com/calcom/docs/issues/new) if this PR makes changes that would require a [documentation change](https://docs.cal.com). If N/A, write N/A here and check the checkbox.
- [ ] I confirm automated tests are in place that prove my fix is effective or that my feature works.
## How should this be tested?
+2 -1
View File
@@ -17,6 +17,7 @@ runs:
cache-name: cache-db
key-1: ${{ hashFiles('packages/prisma/schema.prisma', 'packages/prisma/migrations/**/**.sql', 'packages/prisma/*.ts') }}
key-2: ${{ github.event.pull_request.number || github.ref }}
key-3: ${{ github.event.pull_request.head.sha }}
DATABASE_URL: ${{ inputs.DATABASE_URL }}
DATABASE_DIRECT_URL: ${{ inputs.DATABASE_URL }}
E2E_TEST_CALCOM_QA_EMAIL: ${{ inputs.E2E_TEST_CALCOM_QA_EMAIL }}
@@ -24,7 +25,7 @@ runs:
E2E_TEST_CALCOM_QA_GCAL_CREDENTIALS: ${{ inputs.E2E_TEST_CALCOM_QA_GCAL_CREDENTIALS }}
with:
path: ${{ inputs.path }}
key: ${{ runner.os }}-${{ env.cache-name }}-${{ inputs.path }}-${{ env.key-1 }}-${{ env.key-2 }}
key: ${{ runner.os }}-${{ env.cache-name }}-${{ inputs.path }}-${{ env.key-1 }}-${{ env.key-2 }}-${{ env.key-3 }}
- run: echo ${{ env.E2E_TEST_CALCOM_QA_GCAL_CREDENTIALS }} && yarn db-seed
if: steps.cache-db.outputs.cache-hit != 'true'
shell: bash
-54
View File
@@ -1,54 +0,0 @@
name: PR Labeled with ready-for-e2e
on:
pull_request_target:
types: [labeled]
branches:
- main
- gh-actions-test-branch
workflow_dispatch:
jobs:
run-e2e-jobs:
name: Run E2E Jobs
runs-on: buildjet-2vcpu-ubuntu-2204
permissions:
pull-requests: read
steps:
- uses: actions/checkout@v4
- uses: ./.github/actions/dangerous-git-checkout
- name: Check Label and Retrigger Checks
uses: actions/github-script@v7
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
script: |
const labelName = context.payload.label.name;
const prNumber = context.payload.pull_request.number;
const owner = context.repo.owner;
const repo = context.repo.repo;
if (labelName === 'ready-for-e2e') {
console.log(`Running E2E jobs for PR #${prNumber}...`);
const checkRuns = await github.rest.checks.listForRef({
owner,
repo,
ref: context.payload.pull_request.head.sha,
});
for (const check of checkRuns.data.check_runs) {
console.log('check.name', check.name);
if (check.name.includes('e2e')) {
await github.rest.actions.reRunJob({
owner,
repo,
job_id: check.id,
});
console.log(`Triggering job #${check.id}`);
}
}
console.log(`Triggered E2E checks for PR #${prNumber}`);
} else {
console.log(`Label ${labelName} does not trigger re-running checks.`);
}
+15 -15
View File
@@ -105,13 +105,6 @@ jobs:
uses: ./.github/workflows/unit-tests.yml
secrets: inherit
integration-test:
name: Tests
needs: [changes, check-label]
if: ${{ needs.changes.outputs.has-files-requiring-all-checks == 'true' }}
uses: ./.github/workflows/integration-tests.yml
secrets: inherit
build-api-v1:
name: Production builds
needs: [changes, check-label]
@@ -133,30 +126,37 @@ jobs:
uses: ./.github/workflows/production-build-without-database.yml
secrets: inherit
integration-test:
name: Tests
needs: [changes, check-label, build, build-api-v1, build-api-v2]
if: ${{ needs.changes.outputs.has-files-requiring-all-checks == 'true' }}
uses: ./.github/workflows/integration-tests.yml
secrets: inherit
e2e:
name: e2e
needs: [changes, check-label, build]
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.yml
secrets: inherit
e2e-app-store:
name: e2e-app-store
needs: [changes, check-label, build]
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-app-store.yml
secrets: inherit
e2e-embed:
name: e2e-embed
needs: [changes, check-label, build]
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-embed.yml
secrets: inherit
e2e-embed-react:
name: e2e-embed-react
needs: [changes, check-label, build]
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-embed-react.yml
secrets: inherit
+2
View File
@@ -26,8 +26,10 @@
"dependencies": {
"@calcom/platform-constants": "*",
"@calcom/platform-libraries-0.0.13": "npm:@calcom/platform-libraries@0.0.13",
"@calcom/platform-libraries-0.0.15": "npm:@calcom/platform-libraries@0.0.15",
"@calcom/platform-libraries-0.0.2": "npm:@calcom/platform-libraries@0.0.2",
"@calcom/platform-libraries-0.0.4": "npm:@calcom/platform-libraries@0.0.4",
"@calcom/platform-libraries-0.0.14": "npm:@calcom/platform-libraries@0.0.14",
"@calcom/platform-types": "*",
"@calcom/platform-utils": "*",
"@calcom/prisma": "*",
@@ -7,6 +7,11 @@ export interface CalendarApp {
check(userId: number): Promise<ApiResponse>;
}
export interface CredentialSyncCalendarApp {
save(userId: number, userEmail: string, username: string, password: string): Promise<{ status: string }>;
check(userId: number): Promise<ApiResponse>;
}
export interface OAuthCalendarApp extends CalendarApp {
connect(authorization: string, req: Request): Promise<ApiResponse<{ authUrl: string }>>;
}
@@ -1,4 +1,5 @@
import { CalendarsController } from "@/ee/calendars/controllers/calendars.controller";
import { AppleCalendarService } from "@/ee/calendars/services/apple-calendar.service";
import { CalendarsService } from "@/ee/calendars/services/calendars.service";
import { GoogleCalendarService } from "@/ee/calendars/services/gcal.service";
import { OutlookService } from "@/ee/calendars/services/outlook.service";
@@ -17,6 +18,7 @@ import { Module } from "@nestjs/common";
CalendarsService,
OutlookService,
GoogleCalendarService,
AppleCalendarService,
SelectedCalendarsRepository,
AppsRepository,
],
@@ -1,5 +1,6 @@
import { GetBusyTimesOutput } from "@/ee/calendars/outputs/busy-times.output";
import { ConnectedCalendarsOutput } from "@/ee/calendars/outputs/connected-calendars.output";
import { AppleCalendarService } from "@/ee/calendars/services/apple-calendar.service";
import { CalendarsService } from "@/ee/calendars/services/calendars.service";
import { GoogleCalendarService } from "@/ee/calendars/services/gcal.service";
import { OutlookService } from "@/ee/calendars/services/outlook.service";
@@ -21,13 +22,22 @@ import {
Headers,
Redirect,
BadRequestException,
Post,
Body,
} from "@nestjs/common";
import { ApiTags as DocsTags } from "@nestjs/swagger";
import { User } from "@prisma/client";
import { Request } from "express";
import { z } from "zod";
import { APPS_READ } from "@calcom/platform-constants";
import { SUCCESS_STATUS, CALENDARS, GOOGLE_CALENDAR, OFFICE_365_CALENDAR } from "@calcom/platform-constants";
import {
SUCCESS_STATUS,
CALENDARS,
GOOGLE_CALENDAR,
OFFICE_365_CALENDAR,
APPLE_CALENDAR,
} from "@calcom/platform-constants";
import { ApiResponse, CalendarBusyTimesInput } from "@calcom/platform-types";
@Controller({
@@ -39,7 +49,8 @@ export class CalendarsController {
constructor(
private readonly calendarsService: CalendarsService,
private readonly outlookService: OutlookService,
private readonly googleCalendarService: GoogleCalendarService
private readonly googleCalendarService: GoogleCalendarService,
private readonly appleCalendarService: AppleCalendarService
) {}
@UseGuards(ApiAuthGuard)
@@ -133,6 +144,26 @@ export class CalendarsController {
}
}
@UseGuards(ApiAuthGuard)
@Post("/:calendar/credentials")
async syncCredentials(
@GetUser() user: User,
@Param("calendar") calendar: string,
@Body() body: { username: string; password: string }
): Promise<{ status: string }> {
const { username, password } = body;
switch (calendar) {
case APPLE_CALENDAR:
return await this.appleCalendarService.save(user.id, user.email, username, password);
default:
throw new BadRequestException(
"Invalid calendar type, available calendars are: ",
CALENDARS.join(", ")
);
}
}
@Get("/:calendar/check")
@HttpCode(HttpStatus.OK)
@UseGuards(ApiAuthGuard, PermissionsGuard)
@@ -143,6 +174,8 @@ export class CalendarsController {
return await this.outlookService.check(userId);
case GOOGLE_CALENDAR:
return await this.googleCalendarService.check(userId);
case APPLE_CALENDAR:
return await this.appleCalendarService.check(userId);
default:
throw new BadRequestException(
"Invalid calendar type, available calendars are: ",
@@ -0,0 +1,92 @@
import { CredentialSyncCalendarApp } from "@/ee/calendars/calendars.interface";
import { CalendarsService } from "@/ee/calendars/services/calendars.service";
import { CredentialsRepository } from "@/modules/credentials/credentials.repository";
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.14";
@Injectable()
export class AppleCalendarService implements CredentialSyncCalendarApp {
constructor(
private readonly calendarsService: CalendarsService,
private readonly credentialRepository: CredentialsRepository
) {}
async save(
userId: number,
userEmail: string,
username: string,
password: string
): Promise<{ status: string }> {
return await this.saveCalendarCredentials(userId, userEmail, username, password);
}
async check(userId: number): Promise<{ status: typeof SUCCESS_STATUS }> {
return await this.checkIfCalendarConnected(userId);
}
async checkIfCalendarConnected(userId: number): Promise<{ status: typeof SUCCESS_STATUS }> {
const appleCalendarCredentials = await this.credentialRepository.getByTypeAndUserId(
APPLE_CALENDAR_TYPE,
userId
);
if (!appleCalendarCredentials) {
throw new BadRequestException("Credentials for apple calendar not found.");
}
if (appleCalendarCredentials.invalid) {
throw new BadRequestException("Invalid apple calendar credentials.");
}
const { connectedCalendars } = await this.calendarsService.getCalendars(userId);
const appleCalendar = connectedCalendars.find(
(cal: { integration: { type: string } }) => cal.integration.type === APPLE_CALENDAR_TYPE
);
if (!appleCalendar) {
throw new UnauthorizedException("Apple calendar not connected.");
}
if (appleCalendar.error?.message) {
throw new UnauthorizedException(appleCalendar.error?.message);
}
return {
status: SUCCESS_STATUS,
};
}
async saveCalendarCredentials(userId: number, userEmail: string, username: string, password: string) {
if (username.length <= 1 || password.length <= 1)
throw new BadRequestException(`Username or password cannot be empty`);
const data = {
type: APPLE_CALENDAR_TYPE,
key: symmetricEncrypt(
JSON.stringify({ username, password }),
process.env.CALENDSO_ENCRYPTION_KEY || ""
),
userId: userId,
teamId: null,
appId: APPLE_CALENDAR_ID,
invalid: false,
};
try {
const dav = new CalendarService({
id: 0,
...data,
user: { email: userEmail },
});
await dav?.listCalendars();
await this.credentialRepository.createAppCredential(APPLE_CALENDAR_TYPE, data.key, userId);
} catch (reason) {
throw new BadRequestException(`Could not add this apple calendar account: ${reason}`);
}
return {
status: SUCCESS_STATUS,
};
}
}
@@ -17,6 +17,6 @@ import { Module } from "@nestjs/common";
OutputSchedulesService_2024_06_11,
],
controllers: [SchedulesController_2024_06_11],
exports: [SchedulesService_2024_06_11, SchedulesRepository_2024_06_11],
exports: [SchedulesService_2024_06_11, SchedulesRepository_2024_06_11, OutputSchedulesService_2024_06_11],
})
export class SchedulesModule_2024_06_11 {}
+13
View File
@@ -0,0 +1,13 @@
import { MembershipRole } from "@prisma/client";
export const SYSTEM_ADMIN_ROLE = "SYSADMIN";
export const ORG_ROLES = [
`ORG_${MembershipRole.OWNER}`,
`ORG_${MembershipRole.ADMIN}`,
`ORG_${MembershipRole.MEMBER}`,
] as const;
export const TEAM_ROLES = [
`TEAM_${MembershipRole.OWNER}`,
`TEAM_${MembershipRole.ADMIN}`,
`TEAM_${MembershipRole.MEMBER}`,
] as const;
@@ -0,0 +1,33 @@
import { ExecutionContext } from "@nestjs/common";
import { createParamDecorator } from "@nestjs/common";
import { Team } from "@calcom/prisma/client";
export type GetTeamReturnType = Team;
export const GetTeam = createParamDecorator<
keyof GetTeamReturnType | (keyof GetTeamReturnType)[],
ExecutionContext
>((data, ctx) => {
const request = ctx.switchToHttp().getRequest();
const team = request.team as GetTeamReturnType;
if (!team) {
throw new Error("GetTeam decorator : Team not found");
}
if (Array.isArray(data)) {
return data.reduce((prev, curr) => {
return {
...prev,
[curr]: team[curr],
};
}, {});
}
if (data) {
return team[data];
}
return team;
});
@@ -0,0 +1,4 @@
import { Reflector } from "@nestjs/core";
import { MembershipRole } from "@prisma/client";
export const MembershipRoles = Reflector.createDecorator<MembershipRole[]>();
@@ -1,4 +1,6 @@
import { SYSTEM_ADMIN_ROLE, ORG_ROLES, TEAM_ROLES } from "@/lib/roles/constants";
import { Reflector } from "@nestjs/core";
import { MembershipRole } from "@prisma/client";
export const Roles = Reflector.createDecorator<MembershipRole[]>();
export const Roles = Reflector.createDecorator<
(typeof ORG_ROLES)[number] | (typeof TEAM_ROLES)[number] | typeof SYSTEM_ADMIN_ROLE
>();
@@ -1,4 +1,4 @@
import { Roles } from "@/modules/auth/decorators/roles/roles.decorator";
import { MembershipRoles } from "@/modules/auth/decorators/roles/membership-roles.decorator";
import { MembershipsRepository } from "@/modules/memberships/memberships.repository";
import { OrganizationsService } from "@/modules/organizations/services/organizations.service";
import { UserWithProfile } from "@/modules/users/users.repository";
@@ -27,7 +27,7 @@ export class OrganizationRolesGuard implements CanActivate {
await this.isPlatform(organizationId);
const membership = await this.membershipRepository.findOrgUserMembership(organizationId, user.id);
const allowedRoles = this.reflector.get(Roles, context.getHandler());
const allowedRoles = this.reflector.get(MembershipRoles, context.getHandler());
this.isMembershipAccepted(membership.accepted);
this.isRoleAllowed(membership.role, allowedRoles);
@@ -0,0 +1,148 @@
import { ORG_ROLES, TEAM_ROLES, SYSTEM_ADMIN_ROLE } from "@/lib/roles/constants";
import { GetUserReturnType } from "@/modules/auth/decorators/get-user/get-user.decorator";
import { Roles } from "@/modules/auth/decorators/roles/roles.decorator";
import { MembershipsRepository } from "@/modules/memberships/memberships.repository";
import { Injectable, CanActivate, ExecutionContext, ForbiddenException, Logger } from "@nestjs/common";
import { Reflector } from "@nestjs/core";
import { Request } from "express";
import { Team } from "@calcom/prisma/client";
@Injectable()
export class RolesGuard implements CanActivate {
private readonly logger = new Logger("RolesGuard Logger");
constructor(private reflector: Reflector, private membershipRepository: MembershipsRepository) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const request = context.switchToHttp().getRequest<Request & { team: Team }>();
const teamId = request.params.teamId as string;
const orgId = request.params.orgId as string;
const user = request.user as GetUserReturnType;
const allowedRole = this.reflector.get(Roles, context.getHandler());
// User is not authenticated
if (!user) {
this.logger.log("User is not authenticated, denying access.");
return false;
}
// System admin can access everything
if (user.isSystemAdmin) {
this.logger.log(`User (${user.id}) is system admin, allowing access.`);
return true;
}
// if the required role is SYSTEM_ADMIN_ROLE but user is not system admin, return false
if (allowedRole === SYSTEM_ADMIN_ROLE && !user.isSystemAdmin) {
this.logger.log(`User (${user.id}) is not system admin, denying access.`);
return false;
}
// Checking the role of the user within the organization
if (Boolean(orgId) && !Boolean(teamId)) {
const membership = await this.membershipRepository.findMembershipByOrgId(Number(orgId), user.id);
if (!membership) {
this.logger.log(`User (${user.id}) is not a member of the organization (${orgId}), denying access.`);
throw new ForbiddenException(`User is not a member of the organization.`);
}
if (ORG_ROLES.includes(allowedRole as unknown as (typeof ORG_ROLES)[number])) {
return hasMinimumRole({
checkRole: `ORG_${membership.role}`,
minimumRole: allowedRole,
roles: ORG_ROLES,
});
}
}
// Checking the role of the user within the team
if (Boolean(teamId) && !Boolean(orgId)) {
const membership = await this.membershipRepository.findMembershipByTeamId(Number(teamId), user.id);
if (!membership) {
this.logger.log(`User (${user.id}) is not a member of the team (${teamId}), denying access.`);
throw new ForbiddenException(`User is not a member of the team.`);
}
if (TEAM_ROLES.includes(allowedRole as unknown as (typeof TEAM_ROLES)[number])) {
return hasMinimumRole({
checkRole: `TEAM_${membership.role}`,
minimumRole: allowedRole,
roles: TEAM_ROLES,
});
}
}
// Checking the role for team and org, org is above team in term of permissions
if (Boolean(teamId) && Boolean(orgId)) {
const teamMembership = await this.membershipRepository.findMembershipByTeamId(Number(teamId), user.id);
const orgMembership = await this.membershipRepository.findMembershipByOrgId(Number(orgId), user.id);
if (!orgMembership) {
this.logger.log(`User (${user.id}) is not part of the organization (${orgId}), denying access.`);
throw new ForbiddenException(`User is not part of the organization.`);
}
// if the role checked is a TEAM role
if (TEAM_ROLES.includes(allowedRole as unknown as (typeof TEAM_ROLES)[number])) {
// if the user is admin or owner of org, allow request because org > team
if (`ORG_${orgMembership.role}` === "ORG_ADMIN" || `ORG_${orgMembership.role}` === "ORG_OWNER") {
return true;
}
if (!teamMembership) {
this.logger.log(
`User (${user.id}) is not part of the team (${teamId}) and/or, is not an admin nor an owner of the organization (${orgId}).`
);
throw new ForbiddenException(
"User is not part of the team and/or, is not an admin nor an owner of the organization."
);
}
// if user is not admin nor an owner of org, and is part of the team, then check user team membership role
return hasMinimumRole({
checkRole: `TEAM_${teamMembership.role}`,
minimumRole: allowedRole,
roles: TEAM_ROLES,
});
}
// if allowed role is a ORG ROLE, check org membersip role
if (ORG_ROLES.includes(allowedRole as unknown as (typeof ORG_ROLES)[number])) {
return hasMinimumRole({
checkRole: `ORG_${orgMembership.role}`,
minimumRole: allowedRole,
roles: ORG_ROLES,
});
}
}
return false;
}
}
type Roles = (typeof ORG_ROLES)[number] | (typeof TEAM_ROLES)[number];
type HasMinimumTeamRoleProp = {
checkRole: (typeof TEAM_ROLES)[number];
minimumRole: string;
roles: typeof TEAM_ROLES;
};
type HasMinimumOrgRoleProp = {
checkRole: (typeof ORG_ROLES)[number];
minimumRole: string;
roles: typeof ORG_ROLES;
};
type HasMinimumRoleProp = HasMinimumTeamRoleProp | HasMinimumOrgRoleProp;
export function hasMinimumRole(props: HasMinimumRoleProp): boolean {
const checkedRoleIndex = props.roles.indexOf(props.checkRole as never);
const requiredRoleIndex = props.roles.indexOf(props.minimumRole as never);
// minimum role given does not exist
if (checkedRoleIndex === -1 || requiredRoleIndex === -1) {
throw new Error("Invalid role");
}
return checkedRoleIndex <= requiredRoleIndex;
}
@@ -0,0 +1,43 @@
import { OrganizationsTeamsRepository } from "@/modules/organizations/repositories/organizations-teams.repository";
import {
Injectable,
CanActivate,
ExecutionContext,
ForbiddenException,
NotFoundException,
} from "@nestjs/common";
import { Request } from "express";
import { Team } from "@calcom/prisma/client";
@Injectable()
export class IsTeamInOrg implements CanActivate {
constructor(private organizationsTeamsRepository: OrganizationsTeamsRepository) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const request = context.switchToHttp().getRequest<Request & { team: Team }>();
const teamId: string = request.params.teamId;
const orgId: string = request.params.orgId;
if (!orgId) {
throw new ForbiddenException("No org id found in request params.");
}
if (!teamId) {
throw new ForbiddenException("No team id found in request params.");
}
const team = await this.organizationsTeamsRepository.findOrgTeam(Number(orgId), Number(teamId));
if (team && !team.isOrganization && team.parentId === Number(orgId)) {
request.team = team;
return true;
}
if (!team) {
throw new NotFoundException(`Team (${teamId}) not found.`);
}
return false;
}
}
@@ -0,0 +1,33 @@
import { OrganizationsRepository } from "@/modules/organizations/organizations.repository";
import { Injectable, CanActivate, ExecutionContext, ForbiddenException } from "@nestjs/common";
import { Request } from "express";
import { Team } from "@calcom/prisma/client";
@Injectable()
export class IsUserInOrg implements CanActivate {
constructor(private organizationsRepository: OrganizationsRepository) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const request = context.switchToHttp().getRequest<Request & { team: Team }>();
const orgId: string = request.params.orgId;
const userId: string = request.params.userId;
if (!userId) {
throw new ForbiddenException("No user id found in request params.");
}
if (!orgId) {
throw new ForbiddenException("No org id found in request params.");
}
const user = await this.organizationsRepository.findOrgUser(Number(orgId), Number(userId));
if (user) {
request.user = user;
return true;
}
return false;
}
}
@@ -1,6 +1,6 @@
import { AppConfig } from "@/config/type";
import { API_VERSIONS_VALUES } from "@/lib/api-versions";
import { Roles } from "@/modules/auth/decorators/roles/roles.decorator";
import { MembershipRoles } from "@/modules/auth/decorators/roles/membership-roles.decorator";
import { NextAuthGuard } from "@/modules/auth/guards/next-auth/next-auth.guard";
import { OrganizationRolesGuard } from "@/modules/auth/guards/organization-roles/organization-roles.guard";
import { SubscribeToPlanInput } from "@/modules/billing/controllers/inputs/subscribe-to-plan.input";
@@ -47,7 +47,7 @@ export class BillingController {
@Get("/:teamId/check")
@UseGuards(NextAuthGuard, OrganizationRolesGuard)
@Roles(["OWNER", "ADMIN", "MEMBER"])
@MembershipRoles(["OWNER", "ADMIN", "MEMBER"])
async checkTeamBilling(
@Param("teamId") teamId: number
): Promise<ApiResponse<CheckPlatformBillingResponseDto>> {
@@ -64,7 +64,7 @@ export class BillingController {
@Post("/:teamId/subscribe")
@UseGuards(NextAuthGuard, OrganizationRolesGuard)
@Roles(["OWNER", "ADMIN"])
@MembershipRoles(["OWNER", "ADMIN"])
async subscribeTeamToStripe(
@Param("teamId") teamId: number,
@Body() input: SubscribeToPlanInput
@@ -18,6 +18,23 @@ export class MembershipsRepository {
return membership;
}
async findMembershipByTeamId(teamId: number, userId: number) {
const membership = await this.dbRead.prisma.membership.findUnique({
where: {
userId_teamId: {
userId: userId,
teamId: teamId,
},
},
});
return membership;
}
async findMembershipByOrgId(orgId: number, userId: number) {
return this.findMembershipByTeamId(orgId, userId);
}
async isUserOrganizationAdmin(userId: number, organizationId: number) {
const adminMembership = await this.dbRead.prisma.membership.findFirst({
where: {
@@ -1,7 +1,7 @@
import { getEnv } from "@/env";
import { API_VERSIONS_VALUES } from "@/lib/api-versions";
import { GetUser } from "@/modules/auth/decorators/get-user/get-user.decorator";
import { Roles } from "@/modules/auth/decorators/roles/roles.decorator";
import { MembershipRoles } from "@/modules/auth/decorators/roles/membership-roles.decorator";
import { NextAuthGuard } from "@/modules/auth/guards/next-auth/next-auth.guard";
import { OrganizationRolesGuard } from "@/modules/auth/guards/organization-roles/organization-roles.guard";
import { GetManagedUsersOutput } from "@/modules/oauth-clients/controllers/oauth-client-users/outputs/get-managed-users.output";
@@ -64,7 +64,7 @@ export class OAuthClientsController {
@Post("/")
@HttpCode(HttpStatus.CREATED)
@Roles([MembershipRole.ADMIN, MembershipRole.OWNER])
@MembershipRoles([MembershipRole.ADMIN, MembershipRole.OWNER])
@DocsOperation({ description: AUTH_DOCUMENTATION })
@DocsCreatedResponse({
description: "Create an OAuth client",
@@ -96,7 +96,7 @@ export class OAuthClientsController {
@Get("/")
@HttpCode(HttpStatus.OK)
@Roles([MembershipRole.ADMIN, MembershipRole.OWNER, MembershipRole.MEMBER])
@MembershipRoles([MembershipRole.ADMIN, MembershipRole.OWNER, MembershipRole.MEMBER])
@DocsOperation({ description: AUTH_DOCUMENTATION })
async getOAuthClients(@GetUser() user: UserWithProfile): Promise<GetOAuthClientsResponseDto> {
const organizationId = (user.movedToProfile?.organizationId ?? user.organizationId) as number;
@@ -107,7 +107,7 @@ export class OAuthClientsController {
@Get("/:clientId")
@HttpCode(HttpStatus.OK)
@Roles([MembershipRole.ADMIN, MembershipRole.OWNER, MembershipRole.MEMBER])
@MembershipRoles([MembershipRole.ADMIN, MembershipRole.OWNER, MembershipRole.MEMBER])
@DocsOperation({ description: AUTH_DOCUMENTATION })
async getOAuthClientById(@Param("clientId") clientId: string): Promise<GetOAuthClientResponseDto> {
const client = await this.oauthClientRepository.getOAuthClient(clientId);
@@ -119,7 +119,7 @@ export class OAuthClientsController {
@Get("/:clientId/managed-users")
@HttpCode(HttpStatus.OK)
@Roles([MembershipRole.ADMIN, MembershipRole.OWNER, MembershipRole.MEMBER])
@MembershipRoles([MembershipRole.ADMIN, MembershipRole.OWNER, MembershipRole.MEMBER])
@DocsOperation({ description: AUTH_DOCUMENTATION })
async getOAuthClientManagedUsersById(
@Param("clientId") clientId: string,
@@ -137,7 +137,7 @@ export class OAuthClientsController {
@Patch("/:clientId")
@HttpCode(HttpStatus.OK)
@Roles([MembershipRole.ADMIN, MembershipRole.OWNER])
@MembershipRoles([MembershipRole.ADMIN, MembershipRole.OWNER])
@DocsOperation({ description: AUTH_DOCUMENTATION })
async updateOAuthClient(
@Param("clientId") clientId: string,
@@ -150,7 +150,7 @@ export class OAuthClientsController {
@Delete("/:clientId")
@HttpCode(HttpStatus.OK)
@Roles([MembershipRole.ADMIN, MembershipRole.OWNER])
@MembershipRoles([MembershipRole.ADMIN, MembershipRole.OWNER])
@DocsOperation({ description: AUTH_DOCUMENTATION })
async deleteOAuthClient(@Param("clientId") clientId: string): Promise<GetOAuthClientResponseDto> {
this.logger.log(`Deleting OAuth Client with ID: ${clientId}`);
@@ -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.2";
import { createNewUsersConnectToOrgIfExists, slugify } from "@calcom/platform-libraries-0.0.15";
@Injectable()
export class OAuthClientUsersService {
@@ -36,17 +36,17 @@ export class OAuthClientUsersService {
const email = this.getOAuthUserEmail(oAuthClientId, body.email);
user = (
await createNewUsersConnectToOrgIfExists({
usernamesOrEmails: [email],
input: {
teamId: organizationId,
role: "MEMBER",
usernameOrEmail: [email],
isOrg: true,
language: "en",
},
invitations: [
{
usernameOrEmail: email,
role: "MEMBER",
},
],
teamId: organizationId,
isOrg: true,
parentId: null,
autoAcceptEmailDomain: "never-auto-accept-email-domain-for-managed-users",
connectionInfoMap: {
orgConnectInfoByUsernameOrEmail: {
[email]: {
orgId: organizationId,
autoAccept: true,
@@ -1,5 +1,6 @@
import { bootstrap } from "@/app";
import { AppModule } from "@/app.module";
import { CreateOrgTeamDto } from "@/modules/organizations/inputs/create-organization-team.input";
import { PrismaModule } from "@/modules/prisma/prisma.module";
import { TokensModule } from "@/modules/tokens/tokens.module";
import { UsersModule } from "@/modules/users/users.module";
@@ -8,6 +9,7 @@ import { NestExpressApplication } from "@nestjs/platform-express";
import { Test } from "@nestjs/testing";
import { User } from "@prisma/client";
import * as request from "supertest";
import { MembershipRepositoryFixture } from "test/fixtures/repository/membership.repository.fixture";
import { TeamRepositoryFixture } from "test/fixtures/repository/team.repository.fixture";
import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture";
import { withApiAuth } from "test/utils/withApiAuth";
@@ -17,14 +19,20 @@ import { ApiSuccessResponse } from "@calcom/platform-types";
import { Team } from "@calcom/prisma/client";
describe("Organizations Team Endpoints", () => {
describe("User Authentication", () => {
describe("User Authentication - User is Org Admin", () => {
let app: INestApplication;
let userRepositoryFixture: UserRepositoryFixture;
let organizationsRepositoryFixture: TeamRepositoryFixture;
let org: Team;
let teamsRepositoryFixture: TeamRepositoryFixture;
let membershipsRepositoryFixture: MembershipRepositoryFixture;
const userEmail = "org-teams-controller-e2e@api.com";
let org: Team;
let team: Team;
let team2: Team;
let teamCreatedViaApi: Team;
const userEmail = "org-admin-teams-controller-e2e@api.com";
let user: User;
beforeAll(async () => {
@@ -37,6 +45,8 @@ describe("Organizations Team Endpoints", () => {
userRepositoryFixture = new UserRepositoryFixture(moduleRef);
organizationsRepositoryFixture = new TeamRepositoryFixture(moduleRef);
teamsRepositoryFixture = new TeamRepositoryFixture(moduleRef);
membershipsRepositoryFixture = new MembershipRepositoryFixture(moduleRef);
user = await userRepositoryFixture.create({
email: userEmail,
@@ -48,6 +58,24 @@ describe("Organizations Team Endpoints", () => {
isOrganization: true,
});
await membershipsRepositoryFixture.create({
role: "ADMIN",
user: { connect: { id: user.id } },
team: { connect: { id: org.id } },
});
team = await teamsRepositoryFixture.create({
name: "Test org team",
isOrganization: false,
parent: { connect: { id: org.id } },
});
team2 = await teamsRepositoryFixture.create({
name: "Test org team 2",
isOrganization: false,
parent: { connect: { id: org.id } },
});
app = moduleRef.createNestApplication();
bootstrap(app as NestExpressApplication);
@@ -68,12 +96,336 @@ describe("Organizations Team Endpoints", () => {
.then((response) => {
const responseBody: ApiSuccessResponse<Team[]> = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data).toEqual([]);
expect(responseBody.data[0].id).toEqual(team.id);
expect(responseBody.data[1].id).toEqual(team2.id);
});
});
it("should get all the teams of the org paginated", async () => {
return request(app.getHttpServer())
.get(`/v2/organizations/${org.id}/teams?skip=1&take=1`)
.expect(200)
.then((response) => {
const responseBody: ApiSuccessResponse<Team[]> = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data[0].id).toEqual(team2.id);
});
});
it("should fail if org does not exist", async () => {
return request(app.getHttpServer()).get(`/v2/organizations/120494059/teams`).expect(403);
});
it("should get the team of the org", async () => {
return request(app.getHttpServer())
.get(`/v2/organizations/${org.id}/teams/${team.id}`)
.expect(200)
.then((response) => {
const responseBody: ApiSuccessResponse<Team> = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data.id).toEqual(team.id);
expect(responseBody.data.parentId).toEqual(team.parentId);
});
});
it("should create the team of the org", async () => {
return request(app.getHttpServer())
.post(`/v2/organizations/${org.id}/teams`)
.send({
name: "Team created via API",
} satisfies CreateOrgTeamDto)
.expect(201)
.then((response) => {
const responseBody: ApiSuccessResponse<Team> = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
teamCreatedViaApi = responseBody.data;
expect(teamCreatedViaApi.name).toEqual("Team created via API");
expect(teamCreatedViaApi.parentId).toEqual(org.id);
});
});
it("should update the team of the org", async () => {
return request(app.getHttpServer())
.patch(`/v2/organizations/${org.id}/teams/${teamCreatedViaApi.id}`)
.send({
name: "Team created via API Updated",
} satisfies CreateOrgTeamDto)
.expect(200)
.then((response) => {
const responseBody: ApiSuccessResponse<Team> = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
teamCreatedViaApi = responseBody.data;
expect(teamCreatedViaApi.name).toEqual("Team created via API Updated");
expect(teamCreatedViaApi.parentId).toEqual(org.id);
});
});
it("should delete the team of the org we created via api", async () => {
return request(app.getHttpServer())
.delete(`/v2/organizations/${org.id}/teams/${teamCreatedViaApi.id}`)
.expect(200)
.then((response) => {
const responseBody: ApiSuccessResponse<Team> = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data.id).toEqual(teamCreatedViaApi.id);
expect(responseBody.data.parentId).toEqual(teamCreatedViaApi.parentId);
});
});
it("should fail to get the team of the org we just deleted", async () => {
return request(app.getHttpServer())
.get(`/v2/organizations/${org.id}/teams/${teamCreatedViaApi.id}`)
.expect(404);
});
it("should fail if the team does not exist", async () => {
return request(app.getHttpServer()).get(`/v2/organizations/${org.id}/teams/123132145`).expect(404);
});
afterAll(async () => {
await userRepositoryFixture.deleteByEmail(user.email);
await teamsRepositoryFixture.delete(team.id);
await teamsRepositoryFixture.delete(team2.id);
await organizationsRepositoryFixture.delete(org.id);
await app.close();
});
});
});
describe("Organizations Team Endpoints", () => {
describe("User Authentication - User is Org Member", () => {
let app: INestApplication;
let userRepositoryFixture: UserRepositoryFixture;
let organizationsRepositoryFixture: TeamRepositoryFixture;
let teamsRepositoryFixture: TeamRepositoryFixture;
let membershipsRepositoryFixture: MembershipRepositoryFixture;
let org: Team;
let team: Team;
let team2: Team;
const userEmail = "org-member-teams-controller-e2e@api.com";
let user: User;
beforeAll(async () => {
const moduleRef = await withApiAuth(
userEmail,
Test.createTestingModule({
imports: [AppModule, PrismaModule, UsersModule, TokensModule],
})
).compile();
userRepositoryFixture = new UserRepositoryFixture(moduleRef);
organizationsRepositoryFixture = new TeamRepositoryFixture(moduleRef);
teamsRepositoryFixture = new TeamRepositoryFixture(moduleRef);
membershipsRepositoryFixture = new MembershipRepositoryFixture(moduleRef);
user = await userRepositoryFixture.create({
email: userEmail,
username: userEmail,
});
org = await organizationsRepositoryFixture.create({
name: "Test Organization",
isOrganization: true,
});
await membershipsRepositoryFixture.create({
role: "MEMBER",
user: { connect: { id: user.id } },
team: { connect: { id: org.id } },
});
team = await teamsRepositoryFixture.create({
name: "Test org team",
isOrganization: false,
parent: { connect: { id: org.id } },
});
team2 = await teamsRepositoryFixture.create({
name: "Test org team 2",
isOrganization: false,
parent: { connect: { id: org.id } },
});
app = moduleRef.createNestApplication();
bootstrap(app as NestExpressApplication);
await app.init();
});
it("should be defined", () => {
expect(userRepositoryFixture).toBeDefined();
expect(organizationsRepositoryFixture).toBeDefined();
expect(user).toBeDefined();
expect(org).toBeDefined();
});
it("should deny get all the teams of the org", async () => {
return request(app.getHttpServer()).get(`/v2/organizations/${org.id}/teams`).expect(403);
});
it("should deny get all the teams of the org paginated", async () => {
return request(app.getHttpServer()).get(`/v2/organizations/${org.id}/teams?skip=1&take=1`).expect(403);
});
it("should deny get the team of the org", async () => {
return request(app.getHttpServer()).get(`/v2/organizations/${org.id}/teams/${team.id}`).expect(403);
});
it("should deny create the team of the org", async () => {
return request(app.getHttpServer())
.post(`/v2/organizations/${org.id}/teams`)
.send({
name: "Team created via API",
} satisfies CreateOrgTeamDto)
.expect(403);
});
it("should deny update the team of the org", async () => {
return request(app.getHttpServer())
.patch(`/v2/organizations/${org.id}/teams/${team.id}`)
.send({
name: "Team created via API Updated",
} satisfies CreateOrgTeamDto)
.expect(403);
});
it("should deny delete the team of the org we created via api", async () => {
return request(app.getHttpServer()).delete(`/v2/organizations/${org.id}/teams/${team2.id}`).expect(403);
});
afterAll(async () => {
await userRepositoryFixture.deleteByEmail(user.email);
await teamsRepositoryFixture.delete(team.id);
await teamsRepositoryFixture.delete(team2.id);
await organizationsRepositoryFixture.delete(org.id);
await app.close();
});
});
});
describe("Organizations Team Endpoints", () => {
describe("User Authentication - User is Team Owner", () => {
let app: INestApplication;
let userRepositoryFixture: UserRepositoryFixture;
let organizationsRepositoryFixture: TeamRepositoryFixture;
let teamsRepositoryFixture: TeamRepositoryFixture;
let membershipsRepositoryFixture: MembershipRepositoryFixture;
let org: Team;
let team: Team;
let team2: Team;
const userEmail = "org-member-teams-owner-controller-e2e@api.com";
let user: User;
beforeAll(async () => {
const moduleRef = await withApiAuth(
userEmail,
Test.createTestingModule({
imports: [AppModule, PrismaModule, UsersModule, TokensModule],
})
).compile();
userRepositoryFixture = new UserRepositoryFixture(moduleRef);
organizationsRepositoryFixture = new TeamRepositoryFixture(moduleRef);
teamsRepositoryFixture = new TeamRepositoryFixture(moduleRef);
membershipsRepositoryFixture = new MembershipRepositoryFixture(moduleRef);
user = await userRepositoryFixture.create({
email: userEmail,
username: userEmail,
});
org = await organizationsRepositoryFixture.create({
name: "Test Organization",
isOrganization: true,
});
await membershipsRepositoryFixture.create({
role: "MEMBER",
user: { connect: { id: user.id } },
team: { connect: { id: org.id } },
});
team = await teamsRepositoryFixture.create({
name: "Test org team",
isOrganization: false,
parent: { connect: { id: org.id } },
});
team2 = await teamsRepositoryFixture.create({
name: "Test org team 2",
isOrganization: false,
parent: { connect: { id: org.id } },
});
await membershipsRepositoryFixture.create({
role: "OWNER",
user: { connect: { id: user.id } },
team: { connect: { id: team.id } },
});
await membershipsRepositoryFixture.create({
role: "OWNER",
user: { connect: { id: user.id } },
team: { connect: { id: team2.id } },
});
app = moduleRef.createNestApplication();
bootstrap(app as NestExpressApplication);
await app.init();
});
it("should be defined", () => {
expect(userRepositoryFixture).toBeDefined();
expect(organizationsRepositoryFixture).toBeDefined();
expect(user).toBeDefined();
expect(org).toBeDefined();
});
it("should deny get all the teams of the org", async () => {
return request(app.getHttpServer()).get(`/v2/organizations/${org.id}/teams`).expect(403);
});
it("should deny get all the teams of the org paginated", async () => {
return request(app.getHttpServer()).get(`/v2/organizations/${org.id}/teams?skip=1&take=1`).expect(403);
});
it("should get the team of the org for which the user is team owner", async () => {
return request(app.getHttpServer()).get(`/v2/organizations/${org.id}/teams/${team.id}`).expect(200);
});
it("should deny create the team of the org", async () => {
return request(app.getHttpServer())
.post(`/v2/organizations/${org.id}/teams`)
.send({
name: "Team created via API",
} satisfies CreateOrgTeamDto)
.expect(403);
});
it("should deny update the team of the org", async () => {
return request(app.getHttpServer())
.patch(`/v2/organizations/${org.id}/teams/${team.id}`)
.send({
name: "Team created via API Updated",
} satisfies CreateOrgTeamDto)
.expect(403);
});
it("should deny delete the team of the org we created via api", async () => {
return request(app.getHttpServer()).delete(`/v2/organizations/${org.id}/teams/${team2.id}`).expect(403);
});
afterAll(async () => {
await userRepositoryFixture.deleteByEmail(user.email);
await teamsRepositoryFixture.delete(team.id);
await teamsRepositoryFixture.delete(team2.id);
await organizationsRepositoryFixture.delete(org.id);
await app.close();
});
@@ -1,31 +1,106 @@
import { API_VERSIONS_VALUES } from "@/lib/api-versions";
import { GetOrg } from "@/modules/auth/decorators/get-org/get-org.decorator";
import { GetTeam } from "@/modules/auth/decorators/get-team/get-team.decorator";
import { Roles } from "@/modules/auth/decorators/roles/roles.decorator";
import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard";
import { IsOrgGuard } from "@/modules/auth/guards/organizations/is-org.guard";
import { Controller, UseGuards, Get, Param, ParseIntPipe } from "@nestjs/common";
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 { OrgTeamOutputDto } from "@/modules/organizations/outputs/organization-team.output";
import { OrganizationsTeamsService } from "@/modules/organizations/services/organizations-teams.service";
import {
Controller,
UseGuards,
Get,
Param,
ParseIntPipe,
Query,
Delete,
Patch,
Post,
Body,
} from "@nestjs/common";
import { ApiTags as DocsTags } from "@nestjs/swagger";
import { plainToClass } from "class-transformer";
import { SUCCESS_STATUS } from "@calcom/platform-constants";
import { ApiResponse } from "@calcom/platform-types";
import { ApiResponse, SkipTakePagination } from "@calcom/platform-types";
import { Team } from "@calcom/prisma/client";
@Controller({
path: "/v2/organizations/:orgId/teams",
version: API_VERSIONS_VALUES,
})
@UseGuards(ApiAuthGuard, IsOrgGuard)
@UseGuards(ApiAuthGuard, IsOrgGuard, RolesGuard)
@DocsTags("Organizations Teams")
export class OrganizationsTeamsController {
constructor(private organizationsTeamsService: OrganizationsTeamsService) {}
@Get()
@UseGuards()
@Roles("ORG_ADMIN")
async getAllTeams(
@Param("orgId", ParseIntPipe) orgId: number,
@GetOrg() organization: Team,
@GetOrg("name") orgName: string
): Promise<ApiResponse<Team[]>> {
console.log(orgId, organization, orgName);
@Query() queryParams: SkipTakePagination
): Promise<ApiResponse<OrgTeamOutputDto[]>> {
const { skip, take } = queryParams;
const teams = await this.organizationsTeamsService.getPaginatedOrgTeams(orgId, skip ?? 0, take ?? 250);
return {
status: SUCCESS_STATUS,
data: [],
data: teams.map((team) => plainToClass(OrgTeamOutputDto, team, { strategy: "excludeAll" })),
};
}
@UseGuards(IsTeamInOrg)
@Roles("TEAM_ADMIN")
@Get("/:teamId")
async getTeam(@GetTeam() team: Team): Promise<ApiResponse<OrgTeamOutputDto>> {
return {
status: SUCCESS_STATUS,
data: plainToClass(OrgTeamOutputDto, team, { strategy: "excludeAll" }),
};
}
@UseGuards(IsTeamInOrg)
@Roles("ORG_ADMIN")
@Delete("/:teamId")
async deleteTeam(
@Param("orgId", ParseIntPipe) orgId: number,
@Param("teamId", ParseIntPipe) teamId: number
): Promise<ApiResponse<OrgTeamOutputDto>> {
const team = await this.organizationsTeamsService.deleteOrgTeam(orgId, teamId);
return {
status: SUCCESS_STATUS,
data: plainToClass(OrgTeamOutputDto, team, { strategy: "excludeAll" }),
};
}
@UseGuards(IsTeamInOrg)
@Roles("ORG_ADMIN")
@Patch("/:teamId")
async updateTeam(
@Param("orgId", ParseIntPipe) orgId: number,
@Param("teamId", ParseIntPipe) teamId: number,
@Body() body: CreateOrgTeamDto
): Promise<ApiResponse<OrgTeamOutputDto>> {
const team = await this.organizationsTeamsService.updateOrgTeam(orgId, teamId, body);
return {
status: SUCCESS_STATUS,
data: plainToClass(OrgTeamOutputDto, team, { strategy: "excludeAll" }),
};
}
@Post()
@UseGuards()
@Roles("ORG_ADMIN")
async createTeam(
@Param("orgId", ParseIntPipe) orgId: number,
@Body() body: CreateOrgTeamDto
): Promise<ApiResponse<OrgTeamOutputDto>> {
const team = await this.organizationsTeamsService.createOrgTeam(orgId, body);
return {
status: SUCCESS_STATUS,
data: plainToClass(OrgTeamOutputDto, team, { strategy: "excludeAll" }),
};
}
}
@@ -0,0 +1,155 @@
import { SchedulesService_2024_06_11 } from "@/ee/schedules/schedules_2024_06_11/services/schedules.service";
import { API_VERSIONS_VALUES } from "@/lib/api-versions";
import { Roles } from "@/modules/auth/decorators/roles/roles.decorator";
import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard";
import { IsOrgGuard } from "@/modules/auth/guards/organizations/is-org.guard";
import { RolesGuard } from "@/modules/auth/guards/roles/roles.guard";
import { IsUserInOrg } from "@/modules/auth/guards/users/is-user-in-org.guard";
import { OrganizationsSchedulesService } from "@/modules/organizations/services/organizations-schedules.service";
import {
Controller,
UseGuards,
Get,
Post,
Param,
ParseIntPipe,
Body,
Patch,
Delete,
HttpCode,
HttpStatus,
Query,
} from "@nestjs/common";
import { ApiTags as DocsTags } from "@nestjs/swagger";
import { Transform } from "class-transformer";
import { IsNumber, Min, Max, IsOptional } from "class-validator";
import { SUCCESS_STATUS } from "@calcom/platform-constants";
import {
CreateScheduleInput_2024_06_11,
CreateScheduleOutput_2024_06_11,
DeleteScheduleOutput_2024_06_11,
GetScheduleOutput_2024_06_11,
GetSchedulesOutput_2024_06_11,
UpdateScheduleInput_2024_06_11,
UpdateScheduleOutput_2024_06_11,
} from "@calcom/platform-types";
class SkipTakePagination {
@Transform(({ value }: { value: string }) => value && parseInt(value))
@IsNumber()
@Min(1)
@Max(250)
@IsOptional()
take?: number;
@Transform(({ value }: { value: string }) => value && parseInt(value))
@IsNumber()
@Min(0)
@IsOptional()
skip?: number;
}
@Controller({
path: "/v2/organizations/:orgId",
version: API_VERSIONS_VALUES,
})
@UseGuards(ApiAuthGuard, IsOrgGuard, RolesGuard)
@DocsTags("Organizations Schedules")
export class OrganizationsSchedulesController {
constructor(
private schedulesService: SchedulesService_2024_06_11,
private organizationScheduleService: OrganizationsSchedulesService
) {}
@Roles("ORG_ADMIN")
@Get("/schedules")
async getOrganizationSchedules(
@Param("orgId", ParseIntPipe) orgId: number,
@Query() queryParams: SkipTakePagination
): Promise<GetSchedulesOutput_2024_06_11> {
const { skip, take } = queryParams;
const schedules = await this.organizationScheduleService.getOrganizationSchedules(orgId, skip, take);
return {
status: SUCCESS_STATUS,
data: schedules,
};
}
@Roles("ORG_ADMIN")
@UseGuards(IsUserInOrg)
@Post("/users/:userId/schedules")
async createUserSchedule(
@Param("userId", ParseIntPipe) userId: number,
@Body() bodySchedule: CreateScheduleInput_2024_06_11
): Promise<CreateScheduleOutput_2024_06_11> {
const schedule = await this.schedulesService.createUserSchedule(userId, bodySchedule);
return {
status: SUCCESS_STATUS,
data: schedule,
};
}
@Roles("ORG_ADMIN")
@UseGuards(IsUserInOrg)
@Get("/users/:userId/schedules/:scheduleId")
async getUserSchedule(
@Param("userId", ParseIntPipe) userId: number,
@Param("scheduleId") scheduleId: number
): Promise<GetScheduleOutput_2024_06_11> {
const schedule = await this.schedulesService.getUserSchedule(userId, scheduleId);
return {
status: SUCCESS_STATUS,
data: schedule,
};
}
@Roles("ORG_ADMIN")
@UseGuards(IsUserInOrg)
@Get("/users/:userId/schedules")
async getUserSchedules(
@Param("userId", ParseIntPipe) userId: number
): Promise<GetSchedulesOutput_2024_06_11> {
const schedules = await this.schedulesService.getUserSchedules(userId);
return {
status: SUCCESS_STATUS,
data: schedules,
};
}
@Roles("ORG_ADMIN")
@UseGuards(IsUserInOrg)
@Patch("/users/:userId/schedules/:scheduleId")
async updateUserSchedule(
@Param("userId", ParseIntPipe) userId: number,
@Param("scheduleId", ParseIntPipe) scheduleId: number,
@Body() bodySchedule: UpdateScheduleInput_2024_06_11
): Promise<UpdateScheduleOutput_2024_06_11> {
const updatedSchedule = await this.schedulesService.updateUserSchedule(userId, scheduleId, bodySchedule);
return {
status: SUCCESS_STATUS,
data: updatedSchedule,
};
}
@Roles("ORG_ADMIN")
@UseGuards(IsUserInOrg)
@Delete("/users/:userId/schedules/:scheduleId")
@HttpCode(HttpStatus.OK)
async deleteUserSchedule(
@Param("userId", ParseIntPipe) userId: number,
@Param("scheduleId", ParseIntPipe) scheduleId: number
): Promise<DeleteScheduleOutput_2024_06_11> {
await this.schedulesService.deleteUserSchedule(userId, scheduleId);
return {
status: SUCCESS_STATUS,
};
}
}
@@ -0,0 +1,338 @@
import { bootstrap } from "@/app";
import { AppModule } from "@/app.module";
import { PrismaModule } from "@/modules/prisma/prisma.module";
import { TokensModule } from "@/modules/tokens/tokens.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 { MembershipRepositoryFixture } from "test/fixtures/repository/membership.repository.fixture";
import { ProfileRepositoryFixture } from "test/fixtures/repository/profiles.repository.fixture";
import { TeamRepositoryFixture } from "test/fixtures/repository/team.repository.fixture";
import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture";
import { withApiAuth } from "test/utils/withApiAuth";
import { SUCCESS_STATUS } from "@calcom/platform-constants";
import {
CreateScheduleInput_2024_06_11,
CreateScheduleOutput_2024_06_11,
GetScheduleOutput_2024_06_11,
GetSchedulesOutput_2024_06_11,
ScheduleAvailabilityInput_2024_06_11,
ScheduleOutput_2024_06_11,
UpdateScheduleInput_2024_06_11,
UpdateScheduleOutput_2024_06_11,
} from "@calcom/platform-types";
import { User, Team, Membership, Profile } from "@calcom/prisma/client";
describe("Organizations Schedules Endpoints", () => {
describe("User lacks required role", () => {
let app: INestApplication;
let userRepositoryFixture: UserRepositoryFixture;
let organizationsRepositoryFixture: TeamRepositoryFixture;
let membershipFixtures: MembershipRepositoryFixture;
const userEmail = "mr-robot@schedules-api.com";
let user: User;
let org: Team;
let membership: Membership;
beforeAll(async () => {
const moduleRef = await withApiAuth(
userEmail,
Test.createTestingModule({
imports: [AppModule, PrismaModule, UsersModule, TokensModule],
})
).compile();
userRepositoryFixture = new UserRepositoryFixture(moduleRef);
organizationsRepositoryFixture = new TeamRepositoryFixture(moduleRef);
membershipFixtures = new MembershipRepositoryFixture(moduleRef);
org = await organizationsRepositoryFixture.create({
name: "Ecorp",
isOrganization: true,
});
user = await userRepositoryFixture.create({
email: userEmail,
username: userEmail,
organization: { connect: { id: org.id } },
});
membership = await membershipFixtures.addUserToOrg(user, org, "MEMBER", true);
app = moduleRef.createNestApplication();
bootstrap(app as NestExpressApplication);
await app.init();
});
it("should be defined", () => {
expect(userRepositoryFixture).toBeDefined();
expect(organizationsRepositoryFixture).toBeDefined();
expect(user).toBeDefined();
expect(org).toBeDefined();
});
it("should not be able to create schedule for org user", async () => {
return request(app.getHttpServer())
.post(`/v2/organizations/${org.id}/users/${user.id}/schedules`)
.send({
name: "work",
timeZone: "Europe/Rome",
isDefault: true,
})
.expect(403);
});
it("should not be able to get org schedules", async () => {
return request(app.getHttpServer()).get(`/v2/organizations/${org.id}/schedules`).expect(403);
});
it("should mot be able to get user schedules", async () => {
return request(app.getHttpServer())
.get(`/v2/organizations/${org.id}/users/${user.id}/schedules/`)
.expect(403);
});
afterAll(async () => {
await membershipFixtures.delete(membership.id);
await userRepositoryFixture.deleteByEmail(user.email);
await organizationsRepositoryFixture.delete(org.id);
await app.close();
});
});
describe("User has required role", () => {
let app: INestApplication;
let userRepositoryFixture: UserRepositoryFixture;
let organizationsRepositoryFixture: TeamRepositoryFixture;
let membershipFixtures: MembershipRepositoryFixture;
let profileRepositoryFixture: ProfileRepositoryFixture;
const userEmail = "mr-robot@schedules-api.com";
const username = "mr-robot";
let user: User;
let org: Team;
let profile: Profile;
let membership: Membership;
let createdSchedule: ScheduleOutput_2024_06_11;
const createScheduleInput: CreateScheduleInput_2024_06_11 = {
name: "work",
timeZone: "Europe/Rome",
isDefault: true,
};
const defaultAvailability: ScheduleAvailabilityInput_2024_06_11[] = [
{
days: ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday"],
startTime: "09:00",
endTime: "17:00",
},
];
beforeAll(async () => {
const moduleRef = await withApiAuth(
userEmail,
Test.createTestingModule({
imports: [AppModule, PrismaModule, UsersModule, TokensModule],
})
).compile();
userRepositoryFixture = new UserRepositoryFixture(moduleRef);
organizationsRepositoryFixture = new TeamRepositoryFixture(moduleRef);
membershipFixtures = new MembershipRepositoryFixture(moduleRef);
profileRepositoryFixture = new ProfileRepositoryFixture(moduleRef);
org = await organizationsRepositoryFixture.create({
name: "Ecorp",
isOrganization: true,
});
user = await userRepositoryFixture.create({
email: userEmail,
username: userEmail,
organization: { connect: { id: org.id } },
});
profile = await profileRepositoryFixture.create({
uid: `usr-${user.id}`,
username: username,
organization: {
connect: {
id: org.id,
},
},
user: {
connect: {
id: user.id,
},
},
});
membership = await membershipFixtures.addUserToOrg(user, org, "ADMIN", true);
app = moduleRef.createNestApplication();
bootstrap(app as NestExpressApplication);
await app.init();
});
it("should be defined", () => {
expect(userRepositoryFixture).toBeDefined();
expect(organizationsRepositoryFixture).toBeDefined();
expect(user).toBeDefined();
expect(org).toBeDefined();
});
it("should create schedule for org user", async () => {
return request(app.getHttpServer())
.post(`/v2/organizations/${org.id}/users/${user.id}/schedules`)
.send(createScheduleInput)
.expect(201)
.then(async (response) => {
const responseBody: CreateScheduleOutput_2024_06_11 = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
createdSchedule = response.body.data;
const expectedSchedule = {
...createScheduleInput,
availability: defaultAvailability,
overrides: [],
};
outputScheduleMatchesExpected(createdSchedule, expectedSchedule, 1);
const scheduleOwner = createdSchedule.ownerId
? await userRepositoryFixture.get(createdSchedule.ownerId)
: null;
expect(scheduleOwner?.defaultScheduleId).toEqual(createdSchedule.id);
});
});
function outputScheduleMatchesExpected(
outputSchedule: ScheduleOutput_2024_06_11 | null,
expected: CreateScheduleInput_2024_06_11 & {
availability: CreateScheduleInput_2024_06_11["availability"];
} & {
overrides: CreateScheduleInput_2024_06_11["overrides"];
},
expectedAvailabilityLength: number
) {
expect(outputSchedule).toBeTruthy();
expect(outputSchedule?.name).toEqual(expected.name);
expect(outputSchedule?.timeZone).toEqual(expected.timeZone);
expect(outputSchedule?.isDefault).toEqual(expected.isDefault);
expect(outputSchedule?.availability.length).toEqual(expectedAvailabilityLength);
const outputScheduleAvailability = outputSchedule?.availability[0];
expect(outputScheduleAvailability).toBeDefined();
expect(outputScheduleAvailability?.days).toEqual(expected.availability?.[0].days);
expect(outputScheduleAvailability?.startTime).toEqual(expected.availability?.[0].startTime);
expect(outputScheduleAvailability?.endTime).toEqual(expected.availability?.[0].endTime);
expect(JSON.stringify(outputSchedule?.overrides)).toEqual(JSON.stringify(expected.overrides));
}
it("should get org schedules", async () => {
return request(app.getHttpServer())
.get(`/v2/organizations/${org.id}/schedules`)
.expect(200)
.then(async (response) => {
const responseBody: GetSchedulesOutput_2024_06_11 = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
const schedules = response.body.data;
expect(schedules.length).toEqual(1);
const expectedSchedule = {
...createScheduleInput,
availability: defaultAvailability,
overrides: [],
};
outputScheduleMatchesExpected(schedules[0], expectedSchedule, 1);
});
});
it("should get org user schedule", async () => {
return request(app.getHttpServer())
.get(`/v2/organizations/${org.id}/users/${user.id}/schedules/${createdSchedule.id}`)
.expect(200)
.then(async (response) => {
const responseBody: GetScheduleOutput_2024_06_11 = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
const schedule = response.body.data;
const expectedSchedule = {
...createScheduleInput,
availability: defaultAvailability,
overrides: [],
};
outputScheduleMatchesExpected(schedule, expectedSchedule, 1);
});
});
it("should get user schedules", async () => {
return request(app.getHttpServer())
.get(`/v2/organizations/${org.id}/users/${user.id}/schedules/`)
.expect(200)
.then(async (response) => {
const responseBody: GetSchedulesOutput_2024_06_11 = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
const schedules = response.body.data;
expect(schedules.length).toEqual(1);
const expectedSchedule = {
...createScheduleInput,
availability: defaultAvailability,
overrides: [],
};
outputScheduleMatchesExpected(schedules[0], expectedSchedule, 1);
});
});
it("should update user schedule name", async () => {
const newScheduleName = "updated-schedule-name";
const body: UpdateScheduleInput_2024_06_11 = {
name: newScheduleName,
};
return request(app.getHttpServer())
.patch(`/v2/organizations/${org.id}/users/${user.id}/schedules/${createdSchedule.id}`)
.send(body)
.expect(200)
.then((response: any) => {
const responseData: UpdateScheduleOutput_2024_06_11 = response.body;
expect(responseData.status).toEqual(SUCCESS_STATUS);
const responseSchedule = responseData.data;
const expectedSchedule = { ...createdSchedule, name: newScheduleName };
outputScheduleMatchesExpected(responseSchedule, expectedSchedule, 1);
createdSchedule = responseSchedule;
});
});
it("should delete user schedule", async () => {
return request(app.getHttpServer())
.delete(`/v2/organizations/${org.id}/users/${user.id}/schedules/${createdSchedule.id}`)
.expect(200);
});
afterAll(async () => {
await profileRepositoryFixture.delete(profile.id);
await membershipFixtures.delete(membership.id);
await userRepositoryFixture.deleteByEmail(user.email);
await organizationsRepositoryFixture.delete(org.id);
await app.close();
});
});
});
@@ -0,0 +1,75 @@
import { IsBoolean, IsOptional, IsString, IsUrl, Length } from "class-validator";
export class CreateOrgTeamDto {
@IsString()
@Length(1)
readonly name!: string;
@IsOptional()
@IsString()
readonly slug?: string;
@IsOptional()
@IsUrl()
readonly logoUrl?: string;
@IsOptional()
@IsUrl()
readonly calVideoLogo?: string;
@IsOptional()
@IsUrl()
readonly appLogo?: string;
@IsOptional()
@IsUrl()
readonly appIconLogo?: string;
@IsOptional()
@IsString()
readonly bio?: string;
@IsOptional()
@IsBoolean()
readonly hideBranding?: boolean = false;
@IsOptional()
@IsBoolean()
readonly isPrivate?: boolean;
@IsOptional()
@IsBoolean()
readonly hideBookATeamMember?: boolean;
@IsOptional()
@IsString()
readonly metadata?: string; // Assuming metadata is a JSON string. Adjust accordingly if it's a nested object.
@IsOptional()
@IsString()
readonly theme?: string;
@IsOptional()
@IsString()
readonly brandColor?: string;
@IsOptional()
@IsString()
readonly darkBrandColor?: string;
@IsOptional()
@IsUrl()
readonly bannerUrl?: string;
@IsOptional()
@IsString()
readonly timeFormat?: number;
@IsOptional()
@IsString()
readonly timeZone?: string = "Europe/London";
@IsOptional()
@IsString()
readonly weekStart?: string = "Sunday";
}
@@ -1,14 +1,30 @@
import { SchedulesModule_2024_06_11 } from "@/ee/schedules/schedules_2024_06_11/schedules.module";
import { MembershipsRepository } from "@/modules/memberships/memberships.repository";
import { OrganizationsTeamsController } from "@/modules/organizations/controllers/organizations-teams.controller";
import { OrganizationsSchedulesController } from "@/modules/organizations/controllers/schedules/organizations-schedules.controller";
import { OrganizationsRepository } from "@/modules/organizations/organizations.repository";
import { OrganizationSchedulesRepository } from "@/modules/organizations/repositories/organizations-schedules.repository";
import { OrganizationsSchedulesService } from "@/modules/organizations/services/organizations-schedules.service";
import { OrganizationsTeamsRepository } from "@/modules/organizations/repositories/organizations-teams.repository";
import { OrganizationsTeamsService } from "@/modules/organizations/services/organizations-teams.service";
import { OrganizationsService } from "@/modules/organizations/services/organizations.service";
import { PrismaModule } from "@/modules/prisma/prisma.module";
import { StripeModule } from "@/modules/stripe/stripe.module";
import { UsersModule } from "@/modules/users/users.module";
import { Module } from "@nestjs/common";
@Module({
imports: [PrismaModule, StripeModule],
providers: [OrganizationsRepository, OrganizationsService],
exports: [OrganizationsService, OrganizationsRepository],
controllers: [OrganizationsTeamsController],
imports: [PrismaModule, StripeModule, SchedulesModule_2024_06_11, UsersModule],
providers: [
OrganizationsRepository,
OrganizationsTeamsRepository,
OrganizationsService,
OrganizationsTeamsService,
MembershipsRepository,
OrganizationsSchedulesService,
OrganizationSchedulesRepository,
],
exports: [OrganizationsService, OrganizationsRepository, OrganizationsTeamsRepository],
controllers: [OrganizationsTeamsController, OrganizationsSchedulesController],
})
export class OrganizationsModule {}
@@ -70,4 +70,12 @@ export class OrganizationsRepository {
},
});
}
async findOrgUser(organizationId: number, userId: number) {
return this.dbRead.prisma.user.findUnique({
where: {
id: userId,
organizationId,
},
});
}
}
@@ -0,0 +1,107 @@
import { Expose } from "class-transformer";
import { IsBoolean, IsInt, IsOptional, IsString, IsUrl, Length } from "class-validator";
export class OrgTeamOutputDto {
@IsInt()
@Expose()
readonly id!: number;
@IsInt()
@IsOptional()
@Expose()
readonly parentId?: number;
@IsString()
@Length(1)
@Expose()
readonly name!: string;
@IsOptional()
@IsString()
@Expose()
readonly slug?: string;
@IsOptional()
@IsUrl()
@Expose()
readonly logoUrl?: string;
@IsOptional()
@IsUrl()
@Expose()
readonly calVideoLogo?: string;
@IsOptional()
@IsUrl()
@Expose()
readonly appLogo?: string;
@IsOptional()
@IsUrl()
@Expose()
readonly appIconLogo?: string;
@IsOptional()
@IsString()
@Expose()
readonly bio?: string;
@IsOptional()
@IsBoolean()
@Expose()
readonly hideBranding?: boolean;
@IsBoolean()
@Expose()
readonly isOrganization?: boolean;
@IsOptional()
@IsBoolean()
@Expose()
readonly isPrivate?: boolean;
@IsOptional()
@IsBoolean()
@Expose()
readonly hideBookATeamMember?: boolean = false;
@IsOptional()
@IsString()
@Expose()
readonly metadata?: string;
@IsOptional()
@IsString()
@Expose()
readonly theme?: string;
@IsOptional()
@IsString()
@Expose()
readonly brandColor?: string;
@IsOptional()
@IsString()
@Expose()
readonly darkBrandColor?: string;
@IsOptional()
@IsUrl()
@Expose()
readonly bannerUrl?: string;
@IsOptional()
@IsString()
@Expose()
readonly timeFormat?: number;
@IsOptional()
@IsString()
@Expose()
readonly timeZone?: string = "Europe/London";
@IsOptional()
@IsString()
@Expose()
readonly weekStart?: string = "Sunday";
}
@@ -0,0 +1,23 @@
import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
import { Injectable } from "@nestjs/common";
@Injectable()
export class OrganizationSchedulesRepository {
constructor(private readonly dbRead: PrismaReadService, private readonly dbWrite: PrismaWriteService) {}
async getSchedulesByUserIds(userIds: number[], skip: number, take: number) {
return this.dbRead.prisma.schedule.findMany({
where: {
userId: {
in: userIds,
},
},
include: {
availability: true,
},
skip,
take,
});
}
}
@@ -0,0 +1,58 @@
import { PlatformPlan } from "@/modules/billing/types";
import { CreateOrgTeamDto } from "@/modules/organizations/inputs/create-organization-team.input";
import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
import { StripeService } from "@/modules/stripe/stripe.service";
import { Injectable } from "@nestjs/common";
@Injectable()
export class OrganizationsTeamsRepository {
constructor(
private readonly dbRead: PrismaReadService,
private readonly dbWrite: PrismaWriteService,
private readonly stripeService: StripeService
) {}
async findOrgTeam(organizationId: number, teamId: number) {
return this.dbRead.prisma.team.findUnique({
where: {
id: teamId,
isOrganization: false,
parentId: organizationId,
},
});
}
async deleteOrgTeam(organizationId: number, teamId: number) {
return this.dbRead.prisma.team.delete({
where: {
id: teamId,
isOrganization: false,
parentId: organizationId,
},
});
}
async createOrgTeam(organizationId: number, data: CreateOrgTeamDto) {
return this.dbRead.prisma.team.create({
data: { ...data, parentId: organizationId },
});
}
async updateOrgTeam(organizationId: number, teamId: number, data: CreateOrgTeamDto) {
return this.dbRead.prisma.team.update({
data: { ...data },
where: { id: teamId, parentId: organizationId, isOrganization: false },
});
}
async findOrgTeamsPaginated(organizationId: number, skip: number, take: number) {
return this.dbRead.prisma.team.findMany({
where: {
parentId: organizationId,
},
skip,
take,
});
}
}
@@ -0,0 +1,30 @@
import { OutputSchedulesService_2024_06_11 } from "@/ee/schedules/schedules_2024_06_11/services/output-schedules.service";
import { OrganizationSchedulesRepository } from "@/modules/organizations/repositories/organizations-schedules.repository";
import { UsersRepository } from "@/modules/users/users.repository";
import { Injectable } from "@nestjs/common";
import { ScheduleOutput_2024_06_11 } from "@calcom/platform-types";
@Injectable()
export class OrganizationsSchedulesService {
constructor(
private readonly organizationSchedulesService: OrganizationSchedulesRepository,
private readonly outputSchedulesService: OutputSchedulesService_2024_06_11,
private readonly usersRepository: UsersRepository
) {}
async getOrganizationSchedules(organizationId: number, skip = 0, take = 250) {
const users = await this.usersRepository.getOrganizationUsers(organizationId);
const usersIds = users.map((user) => user.id);
const schedules = await this.organizationSchedulesService.getSchedulesByUserIds(usersIds, skip, take);
const responseSchedules: ScheduleOutput_2024_06_11[] = [];
for (const schedule of schedules) {
responseSchedules.push(await this.outputSchedulesService.getResponseSchedule(schedule));
}
return responseSchedules;
}
}
@@ -0,0 +1,28 @@
import { CreateOrgTeamDto } from "@/modules/organizations/inputs/create-organization-team.input";
import { OrganizationsTeamsRepository } from "@/modules/organizations/repositories/organizations-teams.repository";
import { Injectable } from "@nestjs/common";
@Injectable()
export class OrganizationsTeamsService {
constructor(private readonly organizationsTeamRepository: OrganizationsTeamsRepository) {}
async getPaginatedOrgTeams(organizationId: number, skip = 0, take = 250) {
const teams = await this.organizationsTeamRepository.findOrgTeamsPaginated(organizationId, skip, take);
return teams;
}
async deleteOrgTeam(organizationId: number, teamId: number) {
const team = await this.organizationsTeamRepository.deleteOrgTeam(organizationId, teamId);
return team;
}
async updateOrgTeam(organizationId: number, teamId: number, data: CreateOrgTeamDto) {
const team = await this.organizationsTeamRepository.updateOrgTeam(organizationId, teamId, data);
return team;
}
async createOrgTeam(organizationId: number, data: CreateOrgTeamDto) {
const team = await this.organizationsTeamRepository.createOrgTeam(organizationId, data);
return team;
}
}
@@ -1,3 +1,4 @@
import { CapitalizeTimeZone } from "@/lib/inputs/capitalize-timezone";
import { TimeFormat, WeekDay } from "@/modules/users/inputs/create-managed-user.input";
import { ApiProperty } from "@nestjs/swagger";
import { IsIn, IsNumber, IsOptional, IsString, IsTimeZone } from "class-validator";
@@ -31,5 +32,6 @@ export class UpdateManagedUserInput {
@IsTimeZone()
@IsOptional()
@CapitalizeTimeZone()
timeZone?: string;
}
@@ -181,6 +181,18 @@ export class UsersRepository {
return user?.defaultScheduleId;
}
async getOrganizationUsers(organizationId: number) {
const profiles = await this.dbRead.prisma.profile.findMany({
where: {
organizationId,
},
include: {
user: true,
},
});
return profiles.map((profile) => profile.user);
}
}
function capitalizeTimezone(timezone: string) {
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,26 @@
import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
import { TestingModule } from "@nestjs/testing";
import { Prisma } from "@prisma/client";
export class ProfileRepositoryFixture {
private primaReadClient: PrismaReadService["prisma"];
private prismaWriteClient: PrismaWriteService["prisma"];
constructor(private readonly module: TestingModule) {
this.primaReadClient = module.get(PrismaReadService).prisma;
this.prismaWriteClient = module.get(PrismaWriteService).prisma;
}
async get(profileId: number) {
return this.primaReadClient.profile.findFirst({ where: { id: profileId } });
}
async create(data: Prisma.ProfileCreateInput) {
return this.prismaWriteClient.profile.create({ data });
}
async delete(profileId: number) {
return this.prismaWriteClient.profile.delete({ where: { id: profileId } });
}
}
+13 -18
View File
@@ -1,6 +1,6 @@
import Link from "next/link";
import { useState } from "react";
import { useForm, Controller, useFieldArray } from "react-hook-form";
import { Controller, useFieldArray, useForm } from "react-hook-form";
import type { EventLocationType, getEventLocationValue } from "@calcom/app-store/locations";
import {
@@ -31,20 +31,20 @@ import {
DialogClose,
DialogContent,
DialogFooter,
Dropdown,
DropdownItem,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
Icon,
MeetingTimeInTimezones,
showToast,
TableActions,
TextAreaField,
Tooltip,
Dropdown,
DropdownMenuContent,
DropdownMenuTrigger,
DropdownMenuItem,
DropdownItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuCheckboxItem,
} from "@calcom/ui";
import { ChargeCardDialog } from "@components/dialog/ChargeCardDialog";
@@ -691,13 +691,8 @@ const Attendee = (attendeeProps: AttendeeProps & NoShowProps) => {
const { copyToClipboard, isCopied } = useCopy();
const noShowMutation = trpc.viewer.public.noShow.useMutation({
onSuccess: async () => {
showToast(
t(noShow ? "x_marked_as_no_show" : "x_unmarked_as_no_show", {
x: name || email,
}),
"success"
);
onSuccess: async (data) => {
showToast(t(data.message, { x: name || email }), "success");
},
onError: (err) => {
showToast(err.message, "error");
@@ -804,8 +799,8 @@ const GroupedAttendees = (groupedAttendeeProps: GroupedAttendeeProps) => {
});
const { t } = useLocale();
const noShowMutation = trpc.viewer.public.noShow.useMutation({
onSuccess: async () => {
showToast(t("no_show_updated"), "success");
onSuccess: async (data) => {
showToast(t(data.message), "success");
},
onError: (err) => {
showToast(err.message, "error");
@@ -2,7 +2,7 @@ import type { Webhook } from "@prisma/client";
import { useSession } from "next-auth/react";
import type { EventTypeSetup } from "pages/event-types/[type]";
import { useState } from "react";
import { useFormContext } from "react-hook-form";
import { useFormContext, Controller } from "react-hook-form";
import LicenseRequired from "@calcom/features/ee/common/components/LicenseRequired";
import useLockedFieldsManager from "@calcom/features/ee/managed-event-types/hooks/useLockedFieldsManager";
@@ -15,7 +15,17 @@ import { classNames } from "@calcom/lib";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { WebhookTriggerEvents } from "@calcom/prisma/enums";
import { trpc } from "@calcom/trpc/react";
import { Alert, Button, EmptyScreen, SettingsToggle, Dialog, DialogContent, showToast } from "@calcom/ui";
import {
Alert,
Button,
EmptyScreen,
SettingsToggle,
Dialog,
DialogContent,
showToast,
TextField,
Label,
} from "@calcom/ui";
type InstantEventControllerProps = {
eventType: EventTypeSetup;
@@ -85,7 +95,33 @@ export default function InstantEventController({
}
}}>
<div className="border-subtle rounded-b-lg border border-t-0 p-6">
{instantEventState && <InstantMeetingWebhooks eventType={eventType} />}
{instantEventState && (
<div className="flex flex-col gap-2">
<Controller
name="instantMeetingExpiryTimeOffsetInSeconds"
render={({ field: { value, onChange } }) => (
<>
<Label>{t("set_instant_meeting_expiry_time_offset_description")}</Label>
<TextField
required
name="instantMeetingExpiryTimeOffsetInSeconds"
labelSrOnly
type="number"
defaultValue={value}
min={10}
containerClassName="max-w-80"
addOnSuffix={<>{t("seconds")}</>}
onChange={(e) => {
onChange(Math.abs(Number(e.target.value)));
}}
data-testid="instant-meeting-expiry-time-offset"
/>
</>
)}
/>
<InstantMeetingWebhooks eventType={eventType} />
</div>
)}
</div>
</SettingsToggle>
</>
@@ -213,9 +249,6 @@ const InstantMeetingWebhooks = ({ eventType }: { eventType: EventTypeSetup }) =>
</>
) : (
<>
<p className="text-default mb-4 text-sm font-normal">
{t("warning_payment_instant_meeting_event")}
</p>
<EmptyScreen
Icon="webhook"
headline={t("create_your_first_webhook")}
@@ -251,6 +251,7 @@ const EventTypePage = (props: EventTypeSetupProps) => {
destinationCalendar: eventType.destinationCalendar,
recurringEvent: eventType.recurringEvent || null,
isInstantEvent: eventType.isInstantEvent,
instantMeetingExpiryTimeOffsetInSeconds: eventType.instantMeetingExpiryTimeOffsetInSeconds,
description: eventType.description ?? undefined,
schedule: eventType.schedule || undefined,
bookingLimits: eventType.bookingLimits || undefined,
+85 -18
View File
@@ -1,10 +1,48 @@
import { useTranscription } from "@daily-co/daily-react";
import { useTranscription, useRecording } from "@daily-co/daily-react";
import { useDaily, useDailyEvent } from "@daily-co/daily-react";
import React, { Fragment, useCallback, useRef, useState, useLayoutEffect, useEffect } from "react";
import { TRANSCRIPTION_STARTED_ICON, TRANSCRIPTION_STOPPED_ICON } from "@calcom/lib/constants";
import {
TRANSCRIPTION_STARTED_ICON,
RECORDING_IN_PROGRESS_ICON,
TRANSCRIPTION_STOPPED_ICON,
RECORDING_DEFAULT_ICON,
} from "@calcom/lib/constants";
import { useLocale } from "@calcom/lib/hooks/useLocale";
const BUTTONS = {
STOP_TRANSCRIPTION: {
label: "Stop",
tooltip: "Stop transcription",
iconPath: TRANSCRIPTION_STARTED_ICON,
iconPathDarkMode: TRANSCRIPTION_STARTED_ICON,
},
START_TRANSCRIPTION: {
label: "Cal.ai",
tooltip: "Transcription powered by AI",
iconPath: TRANSCRIPTION_STOPPED_ICON,
iconPathDarkMode: TRANSCRIPTION_STOPPED_ICON,
},
START_RECORDING: {
label: "Record",
tooltip: "Start recording",
iconPath: RECORDING_DEFAULT_ICON,
iconPathDarkMode: RECORDING_DEFAULT_ICON,
},
WAIT_FOR_RECORDING_TO_START: {
label: "Starting..",
tooltip: "Please wait while we start recording",
iconPath: RECORDING_DEFAULT_ICON,
iconPathDarkMode: RECORDING_DEFAULT_ICON,
},
STOP_RECORDING: {
label: "Stop",
tooltip: "Stop recording",
iconPath: RECORDING_IN_PROGRESS_ICON,
iconPathDarkMode: RECORDING_IN_PROGRESS_ICON,
},
};
export const CalAiTranscribe = () => {
const daily = useDaily();
const { t } = useLocale();
@@ -15,6 +53,7 @@ export const CalAiTranscribe = () => {
const transcriptRef = useRef<HTMLDivElement | null>(null);
const transcription = useTranscription();
const recording = useRecording();
useDailyEvent(
"app-message",
@@ -26,36 +65,64 @@ export const CalAiTranscribe = () => {
useDailyEvent("transcription-started", (ev) => {
daily?.updateCustomTrayButtons({
transcription: {
label: "Stop",
tooltip: "Stop transcription",
iconPath: TRANSCRIPTION_STARTED_ICON,
iconPathDarkMode: TRANSCRIPTION_STARTED_ICON,
},
recording: recording?.isRecording ? BUTTONS.STOP_RECORDING : BUTTONS.START_RECORDING,
transcription: BUTTONS.STOP_TRANSCRIPTION,
});
});
useDailyEvent("recording-started", (ev) => {
daily?.updateCustomTrayButtons({
recording: BUTTONS.STOP_RECORDING,
transcription: transcription?.isTranscribing ? BUTTONS.STOP_TRANSCRIPTION : BUTTONS.START_TRANSCRIPTION,
});
});
useDailyEvent("transcription-stopped", (ev) => {
daily?.updateCustomTrayButtons({
transcription: {
label: "Cal.ai",
tooltip: "Transcription powered by AI",
iconPath: TRANSCRIPTION_STOPPED_ICON,
iconPathDarkMode: TRANSCRIPTION_STOPPED_ICON,
},
recording: recording?.isRecording ? BUTTONS.STOP_RECORDING : BUTTONS.START_RECORDING,
transcription: BUTTONS.START_TRANSCRIPTION,
});
});
useDailyEvent("custom-button-click", (ev) => {
if (ev?.button_id !== "transcription") {
return;
}
useDailyEvent("recording-stopped", (ev) => {
daily?.updateCustomTrayButtons({
recording: BUTTONS.START_RECORDING,
transcription: transcription?.isTranscribing ? BUTTONS.STOP_TRANSCRIPTION : BUTTONS.START_TRANSCRIPTION,
});
});
const toggleRecording = async () => {
if (recording?.isRecording) {
await daily?.stopRecording();
} else {
daily?.updateCustomTrayButtons({
recording: BUTTONS.WAIT_FOR_RECORDING_TO_START,
transcription: transcription?.isTranscribing
? BUTTONS.STOP_TRANSCRIPTION
: BUTTONS.START_TRANSCRIPTION,
});
await daily?.startRecording({
// 480p
videoBitrate: 2000,
});
}
};
const toggleTranscription = async () => {
if (transcription?.isTranscribing) {
daily?.stopTranscription();
} else {
daily?.startTranscription();
}
};
useDailyEvent("custom-button-click", async (ev) => {
if (ev?.button_id === "recording") {
toggleRecording();
} else if (ev?.button_id === "transcription") {
toggleTranscription();
}
});
useLayoutEffect(() => {
@@ -1,6 +1,7 @@
import MarkdownIt from "markdown-it";
import type { GetServerSidePropsContext } from "next";
import { generateGuestMeetingTokenFromOwnerMeetingToken } from "@calcom/app-store/dailyvideo/lib/VideoApiAdapter";
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
import { getCalVideoReference } from "@calcom/features/get-cal-video-reference";
import { UserRepository } from "@calcom/lib/server/repository/user";
@@ -103,12 +104,18 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
const session = await getServerSession({ req });
// set meetingPassword to null for guests
// set meetingPassword for guests
if (session?.user.id !== bookingObj.user?.id) {
const videoReference = getCalVideoReference(bookingObj.references);
const guestMeetingPassword = await generateGuestMeetingTokenFromOwnerMeetingToken(
videoReference.meetingPassword
);
bookingObj.references.forEach((bookRef) => {
bookRef.meetingPassword = null;
bookRef.meetingPassword = guestMeetingPassword;
});
}
const videoReference = getCalVideoReference(bookingObj.references);
return {
@@ -9,7 +9,7 @@ import { useState, useEffect, useRef } from "react";
import dayjs from "@calcom/dayjs";
import classNames from "@calcom/lib/classNames";
import { APP_NAME, SEO_IMG_OGIMG_VIDEO, WEBSITE_URL } from "@calcom/lib/constants";
import { TRANSCRIPTION_STOPPED_ICON } from "@calcom/lib/constants";
import { TRANSCRIPTION_STOPPED_ICON, RECORDING_DEFAULT_ICON } from "@calcom/lib/constants";
import { formatToLocalizedDate, formatToLocalizedTime } from "@calcom/lib/date-fns";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { markdownToSafeHTML } from "@calcom/lib/markdownToSafeHTML";
@@ -50,6 +50,12 @@ export default function JoinCall(props: PageProps) {
...(typeof meetingPassword === "string" && { token: meetingPassword }),
...(hasTeamPlan && {
customTrayButtons: {
recording: {
label: "Record",
tooltip: "Start or stop recording",
iconPath: RECORDING_DEFAULT_ICON,
iconPathDarkMode: RECORDING_DEFAULT_ICON,
},
transcription: {
label: "Cal.ai",
tooltip: "Transcription powered by AI",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@calcom/web",
"version": "4.2.2",
"version": "4.2.3",
"private": true,
"scripts": {
"analyze": "ANALYZE=true next build",
@@ -642,6 +642,7 @@ const ProfileForm = ({
{showRemoveAvatarButton && (
<Button
color="secondary"
className="hover:bg-red-600"
onClick={() => {
onChange(null);
}}>
+19 -2
View File
@@ -63,7 +63,7 @@ test.describe("Bookings", () => {
});
});
test.describe("Past bookings", () => {
test("Mark first guest as no-show", async ({ page, users, bookings }) => {
test("Mark first guest as no-show", async ({ page, users, bookings, webhooks }) => {
const firstUser = await users.create();
const secondUser = await users.create();
@@ -81,8 +81,8 @@ test.describe("Bookings", () => {
],
});
const bookingWhereFirstUserIsOrganizer = await bookingWhereFirstUserIsOrganizerFixture.self();
await firstUser.apiLogin();
const webhookReceiver = await webhooks.createReceiver();
await page.goto(`/bookings/past`);
const pastBookings = page.locator('[data-testid="past-bookings"]');
const firstPastBooking = pastBookings.locator('[data-testid="booking-item"]').nth(0);
@@ -95,6 +95,23 @@ test.describe("Bookings", () => {
await firstGuest.click();
await expect(titleAndAttendees.locator('[data-testid="unmark-no-show"]')).toBeVisible();
await expect(titleAndAttendees.locator('[data-testid="mark-no-show"]')).toBeHidden();
await webhookReceiver.waitForRequestCount(1);
const [request] = webhookReceiver.requestList;
const body = request.body;
// remove dynamic properties that differs depending on where you run the tests
const dynamic = "[redacted/dynamic]";
// @ts-expect-error we are modifying the object
body.createdAt = dynamic;
expect(body).toMatchObject({
triggerEvent: "BOOKING_NO_SHOW_UPDATED",
createdAt: "[redacted/dynamic]",
payload: {
message: "first@cal.com marked as no-show",
attendees: [{ email: "first@cal.com", noShow: true, utcOffset: null }],
bookingUid: bookingWhereFirstUserIsOrganizer?.uid,
},
});
webhookReceiver.close();
});
test("Mark 3rd attendee as no-show", async ({ page, users, bookings }) => {
const firstUser = await users.create();
+39
View File
@@ -0,0 +1,39 @@
import { expect, type Page } from "@playwright/test";
import { createHttpServer } from "../lib/testUtils";
export function createWebhookPageFixture(page: Page) {
return {
createTeamReceiver: async () => {
const webhookReceiver = createHttpServer();
await page.goto(`/settings/developer/webhooks`);
await page.click('[data-testid="new_webhook"]');
await page.click('[data-testid="option-team-1"]');
await page.waitForURL((u) => u.pathname === "/settings/developer/webhooks/new");
const url = page.url();
const teamId = Number(new URL(url).searchParams.get("teamId")) as number;
await page.click('[data-testid="new_webhook"]');
await page.fill('[name="subscriberUrl"]', webhookReceiver.url);
await page.fill('[name="secret"]', "secret");
await Promise.all([
page.click("[type=submit]"),
page.waitForURL((url) => url.pathname.endsWith("/settings/developer/webhooks")),
]);
expect(page.locator(`text='${webhookReceiver.url}'`)).toBeDefined();
return { webhookReceiver, teamId };
},
createReceiver: async () => {
const webhookReceiver = createHttpServer();
await page.goto(`/settings/developer/webhooks`);
await page.click('[data-testid="new_webhook"]');
await page.fill('[name="subscriberUrl"]', webhookReceiver.url);
await page.fill('[name="secret"]', "secret");
await Promise.all([
page.click("[type=submit]"),
page.waitForURL((url) => url.pathname.endsWith("/settings/developer/webhooks")),
]);
expect(page.locator(`text='${webhookReceiver.url}'`)).toBeDefined();
return webhookReceiver;
},
};
}
+6
View File
@@ -16,6 +16,7 @@ import { createBookingPageFixture } from "../fixtures/regularBookings";
import { createRoutingFormsFixture } from "../fixtures/routingForms";
import { createServersFixture } from "../fixtures/servers";
import { createUsersFixture } from "../fixtures/users";
import { createWebhookPageFixture } from "../fixtures/webhooks";
import { createWorkflowPageFixture } from "../fixtures/workflows";
export interface Fixtures {
@@ -34,6 +35,7 @@ export interface Fixtures {
features: ReturnType<typeof createFeatureFixture>;
eventTypePage: ReturnType<typeof createEventTypeFixture>;
appsPage: ReturnType<typeof createAppsFixture>;
webhooks: ReturnType<typeof createWebhookPageFixture>;
}
declare global {
@@ -110,4 +112,8 @@ export const test = base.extend<Fixtures>({
const appsPage = createAppsFixture(page);
await use(appsPage);
},
webhooks: async ({ page }, use) => {
const webhooks = createWebhookPageFixture(page);
await use(webhooks);
},
});
@@ -1,6 +1,7 @@
import type { Page } from "@playwright/test";
import { expect } from "@playwright/test";
import { JSDOM } from "jsdom";
import { uuid } from "short-uuid";
import { getOrgUsernameFromEmail } from "@calcom/features/auth/signup/utils/getOrgUsernameFromEmail";
import { WEBAPP_URL } from "@calcom/lib/constants";
@@ -28,9 +29,9 @@ function getOrgOrigin(orgSlug: string | null) {
}
test.describe("Bookings", () => {
test.afterEach(({ orgs, users }) => {
orgs.deleteAll();
users.deleteAll();
test.afterEach(async ({ orgs, users, page }) => {
await users.deleteAll();
await orgs.deleteAll();
});
test.describe("Team Event", () => {
@@ -324,6 +325,8 @@ test.describe("Bookings", () => {
const username = "john";
const userInsideOrganization = await users.create({
username,
useExactUsername: true,
email: `john-inside-${uuid()}@example.com`,
name: "John Inside Organization",
organizationId: org.id,
roleInOrganization: MembershipRole.MEMBER,
@@ -339,6 +342,8 @@ test.describe("Bookings", () => {
const userOutsideOrganization = await users.create({
username,
name: "John Outside Organization",
email: `john-outside-${uuid()}@example.com`,
useExactUsername: true,
eventTypes: [
{
title: "John Outside Org's Meeting",
+20 -118
View File
@@ -1,4 +1,3 @@
import type { Page } from "@playwright/test";
import { expect } from "@playwright/test";
import { v4 as uuidv4 } from "uuid";
@@ -10,7 +9,6 @@ import { test } from "./lib/fixtures";
import {
bookOptinEvent,
bookTimeSlot,
createHttpServer,
createUserWithSeatedEventAndAttendees,
gotoRoutingLink,
selectFirstAvailableTimeSlotNextMonth,
@@ -24,54 +22,16 @@ test.afterEach(async ({ users }) => {
await users.deleteAll();
});
async function createWebhookReceiver(page: Page) {
const webhookReceiver = createHttpServer();
await page.goto(`/settings/developer/webhooks`);
// --- add webhook
await page.click('[data-testid="new_webhook"]');
await page.fill('[name="subscriberUrl"]', webhookReceiver.url);
await page.fill('[name="secret"]', "secret");
await Promise.all([
page.click("[type=submit]"),
page.waitForURL((url) => url.pathname.endsWith("/settings/developer/webhooks")),
]);
// page contains the url
expect(page.locator(`text='${webhookReceiver.url}'`)).toBeDefined();
return webhookReceiver;
}
test.describe("BOOKING_CREATED", async () => {
test("add webhook & test that creating an event triggers a webhook call", async ({
page,
users,
webhooks,
}, _testInfo) => {
const webhookReceiver = createHttpServer();
const user = await users.create();
const [eventType] = user.eventTypes;
await user.apiLogin();
await page.goto(`/settings/developer/webhooks`);
// --- add webhook
await page.click('[data-testid="new_webhook"]');
await page.fill('[name="subscriberUrl"]', webhookReceiver.url);
await page.fill('[name="secret"]', "secret");
await Promise.all([
page.click("[type=submit]"),
page.waitForURL((url) => url.pathname.endsWith("/settings/developer/webhooks")),
]);
// page contains the url
expect(page.locator(`text='${webhookReceiver.url}'`)).toBeDefined();
const webhookReceiver = await webhooks.createReceiver();
// --- Book the first available day next month in the pro user's "30min"-event
await page.goto(`/${user.username}/${eventType.slug}`);
@@ -169,8 +129,8 @@ test.describe("BOOKING_REJECTED", async () => {
test("can book an event that requires confirmation and then that booking can be rejected by organizer", async ({
page,
users,
webhooks,
}) => {
const webhookReceiver = createHttpServer();
// --- create a user
const user = await users.create();
@@ -182,24 +142,7 @@ test.describe("BOOKING_REJECTED", async () => {
// --- login as that user
await user.apiLogin();
await page.goto(`/settings/developer/webhooks`);
// --- add webhook
await page.click('[data-testid="new_webhook"]');
await page.fill('[name="subscriberUrl"]', webhookReceiver.url);
await page.fill('[name="secret"]', "secret");
await Promise.all([
page.click("[type=submit]"),
page.waitForURL((url) => url.pathname.endsWith("/settings/developer/webhooks")),
]);
// page contains the url
expect(page.locator(`text='${webhookReceiver.url}'`)).toBeDefined();
const webhookReceiver = await webhooks.createReceiver();
await page.goto("/bookings/unconfirmed");
await page.click('[data-testid="reject"]');
await page.click('[data-testid="rejection-confirm"]');
@@ -293,30 +236,14 @@ test.describe("BOOKING_REQUESTED", async () => {
test("can book an event that requires confirmation and get a booking requested event", async ({
page,
users,
webhooks,
}) => {
const webhookReceiver = createHttpServer();
// --- create a user
const user = await users.create();
// --- login as that user
await user.apiLogin();
await page.goto(`/settings/developer/webhooks`);
// --- add webhook
await page.click('[data-testid="new_webhook"]');
await page.fill('[name="subscriberUrl"]', webhookReceiver.url);
await page.fill('[name="secret"]', "secret");
await Promise.all([
page.click("[type=submit]"),
page.waitForURL((url) => url.pathname.endsWith("/settings/developer/webhooks")),
]);
// page contains the url
expect(page.locator(`text='${webhookReceiver.url}'`)).toBeDefined();
const webhookReceiver = await webhooks.createReceiver();
// --- visit user page
await page.goto(`/${user.username}`);
@@ -410,13 +337,18 @@ test.describe("BOOKING_REQUESTED", async () => {
});
test.describe("BOOKING_RESCHEDULED", async () => {
test("can reschedule a booking and get a booking rescheduled event", async ({ page, users, bookings }) => {
test("can reschedule a booking and get a booking rescheduled event", async ({
page,
users,
bookings,
webhooks,
}) => {
const user = await users.create();
const [eventType] = user.eventTypes;
await user.apiLogin();
const webhookReceiver = await createWebhookReceiver(page);
const webhookReceiver = await webhooks.createReceiver();
const booking = await bookings.create(user.id, user.username, eventType.id, {
status: BookingStatus.ACCEPTED,
@@ -451,6 +383,7 @@ test.describe("BOOKING_RESCHEDULED", async () => {
page,
users,
bookings,
webhooks,
}) => {
const { user, eventType, booking } = await createUserWithSeatedEventAndAttendees({ users, bookings }, [
{ name: "John First", email: "first+seats@cal.com", timeZone: "Europe/Berlin" },
@@ -464,7 +397,7 @@ test.describe("BOOKING_RESCHEDULED", async () => {
await user.apiLogin();
const webhookReceiver = await createWebhookReceiver(page);
const webhookReceiver = await webhooks.createReceiver();
const bookingAttendees = await prisma.attendee.findMany({
where: { bookingId: booking.id },
@@ -670,24 +603,11 @@ test.describe("MEETING_ENDED, MEETING_STARTED", async () => {
});
test.describe("FORM_SUBMITTED", async () => {
test("on submitting user form, triggers user webhook", async ({ page, users, routingForms }) => {
const webhookReceiver = createHttpServer();
const user = await users.create(null, {
hasTeam: true,
});
test("on submitting user form, triggers user webhook", async ({ page, users, routingForms, webhooks }) => {
const user = await users.create();
await user.apiLogin();
await page.goto(`/settings/developer/webhooks/new`);
// Add webhook
await page.fill('[name="subscriberUrl"]', webhookReceiver.url);
await page.fill('[name="secret"]', "secret");
await page.click("[type=submit]");
// Page contains the url
expect(page.locator(`text='${webhookReceiver.url}'`)).toBeDefined();
const webhookReceiver = await webhooks.createReceiver();
await page.waitForLoadState("networkidle");
const form = await routingForms.create({
@@ -736,21 +656,12 @@ test.describe("FORM_SUBMITTED", async () => {
webhookReceiver.close();
});
test("on submitting team form, triggers team webhook", async ({ page, users, routingForms }) => {
const webhookReceiver = createHttpServer();
test("on submitting team form, triggers team webhook", async ({ page, users, routingForms, webhooks }) => {
const user = await users.create(null, {
hasTeam: true,
});
await user.apiLogin();
await page.goto(`/settings/developer/webhooks`);
const teamId = await clickFirstTeamWebhookCta(page);
// Add webhook
await page.fill('[name="subscriberUrl"]', webhookReceiver.url);
await page.fill('[name="secret"]', "secret");
await page.click("[type=submit]");
const { webhookReceiver, teamId } = await webhooks.createTeamReceiver();
const form = await routingForms.create({
name: "Test Form",
userId: user.id,
@@ -797,12 +708,3 @@ test.describe("FORM_SUBMITTED", async () => {
webhookReceiver.close();
});
});
async function clickFirstTeamWebhookCta(page: Page) {
await page.click('[data-testid="new_webhook"]');
await page.click('[data-testid="option-team-1"]');
await page.waitForURL((u) => u.pathname === "/settings/developer/webhooks/new");
const url = page.url();
const teamId = Number(new URL(url).searchParams.get("teamId")) as number;
return teamId;
}
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" borderRadius="4px" fill="white" class="jsx-2238566114 icon"><path d="M7.05673 11.7832H6.6259V10.6179H7.06326C7.4941 10.6179 7.7291 10.7737 7.7291 11.1667V11.1938C7.7291 11.5867 7.50715 11.7832 7.05673 11.7832Z" class="jsx-2238566114"></path><path fill-rule="evenodd" clip-rule="evenodd" d="M3 10C3 7.79086 4.79086 6 7 6H17C19.2091 6 21 7.79086 21 10V14C21 16.2091 19.2091 18 17 18H7C4.79086 18 3 16.2091 3 14V10ZM5.29422 14.4187V9.57453H7.12201C8.34271 9.57453 9.02161 10.0894 9.02161 11.1125V11.1396C9.02161 11.8306 8.6691 12.2168 8.17952 12.4133L9.29577 14.4187H7.88576L6.93923 12.6843H6.6259V14.4187H5.29422ZM10.3881 14.4187V9.57453H13.6128V10.645H11.7198V11.4783H13.2277V12.4675H11.7198V13.3482H13.7173V14.4187H10.3881ZM16.9974 14.5C18.205 14.5 19.0798 13.8496 19.1711 12.5962H17.8395C17.7742 13.1314 17.4413 13.3889 16.9713 13.3889C16.3315 13.3889 15.9725 12.8943 15.9725 12.0203V11.9661C15.9725 11.0854 16.3511 10.5976 16.9517 10.5976C17.4217 10.5976 17.6828 10.8686 17.735 11.3631H19.1124C19.0014 10.0827 18.1593 9.5 16.9452 9.5C15.5808 9.5 14.5625 10.4824 14.5625 11.9729C14.5625 13.4681 15.464 14.5 16.9974 14.5Z" class="jsx-2238566114"></path></svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

@@ -471,6 +471,7 @@
"meeting_ended": "Meeting Ended",
"form_submitted": "Form Submitted",
"booking_paid": "Booking Paid",
"booking_no_show_updated": "Booking No-Show Updated",
"event_triggers": "Event Triggers",
"subscriber_url": "Subscriber URL",
"create_new_webhook": "Create a new webhook",
@@ -1043,6 +1044,7 @@
"seats_nearly_full": "Seats almost full",
"seats_half_full": "Seats filling fast",
"number_of_seats": "Number of seats per booking",
"set_instant_meeting_expiry_time_offset_description": "Set meeting join window (seconds): The time frame in seconds within which host can join and start the meeting. After this period, the meeting join url will expire.",
"enter_number_of_seats": "Enter number of seats",
"you_can_manage_your_schedules": "You can manage your schedules on the Availability page.",
"booking_full": "No more seats available",
@@ -1040,6 +1040,7 @@
"seats_nearly_full": "כמעט כל המקומות תפוסים",
"seats_half_full": "המקומות מתמלאים במהירות",
"number_of_seats": "מספר המקומות לכל הזמנה",
"set_instant_meeting_expiry_time_offset_description": "הגדרת חלון הצטרפות לישיבה (שניות): חלון הזמן בשניות בו המארח יכול להצטרף ולהתחיל את הישיבה. לאחר פרק הזמן הזה, תוקף כתובת ההצטרפות לישיבה יפוג.",
"enter_number_of_seats": "יש להזין את מספר המקומות",
"you_can_manage_your_schedules": "ניתן לנהל את לוחות הזמנים בדף 'זמינות'.",
"booking_full": "אין יותר מקומות זמינים",
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" borderRadius="4px" fill="#f63135" class="jsx-3932271029 icon"><path d="M7.05673 11.7832H6.6259V10.6179H7.06326C7.4941 10.6179 7.7291 10.7737 7.7291 11.1667V11.1938C7.7291 11.5867 7.50715 11.7832 7.05673 11.7832Z" class="jsx-3932271029"></path><path fill-rule="evenodd" clip-rule="evenodd" d="M3 10C3 7.79086 4.79086 6 7 6H17C19.2091 6 21 7.79086 21 10V14C21 16.2091 19.2091 18 17 18H7C4.79086 18 3 16.2091 3 14V10ZM5.29422 14.4187V9.57453H7.12201C8.34271 9.57453 9.02161 10.0894 9.02161 11.1125V11.1396C9.02161 11.8306 8.6691 12.2168 8.17952 12.4133L9.29577 14.4187H7.88576L6.93923 12.6843H6.6259V14.4187H5.29422ZM10.3881 14.4187V9.57453H13.6128V10.645H11.7198V11.4783H13.2277V12.4675H11.7198V13.3482H13.7173V14.4187H10.3881ZM16.9974 14.5C18.205 14.5 19.0798 13.8496 19.1711 12.5962H17.8395C17.7742 13.1314 17.4413 13.3889 16.9713 13.3889C16.3315 13.3889 15.9725 12.8943 15.9725 12.0203V11.9661C15.9725 11.0854 16.3511 10.5976 16.9517 10.5976C17.4217 10.5976 17.6828 10.8686 17.735 11.3631H19.1124C19.0014 10.0827 18.1593 9.5 16.9452 9.5C15.5808 9.5 14.5625 10.4824 14.5625 11.9729C14.5625 13.4681 15.464 14.5 16.9974 14.5Z" class="jsx-3932271029"></path></svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

+4
View File
@@ -479,3 +479,7 @@ select:focus {
.react-tel-input .flag-dropdown {
@apply !border-r-default left-0.5 !border-y-0 !border-l-0;
}
.intercom-lightweight-app {
@apply z-40 !important;
}
@@ -8,7 +8,7 @@ export function getTokenObjectFromCredential(credential: CredentialPayload) {
const parsedTokenResponse = OAuth2TokenResponseInDbSchema.safeParse(credential.key);
if (!parsedTokenResponse.success) {
logger.debug(
logger.error(
"GoogleCalendarService-getTokenObjectFromCredential",
safeStringify(parsedTokenResponse.error.issues)
);
@@ -16,60 +16,14 @@ import type { VideoApiAdapter, VideoCallData } from "@calcom/types/VideoApiAdapt
import { ZSubmitBatchProcessorJobRes, ZGetTranscriptAccessLink } from "../zod";
import type { TSubmitBatchProcessorJobRes, TGetTranscriptAccessLink, batchProcessorBody } from "../zod";
import { getDailyAppKeys } from "./getDailyAppKeys";
/** @link https://docs.daily.co/reference/rest-api/rooms/create-room */
const dailyReturnTypeSchema = z.object({
/** Long UID string ie: 987b5eb5-d116-4a4e-8e2c-14fcb5710966 */
id: z.string(),
/** Not a real name, just a random generated string ie: "ePR84NQ1bPigp79dDezz" */
name: z.string(),
api_created: z.boolean(),
privacy: z.union([z.literal("private"), z.literal("public")]),
/** https://api-demo.daily.co/ePR84NQ1bPigp79dDezz */
url: z.string(),
created_at: z.string(),
config: z.object({
/** Timestamps expressed in seconds, not in milliseconds */
nbf: z.number().optional(),
/** Timestamps expressed in seconds, not in milliseconds */
exp: z.number(),
enable_chat: z.boolean(),
enable_knocking: z.boolean(),
enable_prejoin_ui: z.boolean(),
enable_transcription_storage: z.boolean().default(false),
}),
});
const getTranscripts = z.object({
total_count: z.number(),
data: z.array(
z.object({
transcriptId: z.string(),
domainId: z.string(),
roomId: z.string(),
mtgSessionId: z.string(),
duration: z.number(),
status: z.string(),
})
),
});
const getBatchProcessJobs = z.object({
total_count: z.number(),
data: z.array(
z.object({
id: z.string(),
preset: z.string(),
status: z.string(),
})
),
});
const getRooms = z
.object({
id: z.string(),
})
.passthrough();
import {
dailyReturnTypeSchema,
getTranscripts,
getBatchProcessJobs,
getRooms,
meetingTokenSchema,
ZGetMeetingTokenResponseSchema,
} from "./types";
export interface DailyEventResult {
id: string;
@@ -88,10 +42,6 @@ export interface DailyVideoCallData {
url: string;
}
const meetingTokenSchema = z.object({
token: z.string(),
});
/** @deprecated use metadata on index file */
export const FAKE_DAILY_CREDENTIAL: CredentialPayload & { invalid: boolean } = {
id: 0,
@@ -151,6 +101,21 @@ async function processTranscriptsInBatches(transcriptIds: Array<string>) {
return allTranscriptsAccessLinks;
}
export const generateGuestMeetingTokenFromOwnerMeetingToken = async (meetingToken: string | null) => {
if (!meetingToken) return null;
const token = await fetcher(`/meeting-tokens/${meetingToken}`).then(ZGetMeetingTokenResponseSchema.parse);
const guestMeetingToken = await postToDailyAPI("/meeting-tokens", {
properties: {
room_name: token.room_name,
exp: token.exp,
enable_recording_ui: false,
},
}).then(meetingTokenSchema.parse);
return guestMeetingToken.token;
};
const DailyVideoApiAdapter = (): VideoApiAdapter => {
async function createOrUpdateMeeting(endpoint: string, event: CalendarEvent): Promise<VideoCallData> {
if (!event.uid) {
@@ -159,7 +124,12 @@ const DailyVideoApiAdapter = (): VideoApiAdapter => {
const body = await translateEvent(event);
const dailyEvent = await postToDailyAPI(endpoint, body).then(dailyReturnTypeSchema.parse);
const meetingToken = await postToDailyAPI("/meeting-tokens", {
properties: { room_name: dailyEvent.name, exp: dailyEvent.config.exp, is_owner: true },
properties: {
room_name: dailyEvent.name,
exp: dailyEvent.config.exp,
is_owner: true,
enable_recording_ui: false,
},
}).then(meetingTokenSchema.parse);
return Promise.resolve({
@@ -233,7 +203,12 @@ const DailyVideoApiAdapter = (): VideoApiAdapter => {
const dailyEvent = await postToDailyAPI("/rooms", body).then(dailyReturnTypeSchema.parse);
const meetingToken = await postToDailyAPI("/meeting-tokens", {
properties: { room_name: dailyEvent.name, exp: dailyEvent.config.exp, is_owner: true },
properties: {
room_name: dailyEvent.name,
exp: dailyEvent.config.exp,
is_owner: true,
enable_recording_ui: false,
},
}).then(meetingTokenSchema.parse);
return Promise.resolve({
@@ -0,0 +1,66 @@
import { z } from "zod";
/** @link https://docs.daily.co/reference/rest-api/rooms/create-room */
export const dailyReturnTypeSchema = z.object({
/** Long UID string ie: 987b5eb5-d116-4a4e-8e2c-14fcb5710966 */
id: z.string(),
/** Not a real name, just a random generated string ie: "ePR84NQ1bPigp79dDezz" */
name: z.string(),
api_created: z.boolean(),
privacy: z.union([z.literal("private"), z.literal("public")]),
/** https://api-demo.daily.co/ePR84NQ1bPigp79dDezz */
url: z.string(),
created_at: z.string(),
config: z.object({
/** Timestamps expressed in seconds, not in milliseconds */
nbf: z.number().optional(),
/** Timestamps expressed in seconds, not in milliseconds */
exp: z.number(),
enable_chat: z.boolean(),
enable_knocking: z.boolean(),
enable_prejoin_ui: z.boolean(),
enable_transcription_storage: z.boolean().default(false),
}),
});
export const getTranscripts = z.object({
total_count: z.number(),
data: z.array(
z.object({
transcriptId: z.string(),
domainId: z.string(),
roomId: z.string(),
mtgSessionId: z.string(),
duration: z.number(),
status: z.string(),
})
),
});
export const getBatchProcessJobs = z.object({
total_count: z.number(),
data: z.array(
z.object({
id: z.string(),
preset: z.string(),
status: z.string(),
})
),
});
export const getRooms = z
.object({
id: z.string(),
})
.passthrough();
export const meetingTokenSchema = z.object({
token: z.string(),
});
export const ZGetMeetingTokenResponseSchema = z
.object({
room_name: z.string(),
exp: z.number(),
})
.passthrough();
@@ -234,7 +234,7 @@ export const TeamInviteEmail = (
</Trans>
) : (
<Trans i18nKey="email_team_invite|content|invited_to_subteam">
{invitedBy} has added you to the team <strong>{teamName}</strong> in their organization{" "}
{invitedBy} has invited you to the team <strong>{teamName}</strong> in their organization{" "}
<strong>{parentTeamName}</strong>.
</Trans>
)}{" "}
@@ -204,11 +204,6 @@ export const useBookings = ({ event, hashedLink, bookingForm, metadata, teamMemb
});
},
onError: (err, _, ctx) => {
// TODO:
// const vercelId = ctx?.meta?.headers?.get("x-vercel-id");
// if (vercelId) {
// setResponseVercelIdHeader(vercelId);
// }
bookerFormErrorRef && bookerFormErrorRef.current?.scrollIntoView({ behavior: "smooth" });
},
});
@@ -5,7 +5,6 @@ import { createAProfileForAnExistingUser } from "@calcom/lib/createAProfileForAn
import { getTranslation } from "@calcom/lib/server/i18n";
import prisma from "@calcom/prisma";
import { IdentityProvider, MembershipRole } from "@calcom/prisma/enums";
import { teamMetadataSchema } from "@calcom/prisma/zod-utils";
import {
getTeamOrThrow,
sendSignupToOrganizationEmail,
@@ -115,7 +114,7 @@ const handleGroupEvents = async (event: DirectorySyncEvent, organizationId: numb
newUserEmails.map((email) => {
return sendSignupToOrganizationEmail({
usernameOrEmail: email,
team: { ...group.team, metadata: teamMetadataSchema.parse(group.team.metadata) },
team: group.team,
translation,
inviterName: org.name,
teamId: group.teamId,
@@ -0,0 +1,21 @@
import { beforeEach, vi, expect } from "vitest";
import { mockReset, mockDeep } from "vitest-mock-extended";
import type * as payments from "@calcom/features/ee/teams/lib/payments";
vi.mock("@calcom/features/ee/teams/lib/payments", () => paymentsMock);
beforeEach(() => {
mockReset(paymentsMock);
});
const paymentsMock = mockDeep<typeof payments>();
export const paymentsScenarios = {};
export const paymentsExpects = {
expectQuantitySubscriptionToBeUpdatedForTeam: (teamId: number) => {
expect(paymentsMock.updateQuantitySubscriptionFromStripe).toHaveBeenCalledWith(teamId);
},
};
export default paymentsMock;
@@ -239,6 +239,7 @@ export const getPublicEvent = async (
}
: {
username,
profiles: { none: {} },
}),
},
},
@@ -34,6 +34,7 @@ export type FormValues = {
eventName: string;
slug: string;
isInstantEvent: boolean;
instantMeetingExpiryTimeOffsetInSeconds: number;
length: number;
offsetStart: number;
description: string;
@@ -194,12 +194,26 @@ async function handler(req: NextApiRequest) {
const newBooking = await prisma.booking.create(createBookingObj);
// Create Instant Meeting Token
const token = randomBytes(32).toString("hex");
const eventTypeWithExpiryTimeOffset = await prisma.eventType.findUniqueOrThrow({
where: {
id: req.body.eventTypeId,
},
select: {
instantMeetingExpiryTimeOffsetInSeconds: true,
},
});
const instantMeetingExpiryTimeOffsetInSeconds =
eventTypeWithExpiryTimeOffset?.instantMeetingExpiryTimeOffsetInSeconds ?? 90;
const instantMeetingToken = await prisma.instantMeetingToken.create({
data: {
token,
// 90 Seconds
expires: new Date(new Date().getTime() + 1000 * 90),
// current time + offset Seconds
expires: new Date(new Date().getTime() + 1000 * instantMeetingExpiryTimeOffsetInSeconds),
team: {
connect: {
id: eventType.team.id,
@@ -38,6 +38,7 @@ const WEBHOOK_TRIGGER_EVENTS_GROUPED_BY_APP_V2: Record<string, WebhookTriggerEve
{ value: WebhookTriggerEvents.BOOKING_PAYMENT_INITIATED, label: "booking_payment_initiated" },
{ value: WebhookTriggerEvents.BOOKING_RESCHEDULED, label: "booking_rescheduled" },
{ value: WebhookTriggerEvents.BOOKING_PAID, label: "booking_paid" },
{ value: WebhookTriggerEvents.BOOKING_NO_SHOW_UPDATED, label: "booking_no_show_updated" },
{ value: WebhookTriggerEvents.MEETING_ENDED, label: "meeting_ended" },
{ value: WebhookTriggerEvents.MEETING_STARTED, label: "meeting_started" },
{ value: WebhookTriggerEvents.RECORDING_READY, label: "recording_ready" },
@@ -0,0 +1,40 @@
import logger from "@calcom/lib/logger";
import { safeStringify } from "@calcom/lib/safeStringify";
import getWebhooks from "./getWebhooks";
import sendOrSchedulePayload from "./sendOrSchedulePayload";
const log = logger.getSubLogger({ prefix: ["[WebhookService] "] });
/** This is a WIP. With minimal methods until the API matures and stabilizes */
export class WebhookService {
private options = {} as Parameters<typeof getWebhooks>[0];
private webhooks: Awaited<ReturnType<typeof getWebhooks>> = [];
constructor(options: Parameters<typeof getWebhooks>[0]) {
return (async (): Promise<WebhookService> => {
this.options = options;
this.webhooks = await getWebhooks(options);
return this;
})() as unknown as WebhookService;
}
async getWebhooks() {
return this.webhooks;
}
async sendPayload(payload: Parameters<typeof sendOrSchedulePayload>[4]) {
const promises = this.webhooks.map((sub) =>
sendOrSchedulePayload(
sub.secret,
this.options.triggerEvent,
new Date().toISOString(),
sub,
payload
).catch((e) => {
log.error(
`Error executing webhook for event: ${this.options.triggerEvent}, URL: ${sub.subscriberUrl}`,
safeStringify(e)
);
})
);
await Promise.allSettled(promises);
}
}
@@ -15,6 +15,7 @@ export const WEBHOOK_TRIGGER_EVENTS_GROUPED_BY_APP = {
WebhookTriggerEvents.BOOKING_REJECTED,
WebhookTriggerEvents.RECORDING_READY,
WebhookTriggerEvents.INSTANT_MEETING,
WebhookTriggerEvents.BOOKING_NO_SHOW_UPDATED,
] as const,
"routing-forms": [WebhookTriggerEvents.FORM_SUBMITTED] as const,
};
@@ -29,7 +29,14 @@ export type WithUTCOffsetType<T> = T & {
attendees?: (Person & UTCOffset)[];
};
export type BookingNoShowUpdatedPayload = {
message: string;
bookingUid: string;
attendees: { email: string; noShow: boolean }[];
};
export type WebhookDataType = CalendarEvent &
// BookingNoShowUpdatedPayload & // This breaks all other webhooks
EventTypeInfo & {
metadata?: { [key: string]: string | number | boolean | null };
bookingId?: number;
@@ -0,0 +1,141 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { WebhookTriggerEvents } from "@calcom/prisma/enums";
import { WebhookService } from "../WebhookService";
import getWebhooks from "../getWebhooks";
vi.mock("../getWebhooks");
vi.mock("../sendOrSchedulePayload");
vi.mock("@calcom/lib/logger", async () => {
const actual = await vi.importActual<typeof import("@calcom/lib/logger")>("@calcom/lib/logger");
return {
...actual,
getSubLogger: vi.fn(() => ({
error: vi.fn(),
})),
};
});
vi.mock("@calcom/lib/safeStringify", () => ({
safeStringify: JSON.stringify,
}));
describe("WebhookService", () => {
const mockOptions = {
id: "mockOptionsId",
subscriberUrl: "subUrl",
payloadTemplate: "PayloadTemplate",
appId: "AppId",
secret: "WhSecret",
triggerEvent: WebhookTriggerEvents.BOOKING_CREATED,
};
beforeEach(() => {
vi.resetAllMocks();
});
it("should initialize with options and webhooks", async () => {
const mockWebhooks = [
{
id: "webhookId",
subscriberUrl: "url",
secret: "secret",
appId: "appId",
payloadTemplate: "payloadTemplate",
},
];
vi.mocked(getWebhooks).mockResolvedValue(mockWebhooks);
// Has to be called with await due to the iffi being async
const service = await new WebhookService(mockOptions);
expect(service).toBeInstanceOf(WebhookService);
expect(await service.getWebhooks()).toEqual(mockWebhooks);
expect(getWebhooks).toHaveBeenCalledWith(mockOptions);
});
// it("should send payload to all webhooks", async () => {
// const mockWebhooks = [
// {
// id: "webhookId",
// subscriberUrl: "url",
// secret: "secret",
// appId: "appId",
// payloadTemplate: "payloadTemplate",
// },
// {
// id: "webhookId2",
// subscriberUrl: "url",
// secret: "secret2",
// appId: "appId2",
// payloadTemplate: "payloadTemplate",
// },
// ];
// vi.mocked(getWebhooks).mockResolvedValue(mockWebhooks);
// const service = await new WebhookService(mockOptions);
//
// const payload = {
// secretKey: "secret",
// triggerEvent: "triggerEvent",
// createdAt: "now",
// webhook: {
// subscriberUrl: "url",
// appId: "appId",
// payloadTemplate: "payloadTemplate",
// },
// data: "test",
// };
//
// await service.sendPayload(payload as any);
//
// expect(sendOrSchedulePayload).toHaveBeenCalledTimes(mockWebhooks.length);
//
// mockWebhooks.forEach((webhook) => {
// expect(sendOrSchedulePayload).toHaveBeenCalledWith(
// webhook.secret,
// mockOptions.triggerEvent,
// expect.any(String),
// webhook,
// payload
// );
// });
// });
//
// it("should log error when sending payload fails", async () => {
// const mockWebhooks = [
// {
// id: "webhookId",
// subscriberUrl: "url",
// secret: "secret",
// appId: "appId",
// payloadTemplate: "payloadTemplate",
// },
// ];
// vi.mocked(getWebhooks).mockResolvedValue(mockWebhooks);
//
// const logError = vi.fn();
//
// (sendOrSchedulePayload as any).mockImplementation(() => {
// throw new Error("Failure");
// });
//
// const service = new WebhookService(mockOptions);
//
// const payload = {
// secretKey: "secret", triggerEvent: "triggerEvent", createdAt: "now", webhook: {
// subscriberUrl: "url",
// appId: "appId",
// payloadTemplate: "payloadTemplate"
// },
// data: "test"
// };
//
// await service.sendPayload(payload as any);
//
// expect(logError).toHaveBeenCalledWith(
// `Error executing webhook for event: ${mockOptions.triggerEvent}, URL: ${mockWebhooks[0].subscriberUrl}`,
// JSON.stringify(new Error("Failure"))
// );
// });
});
+28
View File
@@ -0,0 +1,28 @@
import { vi, beforeEach } from "vitest";
import type * as constants from "@calcom/lib/constants";
const mockedConstants = {
IS_PRODUCTION: false,
IS_TEAM_BILLING_ENABLED: false,
} as typeof constants;
vi.mock("@calcom/lib/constants", () => {
return mockedConstants;
});
beforeEach(() => {
Object.entries(mockedConstants).forEach(([key]) => {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
delete mockedConstants[key];
});
});
export const constantsScenarios = {
enableTeamBilling: () => {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
mockedConstants.IS_TEAM_BILLING_ENABLED = true;
},
};
+8
View File
@@ -165,3 +165,11 @@ export const TRANSCRIPTION_STARTED_ICON = IS_PRODUCTION
export const TRANSCRIPTION_STOPPED_ICON = IS_PRODUCTION
? `${WEBAPP_URL}/sparkles.svg`
: `https://app.cal.com/sparkles.svg`;
export const RECORDING_DEFAULT_ICON = IS_PRODUCTION
? `${WEBAPP_URL}/start-recording.svg`
: `https://app.cal.com/start-recording.svg`;
export const RECORDING_IN_PROGRESS_ICON = IS_PRODUCTION
? `${WEBAPP_URL}/stop-recording.svg`
: `https://app.cal.com/stop-recording.svg`;
@@ -84,6 +84,7 @@ export const getEventTypeById = async ({
description: true,
length: true,
isInstantEvent: true,
instantMeetingExpiryTimeOffsetInSeconds: true,
aiPhoneCallConfig: true,
offsetStart: true,
hidden: true,
+4
View File
@@ -19,6 +19,10 @@ export function defaultResponder<T>(f: Handle<T>) {
} catch (err) {
console.error(err);
const error = getServerErrorFromUnknown(err);
// dynamic import of Sentry so it's only loaded when something goes wrong.
const captureException = (await import("@sentry/nextjs")).captureException;
captureException(err);
// return API route response
return res
.status(error.statusCode)
.json({ message: error.message, url: error.url, method: error.method });
+1
View File
@@ -40,6 +40,7 @@ export const eventTypeSelect = Prisma.validator<Prisma.EventTypeSelect>()({
slotInterval: true,
successRedirectUrl: true,
isInstantEvent: true,
instantMeetingExpiryTimeOffsetInSeconds: true,
aiPhoneCallConfig: true,
assignAllTeamMembers: true,
recurringEvent: true,
+1
View File
@@ -82,6 +82,7 @@ export const buildEventType = (eventType?: Partial<EventType>): EventType => {
description: faker.lorem.paragraph(),
position: 1,
isInstantEvent: false,
instantMeetingExpiryTimeOffsetInSeconds: 90,
locations: null,
length: 15,
offsetStart: 0,
-1
View File
@@ -1,2 +1 @@
export { BookerWebWrapper } from "./BookerWebWrapper";
export { BookerPlatformWrapper } from "./BookerPlatformWrapper";
@@ -46,6 +46,12 @@ export function CalProvider({
http.setVersionHeader(version);
}, [version]);
useEffect(() => {
if (accessToken) {
queryClient.resetQueries();
}
}, [accessToken]);
return (
<QueryClientProvider client={queryClient}>
<BaseCalProvider
@@ -0,0 +1,141 @@
import {
Dialog,
DialogTrigger,
DialogContent,
DialogTitle,
DialogHeader,
DialogDescription,
} from "@/components/ui/dialog";
import type { FC } from "react";
import { useState } from "react";
import { useForm } from "react-hook-form";
import { Button, Form, PasswordField, TextField } from "@calcom/ui";
import { SUCCESS_STATUS } from "../../../constants/api";
import { useCheck } from "../../hooks/connect/useCheck";
import { useSaveCalendarCredentials } from "../../hooks/connect/useConnect";
import { AtomsWrapper } from "../../src/components/atoms-wrapper";
import { useToast } from "../../src/components/ui/use-toast";
import { cn } from "../../src/lib/utils";
import type { OAuthConnectProps } from "../OAuthConnect";
export const AppleConnect: FC<Partial<Omit<OAuthConnectProps, "redir">>> = ({
label = "Connect Apple Calendar",
alreadyConnectedLabel = "Connected Apple Calendar",
loadingLabel = "Checking Apple Calendar",
className,
}) => {
const form = useForm({
defaultValues: {
username: "",
password: "",
},
});
const { toast } = useToast();
const { allowConnect, checked, refetch } = useCheck({
calendar: "apple",
});
const [isDialogOpen, setIsDialogOpen] = useState(false);
let displayedLabel = label;
const { mutate: saveCredentials, isPending: isSaving } = useSaveCalendarCredentials({
onSuccess: (res) => {
if (res.status === SUCCESS_STATUS) {
form.reset();
setIsDialogOpen(false);
refetch();
toast({
description: "Calendar credentials added successfully",
});
}
},
onError: (err) => {
toast({
description: `Error: ${err}`,
});
},
});
const isChecking = !checked;
const isDisabled = isChecking || !allowConnect;
if (isChecking) {
displayedLabel = loadingLabel;
} else if (!allowConnect) {
displayedLabel = alreadyConnectedLabel;
}
return (
<AtomsWrapper>
<Dialog open={isDialogOpen}>
<DialogTrigger>
<Button
StartIcon="calendar-days"
color="primary"
disabled={isDisabled}
className={cn("", className, isDisabled && "cursor-not-allowed", !isDisabled && "cursor-pointer")}
onClick={() => setIsDialogOpen(true)}>
{displayedLabel}
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Connect to Apple Server</DialogTitle>
<DialogDescription>
Generate an app specific password to use with Cal.com at{" "}
<span className="font-bold">https://appleid.apple.com/account/manage</span>. Your credentials
will be stored and encrypted.
</DialogDescription>
</DialogHeader>
<Form
form={form}
handleSubmit={async (values) => {
const { username, password } = values;
await saveCredentials({ calendar: "apple", username, password });
}}>
<fieldset
className="space-y-4"
disabled={form.formState.isSubmitting}
data-testid="apple-calendar-form">
<TextField
required
type="text"
{...form.register("username")}
label="Apple ID"
placeholder="appleid@domain.com"
data-testid="apple-calendar-email"
/>
<PasswordField
required
{...form.register("password")}
label="Password"
placeholder="•••••••••••••"
autoComplete="password"
data-testid="apple-calendar-password"
/>
</fieldset>
<div className="mt-5 justify-end space-x-2 rtl:space-x-reverse sm:mt-4 sm:flex">
<Button
disabled={isSaving}
type="button"
color="secondary"
onClick={() => setIsDialogOpen(false)}>
Cancel
</Button>
<Button
disabled={isSaving}
type="submit"
loading={form.formState.isSubmitting}
data-testid="apple-calendar-login-button">
Save
</Button>
</div>
</Form>
</DialogContent>
</Dialog>
</AtomsWrapper>
);
};
+1
View File
@@ -1,2 +1,3 @@
export { GcalConnect as GoogleCalendar } from "./google/GcalConnect";
export { OutlookConnect as OutlookCalendar } from "./outlook/OutlookConnect";
export { AppleConnect as AppleCalendar } from "./apple/AppleConnect";
@@ -1,4 +1,4 @@
import { useQuery } from "@tanstack/react-query";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import type { CALENDARS } from "@calcom/platform-constants";
import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants";
@@ -15,11 +15,13 @@ export type OnCheckErrorType = (err: ApiErrorResponse) => void;
export const getQueryKey = (calendar: (typeof CALENDARS)[number]) => [`get-${calendar}-check`];
export const useCheck = ({ onCheckError, calendar }: UseCheckProps) => {
const { isInit } = useAtomsContext();
const { data: check } = useQuery({
const { isInit, accessToken } = useAtomsContext();
const queryClient = useQueryClient();
const { data: check, refetch } = useQuery({
queryKey: getQueryKey(calendar),
staleTime: 6000,
enabled: isInit,
enabled: isInit && !!accessToken,
queryFn: () => {
return http
?.get<ApiResponse<{ checked: boolean; allowConnect: boolean }>>(`/calendars/${calendar}/check`)
@@ -36,5 +38,15 @@ export const useCheck = ({ onCheckError, calendar }: UseCheckProps) => {
});
},
});
return { allowConnect: check?.data?.allowConnect ?? false, checked: check?.data?.checked ?? false };
return {
allowConnect: check?.data?.allowConnect ?? false,
checked: check?.data?.checked ?? false,
refetch: () => {
queryClient.setQueryData(getQueryKey(calendar), {
status: SUCCESS_STATUS,
data: { allowConnect: false, checked: false },
});
refetch();
},
};
};
@@ -1,13 +1,18 @@
import { useQuery } from "@tanstack/react-query";
import { useQuery, useMutation } from "@tanstack/react-query";
import type { CALENDARS } from "@calcom/platform-constants";
import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants";
import type { ApiResponse } from "@calcom/platform-types";
import type { ApiResponse, ApiErrorResponse } from "@calcom/platform-types";
import http from "../../lib/http";
export const getQueryKey = (calendar: (typeof CALENDARS)[number]) => [`get-${calendar}-redirect-uri`];
interface IPUpdateOAuthCredentials {
onSuccess?: (res: ApiResponse) => void;
onError?: (err: ApiErrorResponse) => void;
}
export const useGetRedirectUrl = (calendar: (typeof CALENDARS)[number], redir?: string) => {
const authUrl = useQuery({
queryKey: getQueryKey(calendar),
@@ -44,3 +49,44 @@ export const useConnect = (calendar: (typeof CALENDARS)[number], redir?: string)
return { connect };
};
export const useSaveCalendarCredentials = (
{ onSuccess, onError }: IPUpdateOAuthCredentials = {
onSuccess: () => {
return;
},
onError: () => {
return;
},
}
) => {
const mutation = useMutation<
ApiResponse<{ status: string }>,
unknown,
{ username: string; password: string; calendar: (typeof CALENDARS)[number] }
>({
mutationFn: (data) => {
const { calendar, username, password } = data;
const body = {
username,
password,
};
return http.post(`/calendars/${calendar}/credentials`, body).then((res) => {
return res.data;
});
},
onSuccess: (data) => {
if (data.status === SUCCESS_STATUS) {
onSuccess?.(data);
} else {
onError?.(data);
}
},
onError: (err) => {
onError?.(err as ApiErrorResponse);
},
});
return mutation;
};
+1 -1
View File
@@ -1,7 +1,7 @@
export { CalProvider } from "./cal-provider";
export { GcalConnect } from "./connect/google/GcalConnect";
export { AvailabilitySettingsPlatformWrapper as AvailabilitySettings } from "./availability";
export { BookerPlatformWrapper as Booker } from "./booker";
export { BookerPlatformWrapper as Booker } from "./booker/BookerPlatformWrapper";
export { useIsPlatform } from "./hooks/useIsPlatform";
export { useAtomsContext } from "./hooks/useAtomsContext";
export { useConnectedCalendars } from "./hooks/useConnectedCalendars";
+11 -1
View File
@@ -42,5 +42,15 @@
"../../trpc",
"../../app-store"
],
"exclude": ["dist", "build", "node_modules", "**/*WebWrapper.tsx"]
"exclude": [
"dist",
"build",
"node_modules",
"**/*WebWrapper.tsx",
"**/*.docs.mdx",
"**/*.stories.tsx",
"monorepo.ts",
"booker/export.ts",
"booker/index.ts"
]
}
+4 -2
View File
@@ -5,10 +5,12 @@ export const OFFICE_365_CALENDAR_ID = "office365-calendar";
export const GOOGLE_CALENDAR = "google";
export const OFFICE_365_CALENDAR = "office365";
export const APPLE_CALENDAR = "apple";
// APPLE_CALENDAR is not implemented yet
export const CALENDARS = [GOOGLE_CALENDAR, OFFICE_365_CALENDAR] as const;
export const APPLE_CALENDAR_TYPE = "apple_calendar";
export const APPLE_CALENDAR_ID = "apple-calendar";
export const CALENDARS = [GOOGLE_CALENDAR, OFFICE_365_CALENDAR, APPLE_CALENDAR] as const;
export const APPS_TYPE_ID_MAPPING = {
[GOOGLE_CALENDAR_TYPE]: GOOGLE_CALENDAR_ID,
[OFFICE_365_CALENDAR_TYPE]: OFFICE_365_CALENDAR_ID,
[APPLE_CALENDAR_TYPE]: APPLE_CALENDAR_ID,
} as const;
@@ -28,6 +28,7 @@ export default function Home(props: { calUsername: string; calEmail: string }) {
redir="http://localhost:4321/calendars"
className="h-[40px] bg-gradient-to-r from-[#8A2387] via-[#E94057] to-[#F27121] text-center text-base font-semibold text-transparent text-white hover:bg-orange-700"
/>
<Connect.AppleCalendar className="h-[40px] bg-gradient-to-r from-[#8A2387] via-[#E94057] to-[#F27121] text-center text-base font-semibold text-transparent text-white hover:bg-orange-700" />
</div>
</div>
<div className="hidden lg:block">
@@ -59,6 +59,7 @@ export type EventType = {
bookingLimits: number | null;
durationLimits: number | null;
isInstantEvent: boolean;
instantMeetingExpiryTimeOffsetInSeconds: number;
assignAllTeamMembers: boolean;
useEventTypeDestinationCalendarEmail: boolean;
};
@@ -45,6 +45,7 @@ export type Event = {
eventName: string;
slug: string;
isInstantEvent: boolean;
instantMeetingExpiryTimeOffsetInSeconds: number;
aiPhoneCallConfig: {
eventTypeId: number;
enabled: boolean;
+15
View File
@@ -55,3 +55,18 @@ export class Pagination {
@IsOptional()
offset?: number | null;
}
export class SkipTakePagination {
@Transform(({ value }: { value: string }) => value && parseInt(value))
@IsNumber()
@Min(1)
@Max(250)
@IsOptional()
take?: number;
@Transform(({ value }: { value: string }) => value && parseInt(value))
@IsNumber()
@Min(0)
@IsOptional()
skip?: number;
}
@@ -0,0 +1,2 @@
-- AlterEnum
ALTER TYPE "WebhookTriggerEvents" ADD VALUE 'BOOKING_NO_SHOW_UPDATED';
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "EventType" ADD COLUMN "instantMeetingExpiryTimeOffsetInSeconds" INTEGER NOT NULL DEFAULT 90;
+51 -48
View File
@@ -77,65 +77,66 @@ model EventType {
profileId Int?
profile Profile? @relation(fields: [profileId], references: [id], onDelete: Cascade)
team Team? @relation(fields: [teamId], references: [id], onDelete: Cascade)
teamId Int?
hashedLink HashedLink?
bookings Booking[]
availability Availability[]
webhooks Webhook[]
destinationCalendar DestinationCalendar?
eventName String?
customInputs EventTypeCustomInput[]
parentId Int?
parent EventType? @relation("managed_eventtype", fields: [parentId], references: [id], onDelete: Cascade)
children EventType[] @relation("managed_eventtype")
team Team? @relation(fields: [teamId], references: [id], onDelete: Cascade)
teamId Int?
hashedLink HashedLink?
bookings Booking[]
availability Availability[]
webhooks Webhook[]
destinationCalendar DestinationCalendar?
eventName String?
customInputs EventTypeCustomInput[]
parentId Int?
parent EventType? @relation("managed_eventtype", fields: [parentId], references: [id], onDelete: Cascade)
children EventType[] @relation("managed_eventtype")
/// @zod.custom(imports.eventTypeBookingFields)
bookingFields Json?
timeZone String?
periodType PeriodType @default(UNLIMITED)
bookingFields Json?
timeZone String?
periodType PeriodType @default(UNLIMITED)
/// @zod.custom(imports.coerceToDate)
periodStartDate DateTime?
periodStartDate DateTime?
/// @zod.custom(imports.coerceToDate)
periodEndDate DateTime?
periodDays Int?
periodCountCalendarDays Boolean?
lockTimeZoneToggleOnBookingPage Boolean @default(false)
requiresConfirmation Boolean @default(false)
requiresBookerEmailVerification Boolean @default(false)
periodEndDate DateTime?
periodDays Int?
periodCountCalendarDays Boolean?
lockTimeZoneToggleOnBookingPage Boolean @default(false)
requiresConfirmation Boolean @default(false)
requiresBookerEmailVerification Boolean @default(false)
/// @zod.custom(imports.recurringEventType)
recurringEvent Json?
disableGuests Boolean @default(false)
hideCalendarNotes Boolean @default(false)
recurringEvent Json?
disableGuests Boolean @default(false)
hideCalendarNotes Boolean @default(false)
/// @zod.min(0)
minimumBookingNotice Int @default(120)
beforeEventBuffer Int @default(0)
afterEventBuffer Int @default(0)
seatsPerTimeSlot Int?
onlyShowFirstAvailableSlot Boolean @default(false)
seatsShowAttendees Boolean? @default(false)
seatsShowAvailabilityCount Boolean? @default(true)
schedulingType SchedulingType?
schedule Schedule? @relation(fields: [scheduleId], references: [id])
scheduleId Int?
minimumBookingNotice Int @default(120)
beforeEventBuffer Int @default(0)
afterEventBuffer Int @default(0)
seatsPerTimeSlot Int?
onlyShowFirstAvailableSlot Boolean @default(false)
seatsShowAttendees Boolean? @default(false)
seatsShowAvailabilityCount Boolean? @default(true)
schedulingType SchedulingType?
schedule Schedule? @relation(fields: [scheduleId], references: [id])
scheduleId Int?
// price is deprecated. It has now moved to metadata.apps.stripe.price. Plan to drop this column.
price Int @default(0)
price Int @default(0)
// currency is deprecated. It has now moved to metadata.apps.stripe.currency. Plan to drop this column.
currency String @default("usd")
slotInterval Int?
currency String @default("usd")
slotInterval Int?
/// @zod.custom(imports.EventTypeMetaDataSchema)
metadata Json?
metadata Json?
/// @zod.custom(imports.successRedirectUrl)
successRedirectUrl String?
forwardParamsSuccessRedirect Boolean? @default(true)
workflows WorkflowsOnEventTypes[]
successRedirectUrl String?
forwardParamsSuccessRedirect Boolean? @default(true)
workflows WorkflowsOnEventTypes[]
/// @zod.custom(imports.intervalLimitsType)
bookingLimits Json?
bookingLimits Json?
/// @zod.custom(imports.intervalLimitsType)
durationLimits Json?
isInstantEvent Boolean @default(false)
assignAllTeamMembers Boolean @default(false)
useEventTypeDestinationCalendarEmail Boolean @default(false)
aiPhoneCallConfig AIPhoneCallConfiguration?
durationLimits Json?
isInstantEvent Boolean @default(false)
instantMeetingExpiryTimeOffsetInSeconds Int @default(90)
assignAllTeamMembers Boolean @default(false)
useEventTypeDestinationCalendarEmail Boolean @default(false)
aiPhoneCallConfig AIPhoneCallConfiguration?
secondaryEmailId Int?
secondaryEmail SecondaryEmail? @relation(fields: [secondaryEmailId], references: [id], onDelete: Cascade)
@@ -300,6 +301,7 @@ model User {
verifiedNumbers VerifiedNumber[]
verifiedEmails VerifiedEmail[]
hosts Host[]
// organizationId is deprecated. Instead, rely on the Profile to search profiles by organizationId and then get user from the profile.
organizationId Int?
organization Team? @relation("scope", fields: [organizationId], references: [id], onDelete: SetNull)
accessCodes AccessCode[]
@@ -706,6 +708,7 @@ enum WebhookTriggerEvents {
BOOKING_REQUESTED
BOOKING_CANCELLED
BOOKING_REJECTED
BOOKING_NO_SHOW_UPDATED
FORM_SUBMITTED
MEETING_ENDED
MEETING_STARTED
+1
View File
@@ -608,6 +608,7 @@ export const allManagedEventTypeProps: { [k in keyof Omit<Prisma.EventTypeSelect
title: true,
description: true,
isInstantEvent: true,
instantMeetingExpiryTimeOffsetInSeconds: true,
aiPhoneCallConfig: true,
currency: true,
periodDays: true,
+6 -6
View File
@@ -57,22 +57,22 @@ export function createNextApiHandler(router: AnyRouter, isPublic = false, namesp
defaultHeaders.headers["cache-control"] = `no-cache`;
if (isPublic && paths) {
const ONE_DAY_IN_SECONDS = 60 * 60 * 24;
const FIVE_MINUTES_IN_SECONDS = 5 * 60;
const ONE_YEAR_IN_SECONDS = 31536000;
const SETTING_FOR_CACHED_BY_VERSION =
process.env.NODE_ENV === "development" ? "no-cache" : `max-age=${ONE_YEAR_IN_SECONDS}`;
const cacheRules = {
session: "no-cache",
i18n: process.env.NODE_ENV === "development" ? "no-cache" : `max-age=${ONE_YEAR_IN_SECONDS}`,
// i18n and cityTimezones are now being accessed using the CalComVersion, which updates on every release,
// letting the clients get the new versions when the version number changes.
i18n: SETTING_FOR_CACHED_BY_VERSION,
cityTimezones: SETTING_FOR_CACHED_BY_VERSION,
// FIXME: Using `max-age=1, stale-while-revalidate=60` fails some booking tests.
"slots.getSchedule": `no-cache`,
// Timezones are hardly updated. No need to burden the servers with requests for this by keeping low max-age.
// Keep it cached for a day and then give it 60 seconds more at most to be updated.
cityTimezones: `max-age=${ONE_DAY_IN_SECONDS}, stale-while-revalidate=60`,
// Feature Flags change but it might be okay to have a 5 minute cache to avoid burdening the servers with requests for this.
// Note that feature flags can be used to quickly kill a feature if it's not working as expected. So, we have to keep fresh time lesser than the deployment time atleast
"features.map": `max-age=${FIVE_MINUTES_IN_SECONDS}, stale-while-revalidate=60`, // "map" - Feature Flag Map
@@ -1 +0,0 @@
export {};
@@ -1,5 +1,9 @@
import { WebhookService } from "@calcom/features/webhooks/lib/WebhookService";
import getOrgIdFromMemberOrTeamId from "@calcom/lib/getOrgIdFromMemberOrTeamId";
import logger from "@calcom/lib/logger";
import { getTranslation } from "@calcom/lib/server/i18n";
import { prisma } from "@calcom/prisma";
import { WebhookTriggerEvents } from "@calcom/prisma/client";
import type { TNoShowInputSchema } from "./noShow.schema";
@@ -7,6 +11,24 @@ type NoShowOptions = {
input: TNoShowInputSchema;
};
const getResultPayload = async (attendees: { email: string; noShow: boolean }[]) => {
if (attendees.length === 1) {
const [attendee] = attendees;
return {
message: attendee.noShow ? "x_marked_as_no_show" : "x_unmarked_as_no_show",
attendees: [attendee],
};
}
return { message: "no_show_updated", attendees: attendees };
};
const logFailedResults = (results: PromiseSettledResult<any>[]) => {
const failed = results.filter((x) => x.status === "rejected") as PromiseRejectedResult[];
if (failed.length < 1) return;
const failedMessage = failed.map((r) => r.reason);
console.error("Failed to update no-show status", failedMessage.join(","));
};
export const noShowHandler = async ({ input }: NoShowOptions) => {
const { bookingUid, attendees } = input;
@@ -31,33 +53,73 @@ export const noShowHandler = async ({ input }: NoShowOptions) => {
email: true,
},
});
const allAttendeesMap = allAttendees.reduce((acc, attendee) => {
acc[attendee.email] = attendee;
return acc;
}, {} as Record<string, { id: number; email: string }>);
const updatePromises = attendees.map((attendee) => {
const attendeeToUpdate = allAttendees.find((a) => a.email === attendee.email);
if (attendeeToUpdate) {
return prisma.attendee.update({
where: { id: attendeeToUpdate.id },
data: { noShow: attendee.noShow },
});
}
const attendeeToUpdate = allAttendeesMap[attendee.email];
if (!attendeeToUpdate) return;
return prisma.attendee.update({
where: { id: attendeeToUpdate.id },
data: { noShow: attendee.noShow },
});
});
await Promise.all(updatePromises);
} else {
await prisma.booking.update({
where: {
uid: bookingUid,
},
data: {
noShowHost: true,
const results = await Promise.allSettled(updatePromises);
logFailedResults(results);
const _attendees = results
.filter((x) => x.status === "fulfilled")
.map((x) => (x as PromiseFulfilledResult<{ noShow: boolean; email: string }>).value)
.map((x) => ({ email: x.email, noShow: x.noShow }));
const payload = await getResultPayload(_attendees);
const booking = await prisma.booking.findUnique({
where: { uid: bookingUid },
select: {
eventType: {
select: {
id: true,
teamId: true,
userId: true,
},
},
},
});
const orgId = await getOrgIdFromMemberOrTeamId({
memberId: booking?.eventType?.userId,
teamId: booking?.eventType?.teamId,
});
const webhooks = await new WebhookService({
teamId: booking?.eventType?.teamId,
userId: booking?.eventType?.userId,
eventTypeId: booking?.eventType?.id,
orgId,
triggerEvent: WebhookTriggerEvents.BOOKING_NO_SHOW_UPDATED,
});
const t = await getTranslation("en", "common");
await webhooks.sendPayload({
...payload,
/** We send webhook message pre-translated, on client we already handle this */
// @ts-expect-error payload is too booking specific, we need to refactor this
message: t(payload.message, { x: payload.attendees[0]?.email || "User" }),
bookingUid,
});
return payload;
}
await prisma.booking.update({
where: {
uid: bookingUid,
},
data: {
noShowHost: true,
},
});
return { message: "No-show status updated", noShowHost: true };
} catch (error) {
if (error instanceof Error) {
logger.error(error.message);
}
return { message: "Failed to update no-show status" };
}
};
@@ -1,5 +1,6 @@
import publicProcedure from "../../../procedures/publicProcedure";
import { importHandler, router } from "../../../trpc";
import { cityTimezonesSchema } from "./cityTimezones.schema";
const NAMESPACE = "publicViewer";
@@ -7,7 +8,7 @@ const namespaced = (s: string) => `${NAMESPACE}.${s}`;
// things that unauthenticated users can query about themselves
export const timezonesRouter = router({
cityTimezones: publicProcedure.query(async () => {
cityTimezones: publicProcedure.input(cityTimezonesSchema).query(async () => {
const handler = await importHandler(
namespaced("cityTimezones"),
() => import("@calcom/lib/cityTimezonesHandler")
@@ -1 +1,7 @@
export {};
import { z } from "zod";
export const cityTimezonesSchema = z.object({
CalComVersion: z.string(),
});
export type CityTimezonesSchema = z.infer<typeof cityTimezonesSchema>;
@@ -8,6 +8,7 @@ export const EventTypeUpdateInput = _EventTypeModel
/** Optional fields */
.extend({
isInstantEvent: z.boolean().optional(),
instantMeetingExpiryTimeOffsetInSeconds: z.number().optional(),
aiPhoneCallConfig: z
.object({
generalPrompt: z.string(),

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