Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dfbcf4742f | ||
|
|
3a2a70d83a | ||
|
|
3948b8d203 | ||
|
|
6207afdf03 | ||
|
|
52c7a89785 | ||
|
|
dda4ce5338 | ||
|
|
a02747fc52 | ||
|
|
03311def07 | ||
|
|
5e382ffd21 | ||
|
|
0e0dfcdd88 | ||
|
|
e260809b08 | ||
|
|
835443a0d1 | ||
|
|
48842e72a6 | ||
|
|
ff8931f4a0 | ||
|
|
6670bbc1d7 | ||
|
|
750676f91a | ||
|
|
db60f8faed | ||
|
|
2b3cb20fdf | ||
|
|
7f1d45bb5d | ||
|
|
6632c26063 | ||
|
|
97a3189c12 | ||
|
|
d25595b546 | ||
|
|
a28eb63d61 | ||
|
|
4f944ec12d | ||
|
|
e33930e5c0 | ||
|
|
9613b9da6d | ||
|
|
ec755b142c |
@@ -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?
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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: {
|
||||
|
||||
+7
-7
@@ -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,
|
||||
|
||||
+356
-4
@@ -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();
|
||||
});
|
||||
|
||||
+84
-9
@@ -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" }),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
+155
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
+338
@@ -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";
|
||||
}
|
||||
+23
@@ -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 } });
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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,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);
|
||||
}}>
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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": "אין יותר מקומות זמינים",
|
||||
|
||||
@@ -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 |
@@ -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"))
|
||||
// );
|
||||
// });
|
||||
});
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
@@ -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,
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,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,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,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";
|
||||
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
-- AlterEnum
|
||||
ALTER TYPE "WebhookTriggerEvents" ADD VALUE 'BOOKING_NO_SHOW_UPDATED';
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "EventType" ADD COLUMN "instantMeetingExpiryTimeOffsetInSeconds" INTEGER NOT NULL DEFAULT 90;
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user