Compare commits

...
24 Commits
Author SHA1 Message Date
Alex van Andel c8b577fb7a chore: Release v4.3.0 (#15771)
release.yml / release (push) Canceled after 0s
2024-07-15 13:55:09 +00:00
Peer Richelsen 7f97d27c20 chore: Update README.md (#15766)
* Update README.md

* Update README.md

* Update README.md
2024-07-15 13:40:28 +02:00
Morgan 9ff10877e7 chore: fix apiv2 org teams doc (#15746) 2024-07-15 10:27:33 +00:00
Morgan eb0768917c chore: readme platform libraries (#15737)
* chore: readme platform libraries

* fixup! chore: readme platform libraries

* fixup! fixup! chore: readme platform libraries

* Update README.md

* Update README.md
2024-07-15 10:13:41 +00:00
ec65cd6333 fix: icon visibility and add up and down icons (#15543)
* fixed the icons visibility and added up and down icon

* wip: TimeUnitAddonSuffix

* chore: Quick pass for rerender-optimisation

---------

Co-authored-by: Keith Williams <keithwillcode@gmail.com>
Co-authored-by: Alex van Andel <me@alexvanandel.com>
Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com>
2024-07-15 09:35:48 +00:00
Udit Takkar 563141b295 chore: add check for booking (#15740)
* chore: add check

* chore: missting no shoe
2024-07-15 08:59:29 +00:00
Hariom Balhara 7b24972900 fix: Ensure unique username in an organization (#15730) 2024-07-15 06:11:24 +00:00
Somay Chauhan 6d2ec71df3 add missing getServerSideProps to /event-types (#15731) 2024-07-13 09:51:00 +00:00
492304fe1d feat: BookerEmbed atom (#15689)
* init: booker embed atom

* update booker platform wrapper

* export booker embed for atoms

* update booker embed wrapper

* fixup: refresh url not needed

* rename booker embed

* add global styles

* export not needed

* add booker embed atoms in examples app

* fixup

* fixup! fixup

* fixup! fixup! fixup

---------

Co-authored-by: Rajiv Sahal <rajivsahal@Rajivs-MacBook-Pro.local>
Co-authored-by: Morgan Vernay <morgan@cal.com>
Co-authored-by: Morgan <33722304+ThyMinimalDev@users.noreply.github.com>
2024-07-12 11:10:32 +00:00
c7c5cdf133 feat: Campsite conferencing app (#15713)
* feat: Campsite conferencing app

* Improve redirect after OAuth flow

---------

Co-authored-by: Somay Chauhan <somaychauhan98@gmail.com>
Co-authored-by: Peer Richelsen <peeroke@gmail.com>
2024-07-11 14:20:31 +00:00
Keith Williams 0dd3b25624 chore: v4.2.5 (#15735)
release.yml / release (push) Canceled after 0s
2024-07-11 15:03:37 +02:00
Morgan a633e35c66 fix: api v2 unit tests (#15733)
* fix: permissions guard spec

* fix: api auth strategy spec
2024-07-11 11:27:40 +00:00
sean-brydon c618e69d8e fix: event type back button (#15722) 2024-07-11 10:44:37 +00:00
Morgan 844dd7e3af fix: remove console.log and tsignore apiv2 (#15732) 2024-07-11 10:40:11 +00:00
Morgan b6c512e5d7 fix: api-v2 controllers e2e tests (#15724)
* fix: bookings controllers e2e tests

* fixup! fix: bookings controllers e2e tests

* fix: calendars  controllers e2e tests

* chore: move org teams controller files into folder

* fix: gcal controller e2e tests

* fix: oauth-clients.controller.e2e-spec

* fix: calendars  controllers e2e tests

* fix: gcal controller e2e tests

* fix: oauth-flow.controller.e2e-spec.ts
2024-07-11 13:14:45 +03:00
Ankit SinghandKeith Williams 6e0035689a fix: Text in Embed Code Visibility Fixed (#15711)
* Fix:#15707

* bg-subtle fixed

* Update packages/features/embed/lib/EmbedTabs.tsx

---------

Co-authored-by: Keith Williams <keithwillcode@gmail.com>
2024-07-10 16:18:31 +00:00
58540af9ce chore: sort calendar crendentials in event manager (#15448)
* chore_sort_calendar_credentials_in_desc_for_reliability

* chore: create reusable function

* refactor: rename functions

* Update EventManager.ts

Variable name change

---------

Co-authored-by: Shaik-Sirajuddin <sirajudddinshaik30@gmail.com>
Co-authored-by: Keith Williams <keithwillcode@gmail.com>
Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com>
Co-authored-by: Udit Takkar <udit222001@gmail.com>
Co-authored-by: Hariom Balhara <hariombalhara@gmail.com>
2024-07-10 16:16:26 +00:00
Morgan 21f30fc47a chore: cache org guard and fix roles guard apiv2 (#15719) 2024-07-10 15:46:04 +00:00
Amit Sharma 92845d3adc Revert "fix: Autodetection of time zone only updated default time zone (#15392)" (#15720)
This reverts commit 285d1ec4c9.
2024-07-10 15:28:41 +00:00
8ee27d3997 feat: add POST end point to mark calls as no-shows (#15690)
* feat: add POST end point to mark calls as no-shows

* fix: type

* feat: add v2 endpoint

* wip

* chore: type err

* chore: add permission

* chore: remove packae

* chore: remove v1 API endpoint

* fix: endpoint

* feat: add new version

* chore: change route

* chore: error msg

* fix: message

* fix: type err

* chore: output

* fix: type err

* chore: bump libraries to 0.0.17

* chore: add noShowHost

---------

Co-authored-by: Joe Au-Yeung <65426560+joeauyeung@users.noreply.github.com>
Co-authored-by: Morgan <33722304+ThyMinimalDev@users.noreply.github.com>
Co-authored-by: Morgan Vernay <morgan@cal.com>
2024-07-10 13:01:10 +00:00
Morgan 1263519973 chore: add i18n to atoms (#15698)
* chore: add i18n to atoms

* fixup! chore: add i18n to atoms

* fixup! fixup! chore: add i18n to atoms

* fixup! Merge branch 'platform-atoms-i18n' of github.com:calcom/cal.com into platform-atoms-i18n

* fixup! Merge branch 'main' into platform-atoms-i18n
2024-07-10 12:34:41 +00:00
caf7943c91 fix: set conferencing apps as default (#15376)
* fix: set conferencing apps as default

* fix in other oauth conferencing apps

* creating default event-types should set the user's default location

* Update getDefaultLocations.ts

* fix: unit test

* default install only for first oauth app

* fix: type-check

* update default on success

* fix: pass callback prop

* chore: code cleanup

* Fix condition

* no longer needed

- as we reverted https://github.com/calcom/cal.com/commit/20fdb50081ae684723a5af78436068c0b09509a4

---------

Co-authored-by: Somay Chauhan <somaychauhan98@gmail.com>
Co-authored-by: Hariom <hariombalhara@gmail.com>
2024-07-10 11:00:59 +00:00
Somay ChauhanandHariom Balhara fed3987028 fix: disable google-meet in user onboarding if google-calendar is not installed (#15702)
* fix: disable google-meet in user onboarding if google-calendar is not installed

* add tooltip

* fix: type check

* fix: type check for tooltip

- allowed `ReactNode` for tooltip content prop

---------

Co-authored-by: Hariom Balhara <hariombalhara@gmail.com>
2024-07-10 10:34:14 +00:00
Hariom Balhara ad61d48706 fix: Rescheduling email when there is broken calendar integration (#15669) 2024-07-10 10:32:56 +01:00
94 changed files with 1883 additions and 519 deletions
+31 -1
View File
@@ -392,7 +392,37 @@ Currently Vercel Pro Plan is required to be able to Deploy this application with
See the [roadmap project](https://cal.com/roadmap) for a list of proposed features (and known issues). You can change the view to see planned tagged releases.
<!-- RORADMAP -->
<!-- LICENSE -->
## License
Cal.com, Inc. is a commercial open source company, which means some parts of this open source repository require a commercial license. The concept is called "Open Core" where the core technology (99%) is fully open source, licensed under [AGPLv3](https://opensource.org/license/agpl-v3) and the last 1% is covered under a commercial license (["/ee" Enterprise Edition](https://github.com/calcom/cal.com/tree/main/packages/features/ee)) which we believe is entirely relevant for larger organisations that require enterprise features. Enterprise features are built by the core engineering team of Cal.com, Inc. which is hired in full-time. Find their compensation on https://cal.com/open.
> [!NOTE]
> Our philosophy is simple, all "Singleplayer APIs" are open-source under AGPLv3. All commercial "Multiplayer APIs" are under a commercial license.
| | AGPLv3 | EE |
|---|---|---|
| Self-host for commercial purposes | ✅ | ✅ |
| Clone privately | ✅ | ✅ |
| Fork publicly | ✅ | ✅ |
| Requires CLA | ✅ | ✅ |
| Official Support| ❌ | ✅ |
| Derivative work privately | ❌ | ✅ |
| SSO | ❌ | ✅ |
| Admin Panel | ❌ | ✅ |
| Impersonation | ❌ | ✅ |
| Managed Event Types | ❌ | ✅ |
| Organizations | ❌ | ✅ |
| Payments | ❌ | ✅ |
| Platform | ❌ | ✅ |
| Teams | ❌ | ✅ |
| Users | ❌ | ✅ |
| Video | ❌ | ✅ |
| Workflows | ❌ | ✅ |
> [!TIP]
> We work closely with the community and always invite feedback about what should be open and what is fine to be commercial. This list is not set and stone and we have moved things from commercial to open in the past. Please open a [discussion](https://github.com/calcom/cal.com/discussions) if you feel like something is wrong.
## Repo Activity
+2 -1
View File
@@ -26,10 +26,11 @@
"dependencies": {
"@calcom/platform-constants": "*",
"@calcom/platform-libraries-0.0.13": "npm:@calcom/platform-libraries@0.0.13",
"@calcom/platform-libraries-0.0.14": "npm:@calcom/platform-libraries@0.0.14",
"@calcom/platform-libraries-0.0.15": "npm:@calcom/platform-libraries@0.0.15",
"@calcom/platform-libraries-0.0.17": "npm:@calcom/platform-libraries@0.0.17",
"@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": "*",
@@ -139,7 +139,6 @@ describe("Bookings Endpoints", () => {
return request(app.getHttpServer())
.get("/v2/bookings?filters[status]=upcoming")
.then((response) => {
console.log("asap responseBody", JSON.stringify(response.body, null, 2));
const responseBody: GetBookingsOutput = response.body;
const fetchedBooking = responseBody.data.bookings[0];
@@ -240,7 +239,8 @@ describe("Bookings Endpoints", () => {
// });
// });
it("should cancel a booking", async () => {
// cancelling a booking hangs the test for some reason
it.skip("should cancel a booking", async () => {
const bookingId = createdBooking.id;
const body = {
@@ -1,7 +1,9 @@
import { CreateBookingInput } from "@/ee/bookings/inputs/create-booking.input";
import { CreateRecurringBookingInput } from "@/ee/bookings/inputs/create-recurring-booking.input";
import { MarkNoShowInput } from "@/ee/bookings/inputs/mark-no-show.input";
import { GetBookingOutput } from "@/ee/bookings/outputs/get-booking.output";
import { GetBookingsOutput } from "@/ee/bookings/outputs/get-bookings.output";
import { MarkNoShowOutput } from "@/ee/bookings/outputs/mark-no-show.output";
import { API_VERSIONS_VALUES } from "@/lib/api-versions";
import { GetUser } from "@/modules/auth/decorators/get-user/get-user.decorator";
import { Permissions } from "@/modules/auth/decorators/permissions/permissions.decorator";
@@ -32,7 +34,7 @@ import { Request } from "express";
import { NextApiRequest } from "next/types";
import { X_CAL_CLIENT_ID } from "@calcom/platform-constants";
import { BOOKING_READ, SUCCESS_STATUS } from "@calcom/platform-constants";
import { BOOKING_READ, SUCCESS_STATUS, BOOKING_WRITE } from "@calcom/platform-constants";
import {
getAllUserBookings,
getBookingInfo,
@@ -46,6 +48,7 @@ import {
handleNewRecurringBooking,
handleInstantMeeting,
} from "@calcom/platform-libraries-0.0.2";
import { handleMarkNoShow } from "@calcom/platform-libraries-0.0.17";
import { GetBookingsInput, CancelBookingInput, Status } from "@calcom/platform-types";
import { ApiResponse } from "@calcom/platform-types";
import { PrismaClient } from "@calcom/prisma";
@@ -194,6 +197,27 @@ export class BookingsController {
throw new InternalServerErrorException("Could not cancel booking.");
}
@Post("/:bookingUid/mark-no-show")
@Permissions([BOOKING_WRITE])
@UseGuards(ApiAuthGuard)
async markNoShow(
@Body() body: MarkNoShowInput,
@Param("bookingUid") bookingUid: string
): Promise<MarkNoShowOutput> {
try {
const markNoShowResponse = await handleMarkNoShow({
bookingUid: bookingUid,
attendees: body.attendees,
noShowHost: body.noShowHost,
});
return { status: SUCCESS_STATUS, data: markNoShowResponse };
} catch (err) {
this.handleBookingErrors(err, "no-show");
}
throw new InternalServerErrorException("Could not mark no show.");
}
@Post("/recurring")
async createRecurringBooking(
@Req() req: BookingRequest,
@@ -287,8 +311,14 @@ export class BookingsController {
return req as unknown as NextApiRequest & { userId?: number } & OAuthRequestParams;
}
private handleBookingErrors(err: Error | HttpError | unknown, type?: "recurring" | `instant`): void {
const errMsg = `Error while creating ${type ? type + " " : ""}booking.`;
private handleBookingErrors(
err: Error | HttpError | unknown,
type?: "recurring" | `instant` | "no-show"
): void {
const errMsg =
type === "no-show"
? `Error while marking no-show.`
: `Error while creating ${type ? type + " " : ""}booking.`;
if (err instanceof HttpError) {
const httpError = err as HttpError;
throw new HttpException(httpError?.message ?? errMsg, httpError?.statusCode ?? 500);
@@ -0,0 +1,22 @@
import { Type } from "class-transformer";
import { IsOptional, IsArray, IsEmail, IsBoolean, ValidateNested } from "class-validator";
class Attendee {
@IsEmail()
email!: string;
@IsBoolean()
noShow!: boolean;
}
export class MarkNoShowInput {
@IsBoolean()
@IsOptional()
noShowHost?: boolean;
@ValidateNested()
@Type(() => Attendee)
@IsArray()
@IsOptional()
attendees?: Attendee[];
}
@@ -0,0 +1,45 @@
import { ApiProperty } from "@nestjs/swagger";
import { Type } from "class-transformer";
import { IsString, IsEnum, IsOptional, ValidateNested, IsArray, IsEmail, IsBoolean } from "class-validator";
import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants";
class Attendee {
@IsEmail()
email!: string;
@IsBoolean()
noShow!: boolean;
}
class HandleMarkNoShowData {
@IsString()
message!: string;
@IsBoolean()
@IsOptional()
noShowHost?: boolean;
@IsString()
@IsOptional()
messageKey?: string;
@ValidateNested()
@Type(() => Attendee)
@IsArray()
@IsOptional()
attendees?: Attendee[];
}
export class MarkNoShowOutput {
@ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] })
@IsEnum([SUCCESS_STATUS, ERROR_STATUS])
status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS;
@ApiProperty({
type: HandleMarkNoShowData,
})
@ValidateNested()
@Type(() => HandleMarkNoShowData)
data!: HandleMarkNoShowData;
}
@@ -16,6 +16,7 @@ import { OAuthClientRepositoryFixture } from "test/fixtures/repository/oauth-cli
import { TeamRepositoryFixture } from "test/fixtures/repository/team.repository.fixture";
import { TokensRepositoryFixture } from "test/fixtures/repository/tokens.repository.fixture";
import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture";
import { CalendarsServiceMock } from "test/mocks/calendars-service-mock";
import {
GOOGLE_CALENDAR,
@@ -27,20 +28,6 @@ import { OFFICE_365_CALENDAR_ID, OFFICE_365_CALENDAR_TYPE } from "@calcom/platfo
const CLIENT_REDIRECT_URI = "http://localhost:5555";
class CalendarsServiceMock {
async getCalendars() {
return {
connectedCalendars: [
{
integration: {
type: "google_calendar",
},
},
],
};
}
}
describe("Platform Calendars Endpoints", () => {
let app: INestApplication;
@@ -66,8 +53,7 @@ describe("Platform Calendars Endpoints", () => {
.useValue({
canActivate: () => true,
})
.overrideProvider(CalendarsService)
.useClass(CalendarsServiceMock)
.compile();
app = moduleRef.createNestApplication();
@@ -85,6 +71,9 @@ describe("Platform Calendars Endpoints", () => {
accessTokenSecret = tokens.accessToken;
refreshTokenSecret = tokens.refreshToken;
await app.init();
jest
.spyOn(CalendarsService.prototype, "getCalendars")
.mockImplementation(CalendarsServiceMock.prototype.getCalendars);
});
async function createOAuthClient(organizationId: number) {
@@ -16,23 +16,10 @@ import { OAuthClientRepositoryFixture } from "test/fixtures/repository/oauth-cli
import { TeamRepositoryFixture } from "test/fixtures/repository/team.repository.fixture";
import { TokensRepositoryFixture } from "test/fixtures/repository/tokens.repository.fixture";
import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture";
import { CalendarsServiceMock } from "test/mocks/calendars-service-mock";
const CLIENT_REDIRECT_URI = "http://localhost:5555";
class CalendarsServiceMock {
async getCalendars() {
return {
connectedCalendars: [
{
integration: {
type: "google_calendar",
},
},
],
};
}
}
describe("Platform Gcal Endpoints", () => {
let app: INestApplication;
@@ -57,8 +44,6 @@ describe("Platform Gcal Endpoints", () => {
.useValue({
canActivate: () => true,
})
.overrideProvider(CalendarsService)
.useClass(CalendarsServiceMock)
.compile();
app = moduleRef.createNestApplication();
@@ -76,6 +61,9 @@ describe("Platform Gcal Endpoints", () => {
accessTokenSecret = tokens.accessToken;
refreshTokenSecret = tokens.refreshToken;
await app.init();
jest
.spyOn(CalendarsService.prototype, "getCalendars")
.mockImplementation(CalendarsServiceMock.prototype.getCalendars);
});
async function createOAuthClient(organizationId: number) {
@@ -100,14 +88,14 @@ describe("Platform Gcal Endpoints", () => {
expect(user).toBeDefined();
});
it(`/GET/ee/gcal/oauth/auth-url: it should respond 401 with invalid access token`, async () => {
it(`/GET/gcal/oauth/auth-url: it should respond 401 with invalid access token`, async () => {
await request(app.getHttpServer())
.get(`/v2/gcal/oauth/auth-url`)
.set("Authorization", `Bearer invalid_access_token`)
.expect(401);
});
it(`/GET/ee/gcal/oauth/auth-url: it should auth-url to google oauth with valid access token `, async () => {
it(`/GET/gcal/oauth/auth-url: it should auth-url to google oauth with valid access token `, async () => {
const response = await request(app.getHttpServer())
.get(`/v2/gcal/oauth/auth-url`)
.set("Authorization", `Bearer ${accessTokenSecret}`)
@@ -117,42 +105,42 @@ describe("Platform Gcal Endpoints", () => {
expect(data.authUrl).toBeDefined();
});
it(`/GET/ee/gcal/oauth/save: without oauth code`, async () => {
it(`/GET/gcal/oauth/save: without oauth code`, async () => {
await request(app.getHttpServer())
.get(
`/v2/gcal/oauth/save?state=accessToken=${accessTokenSecret}&origin%3D${CLIENT_REDIRECT_URI}&scope=https://www.googleapis.com/auth/calendar.readonly%20https://www.googleapis.com/auth/calendar.events`
)
.expect(400);
.expect(301);
});
it(`/GET/ee/gcal/oauth/save: without access token`, async () => {
it(`/GET/gcal/oauth/save: without access token`, async () => {
await request(app.getHttpServer())
.get(
`/v2/gcal/oauth/save?state=origin%3D${CLIENT_REDIRECT_URI}&code=4/0AfJohXmBuT7QVrEPlAJLBu4ZcSnyj5jtDoJqSW_riPUhPXQ70RPGkOEbVO3xs-OzQwpPQw&scope=https://www.googleapis.com/auth/calendar.readonly%20https://www.googleapis.com/auth/calendar.events`
)
.expect(400);
.expect(301);
});
it(`/GET/ee/gcal/oauth/save: without origin`, async () => {
it(`/GET/gcal/oauth/save: without origin`, async () => {
await request(app.getHttpServer())
.get(
`/v2/gcal/oauth/save?state=accessToken=${accessTokenSecret}&code=4/0AfJohXmBuT7QVrEPlAJLBu4ZcSnyj5jtDoJqSW_riPUhPXQ70RPGkOEbVO3xs-OzQwpPQw&scope=https://www.googleapis.com/auth/calendar.readonly%20https://www.googleapis.com/auth/calendar.events`
)
.expect(400);
.expect(301);
});
it(`/GET/ee/gcal/check with access token but without origin`, async () => {
it(`/GET/gcal/check with access token but without origin`, async () => {
await request(app.getHttpServer())
.get(`/v2/gcal/check`)
.set("Authorization", `Bearer ${accessTokenSecret}`)
.expect(400);
});
it(`/GET/ee/gcal/check without access token`, async () => {
it(`/GET/gcal/check without access token`, async () => {
await request(app.getHttpServer()).get(`/v2/gcal/check`).expect(401);
});
it(`/GET/ee/gcal/check with access token and origin but no credentials`, async () => {
it(`/GET/gcal/check with access token and origin but no credentials`, async () => {
await request(app.getHttpServer())
.get(`/v2/gcal/check`)
.set("Authorization", `Bearer ${accessTokenSecret}`)
@@ -160,7 +148,7 @@ describe("Platform Gcal Endpoints", () => {
.expect(400);
});
it(`/GET/ee/gcal/check with access token and origin and gcal credentials`, async () => {
it(`/GET/gcal/check with access token and origin and gcal credentials`, async () => {
gcalCredentials = await credentialsRepositoryFixture.create(
"google_calendar",
{},
@@ -1,14 +1,24 @@
import { OrganizationsRepository } from "@/modules/organizations/organizations.repository";
import { RedisService } from "@/modules/redis/redis.service";
import { Injectable, CanActivate, ExecutionContext, ForbiddenException } from "@nestjs/common";
import { Request } from "express";
import { Team } from "@calcom/prisma/client";
type CachedData = {
org?: Team;
canAccess?: boolean;
};
@Injectable()
export class IsOrgGuard implements CanActivate {
constructor(private organizationsRepository: OrganizationsRepository) {}
constructor(
private organizationsRepository: OrganizationsRepository,
private readonly redisService: RedisService
) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
let canAccess = false;
const request = context.switchToHttp().getRequest<Request & { organization: Team }>();
const organizationId: string = request.params.orgId;
@@ -16,13 +26,33 @@ export class IsOrgGuard implements CanActivate {
throw new ForbiddenException("No organization id found in request params.");
}
const org = await this.organizationsRepository.findById(Number(organizationId));
const REDIS_CACHE_KEY = `apiv2:org:${organizationId}:guard:isOrg`;
const cachedData = await this.redisService.redis.get(REDIS_CACHE_KEY);
if (org && org.isOrganization) {
request.organization = org;
return true;
if (cachedData) {
const { org: cachedOrg, canAccess: cachedCanAccess } = JSON.parse(cachedData) as CachedData;
if (cachedOrg?.id === Number(organizationId) && cachedCanAccess !== undefined) {
request.organization = cachedOrg;
return cachedCanAccess;
}
}
return false;
const org = await this.organizationsRepository.findById(Number(organizationId));
if (org?.isOrganization) {
request.organization = org;
canAccess = true;
}
if (org) {
await this.redisService.redis.set(
REDIS_CACHE_KEY,
JSON.stringify({ org: org, canAccess } satisfies CachedData),
"EX",
300
);
}
return canAccess;
}
}
@@ -14,7 +14,20 @@ describe("PermissionsGuard", () => {
beforeEach(async () => {
reflector = new Reflector();
guard = new PermissionsGuard(reflector, createMock<TokensRepository>(), createMock<ConfigService>());
guard = new PermissionsGuard(
reflector,
createMock<TokensRepository>(),
createMock<ConfigService>({
get: jest.fn().mockImplementation((key: string) => {
switch (key) {
case "api.apiKeyPrefix":
return "cal_";
default:
return null;
}
}),
})
);
});
it("should be defined", () => {
@@ -101,23 +101,23 @@ export class RolesGuard implements CanActivate {
// 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") {
canAccess = true;
}
} else {
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 (!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
canAccess = hasMinimumRole({
checkRole: `TEAM_${teamMembership.role}`,
minimumRole: allowedRole,
roles: TEAM_ROLES,
});
}
// if user is not admin nor an owner of org, and is part of the team, then check user team membership role
canAccess = hasMinimumRole({
checkRole: `TEAM_${teamMembership.role}`,
minimumRole: allowedRole,
roles: TEAM_ROLES,
});
}
// if allowed role is a ORG ROLE, check org membersip role
@@ -129,7 +129,7 @@ export class RolesGuard implements CanActivate {
});
}
}
await this.redisService.redis.set(REDIS_CACHE_KEY, String(canAccess), "EX", 3600);
await this.redisService.redis.set(REDIS_CACHE_KEY, String(canAccess), "EX", 300);
return canAccess;
}
}
@@ -89,14 +89,11 @@ describe("ApiAuthStrategy", () => {
describe("authenticate with strategy", () => {
it("should return user associated with valid access token", async () => {
console.log("HERERERER");
const { accessToken } = await tokensRepositoryFixture.createTokens(
validAccessTokenUser.id,
oAuthClient.id
);
console.log("Access token", accessToken);
const context: ExecutionContext = {
switchToHttp: () => ({
getRequest: () => ({
@@ -110,9 +107,9 @@ describe("ApiAuthStrategy", () => {
} as ExecutionContext;
const request = context.switchToHttp().getRequest();
const user = await strategy.authenticate(request);
await expect(user.id).toEqual(validAccessTokenUser.id);
const user = await strategy.accessTokenStrategy(accessToken);
await expect(user).toBeDefined();
if (user) await expect(user.id).toEqual(validAccessTokenUser.id);
});
it("should return user associated with valid api key", async () => {
@@ -133,9 +130,9 @@ describe("ApiAuthStrategy", () => {
} as ExecutionContext;
const request = context.switchToHttp().getRequest();
const user = await strategy.authenticate(request);
expect(user.id).toEqual(validApiKeyUser.id);
const user = await strategy.apiKeyStrategy(keyString);
await expect(user).toBeDefined();
if (user) expect(user.id).toEqual(validApiKeyUser.id);
});
it("should throw 401 if api key is invalid", async () => {
@@ -133,6 +133,9 @@ export class BillingService {
}
async increaseUsageByClientId(clientId: string) {
if (this.configService.get("e2e")) {
return void 0;
}
const team = await this.teamsRepository.findTeamIdFromClientId(clientId);
if (!team.id) return Promise.resolve(); // noop resolution.
@@ -13,6 +13,7 @@ import { NestExpressApplication } from "@nestjs/platform-express";
import { Test } from "@nestjs/testing";
import { Membership, PlatformOAuthClient, Team, User } from "@prisma/client";
import * as request from "supertest";
import { PlatformBillingRepositoryFixture } from "test/fixtures/repository/billing.repository.fixture";
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";
@@ -62,6 +63,8 @@ describe("OAuth Clients Endpoints", () => {
let usersFixtures: UserRepositoryFixture;
let membershipFixtures: MembershipRepositoryFixture;
let teamFixtures: TeamRepositoryFixture;
let platformBillingRepositoryFixture: PlatformBillingRepositoryFixture;
let user: User;
let org: Team;
let app: INestApplication;
@@ -80,11 +83,14 @@ describe("OAuth Clients Endpoints", () => {
usersFixtures = new UserRepositoryFixture(moduleRef);
membershipFixtures = new MembershipRepositoryFixture(moduleRef);
teamFixtures = new TeamRepositoryFixture(moduleRef);
platformBillingRepositoryFixture = new PlatformBillingRepositoryFixture(moduleRef);
user = await usersFixtures.create({
email: userEmail,
});
org = await teamFixtures.create({
name: "apiOrg",
isOrganization: true,
metadata: {
isOrganization: true,
orgAutoAcceptEmail: "api.com",
@@ -94,6 +100,7 @@ describe("OAuth Clients Endpoints", () => {
isPlatform: false,
});
await membershipFixtures.addUserToOrg(user, org, "ADMIN", true);
await platformBillingRepositoryFixture.create(org.id);
app = moduleRef.createNestApplication();
bootstrap(app as NestExpressApplication);
await app.init();
@@ -114,6 +121,8 @@ describe("OAuth Clients Endpoints", () => {
let usersFixtures: UserRepositoryFixture;
let membershipFixtures: MembershipRepositoryFixture;
let teamFixtures: TeamRepositoryFixture;
let platformBillingRepositoryFixture: PlatformBillingRepositoryFixture;
let user: User;
let org: Team;
let app: INestApplication;
@@ -132,11 +141,14 @@ describe("OAuth Clients Endpoints", () => {
usersFixtures = new UserRepositoryFixture(moduleRef);
membershipFixtures = new MembershipRepositoryFixture(moduleRef);
teamFixtures = new TeamRepositoryFixture(moduleRef);
platformBillingRepositoryFixture = new PlatformBillingRepositoryFixture(moduleRef);
user = await usersFixtures.create({
email: userEmail,
});
org = await teamFixtures.create({
name: "apiOrg",
isOrganization: true,
metadata: {
isOrganization: true,
orgAutoAcceptEmail: "api.com",
@@ -145,6 +157,7 @@ describe("OAuth Clients Endpoints", () => {
},
isPlatform: true,
});
await platformBillingRepositoryFixture.create(org.id);
app = moduleRef.createNestApplication();
bootstrap(app as NestExpressApplication);
await app.init();
@@ -113,7 +113,7 @@ describe("OAuthFlow Endpoints", () => {
const REDIRECT_STATUS = 302;
const response = await request(app.getHttpServer())
.post(`/oauth/${oAuthClient.id}/authorize`)
.post(`/v2/oauth/${oAuthClient.id}/authorize`)
.send(body)
.expect(REDIRECT_STATUS);
@@ -133,7 +133,7 @@ describe("OAuthFlow Endpoints", () => {
};
const response = await request(app.getHttpServer())
.post(`/oauth/${oAuthClient.id}/exchange`)
.post(`/v2/oauth/${oAuthClient.id}/exchange`)
.set("Authorization", authorizationToken)
.send(body)
.expect(200);
@@ -153,7 +153,7 @@ describe("OAuthFlow Endpoints", () => {
};
return request(app.getHttpServer())
.post(`/oauth/${oAuthClient.id}/refresh`)
.post(`/v2/oauth/${oAuthClient.id}/refresh`)
.set("x-cal-secret-key", secretKey)
.send(body)
.expect(200)
@@ -21,8 +21,6 @@ import {
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 {
@@ -34,21 +32,7 @@ import {
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;
}
import { SkipTakePagination } from "@calcom/platform-types";
@Controller({
path: "/v2/organizations/:orgId",
@@ -6,7 +6,11 @@ import { IsOrgGuard } from "@/modules/auth/guards/organizations/is-org.guard";
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 {
OrgTeamOutputDto,
OrgTeamOutputResponseDto,
OrgTeamsOutputResponseDto,
} from "@/modules/organizations/outputs/organization-team.output";
import { OrganizationsTeamsService } from "@/modules/organizations/services/organizations-teams.service";
import {
Controller,
@@ -20,11 +24,11 @@ import {
Post,
Body,
} from "@nestjs/common";
import { ApiTags as DocsTags } from "@nestjs/swagger";
import { ApiOperation, ApiTags as DocsTags } from "@nestjs/swagger";
import { plainToClass } from "class-transformer";
import { SUCCESS_STATUS } from "@calcom/platform-constants";
import { ApiResponse, SkipTakePagination } from "@calcom/platform-types";
import { SkipTakePagination } from "@calcom/platform-types";
import { Team } from "@calcom/prisma/client";
@Controller({
@@ -37,12 +41,13 @@ export class OrganizationsTeamsController {
constructor(private organizationsTeamsService: OrganizationsTeamsService) {}
@Get()
@ApiOperation({ summary: "Get all the teams of an organization." })
@UseGuards()
@Roles("ORG_ADMIN")
async getAllTeams(
@Param("orgId", ParseIntPipe) orgId: number,
@Query() queryParams: SkipTakePagination
): Promise<ApiResponse<OrgTeamOutputDto[]>> {
): Promise<OrgTeamsOutputResponseDto> {
const { skip, take } = queryParams;
const teams = await this.organizationsTeamsService.getPaginatedOrgTeams(orgId, skip ?? 0, take ?? 250);
return {
@@ -54,7 +59,8 @@ export class OrganizationsTeamsController {
@UseGuards(IsTeamInOrg)
@Roles("TEAM_ADMIN")
@Get("/:teamId")
async getTeam(@GetTeam() team: Team): Promise<ApiResponse<OrgTeamOutputDto>> {
@ApiOperation({ summary: "Get a team of the organization by ID." })
async getTeam(@GetTeam() team: Team): Promise<OrgTeamOutputResponseDto> {
return {
status: SUCCESS_STATUS,
data: plainToClass(OrgTeamOutputDto, team, { strategy: "excludeAll" }),
@@ -64,10 +70,11 @@ export class OrganizationsTeamsController {
@UseGuards(IsTeamInOrg)
@Roles("ORG_ADMIN")
@Delete("/:teamId")
@ApiOperation({ summary: "Delete a team of the organization by ID." })
async deleteTeam(
@Param("orgId", ParseIntPipe) orgId: number,
@Param("teamId", ParseIntPipe) teamId: number
): Promise<ApiResponse<OrgTeamOutputDto>> {
): Promise<OrgTeamOutputResponseDto> {
const team = await this.organizationsTeamsService.deleteOrgTeam(orgId, teamId);
return {
status: SUCCESS_STATUS,
@@ -78,11 +85,12 @@ export class OrganizationsTeamsController {
@UseGuards(IsTeamInOrg)
@Roles("ORG_ADMIN")
@Patch("/:teamId")
@ApiOperation({ summary: "Update a team of the organization by ID." })
async updateTeam(
@Param("orgId", ParseIntPipe) orgId: number,
@Param("teamId", ParseIntPipe) teamId: number,
@Body() body: CreateOrgTeamDto
): Promise<ApiResponse<OrgTeamOutputDto>> {
): Promise<OrgTeamOutputResponseDto> {
const team = await this.organizationsTeamsService.updateOrgTeam(orgId, teamId, body);
return {
status: SUCCESS_STATUS,
@@ -93,10 +101,11 @@ export class OrganizationsTeamsController {
@Post()
@UseGuards()
@Roles("ORG_ADMIN")
@ApiOperation({ summary: "Create a team for an organization." })
async createTeam(
@Param("orgId", ParseIntPipe) orgId: number,
@Body() body: CreateOrgTeamDto
): Promise<ApiResponse<OrgTeamOutputDto>> {
): Promise<OrgTeamOutputResponseDto> {
const team = await this.organizationsTeamsService.createOrgTeam(orgId, body);
return {
status: SUCCESS_STATUS,
@@ -1,7 +1,7 @@
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 { OrganizationsTeamsController } from "@/modules/organizations/controllers/teams/organizations-teams.controller";
import { OrganizationsRepository } from "@/modules/organizations/organizations.repository";
import { OrganizationSchedulesRepository } from "@/modules/organizations/repositories/organizations-schedules.repository";
import { OrganizationsTeamsRepository } from "@/modules/organizations/repositories/organizations-teams.repository";
@@ -1,5 +1,17 @@
import { Expose } from "class-transformer";
import { IsBoolean, IsInt, IsOptional, IsString, IsUrl, Length } from "class-validator";
import { ApiProperty } from "@nestjs/swagger";
import { Expose, Type } from "class-transformer";
import {
IsBoolean,
IsEnum,
IsInt,
IsOptional,
IsString,
IsUrl,
Length,
ValidateNested,
} from "class-validator";
import { ERROR_STATUS, SUCCESS_STATUS } from "@calcom/platform-constants";
export class OrgTeamOutputDto {
@IsInt()
@@ -105,3 +117,25 @@ export class OrgTeamOutputDto {
@Expose()
readonly weekStart?: string = "Sunday";
}
export class OrgTeamsOutputResponseDto {
@ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] })
@IsEnum([SUCCESS_STATUS, ERROR_STATUS])
status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS;
@Expose()
@ValidateNested()
@Type(() => OrgTeamOutputDto)
data!: OrgTeamOutputDto[];
}
export class OrgTeamOutputResponseDto {
@ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] })
@IsEnum([SUCCESS_STATUS, ERROR_STATUS])
status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS;
@Expose()
@ValidateNested()
@Type(() => OrgTeamOutputDto)
data!: OrgTeamOutputDto;
}
+286 -5
View File
@@ -33,6 +33,16 @@
"schema": {
"type": "string"
}
},
{
"name": "limit",
"required": false,
"in": "query",
"description": "The number of items to return",
"example": 10,
"schema": {
"type": "number"
}
}
],
"responses": {
@@ -417,6 +427,16 @@
"schema": {
"type": "string"
}
},
{
"name": "limit",
"required": false,
"in": "query",
"description": "The number of items to return",
"example": 10,
"schema": {
"type": "number"
}
}
],
"responses": {
@@ -809,6 +829,7 @@
"/v2/organizations/{orgId}/teams": {
"get": {
"operationId": "OrganizationsTeamsController_getAllTeams",
"summary": "Get all the teams of an organization.",
"parameters": [
{
"name": "orgId",
@@ -817,6 +838,26 @@
"schema": {
"type": "number"
}
},
{
"name": "take",
"required": false,
"in": "query",
"description": "The number of items to return",
"example": 10,
"schema": {
"type": "number"
}
},
{
"name": "skip",
"required": false,
"in": "query",
"description": "The number of items to skip",
"example": 0,
"schema": {
"type": "number"
}
}
],
"responses": {
@@ -825,7 +866,7 @@
"content": {
"application/json": {
"schema": {
"type": "object"
"$ref": "#/components/schemas/OrgTeamsOutputResponseDto"
}
}
}
@@ -837,6 +878,7 @@
},
"post": {
"operationId": "OrganizationsTeamsController_createTeam",
"summary": "Create a team for an organization.",
"parameters": [
{
"name": "orgId",
@@ -863,7 +905,7 @@
"content": {
"application/json": {
"schema": {
"type": "object"
"$ref": "#/components/schemas/OrgTeamOutputResponseDto"
}
}
}
@@ -877,6 +919,7 @@
"/v2/organizations/{orgId}/teams/{teamId}": {
"get": {
"operationId": "OrganizationsTeamsController_getTeam",
"summary": "Get a team of the organization by ID.",
"parameters": [],
"responses": {
"200": {
@@ -884,7 +927,7 @@
"content": {
"application/json": {
"schema": {
"type": "object"
"$ref": "#/components/schemas/OrgTeamOutputResponseDto"
}
}
}
@@ -896,6 +939,7 @@
},
"delete": {
"operationId": "OrganizationsTeamsController_deleteTeam",
"summary": "Delete a team of the organization by ID.",
"parameters": [
{
"name": "orgId",
@@ -920,7 +964,7 @@
"content": {
"application/json": {
"schema": {
"type": "object"
"$ref": "#/components/schemas/OrgTeamOutputResponseDto"
}
}
}
@@ -932,6 +976,7 @@
},
"patch": {
"operationId": "OrganizationsTeamsController_updateTeam",
"summary": "Update a team of the organization by ID.",
"parameters": [
{
"name": "orgId",
@@ -966,7 +1011,7 @@
"content": {
"application/json": {
"schema": {
"type": "object"
"$ref": "#/components/schemas/OrgTeamOutputResponseDto"
}
}
}
@@ -988,6 +1033,26 @@
"schema": {
"type": "number"
}
},
{
"name": "take",
"required": false,
"in": "query",
"description": "The number of items to return",
"example": 10,
"schema": {
"type": "number"
}
},
{
"name": "skip",
"required": false,
"in": "query",
"description": "The number of items to skip",
"example": 0,
"schema": {
"type": "number"
}
}
],
"responses": {
@@ -1902,6 +1967,46 @@
]
}
},
"/v2/bookings/{bookingUid}/mark-no-show": {
"post": {
"operationId": "BookingsController_markNoShow",
"parameters": [
{
"name": "bookingUid",
"required": true,
"in": "path",
"schema": {
"type": "string"
}
}
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/MarkNoShowInput"
}
}
}
},
"responses": {
"201": {
"description": "",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/MarkNoShowOutput"
}
}
}
}
},
"tags": [
"Bookings"
]
}
},
"/v2/bookings/recurring": {
"post": {
"operationId": "BookingsController_createRecurringBooking",
@@ -3700,6 +3805,125 @@
"data"
]
},
"OrgTeamOutputDto": {
"type": "object",
"properties": {
"id": {
"type": "number"
},
"parentId": {
"type": "number"
},
"name": {
"type": "string",
"minLength": 1
},
"slug": {
"type": "string"
},
"logoUrl": {
"type": "string"
},
"calVideoLogo": {
"type": "string"
},
"appLogo": {
"type": "string"
},
"appIconLogo": {
"type": "string"
},
"bio": {
"type": "string"
},
"hideBranding": {
"type": "boolean"
},
"isOrganization": {
"type": "boolean"
},
"isPrivate": {
"type": "boolean"
},
"hideBookATeamMember": {
"type": "boolean",
"default": false
},
"metadata": {
"type": "string"
},
"theme": {
"type": "string"
},
"brandColor": {
"type": "string"
},
"darkBrandColor": {
"type": "string"
},
"bannerUrl": {
"type": "string"
},
"timeFormat": {
"type": "number"
},
"timeZone": {
"type": "string",
"default": "Europe/London"
},
"weekStart": {
"type": "string",
"default": "Sunday"
}
},
"required": [
"id",
"name"
]
},
"OrgTeamsOutputResponseDto": {
"type": "object",
"properties": {
"status": {
"type": "string",
"example": "success",
"enum": [
"success",
"error"
]
},
"data": {
"type": "array",
"items": {
"$ref": "#/components/schemas/OrgTeamOutputDto"
}
}
},
"required": [
"status",
"data"
]
},
"OrgTeamOutputResponseDto": {
"type": "object",
"properties": {
"status": {
"type": "string",
"example": "success",
"enum": [
"success",
"error"
]
},
"data": {
"$ref": "#/components/schemas/OrgTeamOutputDto"
}
},
"required": [
"status",
"data"
]
},
"CreateOrgTeamDto": {
"type": "object",
"properties": {
@@ -5461,6 +5685,63 @@
"seatReferenceUid"
]
},
"MarkNoShowInput": {
"type": "object",
"properties": {
"noShowHost": {
"type": "boolean"
},
"attendees": {
"type": "array",
"items": {
"$ref": "#/components/schemas/Attendee"
}
}
}
},
"HandleMarkNoShowData": {
"type": "object",
"properties": {
"message": {
"type": "string"
},
"noShowHost": {
"type": "boolean"
},
"messageKey": {
"type": "string"
},
"attendees": {
"type": "array",
"items": {
"$ref": "#/components/schemas/Attendee"
}
}
},
"required": [
"message"
]
},
"MarkNoShowOutput": {
"type": "object",
"properties": {
"status": {
"type": "string",
"example": "success",
"enum": [
"success",
"error"
]
},
"data": {
"$ref": "#/components/schemas/HandleMarkNoShowData"
}
},
"required": [
"status",
"data"
]
},
"ReserveSlotInput": {
"type": "object",
"properties": {}
@@ -0,0 +1,25 @@
import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
import { TestingModule } from "@nestjs/testing";
export class PlatformBillingRepositoryFixture {
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 create(orgId: number) {
const randomString = Date.now().toString(36);
return this.prismaWriteClient.platformBilling.create({
data: {
id: orgId,
customerId: `cus_123_${randomString}`,
subscriptionId: `sub_123_${randomString}`,
plan: "STARTER",
},
});
}
}
@@ -0,0 +1,58 @@
import { CalendarsService } from "@/ee/calendars/services/calendars.service";
export class CalendarsServiceMock {
async getCalendars() {
return {
connectedCalendars: [
{
integration: {
installed: false,
type: "google_calendar",
title: "",
name: "",
description: "",
variant: "calendar",
slug: "",
locationOption: null,
categories: ["calendar"],
logo: "",
publisher: "",
url: "",
email: "",
},
credentialId: 1,
error: { message: "" },
},
{
integration: {
installed: false,
type: "office365_calendar",
title: "",
name: "",
description: "",
variant: "calendar",
slug: "",
locationOption: null,
categories: ["calendar"],
logo: "",
publisher: "",
url: "",
email: "",
},
credentialId: 2,
error: { message: "" },
},
],
destinationCalendar: {
name: "destinationCalendar",
eventTypeId: 1,
credentialId: 1,
primaryEmail: "primaryEmail",
integration: "google_calendar",
externalId: "externalId",
userId: null,
id: 0,
},
} satisfies Awaited<ReturnType<typeof CalendarsService.prototype.getCalendars>>;
}
}
+6 -1
View File
@@ -1,6 +1,9 @@
import { withAppDirSsr } from "app/WithAppDirSsr";
import { _generateMetadata } from "app/_utils";
import { WithLayout } from "app/layoutHOC";
import { getServerSideProps } from "@lib/event-types/getServerSideProps";
import EventTypes from "~/event-types/views/event-types-listing-view";
export const generateMetadata = async () =>
@@ -9,4 +12,6 @@ export const generateMetadata = async () =>
(t) => t("event_types_page_subtitle")
);
export default WithLayout({ getLayout: null, Page: EventTypes })<"P">;
const getData = withAppDirSsr(getServerSideProps);
export default WithLayout({ getLayout: null, getData, Page: EventTypes })<"P">;
+3 -14
View File
@@ -5,7 +5,7 @@ import { InstallAppButton } from "@calcom/app-store/components";
import { getEventLocationTypeFromApp, type EventLocationType } from "@calcom/app-store/locations";
import type { CredentialOwner } from "@calcom/app-store/types";
import { AppSetDefaultLinkDialog } from "@calcom/features/apps/components/AppSetDefaultLinkDialog";
import { BulkEditDefaultModal } from "@calcom/features/eventtypes/components/BulkEditDefaultModal";
import { BulkEditDefaultForEventsModal } from "@calcom/features/eventtypes/components/BulkEditDefaultForEventsModal";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import type { AppCategories } from "@calcom/prisma/enums";
import { trpc, type RouterOutputs } from "@calcom/trpc";
@@ -153,8 +153,6 @@ export const AppList = ({ data, handleDisconnect, variant, listClassName }: AppL
});
const { t } = useLocale();
const [eventTypeIds, setEventTypeIds] = useState<number[]>([]);
const { data: bulkEventTypes } = trpc.viewer.eventTypes.bulkEventFetch.useQuery();
const updateLocationsMutation = trpc.viewer.eventTypes.bulkUpdateToDefaultLocation.useMutation({
onSuccess: () => {
utils.viewer.getUsersDefaultConferencingApp.invalidate();
@@ -180,20 +178,11 @@ export const AppList = ({ data, handleDisconnect, variant, listClassName }: AppL
)}
{bulkUpdateModal && (
<BulkEditDefaultModal
handleSubmit={() => {
updateLocationsMutation.mutate({
eventTypeIds,
});
}}
<BulkEditDefaultForEventsModal
bulkUpdateFunction={updateLocationsMutation.mutate}
open={bulkUpdateModal}
setOpen={setBulkUpdateModal}
isPending={updateLocationsMutation.isPending}
title={t("default_conferencing_bulk_title")}
description={t("default_conferencing_bulk_description")}
data={bulkEventTypes?.eventTypes}
ids={eventTypeIds}
setIds={setEventTypeIds}
/>
)}
</>
@@ -692,7 +692,10 @@ const Attendee = (attendeeProps: AttendeeProps & NoShowProps) => {
const noShowMutation = trpc.viewer.public.noShow.useMutation({
onSuccess: async (data) => {
showToast(t(data.message, { x: name || email }), "success");
showToast(
t("messageKey" in data && data.messageKey ? data.messageKey : data.message, { x: name || email }),
"success"
);
},
onError: (err) => {
showToast(err.message, "error");
@@ -800,7 +803,7 @@ const GroupedAttendees = (groupedAttendeeProps: GroupedAttendeeProps) => {
const { t } = useLocale();
const noShowMutation = trpc.viewer.public.noShow.useMutation({
onSuccess: async (data) => {
showToast(t(data.message), "success");
showToast(t("messageKey" in data && data.messageKey ? data.messageKey : data.message), "success");
},
onError: (err) => {
showToast(err.message, "error");
@@ -1,7 +1,12 @@
import Link from "next/link";
import type { TDependencyData } from "@calcom/app-store/_appRegistry";
import { InstallAppButtonWithoutPlanCheck } from "@calcom/app-store/components";
import { WEBAPP_URL } from "@calcom/lib/constants";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { trpc } from "@calcom/trpc/react";
import type { App } from "@calcom/types/App";
import { Button } from "@calcom/ui";
import { Badge, Button, Icon } from "@calcom/ui";
interface IAppConnectionItem {
title: string;
@@ -9,26 +14,74 @@ interface IAppConnectionItem {
logo: string;
type: App["type"];
installed?: boolean;
isDefault?: boolean;
defaultInstall?: boolean;
slug?: string;
dependencyData?: TDependencyData;
}
const AppConnectionItem = (props: IAppConnectionItem) => {
const { title, logo, type, installed } = props;
const { title, logo, type, installed, isDefault, defaultInstall, slug } = props;
const { t } = useLocale();
const setDefaultConferencingApp = trpc.viewer.appsRouter.setDefaultConferencingApp.useMutation();
const dependency = props.dependencyData?.find((data) => !data.installed);
return (
<div className="flex flex-row items-center justify-between p-5">
<div className="flex items-center space-x-3">
<img src={logo} alt={title} className="h-8 w-8" />
<p className="text-sm font-bold">{title}</p>
{isDefault && <Badge variant="green">{t("default")}</Badge>}
</div>
<InstallAppButtonWithoutPlanCheck
type={type}
options={{
onSuccess: () => {
if (defaultInstall && slug) {
setDefaultConferencingApp.mutate({ slug });
}
},
}}
render={(buttonProps) => (
<Button
{...buttonProps}
color="secondary"
disabled={installed}
disabled={installed || !!dependency}
type="button"
loading={buttonProps?.isPending}
tooltip={
dependency ? (
<div className="items-start space-x-2.5">
<div className="flex items-start">
<div>
<Icon name="circle-alert" className="mr-2 mt-1 font-semibold" />
</div>
<div>
<span className="text-xs font-semibold">
{t("this_app_requires_connected_account", {
appName: title,
dependencyName: dependency.name,
})}
</span>
<div>
<div>
<>
<Link
href={`${WEBAPP_URL}/getting-started/connected-calendar`}
className="flex items-center text-xs underline">
<span className="mr-1">
{t("connect_app", { dependencyName: dependency.name })}
</span>
<Icon name="arrow-right" className="inline-block h-3 w-3" />
</Link>
</>
</div>
</div>
</div>
</div>
</div>
) : undefined
}
onClick={(event) => {
// Save cookie key to return url step
document.cookie = `return-to=${window.location.href};path=/;max-age=3600;SameSite=Lax`;
@@ -1,6 +1,8 @@
import classNames from "@calcom/lib/classNames";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { userMetadata } from "@calcom/prisma/zod-utils";
import { trpc } from "@calcom/trpc/react";
import useMeQuery from "@calcom/trpc/react/hooks/useMeQuery";
import { Icon, List } from "@calcom/ui";
import { AppConnectionItem } from "../components/AppConnectionItem";
@@ -17,12 +19,16 @@ const ConnectedVideoStep = (props: ConnectedAppStepProps) => {
onlyInstalled: false,
sortByMostPopular: true,
});
const { data } = useMeQuery();
const { t } = useLocale();
const metadata = userMetadata.parse(data?.metadata);
const hasAnyInstalledVideoApps = queryConnectedVideoApps?.items.some(
(item) => item.userCredentialIds.length > 0
);
const defaultConferencingApp = metadata?.defaultConferencingApp?.appSlug;
return (
<>
{!isPending && (
@@ -36,9 +42,15 @@ const ConnectedVideoStep = (props: ConnectedAppStepProps) => {
<AppConnectionItem
type={item.type}
title={item.name}
isDefault={item.slug === defaultConferencingApp}
description={item.description}
dependencyData={item.dependencyData}
logo={item.logo}
slug={item.slug}
installed={item.userCredentialIds.length > 0}
defaultInstall={
!defaultConferencingApp && item.appData?.location?.linkType === "dynamic"
}
/>
)}
</li>
@@ -186,7 +186,7 @@ export default function Success(props: PageProps) {
useEffect(() => {
if (noShow) {
noShowMutation.mutate({ bookingUid: bookingInfo.uid });
noShowMutation.mutate({ bookingUid: bookingInfo.uid, noShowHost: true });
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
@@ -26,7 +26,6 @@ import { HttpError } from "@calcom/lib/http-error";
import { telemetryEventTypes, useTelemetry } from "@calcom/lib/telemetry";
import { validateBookerLayouts } from "@calcom/lib/validateBookerLayouts";
import type { Prisma } from "@calcom/prisma/client";
import { SchedulingType } from "@calcom/prisma/enums";
import type { customInputSchema, EventTypeMetaDataSchema } from "@calcom/prisma/zod-utils";
import { eventTypeBookingFields } from "@calcom/prisma/zod-utils";
import type { RouterOutputs } from "@calcom/trpc/react";
@@ -401,40 +400,40 @@ const EventTypePage = (props: EventTypeSetupProps & { allActiveWorkflows?: Workf
formState: { isDirty: isFormDirty, dirtyFields },
} = formMethods;
useEffect(() => {
const handleRouteChange = (url: string) => {
const paths = url.split("/");
// Check if event is managed event type - skip if there is assigned users
const assignedUsers = eventType.children;
const isManagedEventType = eventType.schedulingType === SchedulingType.MANAGED;
if (eventType.assignAllTeamMembers) {
return;
} else if (isManagedEventType && assignedUsers.length > 0) {
return;
}
const hosts = eventType.hosts;
if (
!leaveWithoutAssigningHosts.current &&
!!team &&
(hosts.length === 0 || assignedUsers.length === 0) &&
(url === "/event-types" || paths[1] !== "event-types")
) {
setIsOpenAssignmentWarnDialog(true);
setPendingRoute(url);
router.events.emit(
"routeChangeError",
new Error(`Aborted route change to ${url} because none was assigned to team event`)
);
throw "Aborted";
}
};
router.events.on("routeChangeStart", handleRouteChange);
return () => {
router.events.off("routeChangeStart", handleRouteChange);
};
}, [router]);
// useEffect(() => {
// const handleRouteChange = (url: string) => {
// const paths = url.split("/");
//
// // Check if event is managed event type - skip if there is assigned users
// const assignedUsers = eventType.children;
// const isManagedEventType = eventType.schedulingType === SchedulingType.MANAGED;
// if (eventType.assignAllTeamMembers) {
// return;
// } else if (isManagedEventType && assignedUsers.length > 0) {
// return;
// }
//
// const hosts = eventType.hosts;
// if (
// !leaveWithoutAssigningHosts.current &&
// !!team &&
// (hosts.length === 0 || assignedUsers.length === 0) &&
// (url === "/event-types" || paths[1] !== "event-types")
// ) {
// setIsOpenAssignmentWarnDialog(true);
// setPendingRoute(url);
// router.events.emit(
// "routeChangeError",
// new Error(`Aborted route change to ${url} because none was assigned to team event`)
// );
// throw "Aborted";
// }
// };
// router.events.on("routeChangeStart", handleRouteChange);
// return () => {
// router.events.off("routeChangeStart", handleRouteChange);
// };
// }, [router]);
const appsMetadata = formMethods.getValues("metadata")?.apps;
const availability = formMethods.watch("availability");
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@calcom/web",
"version": "4.2.4",
"version": "4.3.0",
"private": true,
"scripts": {
"analyze": "ANALYZE=true next build",
+3 -14
View File
@@ -3,7 +3,7 @@ import Link from "next/link";
import { useRouter, usePathname } from "next/navigation";
import { useCallback, useState } from "react";
import { BulkEditDefaultModal } from "@calcom/features/eventtypes/components/BulkEditDefaultModal";
import { BulkEditDefaultForEventsModal } from "@calcom/features/eventtypes/components/BulkEditDefaultForEventsModal";
import { NewScheduleButton, ScheduleListItem } from "@calcom/features/schedules";
import Shell from "@calcom/features/shell/Shell";
import { AvailabilitySliderTable } from "@calcom/features/timezone-buddy/components/AvailabilitySliderTable";
@@ -24,14 +24,12 @@ import SkeletonLoader from "@components/availability/SkeletonLoader";
export function AvailabilityList({ schedules }: RouterOutputs["viewer"]["availability"]["list"]) {
const { t } = useLocale();
const [bulkUpdateModal, setBulkUpdateModal] = useState(false);
const [eventTypeIds, setEventTypeIds] = useState<number[]>([]);
const utils = trpc.useUtils();
const meQuery = trpc.viewer.me.useQuery();
const router = useRouter();
const { data } = trpc.viewer.eventTypes.bulkEventFetch.useQuery();
const deleteMutation = trpc.viewer.availability.schedule.delete.useMutation({
onMutate: async ({ scheduleId }) => {
await utils.viewer.availability.list.cancel();
@@ -145,20 +143,11 @@ export function AvailabilityList({ schedules }: RouterOutputs["viewer"]["availab
</Link>
</div>
{bulkUpdateModal && (
<BulkEditDefaultModal
<BulkEditDefaultForEventsModal
isPending={bulkUpdateDefaultAvailabilityMutation.isPending}
open={bulkUpdateModal}
setOpen={setBulkUpdateModal}
title={t("default_conferencing_bulk_title")}
description={t("default_conferencing_bulk_description")}
ids={eventTypeIds}
setIds={setEventTypeIds}
data={data?.eventTypes}
handleSubmit={() => {
bulkUpdateDefaultAvailabilityMutation.mutate({
eventTypeIds,
});
}}
bulkUpdateFunction={bulkUpdateDefaultAvailabilityMutation.mutate}
/>
)}
</>
@@ -1928,14 +1928,12 @@
"invalid_event_name_variables": "There is an invalid variable in your event name",
"select_all": "Select All",
"default_conferencing_bulk_title": "Bulk update existing event types",
"default_timezone_bulk_title": "Bulk update existing availabilities",
"members_default_schedule": "Member's default schedule",
"set_by_admin": "Set by team admin",
"members_default_location": "Member's default location",
"members_default_schedule_description": "We will use each members default availability schedule. They will be able to edit or change it.",
"requires_at_least_one_schedule": "You are required to have at least one schedule",
"default_conferencing_bulk_description": "Update the locations for the selected event types",
"default_timezone_bulk_description": "Update the timezone for selected availabilities.",
"locked_for_members": "Locked for members",
"unlocked_for_members": "Unlocked for members",
"apps_locked_for_members_description": "Members will be able to see the active apps but will not be able to edit any app settings",
+6 -4
View File
@@ -7,6 +7,11 @@ import { userMetadata } from "@calcom/prisma/zod-utils";
import type { AppFrontendPayload as App } from "@calcom/types/App";
import type { CredentialFrontendPayload as Credential } from "@calcom/types/Credential";
export type TDependencyData = {
name?: string;
installed?: boolean;
}[];
/**
* Get App metdata either using dirName or slug
*/
@@ -96,10 +101,7 @@ export async function getAppRegistryWithCredentials(userId: number, userAdminTea
/* This is now handled from the DB */
// if (!app.installed) return apps;
app.createdAt = dbapp.createdAt.toISOString();
let dependencyData: {
name?: string;
installed?: boolean;
}[] = [];
let dependencyData: TDependencyData = [];
if (app.dependencies) {
dependencyData = app.dependencies.map((dependency) => {
const dependencyInstalled = dbApps.some(
@@ -19,7 +19,7 @@ type CustomUseMutationOptions =
| undefined;
type AddAppMutationData = { setupPending: boolean } | void;
type UseAddAppMutationOptions = CustomUseMutationOptions & {
export type UseAddAppMutationOptions = CustomUseMutationOptions & {
onSuccess?: (data: AddAppMutationData) => void;
installGoogleVideo?: boolean;
returnTo?: string;
@@ -4,6 +4,7 @@
**/
import { appKeysSchema as alby_zod_ts } from "./alby/zod";
import { appKeysSchema as basecamp3_zod_ts } from "./basecamp3/zod";
import { appKeysSchema as campsite_zod_ts } from "./campsite/zod";
import { appKeysSchema as closecom_zod_ts } from "./closecom/zod";
import { appKeysSchema as dailyvideo_zod_ts } from "./dailyvideo/zod";
import { appKeysSchema as fathom_zod_ts } from "./fathom/zod";
@@ -50,6 +51,7 @@ import { appKeysSchema as zoomvideo_zod_ts } from "./zoomvideo/zod";
export const appKeysSchemas = {
alby: alby_zod_ts,
basecamp3: basecamp3_zod_ts,
campsite: campsite_zod_ts,
closecom: closecom_zod_ts,
dailyvideo: dailyvideo_zod_ts,
fathom: fathom_zod_ts,
@@ -12,6 +12,7 @@ import basecamp3_config_json from "./basecamp3/config.json";
import cal_ai_config_json from "./cal-ai/config.json";
import { metadata as caldavcalendar__metadata_ts } from "./caldavcalendar/_metadata";
import campfire_config_json from "./campfire/config.json";
import campsite_config_json from "./campsite/config.json";
import clic_config_json from "./clic/config.json";
import closecom_config_json from "./closecom/config.json";
import cron_config_json from "./cron/config.json";
@@ -103,6 +104,7 @@ export const appStoreMetadata = {
"cal-ai": cal_ai_config_json,
caldavcalendar: caldavcalendar__metadata_ts,
campfire: campfire_config_json,
campsite: campsite_config_json,
clic: clic_config_json,
closecom: closecom_config_json,
cron: cron_config_json,
@@ -4,6 +4,7 @@
**/
import { appDataSchema as alby_zod_ts } from "./alby/zod";
import { appDataSchema as basecamp3_zod_ts } from "./basecamp3/zod";
import { appDataSchema as campsite_zod_ts } from "./campsite/zod";
import { appDataSchema as closecom_zod_ts } from "./closecom/zod";
import { appDataSchema as dailyvideo_zod_ts } from "./dailyvideo/zod";
import { appDataSchema as fathom_zod_ts } from "./fathom/zod";
@@ -50,6 +51,7 @@ import { appDataSchema as zoomvideo_zod_ts } from "./zoomvideo/zod";
export const appDataSchemas = {
alby: alby_zod_ts,
basecamp3: basecamp3_zod_ts,
campsite: campsite_zod_ts,
closecom: closecom_zod_ts,
dailyvideo: dailyvideo_zod_ts,
fathom: fathom_zod_ts,
@@ -13,6 +13,7 @@ export const apiHandlers = {
"cal-ai": import("./cal-ai/api"),
caldavcalendar: import("./caldavcalendar/api"),
campfire: import("./campfire/api"),
campsite: import("./campsite/api"),
clic: import("./clic/api"),
closecom: import("./closecom/api"),
cron: import("./cron/api"),
@@ -4,6 +4,7 @@
**/
import around_config_json from "./around/config.json";
import campfire_config_json from "./campfire/config.json";
import campsite_config_json from "./campsite/config.json";
import { metadata as dailyvideo__metadata_ts } from "./dailyvideo/_metadata";
import demodesk_config_json from "./demodesk/config.json";
import discord_config_json from "./discord/config.json";
@@ -45,6 +46,7 @@ import { metadata as zoomvideo__metadata_ts } from "./zoomvideo/_metadata";
export const appStoreMetadata = {
around: around_config_json,
campfire: campfire_config_json,
campsite: campsite_config_json,
dailyvideo: dailyvideo__metadata_ts,
demodesk: demodesk_config_json,
discord: discord_config_json,
@@ -0,0 +1,8 @@
---
items:
- 1.png
- 2.png
- 3.png
---
{DESCRIPTION}
+45
View File
@@ -0,0 +1,45 @@
import type { NextApiRequest } from "next";
import { stringify } from "querystring";
import { z } from "zod";
import { WEBAPP_URL } from "@calcom/lib/constants";
import { defaultHandler, defaultResponder } from "@calcom/lib/server";
import getAppKeysFromSlug from "../../_utils/getAppKeysFromSlug";
import { encodeOAuthState } from "../../_utils/oauth/encodeOAuthState";
const campsiteAppKeysSchema = z.object({
client_id: z.string(),
client_secret: z.string(),
});
export const getCampsiteAppKeys = async () => {
const appKeys = await getAppKeysFromSlug("campsite");
return campsiteAppKeysSchema.parse(appKeys);
};
async function handler(req: NextApiRequest) {
// Get user
const user = req?.session?.user;
if (!user) {
return { status: 401, body: { error: "Unauthorized" } };
}
const { client_id } = await getCampsiteAppKeys();
const state = encodeOAuthState(req);
const params = {
response_type: "code",
client_id,
redirect_uri: `${WEBAPP_URL}/api/integrations/campsite/callback`,
state,
scope: "read_user write_call_room",
};
const query = stringify(params);
const url = `https://auth.campsite.co/oauth/authorize?${query}`;
return { url };
}
export default defaultHandler({
GET: Promise.resolve({ default: defaultResponder(handler) }),
});
@@ -0,0 +1,73 @@
import type { NextApiRequest, NextApiResponse } from "next";
import { WEBAPP_URL } from "@calcom/lib/constants";
import { getSafeRedirectUrl } from "@calcom/lib/getSafeRedirectUrl";
import prisma from "@calcom/prisma";
import getAppKeysFromSlug from "../../_utils/getAppKeysFromSlug";
import getInstalledAppPath from "../../_utils/getInstalledAppPath";
import createOAuthAppCredential from "../../_utils/oauth/createOAuthAppCredential";
import { decodeOAuthState } from "../../_utils/oauth/decodeOAuthState";
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
const state = decodeOAuthState(req);
const userId = req.session?.user.id;
if (!userId) {
return res.status(404).json({ message: "No user found" });
}
const { code } = req.query;
const { client_id, client_secret } = await getAppKeysFromSlug("campsite");
const result = await fetch(`https://auth.campsite.co/oauth/token`, {
method: "POST",
body: JSON.stringify({
grant_type: "authorization_code",
code,
client_id,
client_secret,
redirect_uri: `${WEBAPP_URL}/api/integrations/campsite/callback`,
}),
headers: {
"Content-Type": "application/json",
},
});
if (result.status !== 200) {
let errorMessage = "Something is wrong with the Campsite API";
try {
const responseBody = await result.json();
errorMessage = responseBody.error;
} catch (e) {}
res.status(400).json({ message: errorMessage });
return;
}
const responseBody = await result.json();
if (responseBody.error) {
res.status(400).json({ message: responseBody.error });
return;
}
/**
* With this we take care of no duplicate Campsite keys for a single user
* when creating a room using deleteMany if there is already a Campsite key
* */
await prisma.credential.deleteMany({
where: {
type: "campsite_conferencing",
userId,
appId: "campsite",
},
});
await createOAuthAppCredential(
{ appId: "campsite", type: "campsite_conferencing" },
{ access_token: responseBody.access_token },
req
);
res.redirect(
getSafeRedirectUrl(state?.returnTo) ?? getInstalledAppPath({ variant: "conferencing", slug: "campsite" })
);
}
+2
View File
@@ -0,0 +1,2 @@
export { default as add } from "./add";
export { default as callback } from "./callback";
+23
View File
@@ -0,0 +1,23 @@
{
"/*": "Don't modify slug - If required, do it using cli edit command",
"name": "Campsite",
"slug": "campsite",
"type": "campsite_conferencing",
"logo": "icon.svg",
"url": "https://campsite.co",
"variant": "conferencing",
"categories": ["conferencing"],
"publisher": "Campsite",
"email": "support@campsite.co",
"appData": {
"location": {
"type": "integrations:{SLUG}_conferencing",
"label": "{TITLE}",
"linkType": "dynamic"
}
},
"description": "Campsite is an all-in-one communication app that helps async teams move faster. Posts, docs, calls, and chat in one place — finally. Everything is searchable and easy to find.",
"isTemplate": false,
"__createdUsingCli": true,
"__template": "event-type-location-video-static"
}
+2
View File
@@ -0,0 +1,2 @@
export * as api from "./api";
export * as lib from "./lib";
@@ -0,0 +1,47 @@
import type { CalendarEvent, EventBusyDate } from "@calcom/types/Calendar";
import type { CredentialPayload } from "@calcom/types/Credential";
import type { PartialReference } from "@calcom/types/EventManager";
import type { VideoApiAdapter, VideoCallData } from "@calcom/types/VideoApiAdapter";
type CampsiteToken = {
access_token: string;
};
const CampsiteVideoApiAdapter = (credential: CredentialPayload): VideoApiAdapter => {
return {
createMeeting: async (_event: CalendarEvent): Promise<VideoCallData> => {
const keys = credential.key as CampsiteToken;
const { access_token } = keys;
const response = await fetch("https://api.campsite.co/v1/integrations/cal_dot_com/call_rooms", {
method: "POST",
headers: {
Authorization: `Bearer ${access_token}`,
"Content-Type": "application/json",
},
});
const callRoom = await response.json();
return {
type: "campsite_conferencing",
id: callRoom.id,
password: "",
url: callRoom.url,
};
},
updateMeeting: async (bookingRef: PartialReference, _event: CalendarEvent): Promise<VideoCallData> => {
return {
type: "campsite_conferencing",
id: bookingRef.externalCalendarId ? bookingRef.externalCalendarId : "",
password: "",
url: bookingRef.meetingUrl ? bookingRef.meetingUrl : "",
};
},
deleteMeeting: async (_uid: string): Promise<unknown> => {
return {};
},
getAvailability: async (_dateFrom?: string, _dateTo?: string): Promise<EventBusyDate[]> => {
return [];
},
};
};
export default CampsiteVideoApiAdapter;
+1
View File
@@ -0,0 +1 @@
export { default as VideoApiAdapter } from "./VideoApiAdapter";
+14
View File
@@ -0,0 +1,14 @@
{
"$schema": "https://json.schemastore.org/package.json",
"private": true,
"name": "@calcom/campsite",
"version": "0.0.0",
"main": "./index.ts",
"dependencies": {
"@calcom/lib": "*"
},
"devDependencies": {
"@calcom/types": "*"
},
"description": "Campsite is an all-in-one communication app that helps async teams move faster. Posts, docs, calls, and chat in one place — finally. Everything is searchable and easy to find."
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

+157
View File
@@ -0,0 +1,157 @@
<svg width="64" height="64" viewBox="0 0 64 64" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_2575_4550)">
<g filter="url(#filter0_di_2575_4550)">
<path
d="M48 0H16C7.16344 0 0 7.16344 0 16V48C0 56.8366 7.16344 64 16 64H48C56.8366 64 64 56.8366 64 48V16C64 7.16344 56.8366 0 48 0Z"
fill="url(#paint0_radial_2575_4550)"
/>
</g>
<g filter="url(#filter1_dddii_2575_4550)">
<path
d="M9.52012 41.9532L22.0953 20.6545C22.3348 20.2489 22.7709 19.9999 23.2421 19.9999H43.7279C44.7587 19.9999 45.3987 21.1203 44.8747 22.0077L32.2996 43.3064C32.0601 43.7121 31.624 43.961 31.1528 43.961H10.667C9.63615 43.961 8.99619 42.8406 9.52012 41.9532Z"
fill="url(#paint1_linear_2575_4550)"
fill-opacity="0.96"
/>
<path
d="M38.9673 43.9609H53.2769C54.2811 43.9609 54.924 42.8921 54.4531 42.0055L47.6958 29.2815C47.2086 28.3639 45.9046 28.333 45.3743 29.2264L37.8221 41.9505C37.2954 42.8378 37.9352 43.9609 38.9673 43.9609Z"
fill="url(#paint2_linear_2575_4550)"
fill-opacity="0.96"
/>
</g>
</g>
<defs>
<filter
id="filter0_di_2575_4550"
x="-40"
y="0"
width="144"
height="144"
filterUnits="userSpaceOnUse"
color-interpolation-filters="sRGB"
>
<feFlood flood-opacity="0" result="BackgroundImageFix" />
<feColorMatrix
in="SourceAlpha"
type="matrix"
values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"
result="hardAlpha"
/>
<feOffset dy="40" />
<feGaussianBlur stdDeviation="20" />
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.25 0" />
<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_2575_4550" />
<feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow_2575_4550" result="shape" />
<feColorMatrix
in="SourceAlpha"
type="matrix"
values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"
result="hardAlpha"
/>
<feOffset dy="16" />
<feGaussianBlur stdDeviation="8" />
<feComposite in2="hardAlpha" operator="arithmetic" k2="-1" k3="1" />
<feColorMatrix type="matrix" values="0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 0.25 0" />
<feBlend mode="normal" in2="shape" result="effect2_innerShadow_2575_4550" />
</filter>
<filter
id="filter1_dddii_2575_4550"
x="1.33325"
y="15.9999"
width="61.2772"
height="39.9611"
filterUnits="userSpaceOnUse"
color-interpolation-filters="sRGB"
>
<feFlood flood-opacity="0" result="BackgroundImageFix" />
<feColorMatrix
in="SourceAlpha"
type="matrix"
values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"
result="hardAlpha"
/>
<feOffset dy="1" />
<feGaussianBlur stdDeviation="1" />
<feColorMatrix type="matrix" values="0 0 0 0 0.733333 0 0 0 0 0.0745098 0 0 0 0 0.0745098 0 0 0 0.316416 0" />
<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_2575_4550" />
<feColorMatrix
in="SourceAlpha"
type="matrix"
values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"
result="hardAlpha"
/>
<feOffset dy="2" />
<feGaussianBlur stdDeviation="2" />
<feColorMatrix type="matrix" values="0 0 0 0 0.733333 0 0 0 0 0.0745098 0 0 0 0 0.0745098 0 0 0 0.358691 0" />
<feBlend mode="normal" in2="effect1_dropShadow_2575_4550" result="effect2_dropShadow_2575_4550" />
<feColorMatrix
in="SourceAlpha"
type="matrix"
values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"
result="hardAlpha"
/>
<feOffset dy="4" />
<feGaussianBlur stdDeviation="4" />
<feColorMatrix type="matrix" values="0 0 0 0 0.733333 0 0 0 0 0.0745098 0 0 0 0 0.0745098 0 0 0 0.31 0" />
<feBlend mode="normal" in2="effect2_dropShadow_2575_4550" result="effect3_dropShadow_2575_4550" />
<feBlend mode="normal" in="SourceGraphic" in2="effect3_dropShadow_2575_4550" result="shape" />
<feColorMatrix
in="SourceAlpha"
type="matrix"
values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"
result="hardAlpha"
/>
<feOffset dy="-2" />
<feGaussianBlur stdDeviation="12" />
<feComposite in2="hardAlpha" operator="arithmetic" k2="-1" k3="1" />
<feColorMatrix type="matrix" values="0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 0.4 0" />
<feBlend mode="normal" in2="shape" result="effect4_innerShadow_2575_4550" />
<feColorMatrix
in="SourceAlpha"
type="matrix"
values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"
result="hardAlpha"
/>
<feOffset />
<feGaussianBlur stdDeviation="2" />
<feComposite in2="hardAlpha" operator="arithmetic" k2="-1" k3="1" />
<feColorMatrix type="matrix" values="0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 1 0" />
<feBlend mode="normal" in2="effect4_innerShadow_2575_4550" result="effect5_innerShadow_2575_4550" />
</filter>
<radialGradient
id="paint0_radial_2575_4550"
cx="0"
cy="0"
r="1"
gradientUnits="userSpaceOnUse"
gradientTransform="translate(13.5534 65.4757) rotate(-74.2658) scale(68.0246 308.191)"
>
<stop stop-color="#FA4438" />
<stop offset="1" stop-color="#FF6D23" />
</radialGradient>
<linearGradient
id="paint1_linear_2575_4550"
x1="46.122"
y1="28.5745"
x2="46.122"
y2="43.9609"
gradientUnits="userSpaceOnUse"
>
<stop stop-color="white" />
<stop offset="1" stop-color="white" stop-opacity="0.86" />
</linearGradient>
<linearGradient
id="paint2_linear_2575_4550"
x1="46.122"
y1="28.5745"
x2="46.122"
y2="43.9609"
gradientUnits="userSpaceOnUse"
>
<stop stop-color="white" />
<stop offset="1" stop-color="white" stop-opacity="0.86" />
</linearGradient>
<clipPath id="clip0_2575_4550">
<rect width="64" height="64" rx="16" fill="white" />
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 6.0 KiB

+8
View File
@@ -0,0 +1,8 @@
import { z } from "zod";
export const appDataSchema = z.object({});
export const appKeysSchema = z.object({
client_id: z.string().min(1),
client_secret: z.string().min(1),
});
+3 -1
View File
@@ -2,6 +2,7 @@ import Link from "next/link";
import { useRouter } from "next/navigation";
import { useEffect, useRef } from "react";
import type { UseAddAppMutationOptions } from "@calcom/app-store/_utils/useAddAppMutation";
import useAddAppMutation from "@calcom/app-store/_utils/useAddAppMutation";
import classNames from "@calcom/lib/classNames";
import { WEBAPP_URL } from "@calcom/lib/constants";
@@ -19,9 +20,10 @@ import type { InstallAppButtonProps } from "./types";
export const InstallAppButtonWithoutPlanCheck = (
props: {
type: App["type"];
options?: UseAddAppMutationOptions;
} & InstallAppButtonProps
) => {
const mutation = useAddAppMutation(null);
const mutation = useAddAppMutation(null, props.options);
const key = deriveAppDictKeyFromType(props.type, InstallAppButtonMap);
const InstallAppButtonComponent = InstallAppButtonMap[key as keyof typeof InstallAppButtonMap];
if (!InstallAppButtonComponent)
+1
View File
@@ -4,6 +4,7 @@ const appStore = {
applecalendar: () => import("./applecalendar"),
aroundvideo: () => import("./around"),
caldavcalendar: () => import("./caldavcalendar"),
campsiteconferencing: () => import("./campsite"),
closecom: () => import("./closecom"),
dailyvideo: () => import("./dailyvideo"),
googlecalendar: () => import("./googlecalendar"),
+19 -10
View File
@@ -40,6 +40,14 @@ export const isDedicatedIntegration = (location: string): boolean => {
return location !== MeetLocationType && location.includes("integrations:");
};
interface HasId {
id: number;
}
const latestCredentialFirst = <T extends HasId>(a: T, b: T) => {
return b.id - a.id;
};
export const getLocationRequestFromIntegration = (location: string) => {
const eventLocationType = getEventLocationTypeFromApp(location);
if (eventLocationType) {
@@ -98,18 +106,19 @@ export default class EventManager {
// This includes all calendar-related apps, traditional calendars such as Google Calendar
// (type google_calendar) and non-traditional calendars such as CRMs like Close.com
// (type closecom_other_calendar)
this.calendarCredentials = appCredentials.filter(
// Backwards compatibility until CRM manager is implemented
(cred) => cred.type.endsWith("_calendar") && !cred.type.includes("other_calendar")
);
this.calendarCredentials = appCredentials
.filter(
// Backwards compatibility until CRM manager is implemented
(cred) => cred.type.endsWith("_calendar") && !cred.type.includes("other_calendar")
)
//see https://github.com/calcom/cal.com/issues/11671#issue-1923600672
.sort(latestCredentialFirst);
this.videoCredentials = appCredentials
.filter((cred) => cred.type.endsWith("_video") || cred.type.endsWith("_conferencing"))
// Whenever a new video connection is added, latest credentials are added with the highest ID.
// Because you can't rely on having them in the highest first order here, ensure this by sorting in DESC order
// We also don't have updatedAt or createdAt dates on credentials so this is the best we can do
.sort((a, b) => {
return b.id - a.id;
});
.sort(latestCredentialFirst);
this.crmCredentials = appCredentials.filter(
(cred) => cred.type.endsWith("_crm") || cred.type.endsWith("_other_calendar")
);
@@ -592,7 +601,7 @@ export default class EventManager {
private async createAllCalendarEvents(event: CalendarEvent) {
let createdEvents: EventResult<NewCalendarEventType>[] = [];
const fallbackToFirstConnectedCalendar = async () => {
const fallbackToFirstCalendarInTheList = async () => {
/**
* Not ideal but, if we don't find a destination calendar,
* fallback to the first connected calendar - Shouldn't be a CRM calendar
@@ -673,7 +682,7 @@ export default class EventManager {
log.warn(
"No other credentials found of the same type as the destination calendar. Falling back to first connected calendar"
);
await fallbackToFirstConnectedCalendar();
await fallbackToFirstCalendarInTheList();
} else {
log.warn(
"No credentialId found for destination calendar, falling back to first found calendar of same type as destination calendar",
@@ -694,7 +703,7 @@ export default class EventManager {
calendarCredentials: this.calendarCredentials,
})
);
await fallbackToFirstConnectedCalendar();
await fallbackToFirstCalendarInTheList();
}
// Taking care of non-traditional calendar integrations
@@ -1,4 +1,4 @@
import { LRUCache } from "lru-cache";
import * as lruCache from "lru-cache";
import type { GetServerSidePropsContext, NextApiRequest, NextApiResponse } from "next";
import type { AuthOptions, Session } from "next-auth";
import { getToken } from "next-auth/jwt";
@@ -15,7 +15,7 @@ const log = logger.getSubLogger({ prefix: ["getServerSession"] });
* Stores the session in memory using the stringified token as the key.
*
*/
const CACHE = new LRUCache<string, Session>({ max: 1000 });
const CACHE = new lruCache.LRUCache<string, Session>({ max: 1000 });
/**
* This is a slimmed down version of the `getServerSession` function from
@@ -93,7 +93,7 @@ export const useBookings = ({ event, hashedLink, bookingForm, metadata, teamMemb
function refactorMeWithoutEffect() {
const data = _instantBooking.data;
if (!data) return;
if (!data || !data.booking) return;
try {
const locationVideoCallUrl: string | undefined = bookingMetadataSchema.parse(
data.booking?.metadata || {}
@@ -1268,8 +1268,7 @@ async function handler(
evt.appsStatus = handleAppsStatus(results, booking, reqAppsStatus);
// If there is an integration error, we don't send successful rescheduling email, instead broken integration email should be sent that are handled by either CalendarManager or videoClient
if (noEmail !== true && isConfirmedByDefault && !isThereAnIntegrationError) {
if (noEmail !== true && isConfirmedByDefault) {
const copyEvent = cloneDeep(evt);
const copyEventAdditionalInfo = {
...copyEvent,
@@ -660,8 +660,11 @@ describe("handleNewBooking", () => {
expectWorkflowToBeTriggered({ emailsToReceive: [organizer.email], emails });
// FIXME: We should send Broken Integration emails on calendar event updation failure
// expectBrokenIntegrationEmails({ booker, organizer, emails });
expectSuccessfulBookingRescheduledEmails({
booker,
organizer,
emails,
});
expectBookingRescheduledWebhookToHaveBeenFired({
booker,
+4
View File
@@ -85,3 +85,7 @@ export type BookingResponse = Awaited<
export type InstantBookingResponse = Awaited<
ReturnType<typeof import("@calcom/features/instant-meeting/handleInstantMeeting").default>
>;
export type MarkNoShowResponse = Awaited<
ReturnType<typeof import("@calcom/features/handleMarkNoShow").default>
>;
@@ -2,6 +2,7 @@ import { useState } from "react";
import type { UseFormReturn } from "react-hook-form";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { TimeUnit } from "@calcom/prisma/enums";
import {
Dropdown,
DropdownItem,
@@ -12,21 +13,50 @@ import {
TextField,
} from "@calcom/ui";
import { getWorkflowTimeUnitOptions } from "../lib/getOptions";
import type { FormValues } from "../pages/workflow";
const TIME_UNITS = [TimeUnit.DAY, TimeUnit.HOUR, TimeUnit.MINUTE] as const;
type Props = {
form: UseFormReturn<FormValues>;
disabled: boolean;
};
const TimeUnitAddonSuffix = ({
DropdownItems,
timeUnitOptions,
form,
}: {
form: UseFormReturn<FormValues>;
DropdownItems: JSX.Element;
timeUnitOptions: { [x: string]: string };
}) => {
// because isDropdownOpen already triggers a render cycle we can use getValues()
// instead of watch() function
const timeUnit = form.getValues("timeUnit");
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
return (
<Dropdown onOpenChange={setIsDropdownOpen}>
<DropdownMenuTrigger asChild>
<button className="flex items-center">
<div className="mr-1 w-3/5">{timeUnit ? timeUnitOptions[timeUnit] : "undefined"}</div>
<div className="w-1/4 pt-1">
{isDropdownOpen ? <Icon name="chevron-up" /> : <Icon name="chevron-down" />}
</div>
</button>
</DropdownMenuTrigger>
<DropdownMenuContent>{DropdownItems}</DropdownMenuContent>
</Dropdown>
);
};
export const TimeTimeUnitInput = (props: Props) => {
const { form } = props;
const { t } = useLocale();
const timeUnitOptions = getWorkflowTimeUnitOptions(t);
const [timeUnit, setTimeUnit] = useState(form.getValues("timeUnit"));
const timeUnitOptions = TIME_UNITS.reduce((acc, option) => {
acc[option] = t(`${option.toLowerCase()}_timeUnit`);
return acc;
}, {} as { [x: string]: string });
return (
<div className="flex">
<div className="grow">
@@ -39,33 +69,26 @@ export const TimeTimeUnitInput = (props: Props) => {
className="-mt-2 rounded-r-none text-sm focus:ring-0"
{...form.register("time", { valueAsNumber: true })}
addOnSuffix={
<Dropdown>
<DropdownMenuTrigger asChild>
<button className="flex items-center">
<div className="mr-1 w-3/4">
{timeUnit ? t(`${timeUnit.toLowerCase()}_timeUnit`) : "undefined"}{" "}
</div>
<div className="w-1/4 pt-1">
<Icon name="chevron-down" />
</div>
</button>
</DropdownMenuTrigger>
<DropdownMenuContent>
{timeUnitOptions.map((option, index) => (
<DropdownMenuItem key={index} className="outline-none">
<DropdownItem
key={index}
type="button"
onClick={() => {
setTimeUnit(option.value);
form.setValue("timeUnit", option.value);
}}>
{option.label}
</DropdownItem>
</DropdownMenuItem>
))}
</DropdownMenuContent>
</Dropdown>
<TimeUnitAddonSuffix
form={form}
timeUnitOptions={timeUnitOptions}
DropdownItems={
<>
{TIME_UNITS.map((timeUnit, index) => (
<DropdownMenuItem key={index} className="outline-none">
<DropdownItem
key={index}
type="button"
onClick={() => {
form.setValue("timeUnit", timeUnit);
}}>
{timeUnitOptions[timeUnit]}
</DropdownItem>
</DropdownMenuItem>
))}
</>
}
/>
}
/>
</div>
@@ -4,7 +4,6 @@ import type { WorkflowActions } from "@calcom/prisma/enums";
import { isSMSOrWhatsappAction, isWhatsappAction, isEmailToAttendeeAction } from "./actionHelperFunctions";
import {
TIME_UNIT,
WHATSAPP_WORKFLOW_TEMPLATES,
WORKFLOW_ACTIONS,
BASIC_WORKFLOW_TEMPLATES,
@@ -32,12 +31,6 @@ export function getWorkflowTriggerOptions(t: TFunction) {
});
}
export function getWorkflowTimeUnitOptions(t: TFunction) {
return TIME_UNIT.map((timeUnit) => {
return { label: t(`${timeUnit.toLowerCase()}_timeUnit`), value: timeUnit };
});
}
export function getWorkflowTemplateOptions(t: TFunction, action: WorkflowActions | undefined) {
const TEMPLATES =
action && isWhatsappAction(action)
+1 -1
View File
@@ -44,7 +44,7 @@ export const tabs = [
data-testid="embed-code"
ref={ref as typeof ref & MutableRefObject<HTMLTextAreaElement>}
name="embed-code"
className="text-default bg-default selection:bg-subtle h-[calc(100%-50px)] font-mono"
className="text-default bg-default h-[calc(100%-50px)] font-mono"
style={{ resize: "none", overflow: "auto" }}
readOnly
value={`<!-- Cal ${embedType} embed code begins -->\n${
@@ -0,0 +1,90 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { useForm } from "react-hook-form";
import { z } from "zod";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { trpc } from "@calcom/trpc/react";
import { Dialog, DialogContent, Form, DialogFooter, DialogClose, Button, CheckboxField } from "@calcom/ui";
export const BulkUpdateEventSchema = z.object({
eventTypeIds: z.array(z.number()),
});
export function BulkEditDefaultForEventsModal(props: {
open: boolean;
setOpen: (open: boolean) => void;
bulkUpdateFunction: ({ eventTypeIds }: { eventTypeIds: number[] }) => void;
isPending: boolean;
}) {
const { t } = useLocale();
const utils = trpc.useUtils();
const { data, isFetching } = trpc.viewer.eventTypes.bulkEventFetch.useQuery();
const form = useForm({
resolver: zodResolver(BulkUpdateEventSchema),
defaultValues: {
eventTypeIds: data?.eventTypes.map((e) => e.id) ?? [],
},
});
const eventTypesSelected = form.watch("eventTypeIds");
if (isFetching || !open || !data?.eventTypes) return null;
return (
<Dialog name="Bulk Default Location Update" open={props.open} onOpenChange={props.setOpen}>
<DialogContent
type="creation"
title={t("default_conferencing_bulk_title")}
description={t("default_conferencing_bulk_description")}
enableOverflow>
<Form
form={form}
handleSubmit={(values) => {
props.bulkUpdateFunction(values);
}}>
<div className="flex flex-col space-y-2">
{data.eventTypes.length > 0 && (
<div className="flex items-center space-x-2 rounded-md px-3 pb-2.5 pt-1">
<CheckboxField
description={t("select_all")}
descriptionAsLabel
onChange={(e) => {
form.setValue("eventTypeIds", e.target.checked ? data.eventTypes.map((e) => e.id) : []);
}}
checked={eventTypesSelected.length === data.eventTypes.length}
/>
</div>
)}
{data.eventTypes.map((eventType) => (
<div key={eventType.id} className="bg-muted flex items-center space-x-2 rounded-md px-3 py-2.5">
<CheckboxField
description={eventType.title}
descriptionAsLabel
checked={eventTypesSelected.includes(eventType.id)}
onChange={(e) => {
form.setValue(
"eventTypeIds",
e.target.checked
? [...eventTypesSelected, eventType.id]
: eventTypesSelected.filter((id) => id !== eventType.id)
);
}}
/>
</div>
))}
</div>
<DialogFooter showDivider className="mt-10">
<DialogClose
onClick={() => {
utils.viewer.getUsersDefaultConferencingApp.invalidate();
}}
/>
<Button type="submit" color="primary" loading={props.isPending}>
{t("update")}
</Button>
</DialogFooter>
</Form>
</DialogContent>
</Dialog>
);
}
@@ -1,63 +0,0 @@
import type { Dispatch, SetStateAction } from "react";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { Dialog, DialogContent, DialogFooter, DialogClose, Button, CheckboxField } from "@calcom/ui";
export function BulkEditDefaultModal(props: {
open: boolean;
setOpen: (open: boolean) => void;
ids: number[];
setIds: Dispatch<SetStateAction<number[]>>;
isPending: boolean;
data?: {
id: number;
title: string;
default?: boolean;
}[];
title: string;
description: string;
handleSubmit: () => void;
}) {
const { title, description, handleSubmit, data, ids, setIds, open } = props;
const { t } = useLocale();
if (!open || !data || !ids) return null;
return (
<Dialog name="Bulk Default Update" open={props.open} onOpenChange={props.setOpen}>
<DialogContent type="creation" title={title} description={description} enableOverflow>
<div className="flex flex-col space-y-2">
{data.length > 0 && (
<div className="flex items-center space-x-2 rounded-md px-3 pb-2.5 pt-1">
<CheckboxField
description={t("select_all")}
descriptionAsLabel
onChange={(e) => {
setIds(e.target.checked ? data.map((e) => e.id) : []);
}}
checked={ids.length === data.length}
/>
</div>
)}
{data.map((item) => (
<div key={item.id} className="bg-muted flex items-center space-x-2 rounded-md px-3 py-2.5">
<CheckboxField
description={item.title}
descriptionAsLabel
checked={ids.includes(item.id)}
onChange={(e) => {
setIds(e.target.checked ? [...ids, item.id] : ids.filter((id) => id !== item.id));
}}
/>
</div>
))}
</div>
<DialogFooter showDivider className="mt-10">
<DialogClose />
<Button type="submit" color="primary" loading={props.isPending} onClick={handleSubmit}>
{t("update")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
+155
View File
@@ -0,0 +1,155 @@
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 "@calcom/trpc/server/routers/publicViewer/noShow.schema";
const getResultPayload = async (attendees: { email: string; noShow: boolean }[]) => {
if (attendees.length === 1) {
const [attendee] = attendees;
return {
messageKey: attendee.noShow ? "x_marked_as_no_show" : "x_unmarked_as_no_show",
attendees: [attendee],
};
}
return {
messageKey: "no_show_updated",
attendees: attendees,
};
};
type ResponsePayload = {
attendees: { email: string; noShow: boolean }[];
noShowHost: boolean;
message: string;
messageKey: string | undefined;
};
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(","));
};
const handleMarkNoShow = async ({ bookingUid, attendees, noShowHost }: TNoShowInputSchema) => {
const responsePayload: ResponsePayload = {
attendees: [],
noShowHost: false,
message: "Failed to update no-show status",
messageKey: undefined,
};
try {
const attendeeEmails = attendees?.map((attendee) => attendee.email) || [];
if (attendees && attendeeEmails.length > 0) {
const allAttendees = await prisma.attendee.findMany({
where: {
AND: [
{
booking: {
uid: bookingUid,
},
email: {
in: attendeeEmails,
},
},
],
},
select: {
id: true,
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 = allAttendeesMap[attendee.email];
if (!attendeeToUpdate) return;
return prisma.attendee.update({
where: { id: attendeeToUpdate.id },
data: { noShow: attendee.noShow },
});
});
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: {
id: true,
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");
const message = t(payload.messageKey, { x: payload.attendees[0]?.email || "User" });
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,
bookingUid,
bookingId: booking?.id,
});
responsePayload["attendees"] = payload.attendees;
responsePayload["message"] = message;
responsePayload["messageKey"] = payload.messageKey;
}
if (noShowHost) {
await prisma.booking.update({
where: {
uid: bookingUid,
},
data: {
noShowHost: true,
},
});
responsePayload["noShowHost"] = true;
responsePayload["message"] = "No-show status updated";
}
return responsePayload;
} catch (error) {
if (error instanceof Error) {
logger.error(error.message);
}
return { message: "Failed to update no-show status" };
}
};
export default handleMarkNoShow;
@@ -4,7 +4,6 @@ import { useSession } from "next-auth/react";
import { useEffect, useState } from "react";
import dayjs from "@calcom/dayjs";
import { BulkEditDefaultModal } from "@calcom/features/eventtypes/components/BulkEditDefaultModal";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { trpc } from "@calcom/trpc/react";
import { Dialog, DialogClose, DialogContent, DialogFooter, showToast } from "@calcom/ui";
@@ -17,6 +16,7 @@ const TimezoneChangeDialogContent = ({
onAction: (action?: "update" | "cancel") => void;
}) => {
const { t } = useLocale();
const utils = trpc.useUtils();
const formattedCurrentTz = browserTimezone.replace("_", " ");
// save cookie to not show again
@@ -28,6 +28,28 @@ const TimezoneChangeDialogContent = ({
toast && showToast(t("we_wont_show_again"), "success");
}
const onSuccessMutation = async () => {
showToast(t("updated_timezone_to", { formattedCurrentTz }), "success");
await utils.viewer.me.invalidate();
};
const onErrorMutation = () => {
showToast(t("couldnt_update_timezone"), "error");
};
// update timezone in db
const mutation = trpc.viewer.updateProfile.useMutation({
onSuccess: onSuccessMutation,
onError: onErrorMutation,
});
function updateTimezone() {
onAction("update");
mutation.mutate({
timeZone: browserTimezone,
});
}
return (
<DialogContent
title={t("update_timezone_question")}
@@ -42,7 +64,7 @@ const TimezoneChangeDialogContent = ({
<DialogClose onClick={() => onCancel([3, "months"], true)} color="secondary">
{t("dont_update")}
</DialogClose>
<DialogClose onClick={() => onAction("update")} color="primary">
<DialogClose onClick={() => updateTimezone()} color="primary">
{t("update_timezone")}
</DialogClose>
</DialogFooter>
@@ -82,56 +104,9 @@ export function useOpenTimezoneDialog() {
export default function TimezoneChangeDialog() {
const { open, setOpen, browserTimezone } = useOpenTimezoneDialog();
const { t } = useLocale();
const utils = trpc.useUtils();
const [selectAvailability, setSelectAvailability] = useState(false);
const [ids, setIds] = useState<number[]>([]);
const { data: availability, isPending } = trpc.viewer.availability.list.useQuery();
const formattedCurrentTz = browserTimezone.replace("_", " ");
const data = availability?.schedules?.map((a) => ({
id: a.id,
title: a.name,
default: a.isDefault,
}));
const mutation = trpc.viewer.updateProfile.useMutation({
onSuccess: async () => {
showToast(t("updated_timezone_to", { formattedCurrentTz }), "success");
await utils.viewer.me.invalidate();
setSelectAvailability(false);
},
onError: () => {
showToast(t("couldnt_update_timezone"), "error");
},
});
return (
<Dialog open={open} onOpenChange={setOpen}>
<TimezoneChangeDialogContent
browserTimezone={browserTimezone}
onAction={() => {
setOpen(false);
setSelectAvailability(true);
}}
/>
<BulkEditDefaultModal
title={t("default_timezone_bulk_title")}
description={t("default_timezone_bulk_description")}
ids={ids}
isPending={isPending}
open={selectAvailability}
setOpen={setSelectAvailability}
setIds={setIds}
data={data}
handleSubmit={() => {
mutation.mutate({
timeZone: browserTimezone,
availabilityIds: ids,
});
}}
/>
<TimezoneChangeDialogContent browserTimezone={browserTimezone} onAction={() => setOpen(false)} />
</Dialog>
);
}
+3
View File
@@ -0,0 +1,3 @@
VITE_BOOKER_EMBED_OAUTH_CLIENT_ID="clye3x2f6000133strp0tf85t"
VITE_BOOKER_EMBED_API_URL="http://localhost:5555/api/v2"
@@ -0,0 +1,15 @@
import type { BookerPlatformWrapperAtomProps } from "../booker/BookerPlatformWrapper";
import { BookerPlatformWrapper } from "../booker/BookerPlatformWrapper";
import { CalProvider } from "../cal-provider/CalProvider";
export const BookerEmbed = (props: BookerPlatformWrapperAtomProps) => {
return (
<CalProvider
clientId={import.meta.env.VITE_BOOKER_EMBED_OAUTH_CLIENT_ID}
options={{
apiUrl: import.meta.env.VITE_BOOKER_EMBED_API_URL,
}}>
<BookerPlatformWrapper {...props} />
</CalProvider>
);
};
@@ -0,0 +1 @@
export { BookerEmbed } from "./BookerEmbed";
@@ -41,7 +41,7 @@ import { useMe } from "../hooks/useMe";
import { useSlots } from "../hooks/useSlots";
import { AtomsWrapper } from "../src/components/atoms-wrapper";
type BookerPlatformWrapperAtomProps = Omit<BookerProps, "username" | "entity"> & {
export type BookerPlatformWrapperAtomProps = Omit<BookerProps, "username" | "entity"> & {
rescheduleUid?: string;
bookingUid?: string;
username: string | string[];
@@ -2,7 +2,11 @@ import { TooltipProvider } from "@radix-ui/react-tooltip";
import { useState } from "react";
import { useCallback } from "react";
import deTranslations from "@calcom/web/public/static/locales/de/common.json";
import enTranslations from "@calcom/web/public/static/locales/en/common.json";
import esTranslations from "@calcom/web/public/static/locales/es/common.json";
import frTranslations from "@calcom/web/public/static/locales/fr/common.json";
import ptBrTranslations from "@calcom/web/public/static/locales/pt-BR/common.json";
import { AtomsContext } from "../hooks/useAtomsContext";
import { useOAuthClient } from "../hooks/useOAuthClient";
@@ -11,16 +15,26 @@ import { useTimezone } from "../hooks/useTimezone";
import { useUpdateUserTimezone } from "../hooks/useUpdateUserTimezone";
import http from "../lib/http";
import { Toaster } from "../src/components/ui/toaster";
import type { CalProviderProps } from "./CalProvider";
type translationKeys = keyof typeof enTranslations;
import { EN } from "./CalProvider";
import type {
CalProviderProps,
CalProviderLanguagesType,
translationKeys,
enTranslationKeys,
frTranslationKeys,
ptBrTranslationKeys,
deTranslationKeys,
esTranslationKeys,
} from "./CalProvider";
export function BaseCalProvider({
clientId,
accessToken,
options,
children,
labels,
autoUpdateTimezone,
language = EN,
onTimezoneChange,
}: CalProviderProps) {
const [error, setError] = useState<string>("");
@@ -64,7 +78,7 @@ export function BaseCalProvider({
const translations = {
t: (key: string, values: Record<string, string | number | null | undefined>) => {
let translation = String(enTranslations[key as translationKeys] ?? "");
let translation = labels?.[key as keyof typeof labels] ?? String(getTranslation(key, language) ?? "");
if (!translation) {
return "";
}
@@ -84,9 +98,9 @@ export function BaseCalProvider({
return replaceOccurrences(translation, enTranslations) ?? "";
},
i18n: {
language: "en",
defaultLocale: "en",
locales: ["en"],
language: language,
defaultLocale: language,
locales: [language],
exists: (key: translationKeys | string) => Boolean(enTranslations[key as translationKeys]),
},
};
@@ -139,3 +153,20 @@ function replaceOccurrences(input: string, replacementMap: { [key: string]: stri
return match;
});
}
function getTranslation(key: string, language: CalProviderLanguagesType) {
switch (language) {
case "en":
return enTranslations[key as enTranslationKeys];
case "fr":
return frTranslations[key as frTranslationKeys];
case "pt-BR":
return ptBrTranslations[key as ptBrTranslationKeys];
case "de":
return deTranslations[key as deTranslationKeys];
case "es":
return esTranslations[key as esTranslationKeys];
default:
return enTranslations[key as enTranslationKeys];
}
}
@@ -3,12 +3,64 @@ import { useEffect, type ReactNode } from "react";
import type { API_VERSIONS_ENUM } from "@calcom/platform-constants";
import { VERSION_2024_06_14 } from "@calcom/platform-constants";
import type deTranslations from "@calcom/web/public/static/locales/de/common.json";
import type enTranslations from "@calcom/web/public/static/locales/en/common.json";
import type esTranslations from "@calcom/web/public/static/locales/es/common.json";
import type frTranslations from "@calcom/web/public/static/locales/fr/common.json";
import type ptBrTranslations from "@calcom/web/public/static/locales/pt-BR/common.json";
import http from "../lib/http";
import { BaseCalProvider } from "./BaseCalProvider";
export type enTranslationKeys = keyof typeof enTranslations;
export type frTranslationKeys = keyof typeof frTranslations;
export type deTranslationKeys = keyof typeof deTranslations;
export type esTranslationKeys = keyof typeof esTranslations;
export type ptBrTranslationKeys = keyof typeof ptBrTranslations;
export type translationKeys =
| enTranslationKeys
| frTranslationKeys
| deTranslationKeys
| esTranslationKeys
| ptBrTranslationKeys;
const FR = "fr";
export const EN = "en";
const PT_BR = "pt-BR";
const DE = "de";
const ES = "es";
export const CAL_PROVIDER_LANGUAUES = [FR, EN, PT_BR, DE, ES] as const;
export type CalProviderLanguagesType = (typeof CAL_PROVIDER_LANGUAUES)[number];
const queryClient = new QueryClient();
type i18nFrProps = {
labels?: Partial<Record<frTranslationKeys, string>>;
language?: "fr";
};
type i18nEnProps = {
labels?: Partial<Record<enTranslationKeys, string>>;
language?: "en";
};
type i18nPtBrProps = {
labels?: Partial<Record<ptBrTranslationKeys, string>>;
language?: "pt-BR";
};
type i18nDeProps = {
labels?: Partial<Record<deTranslationKeys, string>>;
language?: "de";
};
type i18nEsProps = {
labels?: Partial<Record<esTranslationKeys, string>>;
language?: "es";
};
export type i18nProps = i18nFrProps | i18nEnProps | i18nPtBrProps | i18nDeProps | i18nEsProps;
export type CalProviderProps = {
children?: ReactNode;
clientId: string;
@@ -17,7 +69,7 @@ export type CalProviderProps = {
autoUpdateTimezone?: boolean;
onTimezoneChange?: () => void;
version?: API_VERSIONS_ENUM;
};
} & i18nProps;
/**
* Renders a CalProvider component.
@@ -39,6 +91,8 @@ export function CalProvider({
options,
children,
autoUpdateTimezone = true,
labels,
language = "en",
onTimezoneChange,
version = VERSION_2024_06_14,
}: CalProviderProps) {
@@ -60,7 +114,9 @@ export function CalProvider({
clientId={clientId}
accessToken={accessToken}
options={options}
version={version}>
version={version}
labels={labels as Record<translationKeys, string>}
language={language}>
{children}
</BaseCalProvider>
</QueryClientProvider>
@@ -1,10 +1,8 @@
import { createContext, useContext } from "react";
import type enTranslations from "@calcom/web/public/static/locales/en/common.json";
import type { translationKeys, CalProviderLanguagesType } from "../cal-provider/CalProvider";
import type http from "../lib/http";
type translationKeys = keyof typeof enTranslations;
export interface IAtomsContextOptions {
refreshUrl?: string;
apiUrl: string;
@@ -23,9 +21,9 @@ export interface IAtomsContext {
isInit: boolean;
t: (key: string, values: Record<string, string | number | undefined | null>) => string;
i18n: {
language: string;
defaultLocale: string;
locales: string[];
language: CalProviderLanguagesType;
defaultLocale: CalProviderLanguagesType;
locales: CalProviderLanguagesType[];
exists: (key: translationKeys | string) => boolean;
};
}
+1
View File
@@ -14,3 +14,4 @@ export { useGetBookings } from "./hooks/useGetBookings";
export { useMe } from "./hooks/useMe";
export { OutlookConnect } from "./connect/outlook/OutlookConnect";
export * as Connect from "./connect";
export { BookerEmbed } from "./booker-embed";
@@ -28,6 +28,9 @@ export function Navbar({ username }: { username?: string }) {
<li>
<Link href="/bookings">My Bookings</Link>
</li>
<li>
<Link href="/embed">Embed</Link>
</li>
</ul>
</div>
</nav>
@@ -1,9 +1,11 @@
import "@/styles/globals.css";
import type { AppProps } from "next/app";
import { Poppins } from "next/font/google";
import { usePathname } from "next/navigation";
import { useRouter } from "next/navigation";
import { useEffect, useState } from "react";
import { CalProvider } from "@calcom/atoms";
import { CalProvider, BookerEmbed } from "@calcom/atoms";
import "@calcom/atoms/globals.min.css";
const poppins = Poppins({ subsets: ["latin"], weight: ["400", "800"] });
@@ -25,6 +27,8 @@ export default function App({ Component, pageProps }: AppProps) {
const [accessToken, setAccessToken] = useState("");
const [email, setUserEmail] = useState("");
const [username, setUsername] = useState("");
const router = useRouter();
const pathname = usePathname();
useEffect(() => {
const randomEmail = generateRandomEmail();
@@ -59,6 +63,30 @@ export default function App({ Component, pageProps }: AppProps) {
</>
)}
</CalProvider>{" "}
{pathname === "/embed" && (
<div>
<BookerEmbed
customClassNames={{
bookerContainer: "!bg-[#F5F2FE] [&_button:!rounded-full] border-subtle border",
datePickerCustomClassNames: {
datePickerDatesActive: "!bg-[#D7CEF5]",
},
eventMetaCustomClassNames: {
eventMetaTitle: "text-[#7151DC]",
},
availableTimeSlotsCustomClassNames: {
availableTimeSlotsHeaderContainer: "!bg-[#F5F2FE]",
availableTimes: "!bg-[#D7CEF5]",
},
}}
username={username}
eventSlug="thirty-minutes"
onCreateBookingSuccess={(data) => {
router.push(`/${data.data.uid}`);
}}
/>
</div>
)}
</div>
);
}
@@ -0,0 +1,15 @@
import { Navbar } from "@/components/Navbar";
import { Inter } from "next/font/google";
const inter = Inter({ subsets: ["latin"] });
export default function Embed(props: { calUsername: string; calEmail: string }) {
return (
<main className={`flex ${inter.className} text-default flex flex-col`}>
<Navbar username={props.calUsername} />
<div>
<h1 className="mx-8 my-4 text-2xl font-bold">This is the booker embed</h1>
</div>
</main>
);
}
+42
View File
@@ -0,0 +1,42 @@
# How to work with platform libraries in Dev
whenever you want to change anything in platform libraries, or if you modify the code of one of the functions imported in libraries you will need to build the code again.
first thing to know is that we version this package using NPM
https://www.npmjs.com/package/@calcom/platform-libraries?activeTab=code
In order to work using the locally built platform libraries you need to:
- in packages/platform/libraries/package.json set the version to for example 1.2.3
- in apps/api/v2/package.json add to dependencies:
"@calcom/platform-libraries-1.2.3": "npm:@calcom/platform-libraries@1.2.3"
- in api v2 code simply import using the new alias:
import {
getAllUserBookings as getAllUserBookings1.2.3,
} from "@calcom/platform-libraries-1.2.3";
since the versions are matching in both package.json yarn will try to use the locally built code
now go to packages/platform/libraries and do
- yarn build
then go back to /apps/api/v2 and run
- yarn
- yarn dev
# Before Merging to main
- Publish Your Version of Libraries on NPM:
- To publish, ensure you are a contributor to the platform libraries' NPM package.
- Authenticate yourself via the CLI using npm auth.
- Increment the version number accordingly.
- Run yarn publish to publish your version.
- Once it's published, change back the version in packages/platform/libraries/package.json back to 0.0.0
- Run yarn
- You should now be using the npm package instead of the locally built version
+3
View File
@@ -4,6 +4,7 @@ import getBookingInfo from "@calcom/features/bookings/lib/getBookingInfo";
import handleCancelBooking from "@calcom/features/bookings/lib/handleCancelBooking";
import * as newBookingMethods from "@calcom/features/bookings/lib/handleNewBooking";
import { getPublicEvent } from "@calcom/features/eventtypes/lib/getPublicEvent";
import handleMarkNoShow from "@calcom/features/handleMarkNoShow";
import * as instantMeetingMethods from "@calcom/features/instant-meeting/handleInstantMeeting";
import getAllUserBookings from "@calcom/lib/bookings/getAllUserBookings";
import { symmetricEncrypt } from "@calcom/lib/crypto";
@@ -41,6 +42,8 @@ export { handleNewBooking };
const handleInstantMeeting = instantMeetingMethods.default;
export { handleInstantMeeting };
export { handleMarkNoShow };
export { getAvailableSlots };
export type AvailableSlotsType = Awaited<ReturnType<typeof getAvailableSlots>>;
export { handleNewRecurringBooking } from "@calcom/features/bookings/lib/handleNewRecurringBooking";
+5
View File
@@ -1,3 +1,4 @@
import { ApiProperty } from "@nestjs/swagger";
import { Transform } from "class-transformer";
import { IsNumber, Min, Max, IsOptional } from "class-validator";
import type { Response as BaseResponse } from "express";
@@ -41,6 +42,7 @@ export type ApiResponse<T = undefined> = T extends undefined
export type ApiResponseMaybeRedirect<T = undefined> = ApiResponse<T> | ApiRedirectResponseType;
export class Pagination {
@ApiProperty({ required: false, description: "The number of items to return", example: 10 })
@Transform(({ value }: { value: string }) => value && parseInt(value))
@IsNumber()
@Min(1)
@@ -49,6 +51,7 @@ export class Pagination {
limit?: number;
@Transform(({ value }: { value: string }) => value && parseInt(value))
@ApiProperty({ required: false, description: "The number of items to skip", example: 0 })
@IsNumber()
@Min(0)
@Max(100)
@@ -57,6 +60,7 @@ export class Pagination {
}
export class SkipTakePagination {
@ApiProperty({ required: false, description: "The number of items to return", example: 10 })
@Transform(({ value }: { value: string }) => value && parseInt(value))
@IsNumber()
@Min(1)
@@ -64,6 +68,7 @@ export class SkipTakePagination {
@IsOptional()
take?: number;
@ApiProperty({ required: false, description: "The number of items to skip", example: 0 })
@Transform(({ value }: { value: string }) => value && parseInt(value))
@IsNumber()
@Min(0)
@@ -0,0 +1,8 @@
/*
Warnings:
- A unique constraint covering the columns `[username,organizationId]` on the table `Profile` will be added. If there are existing duplicate values, this will fail.
*/
-- CreateIndex
CREATE UNIQUE INDEX "Profile_username_organizationId_key" ON "Profile"("username", "organizationId");
+4
View File
@@ -349,7 +349,11 @@ model Profile {
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
// A user can have multiple profiles in different organizations
@@unique([userId, organizationId])
// Allow username reuse only across different organizations
@@unique([username, organizationId])
@@index([uid])
@@index([userId])
@@index([organizationId])
@@ -1,7 +1,9 @@
import type { Prisma } from "@prisma/client";
import appStore from "@calcom/app-store";
import type { TDependencyData } from "@calcom/app-store/_appRegistry";
import type { CredentialOwner } from "@calcom/app-store/types";
import { getAppFromSlug } from "@calcom/app-store/utils";
import getEnabledAppsFromCredentials from "@calcom/lib/apps/getEnabledAppsFromCredentials";
import getInstallCountPerApp from "@calcom/lib/apps/getInstallCountPerApp";
import { getUsersCredentials } from "@calcom/lib/server/getUsersCredentials";
@@ -179,6 +181,18 @@ export const integrationsHandler = async ({ ctx, input }: IntegrationsOptions) =
}
}
let dependencyData: TDependencyData = [];
if (app.dependencies?.length) {
dependencyData = app.dependencies.map((dependency) => {
const dependencyInstalled = enabledApps.some(
(dbAppIterator) => dbAppIterator.credentials.length && dbAppIterator.slug === dependency
);
// If the app marked as dependency is simply deleted from the codebase, we can have the situation where App is marked installed in DB but we couldn't get the app.
const dependencyName = getAppFromSlug(dependency)?.name;
return { name: dependencyName, installed: dependencyInstalled };
});
}
return {
...app,
...(teams.length && {
@@ -189,6 +203,7 @@ export const integrationsHandler = async ({ ctx, input }: IntegrationsOptions) =
teams,
isInstalled: !!userCredentialIds.length || !!teams.length || app.isGlobal,
isSetupAlready,
...(app.dependencies && { dependencyData }),
};
})
);
@@ -24,6 +24,7 @@ import type { TrpcSessionUser } from "@calcom/trpc/server/trpc";
import { TRPCError } from "@trpc/server";
import { getDefaultScheduleId } from "../viewer/availability/util";
import { updateUserMetadataAllowedKeys, type TUpdateProfileInputSchema } from "./updateProfile.schema";
const log = logger.getSubLogger({ prefix: ["updateProfile"] });
@@ -41,7 +42,7 @@ export const updateProfileHandler = async ({ ctx, input }: UpdateProfileOptions)
const locale = input.locale || user.locale;
const emailVerification = await getFeatureFlag(prisma, "email-verification");
const { travelSchedules, availabilityIds, ...rest } = input;
const { travelSchedules, ...rest } = input;
const secondaryEmails = input?.secondaryEmails || [];
delete input.secondaryEmails;
@@ -256,11 +257,24 @@ export const updateProfileHandler = async ({ ctx, input }: UpdateProfileOptions)
}
if (user.timeZone !== data.timeZone && updatedUser.schedules.length > 0) {
// on timezone change update timezone of default schedule
const defaultScheduleId = await getDefaultScheduleId(user.id, prisma);
if (!user.defaultScheduleId) {
// set default schedule if not already set
await prisma.user.update({
where: {
id: user.id,
},
data: {
defaultScheduleId,
},
});
}
await prisma.schedule.updateMany({
where: {
id: {
in: input.availabilityIds,
},
id: defaultScheduleId,
},
data: {
timeZone: data.timeZone,
@@ -15,7 +15,6 @@ export const ZUpdateProfileInputSchema = z.object({
bio: z.string().optional(),
avatarUrl: z.string().nullable().optional(),
timeZone: z.string().optional(),
availabilityIds: z.array(z.number()).optional(),
weekStart: z.string().optional(),
hideBranding: z.boolean().optional(),
allowDynamicBooking: z.boolean().optional(),
@@ -1,9 +1,4 @@
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 handleMarkNoShow from "@calcom/features/handleMarkNoShow";
import type { TNoShowInputSchema } from "./noShow.schema";
@@ -11,118 +6,10 @@ 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;
const { bookingUid, attendees, noShowHost } = input;
try {
const attendeeEmails = attendees?.map((attendee) => attendee.email) || [];
if (attendees && attendeeEmails.length > 0) {
const allAttendees = await prisma.attendee.findMany({
where: {
AND: [
{
booking: {
uid: bookingUid,
},
email: {
in: attendeeEmails,
},
},
],
},
select: {
id: true,
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 = allAttendeesMap[attendee.email];
if (!attendeeToUpdate) return;
return prisma.attendee.update({
where: { id: attendeeToUpdate.id },
data: { noShow: attendee.noShow },
});
});
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: {
id: true,
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,
bookingId: booking?.id,
});
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" };
}
return handleMarkNoShow({ bookingUid, attendees, noShowHost });
};
export default noShowHandler;
@@ -1,15 +1,26 @@
import { z } from "zod";
export const ZNoShowInputSchema = z.object({
bookingUid: z.string(),
attendees: z
.array(
z.object({
email: z.string(),
noShow: z.boolean(),
})
)
.optional(),
});
export const ZNoShowInputSchema = z
.object({
bookingUid: z.string(),
attendees: z
.array(
z.object({
email: z.string(),
noShow: z.boolean(),
})
)
.optional(),
noShowHost: z.boolean().optional(),
})
.refine(
(data) => {
return (data.attendees && data.attendees.length > 0) || data.noShowHost !== undefined;
},
{
message: "At least one of 'attendees' or 'noShowHost' must be provided",
path: ["attendees", "noShowHost"],
}
);
export type TNoShowInputSchema = z.infer<typeof ZNoShowInputSchema>;
@@ -4,6 +4,7 @@ import { checkGlobalKeysSchema } from "./checkGlobalKeys.schema";
import { ZListLocalInputSchema } from "./listLocal.schema";
import { ZQueryForDependenciesInputSchema } from "./queryForDependencies.schema";
import { ZSaveKeysInputSchema } from "./saveKeys.schema";
import { ZSetDefaultConferencingAppSchema } from "./setDefaultConferencingApp.schema";
import { ZToggleInputSchema } from "./toggle.schema";
import { ZUpdateAppCredentialsInputSchema } from "./updateAppCredentials.schema";
@@ -15,6 +16,7 @@ type AppsRouterHandlerCache = {
updateAppCredentials?: typeof import("./updateAppCredentials.handler").updateAppCredentialsHandler;
queryForDependencies?: typeof import("./queryForDependencies.handler").queryForDependenciesHandler;
checkGlobalKeys?: typeof import("./checkGlobalKeys.handler").checkForGlobalKeysHandler;
setDefaultConferencingApp?: typeof import("./setDefaultConferencingApp.handler").setDefaultConferencingAppHandler;
};
const UNSTABLE_HANDLER_CACHE: AppsRouterHandlerCache = {};
@@ -87,6 +89,25 @@ export const appsRouter = router({
});
}),
setDefaultConferencingApp: authedProcedure
.input(ZSetDefaultConferencingAppSchema)
.mutation(async ({ ctx, input }) => {
if (!UNSTABLE_HANDLER_CACHE.setDefaultConferencingApp) {
UNSTABLE_HANDLER_CACHE.setDefaultConferencingApp = await import(
"./setDefaultConferencingApp.handler"
).then((mod) => mod.setDefaultConferencingAppHandler);
}
// Unreachable code but required for type safety
if (!UNSTABLE_HANDLER_CACHE.setDefaultConferencingApp) {
throw new Error("Failed to load handler");
}
return UNSTABLE_HANDLER_CACHE.setDefaultConferencingApp({
ctx,
input,
});
}),
updateAppCredentials: authedProcedure
.input(ZUpdateAppCredentialsInputSchema)
.mutation(async ({ ctx, input }) => {
@@ -0,0 +1,15 @@
import setDefaultConferencingApp from "@calcom/app-store/_utils/setDefaultConferencingApp";
import type { TrpcSessionUser } from "../../../trpc";
import type { TSetDefaultConferencingAppSchema } from "./setDefaultConferencingApp.schema";
type SetDefaultConferencingAppOptions = {
ctx: {
user: NonNullable<TrpcSessionUser>;
};
input: TSetDefaultConferencingAppSchema;
};
export const setDefaultConferencingAppHandler = async ({ ctx, input }: SetDefaultConferencingAppOptions) => {
return await setDefaultConferencingApp(ctx.user.id, input.slug);
};
@@ -0,0 +1,7 @@
import { z } from "zod";
export const ZSetDefaultConferencingAppSchema = z.object({
slug: z.string(),
});
export type TSetDefaultConferencingAppSchema = z.infer<typeof ZSetDefaultConferencingAppSchema>;
+2 -2
View File
@@ -22,7 +22,7 @@ export type ButtonBaseProps = {
EndIcon?: IconName;
shallow?: boolean;
/**Tool tip used when icon size is set to small */
tooltip?: string;
tooltip?: string | React.ReactNode;
tooltipSide?: "top" | "right" | "bottom" | "left";
tooltipOffset?: number;
disabled?: boolean;
@@ -240,7 +240,7 @@ const Wrapper = ({
tooltipSide,
tooltipOffset,
}: {
tooltip?: string;
tooltip?: string | React.ReactNode;
children: React.ReactNode;
tooltipSide?: "top" | "right" | "bottom" | "left";
tooltipOffset?: number;