Compare commits

...
Author SHA1 Message Date
GitHub Actions 90f55670dc chore: release v5.4.11
Post release / release (push) Canceled after 0s
2025-07-01 11:06:49 +00:00
Anik Dhabal Babu 9c53f392ca fix: Optional Question ' Leaves Variable Tag in Event Name (#22175) 2025-07-01 10:50:41 +00:00
Morganandcubic-dev-ai[bot] cc19a81875 chore: deprecate platform libraries 0.0.2 (#22149)
* chore: deprecate platform libraries 0.0.2

* fixup! chore: deprecate platform libraries 0.0.2

* Update packages/lib/server/repository/schedule.ts

Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com>

* Update apps/api/v2/src/ee/schedules/schedules_2024_04_15/services/schedules.service.ts

Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com>

* chore: bump platform libs

* Update apps/api/v2/src/ee/event-types/event-types_2024_04_15/controllers/event-types.controller.ts

Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com>

* chore: bump platform libs

---------

Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com>
2025-07-01 10:23:58 +00:00
Benny Joo 67032ddaf1 chore: add test for updateProfilePhotoGoogle (#22169)
* fix: process base64 avatar image

* better name

* more

* fix import

* add test

* util

* clean up

* address
2025-07-01 11:06:57 +01:00
2fe71c3695 refactor: destination calendars when rescheduling (#21873)
* refactor: destination calendars when rescheduling

* test: add unit test for collective evnet type

* chore: remove log

* chore: bump libraries

* fix: test

* chore: bump libraries

* fix: tests

* reset lock file

* Revert "reset lock file"

This reverts commit 4d48b678778b0daa19624338b302478e4655d200.

* Revert "Revert "reset lock file""

This reverts commit a7c3120c926a0d37a2b55a6ff8d2a070969ec0f4.

* regenerate lock file

---------

Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com>
Co-authored-by: Udit Takkar <udit222001@gmail.com>
2025-07-01 12:07:48 +03:00
21422fe027 feat: enhance API v2 exception filters with user context for improved Axiom observability (#22157)
* feat: enhance API v2 exception filters with user context for improved Axiom observability

- Add extractUserContext utility to safely extract user/org/team context from request
- Enhance all 5 exception filters (HTTP, TRPC, Prisma, Zod, Calendar) with user context
- Include userId, userEmail, organizationId, teamId in Axiom logs when available
- Improve observability for debugging and monitoring authenticated requests

Co-Authored-By: morgan@cal.com <morgan@cal.com>

* chore: update yarn.lock from lint-staged hooks

Co-Authored-By: morgan@cal.com <morgan@cal.com>

* fix: use request.organization instead of request.organizationId

- Address GitHub comment feedback from ThyMinimalDev
- Extract organization ID from organization object for consistency
- Follows same pattern as user and team extraction

Co-Authored-By: morgan@cal.com <morgan@cal.com>

* fix: revert to using request.organizationId as requested in GitHub comment

- Change back from request.organization to request.organizationId
- This aligns with how auth strategies actually populate the request object
- Addresses GitHub comment from ThyMinimalDev

Co-Authored-By: morgan@cal.com <morgan@cal.com>

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: morgan@cal.com <morgan@cal.com>
Co-authored-by: Morgan <33722304+ThyMinimalDev@users.noreply.github.com>
2025-07-01 10:05:21 +03:00
Devanshu Sharma 95575291ff feat: add Finnish locale to Day.js locales (#22174) 2025-07-01 07:01:32 +00:00
KartikandKartik Saini 3e61a0633d prevent info icon from repositioning when text wraps on small screens (#22132)
Co-authored-by: Kartik Saini <41051387+kart1ka@users.noreply.github.com>
2025-07-01 09:32:41 +05:30
abhix4 85c9a790f2 fix: prevent location input from losing focus after each key stroke (#22167)
* fix: prevent location input from losing focus after each key stroke

* typo
2025-07-01 03:54:35 +00:00
Omar López e17d55457f refactor: cookie domain logic in defaultCookies (#22168) 2025-06-30 20:21:16 +00:00
Benny Joo d8d8bc4d00 fix: Process base64 avatar image (#22165)
* fix: process base64 avatar image

* better name

* more

* fix import
2025-06-30 21:08:46 +01:00
Udit TakkarandAlex van Andel 55cc1aea59 perf: optimize listWithTeamHandler query (#22069)
* perf: optimize listWithTeamHandler query

* chore: update query

* chore: add accepted true

* Update packages/trpc/server/routers/viewer/eventTypes/listWithTeam.handler.ts

* test: add integration test for the sql query

---------

Co-authored-by: Alex van Andel <me@alexvanandel.com>
2025-06-30 18:35:49 +00:00
GitHub Actions ae383164bc chore: release v5.4.10
Post release / release (push) Canceled after 0s
2025-06-30 13:22:55 +00:00
GitHub Actions 0d8863fe8e chore: release v5.4.10 2025-06-30 13:02:06 +00:00
GitHub Actions 51f6654635 chore: release v5.5.0 2025-06-30 12:39:41 +00:00
Eunjae LeeandDevin AI fa11e10bcb feat: remove 3-attendee limitation from CSV export (#22092)
Co-authored-by: eunjae@cal.com <hey@eunjae.dev>
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2025-06-30 12:28:54 +00:00
46 changed files with 4077 additions and 1007 deletions
+2
View File
@@ -0,0 +1,2 @@
undecided:
- "@calcom/prisma"
View File
View File
+8
View File
@@ -0,0 +1,8 @@
undecided:
- calcom-monorepo
- "@calcom/app-store-cli"
- "@calcom/platform-constants"
- "@calcom/platform-enums"
- "@calcom/platform-types"
- "@calcom/platform-utils"
- "@calcom/prisma"
+1 -2
View File
@@ -38,8 +38,7 @@
"@axiomhq/winston": "^1.2.0",
"@calcom/platform-constants": "*",
"@calcom/platform-enums": "*",
"@calcom/platform-libraries": "npm:@calcom/platform-libraries@0.0.231",
"@calcom/platform-libraries-0.0.2": "npm:@calcom/platform-libraries@0.0.2",
"@calcom/platform-libraries": "npm:@calcom/platform-libraries@0.0.236",
"@calcom/platform-types": "*",
"@calcom/platform-utils": "*",
"@calcom/prisma": "*",
@@ -18,6 +18,7 @@ import { GetUser } from "@/modules/auth/decorators/get-user/get-user.decorator";
import { Permissions } from "@/modules/auth/decorators/permissions/permissions.decorator";
import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard";
import { PermissionsGuard } from "@/modules/auth/guards/permissions/permissions.guard";
import { OrganizationsRepository } from "@/modules/organizations/index/organizations.repository";
import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
import { UserWithProfile } from "@/modules/users/users.repository";
import {
@@ -33,13 +34,19 @@ import {
HttpStatus,
Delete,
Query,
Headers,
InternalServerErrorException,
ParseIntPipe,
} from "@nestjs/common";
import { ApiExcludeController as DocsExcludeController } from "@nestjs/swagger";
import { EVENT_TYPE_READ, EVENT_TYPE_WRITE, SUCCESS_STATUS } from "@calcom/platform-constants";
import { getPublicEvent, getEventTypesByViewer } from "@calcom/platform-libraries-0.0.2";
import {
EVENT_TYPE_READ,
EVENT_TYPE_WRITE,
SUCCESS_STATUS,
X_CAL_CLIENT_ID,
} from "@calcom/platform-constants";
import { getPublicEvent, getEventTypesByViewer } from "@calcom/platform-libraries/event-types";
import { PrismaClient } from "@calcom/prisma";
@Controller({
@@ -51,7 +58,8 @@ import { PrismaClient } from "@calcom/prisma";
export class EventTypesController_2024_04_15 {
constructor(
private readonly eventTypesService: EventTypesService_2024_04_15,
private readonly prismaReadService: PrismaReadService
private readonly prismaReadService: PrismaReadService,
private readonly organizationsRepository: OrganizationsRepository
) {}
@Post("/")
@@ -109,19 +117,30 @@ export class EventTypesController_2024_04_15 {
async getPublicEventType(
@Param("username") username: string,
@Param("eventSlug") eventSlug: string,
@Query() queryParams: GetPublicEventTypeQueryParams_2024_04_15
@Query() queryParams: GetPublicEventTypeQueryParams_2024_04_15,
@Headers(X_CAL_CLIENT_ID) clientId?: string
): Promise<GetEventTypePublicOutput> {
try {
let orgSlug = queryParams.org;
if (clientId && !orgSlug && username.includes(`-${clientId}`)) {
const org = await this.organizationsRepository.findTeamIdAndSlugFromClientId(clientId).catch(() => null);
if (org) {
orgSlug = org.slug;
}
}
const event = await getPublicEvent(
username.toLowerCase(),
eventSlug,
queryParams.isTeamEvent,
queryParams.org || null,
orgSlug ?? null,
this.prismaReadService.prisma as unknown as PrismaClient,
// We should be fine allowing unpublished orgs events to be servable through platform because Platform access is behind license
// If there is ever a need to restrict this, we can introduce a new query param `fromRedirectOfNonOrgLink`
true
);
return {
data: event,
status: SUCCESS_STATUS,
@@ -2,6 +2,7 @@ import { EventTypesController_2024_04_15 } from "@/ee/event-types/event-types_20
import { EventTypesRepository_2024_04_15 } from "@/ee/event-types/event-types_2024_04_15/event-types.repository";
import { EventTypesService_2024_04_15 } from "@/ee/event-types/event-types_2024_04_15/services/event-types.service";
import { MembershipsModule } from "@/modules/memberships/memberships.module";
import { OrganizationsModule } from "@/modules/organizations/organizations.module";
import { PrismaModule } from "@/modules/prisma/prisma.module";
import { SelectedCalendarsModule } from "@/modules/selected-calendars/selected-calendars.module";
import { TokensModule } from "@/modules/tokens/tokens.module";
@@ -9,7 +10,14 @@ import { UsersModule } from "@/modules/users/users.module";
import { Module } from "@nestjs/common";
@Module({
imports: [PrismaModule, MembershipsModule, TokensModule, UsersModule, SelectedCalendarsModule],
imports: [
PrismaModule,
MembershipsModule,
TokensModule,
UsersModule,
SelectedCalendarsModule,
OrganizationsModule,
],
providers: [EventTypesRepository_2024_04_15, EventTypesService_2024_04_15],
controllers: [EventTypesController_2024_04_15],
exports: [EventTypesService_2024_04_15, EventTypesRepository_2024_04_15],
@@ -1721,7 +1721,7 @@ describe("Event types Endpoints", () => {
description: "undefined booking fields",
length: 40,
hidden: false,
slug: "undefined-booking-fields",
slug: `undefined-booking-fields-${randomString()}`,
locations: [],
schedulingType: SchedulingType.ROUND_ROBIN,
};
@@ -1751,6 +1751,14 @@ describe("Event types Endpoints", () => {
disableOnPrefill: false,
hidden: false,
},
{
disableOnPrefill: false,
hidden: true,
isDefault: true,
required: false,
slug: "attendeePhoneNumber",
type: "phone",
},
{
isDefault: true,
type: "radioInput",
@@ -46,11 +46,10 @@ export class SchedulesController_2024_04_15 {
@Body() bodySchedule: CreateScheduleInput_2024_04_15
): Promise<CreateScheduleOutput_2024_04_15> {
const schedule = await this.schedulesService.createUserSchedule(user.id, bodySchedule);
const scheduleFormatted = await this.schedulesService.formatScheduleForAtom(user, schedule);
return {
status: SUCCESS_STATUS,
data: scheduleFormatted,
data: schedule,
};
}
@@ -60,13 +59,10 @@ export class SchedulesController_2024_04_15 {
@GetUser() user: UserWithProfile
): Promise<GetDefaultScheduleOutput_2024_04_15 | null> {
const schedule = await this.schedulesService.getUserScheduleDefault(user.id);
const scheduleFormatted = schedule
? await this.schedulesService.formatScheduleForAtom(user, schedule)
: null;
return {
status: SUCCESS_STATUS,
data: scheduleFormatted,
data: schedule,
};
}
@@ -77,23 +73,25 @@ export class SchedulesController_2024_04_15 {
@Param("scheduleId") scheduleId: number
): Promise<GetScheduleOutput_2024_04_15> {
const schedule = await this.schedulesService.getUserSchedule(user.id, scheduleId);
const scheduleFormatted = await this.schedulesService.formatScheduleForAtom(user, schedule);
return {
status: SUCCESS_STATUS,
data: scheduleFormatted,
data: schedule,
};
}
@Get("/")
@Permissions([SCHEDULE_READ])
async getSchedules(@GetUser() user: UserWithProfile): Promise<GetSchedulesOutput_2024_04_15> {
const schedules = await this.schedulesService.getUserSchedules(user.id);
const schedulesFormatted = await this.schedulesService.formatSchedulesForAtom(user, schedules);
const schedules = await this.schedulesService.getUserSchedules(
user.id,
user.timeZone,
user.defaultScheduleId
);
return {
status: SUCCESS_STATUS,
data: schedulesFormatted,
data: schedules,
};
}
@@ -1,26 +1,22 @@
import { CreateAvailabilityInput_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/inputs/create-availability.input";
import { CreateScheduleInput_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/inputs/create-schedule.input";
import { ScheduleOutput } from "@/ee/schedules/schedules_2024_04_15/outputs/schedule.output";
import { SchedulesRepository_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/schedules.repository";
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
import { UserWithProfile, UsersRepository } from "@/modules/users/users.repository";
import { BadRequestException, ForbiddenException, Injectable, NotFoundException } from "@nestjs/common";
import { Schedule } from "@prisma/client";
import { User } from "@prisma/client";
import type { ScheduleWithAvailabilities } from "@calcom/platform-libraries-0.0.2";
import { updateScheduleHandler } from "@calcom/platform-libraries-0.0.2";
import {
transformWorkingHoursForClient,
transformAvailabilityForClient,
transformDateOverridesForClient,
} from "@calcom/platform-libraries-0.0.2";
import { updateSchedule } from "@calcom/platform-libraries/schedules";
import { ScheduleRepository } from "@calcom/platform-libraries/schedules";
import { UpdateScheduleInput_2024_04_15 } from "@calcom/platform-types";
import { PrismaClient } from "@calcom/prisma";
@Injectable()
export class SchedulesService_2024_04_15 {
constructor(
private readonly schedulesRepository: SchedulesRepository_2024_04_15,
private readonly usersRepository: UsersRepository
private readonly usersRepository: UsersRepository,
private readonly dbWrite: PrismaWriteService
) {}
async createUserDefaultSchedule(userId: number, timeZone: string) {
@@ -48,7 +44,9 @@ export class SchedulesService_2024_04_15 {
await this.usersRepository.setDefaultSchedule(userId, createdSchedule.id);
}
return createdSchedule;
const formattedSchedule = await this.getUserSchedule(userId, createdSchedule.id);
return formattedSchedule;
}
async getUserScheduleDefault(userId: number) {
@@ -56,11 +54,29 @@ export class SchedulesService_2024_04_15 {
if (!user?.defaultScheduleId) return null;
return this.schedulesRepository.getScheduleById(user.defaultScheduleId);
return await ScheduleRepository.findDetailedScheduleById({
scheduleId: user.defaultScheduleId,
isManagedEventType: undefined,
userId,
timeZone: user.timeZone,
defaultScheduleId: user.defaultScheduleId,
});
}
async getUserSchedule(userId: number, scheduleId: number) {
const existingSchedule = await this.schedulesRepository.getScheduleById(scheduleId);
const user = await this.usersRepository.findById(userId);
if (!user) {
throw new NotFoundException(`User with ID=${userId} does not exist.`);
}
const existingSchedule = await ScheduleRepository.findDetailedScheduleById({
scheduleId: scheduleId,
isManagedEventType: undefined,
userId,
timeZone: user.timeZone,
defaultScheduleId: user.defaultScheduleId,
});
if (!existingSchedule) {
throw new NotFoundException(`Schedule with ID=${scheduleId} does not exist.`);
@@ -71,8 +87,8 @@ export class SchedulesService_2024_04_15 {
return existingSchedule;
}
async getUserSchedules(userId: number) {
return this.schedulesRepository.getSchedulesByUserId(userId);
async getUserSchedules(userId: number, timeZone: string, defaultScheduleId: number | null) {
return ScheduleRepository.findManyDetailedScheduleByUserId({ userId, timeZone, defaultScheduleId });
}
async updateUserSchedule(
@@ -89,7 +105,6 @@ export class SchedulesService_2024_04_15 {
this.checkUserOwnsSchedule(user.id, existingSchedule);
const schedule = await this.getUserSchedule(user.id, Number(scheduleId));
const scheduleFormatted = await this.formatScheduleForAtom(user, schedule);
if (!bodySchedule.schedule) {
// note(Lauris): When updating an availability in cal web app, lets say only its name, also
@@ -97,12 +112,16 @@ export class SchedulesService_2024_04_15 {
// and they have same shape, so to match shapes I attach "scheduleFormatted.availability" to reflect
// schedule that would be passed by the web app. If we don't, then updating schedule name will erase
// schedule.
bodySchedule.schedule = scheduleFormatted.availability;
bodySchedule.schedule = schedule.availability;
}
return updateScheduleHandler({
input: { scheduleId: Number(scheduleId), ...bodySchedule },
ctx: { user },
return updateSchedule({
input: {
scheduleId: Number(scheduleId),
...bodySchedule,
},
user,
prisma: this.dbWrite.prisma as unknown as PrismaClient,
});
}
@@ -118,44 +137,6 @@ export class SchedulesService_2024_04_15 {
return this.schedulesRepository.deleteScheduleById(scheduleId);
}
async formatScheduleForAtom(user: User, schedule: ScheduleWithAvailabilities): Promise<ScheduleOutput> {
const usersSchedulesCount = await this.schedulesRepository.getUserSchedulesCount(user.id);
return this.transformScheduleForAtom(schedule, usersSchedulesCount, user);
}
async formatSchedulesForAtom(
user: User,
schedules: ScheduleWithAvailabilities[]
): Promise<ScheduleOutput[]> {
const usersSchedulesCount = await this.schedulesRepository.getUserSchedulesCount(user.id);
return Promise.all(
schedules.map((schedule) => this.transformScheduleForAtom(schedule, usersSchedulesCount, user))
);
}
async transformScheduleForAtom(
schedule: ScheduleWithAvailabilities,
userSchedulesCount: number,
user: Pick<User, "id" | "defaultScheduleId" | "timeZone">
): Promise<ScheduleOutput> {
const timeZone = schedule.timeZone || user.timeZone;
const defaultSchedule = await this.getUserScheduleDefault(user.id);
return {
id: schedule.id,
name: schedule.name,
isManaged: schedule.userId !== user.id,
workingHours: transformWorkingHoursForClient(schedule),
schedule: schedule.availability,
availability: transformAvailabilityForClient(schedule),
timeZone,
dateOverrides: transformDateOverridesForClient(schedule, timeZone),
isDefault: defaultSchedule?.id === schedule.id,
isLastSchedule: userSchedulesCount <= 1,
readOnly: schedule.userId !== user.id,
};
}
checkUserOwnsSchedule(userId: number, schedule: Pick<Schedule, "id" | "userId">) {
if (userId !== schedule.userId) {
throw new ForbiddenException(`User with ID=${userId} does not own schedule with ID=${schedule.id}`);
@@ -1,3 +1,4 @@
import { extractUserContext } from "@/lib/extract-user-context";
import { filterReqHeaders } from "@/lib/filterReqHeaders";
import { ArgumentsHost, Catch, ExceptionFilter, Logger } from "@nestjs/common";
import { Request } from "express";
@@ -39,6 +40,7 @@ export class CalendarServiceExceptionFilter implements ExceptionFilter {
const requestId = request.headers["X-Request-Id"] ?? "unknown-request-id";
response.setHeader("X-Request-Id", requestId.toString());
const userContext = extractUserContext(request);
this.logger.error(`Calendar Service Exception Filter: ${exception?.message}`, {
exception,
body: request.body,
@@ -46,6 +48,7 @@ export class CalendarServiceExceptionFilter implements ExceptionFilter {
url: request.url,
method: request.method,
requestId,
...userContext,
});
response.status(400).json({
@@ -1,3 +1,4 @@
import { extractUserContext } from "@/lib/extract-user-context";
import { filterReqHeaders } from "@/lib/filterReqHeaders";
import { ArgumentsHost, Catch, ExceptionFilter, HttpException, Logger } from "@nestjs/common";
import { Request } from "express";
@@ -16,6 +17,7 @@ export class HttpExceptionFilter implements ExceptionFilter<HttpException> {
const statusCode = exception.getStatus();
const requestId = request.headers["X-Request-Id"] ?? "unknown-request-id";
response.setHeader("X-Request-Id", requestId.toString());
const userContext = extractUserContext(request);
this.logger.error(`Http Exception Filter: ${exception?.message}`, {
exception,
body: request.body,
@@ -23,6 +25,7 @@ export class HttpExceptionFilter implements ExceptionFilter<HttpException> {
url: request.url,
method: request.method,
requestId,
...userContext,
});
response.status(statusCode).json({
@@ -1,3 +1,4 @@
import { extractUserContext } from "@/lib/extract-user-context";
import { filterReqHeaders } from "@/lib/filterReqHeaders";
import type { ArgumentsHost, ExceptionFilter } from "@nestjs/common";
import { Catch, HttpStatus, Logger } from "@nestjs/common";
@@ -43,6 +44,7 @@ export class PrismaExceptionFilter implements ExceptionFilter {
const requestId = request.headers["X-Request-Id"] ?? "unknown-request-id";
response.setHeader("X-Request-Id", requestId.toString());
const userContext = extractUserContext(request);
this.logger.error(`PrismaError: ${error.message}`, {
error,
body: request.body,
@@ -50,6 +52,7 @@ export class PrismaExceptionFilter implements ExceptionFilter {
url: request.url,
method: request.method,
requestId,
...userContext,
});
let message = "There was an error, please try again later.";
@@ -1,3 +1,4 @@
import { extractUserContext } from "@/lib/extract-user-context";
import { filterReqHeaders } from "@/lib/filterReqHeaders";
import { ArgumentsHost, Catch, ExceptionFilter, Logger } from "@nestjs/common";
import { Request } from "express";
@@ -120,6 +121,7 @@ export class TRPCExceptionFilter implements ExceptionFilter {
const requestId = request.headers["X-Request-Id"] ?? "unknown-request-id";
response.setHeader("X-Request-Id", requestId.toString());
const userContext = extractUserContext(request);
this.logger.error(`TRPC Exception Filter: ${exception?.message}`, {
exception,
body: request.body,
@@ -127,6 +129,7 @@ export class TRPCExceptionFilter implements ExceptionFilter {
url: request.url,
method: request.method,
requestId,
...userContext,
});
response.status(statusCode).json({
@@ -1,3 +1,4 @@
import { extractUserContext } from "@/lib/extract-user-context";
import { filterReqHeaders } from "@/lib/filterReqHeaders";
import type { ArgumentsHost, ExceptionFilter } from "@nestjs/common";
import { Catch, HttpStatus, Logger } from "@nestjs/common";
@@ -18,6 +19,7 @@ export class ZodExceptionFilter implements ExceptionFilter {
const requestId = request.headers["X-Request-Id"] ?? "unknown-request-id";
response.setHeader("X-Request-Id", requestId.toString());
const userContext = extractUserContext(request);
this.logger.error(`ZodError: ${error.message}`, {
error,
body: request.body,
@@ -25,6 +27,7 @@ export class ZodExceptionFilter implements ExceptionFilter {
url: request.url,
method: request.method,
requestId,
...userContext,
});
response.status(HttpStatus.BAD_REQUEST).json({
@@ -0,0 +1,34 @@
import { Request } from "express";
import { Team } from "@calcom/prisma/client";
import { ApiAuthGuardUser } from "../modules/auth/strategies/api-auth/api-auth.strategy";
export interface UserContext {
userId?: number;
userEmail?: string;
organizationId?: number;
teamId?: number;
}
export function extractUserContext(request: Request): UserContext {
const context: UserContext = {};
const user = (request as any).user as ApiAuthGuardUser | undefined;
if (user) {
context.userId = user.id;
context.userEmail = user.email;
}
const organizationId = (request as any).organizationId as number | undefined;
if (organizationId) {
context.organizationId = organizationId;
}
const team = (request as any).team as Team | undefined;
if (team) {
context.teamId = team.id;
}
return context;
}
@@ -69,7 +69,7 @@ export class OrganizationsRepository {
return id;
}
async findTeamIdFromClientId(clientId: string) {
async findTeamIdAndSlugFromClientId(clientId: string) {
return this.dbRead.prisma.team.findFirstOrThrow({
where: {
platformOAuthClient: {
@@ -80,6 +80,7 @@ export class OrganizationsRepository {
},
select: {
id: true,
slug: true,
},
});
}
@@ -652,8 +652,9 @@ const ProfileForm = ({
/>
</div>
{extraField}
<p className="text-subtle mt-1 flex items-center gap-1 text-sm">
<Icon name="info" /> {t("tip_username_plus")}
<p className="text-subtle mt-1 flex gap-1 text-sm">
<Icon name="info" className="mt-0.5 flex-shrink-0" />
<span className="flex-1">{t("tip_username_plus")}</span>
</p>
<div className="mt-6">
<TextField label={t("full_name")} {...formMethods.register("name")} />
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@calcom/web",
"version": "5.4.9",
"version": "5.4.11",
"private": true,
"scripts": {
"analyze": "ANALYZE=true next build",
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,114 @@
import { oauth2_v2 } from "@googleapis/oauth2";
import type { OAuth2Client } from "googleapis-common";
import { describe, it, expect, vi, beforeEach } from "vitest";
import logger from "@calcom/lib/logger";
import { uploadAvatar } from "@calcom/lib/server/avatar";
import { UserRepository } from "@calcom/lib/server/repository/user";
import { resizeBase64Image } from "@calcom/lib/server/resizeBase64Image";
import { updateProfilePhotoGoogle } from "./updateProfilePhotoGoogle";
vi.mock("@googleapis/oauth2", () => ({
oauth2_v2: {
Oauth2: vi.fn(),
},
}));
vi.mock("@calcom/lib/logger", () => ({
default: {
error: vi.fn(),
},
}));
vi.mock("@calcom/lib/server/avatar", () => ({
uploadAvatar: vi.fn(),
}));
vi.mock("@calcom/lib/server/resizeBase64Image", () => ({
resizeBase64Image: vi.fn(),
}));
vi.mock("@calcom/lib/server/repository/user", () => ({
UserRepository: {
updateAvatar: vi.fn(),
},
}));
describe("updateProfilePhotoGoogle", () => {
const mockOAuth2Client = {} as OAuth2Client;
const userId = 123;
let mockOauth2Instance: any;
beforeEach(() => {
vi.clearAllMocks();
mockOauth2Instance = {
userinfo: {
get: vi.fn(),
},
};
(oauth2_v2.Oauth2 as any).mockImplementation(() => mockOauth2Instance);
(uploadAvatar as any).mockResolvedValue("/api/avatar/processed-123.png");
(resizeBase64Image as any).mockResolvedValue("data:image/png;base64,resized-data");
(UserRepository.updateAvatar as any).mockResolvedValue(undefined);
});
it("should update avatar with valid URL", async () => {
const validUrl = "https://example.com/avatar.jpg";
mockOauth2Instance.userinfo.get.mockResolvedValue({
data: { picture: validUrl },
});
await updateProfilePhotoGoogle(mockOAuth2Client, userId);
expect(UserRepository.updateAvatar).toHaveBeenCalledWith({
id: userId,
avatarUrl: validUrl,
});
expect(uploadAvatar).not.toHaveBeenCalled();
});
it("should process base64 data", async () => {
const base64Data =
"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=";
mockOauth2Instance.userinfo.get.mockResolvedValue({
data: { picture: base64Data },
});
await updateProfilePhotoGoogle(mockOAuth2Client, userId);
expect(resizeBase64Image).toHaveBeenCalledWith(base64Data);
expect(uploadAvatar).toHaveBeenCalledWith({
avatar: "data:image/png;base64,resized-data",
userId,
});
expect(UserRepository.updateAvatar).toHaveBeenCalledWith({
id: userId,
avatarUrl: "/api/avatar/processed-123.png",
});
});
it("should do nothing when no picture provided", async () => {
mockOauth2Instance.userinfo.get.mockResolvedValue({
data: {},
});
await updateProfilePhotoGoogle(mockOAuth2Client, userId);
expect(UserRepository.updateAvatar).not.toHaveBeenCalled();
expect(uploadAvatar).not.toHaveBeenCalled();
});
it("should handle errors gracefully", async () => {
const error = new Error("OAuth API failed");
mockOauth2Instance.userinfo.get.mockRejectedValue(error);
await updateProfilePhotoGoogle(mockOAuth2Client, userId);
expect(logger.error).toHaveBeenCalledWith("Error updating avatarUrl from google calendar connect", error);
expect(UserRepository.updateAvatar).not.toHaveBeenCalled();
});
});
@@ -1,16 +1,32 @@
import { oauth2_v2 } from "@googleapis/oauth2";
import type { OAuth2Client } from "googleapis-common";
import { isBase64Image } from "@calcom/lib/isBase64Image";
import logger from "@calcom/lib/logger";
import { uploadAvatar } from "@calcom/lib/server/avatar";
import { UserRepository } from "@calcom/lib/server/repository/user";
import { resizeBase64Image } from "@calcom/lib/server/resizeBase64Image";
export async function updateProfilePhotoGoogle(oAuth2Client: OAuth2Client, userId: number) {
try {
const oauth2 = new oauth2_v2.Oauth2({ auth: oAuth2Client });
const userDetails = await oauth2.userinfo.get();
if (userDetails.data?.picture) {
await UserRepository.updateAvatar({ id: userId, avatarUrl: userDetails.data.picture });
const avatarUrl = userDetails.data?.picture;
if (!avatarUrl) {
return;
}
// Handle base64 data
if (isBase64Image(avatarUrl)) {
const resizedAvatarUrl = await uploadAvatar({
avatar: await resizeBase64Image(avatarUrl),
userId,
});
await UserRepository.updateAvatar({ id: userId, avatarUrl: resizedAvatarUrl });
return;
}
await UserRepository.updateAvatar({ id: userId, avatarUrl });
} catch (error) {
logger.error("Error updating avatarUrl from google calendar connect", error);
}
@@ -4,7 +4,12 @@ import type { NextApiRequest, NextApiResponse } from "next";
import GoogleCalendarService from "@calcom/app-store/googlecalendar/lib/CalendarService";
import { renewSelectedCalendarCredentialId } from "@calcom/lib/connectedCalendar";
import { GOOGLE_CALENDAR_SCOPES, WEBAPP_URL, WEBAPP_URL_FOR_OAUTH } from "@calcom/lib/constants";
import {
GOOGLE_CALENDAR_SCOPES,
SCOPE_USERINFO_PROFILE,
WEBAPP_URL,
WEBAPP_URL_FOR_OAUTH,
} from "@calcom/lib/constants";
import { getSafeRedirectUrl } from "@calcom/lib/getSafeRedirectUrl";
import { HttpError } from "@calcom/lib/http-error";
import { defaultHandler } from "@calcom/lib/server/defaultHandler";
@@ -14,6 +19,7 @@ import { Prisma } from "@calcom/prisma/client";
import getInstalledAppPath from "../../_utils/getInstalledAppPath";
import { decodeOAuthState } from "../../_utils/oauth/decodeOAuthState";
import { updateProfilePhotoGoogle } from "../../_utils/oauth/updateProfilePhotoGoogle";
import { getGoogleAppKeys } from "../lib/getGoogleAppKeys";
async function getHandler(req: NextApiRequest, res: NextApiResponse) {
@@ -95,10 +101,9 @@ async function getHandler(req: NextApiRequest, res: NextApiResponse) {
}
// Only attempt to update the user's profile photo if the user has granted the required scope
// TODO: Use the avatarUrl when setting the profile picture
// if (grantedScopes.includes(SCOPE_USERINFO_PROFILE)) {
// await updateProfilePhotoGoogle(oAuth2Client, req.session.user.id);
// }
if (grantedScopes.includes(SCOPE_USERINFO_PROFILE)) {
await updateProfilePhotoGoogle(oAuth2Client, req.session.user.id);
}
const selectedCalendarWhereUnique = {
userId: req.session.user.id,
+1
View File
@@ -4,6 +4,7 @@ import "dayjs/locale/cs";
import "dayjs/locale/de";
import "dayjs/locale/es";
import "dayjs/locale/es-mx";
import "dayjs/locale/fi";
import "dayjs/locale/fr";
import "dayjs/locale/he";
import "dayjs/locale/hu";
@@ -1502,15 +1502,6 @@ async function handler(
evt.videoCallData = undefined;
// To prevent "The requested identifier already exists" error while updating event, we need to remove iCalUID
evt.iCalUID = undefined;
} else {
// In case of rescheduling, we need to keep the previous host destination calendar
evt = CalendarEventBuilder.fromEvent(evt)
.withDestinationCalendar(
originalRescheduledBooking?.destinationCalendar
? [originalRescheduledBooking?.destinationCalendar]
: evt.destinationCalendar
)
.build();
}
const updateManager = await eventManager.reschedule(
@@ -0,0 +1,240 @@
import {
createBookingScenario,
getGoogleCalendarCredential,
TestData,
getOrganizer,
getBooker,
getScenarioData,
mockSuccessfulVideoMeetingCreation,
mockCalendarToHaveNoBusySlots,
getDate,
BookingLocations,
} from "@calcom/web/test/utils/bookingScenario/bookingScenario";
import { expectBookingToBeInDatabase } from "@calcom/web/test/utils/bookingScenario/expects";
import { getMockRequestDataForBooking } from "@calcom/web/test/utils/bookingScenario/getMockRequestDataForBooking";
import { setupAndTeardown } from "@calcom/web/test/utils/bookingScenario/setupAndTeardown";
import type { Request, Response } from "express";
import type { NextApiRequest, NextApiResponse } from "next";
import { describe, expect, beforeEach } from "vitest";
import { appStoreMetadata } from "@calcom/app-store/appStoreMetaData";
import { resetTestEmails } from "@calcom/lib/testEmails";
import { SchedulingType } from "@calcom/prisma/enums";
import { BookingStatus } from "@calcom/prisma/enums";
import { test } from "@calcom/web/test/fixtures/fixtures";
export type CustomNextApiRequest = NextApiRequest & Request;
export type CustomNextApiResponse = NextApiResponse & Response;
const timeout = process.env.CI ? 5000 : 20000;
describe("handleNewBooking", () => {
setupAndTeardown();
beforeEach(() => {
resetTestEmails();
});
describe("Collective Event Reschedule with Destination Calendar Bug", () => {
test(
`should reschedule a collective event booking and ensure both hosts have the booking in their calendars when a second host is added
1. Create a collective event with 1 host and book it
2. Add a second host to the event type
3. Reschedule the booking
4. Verify both hosts have the booking in their calendars
`,
async ({ emails }) => {
const handleNewBooking = (await import("@calcom/features/bookings/lib/handleNewBooking")).default;
const booker = getBooker({
email: "booker@example.com",
name: "Booker",
});
const organizer = getOrganizer({
name: "Organizer",
email: "organizer@example.com",
id: 101,
schedules: [TestData.schedules.IstWorkHours],
credentials: [getGoogleCalendarCredential()],
selectedCalendars: [TestData.selectedCalendars.google],
destinationCalendar: {
integration: "google_calendar",
externalId: "organizer@google-calendar.com",
primaryEmail: "organizer@google-calendar.com",
},
});
const { dateString: plus1DateString } = getDate({ dateIncrement: 1 });
const { dateString: plus2DateString } = getDate({ dateIncrement: 2 });
const uidOfBookingToBeRescheduled = "n5Wv3eHgconAED2j4gcVhP";
const iCalUID = `${uidOfBookingToBeRescheduled}@Cal.com`;
await createBookingScenario(
getScenarioData({
eventTypes: [
{
id: 1,
slotInterval: 15,
length: 30,
schedulingType: SchedulingType.COLLECTIVE,
users: [
{
id: 101, // organizer
},
{
id: 102, // second host (will be added to event type)
},
],
destinationCalendar: {
integration: "google_calendar",
externalId: "event-type-1@google-calendar.com",
primaryEmail: "event-type-1@google-calendar.com",
},
},
],
users: [
{
id: 101,
name: "Organizer",
email: "organizer@example.com",
username: "organizer",
timeZone: "Asia/Kolkata",
defaultScheduleId: null,
schedules: [TestData.schedules.IstWorkHours],
credentials: [getGoogleCalendarCredential()],
selectedCalendars: [TestData.selectedCalendars.google],
destinationCalendar: {
integration: "google_calendar",
externalId: "organizer@google-calendar.com",
primaryEmail: "organizer@google-calendar.com",
},
},
{
id: 102,
name: "Second Host",
email: "second-host@example.com",
username: "second-host",
timeZone: "Asia/Kolkata",
defaultScheduleId: null,
schedules: [TestData.schedules.IstWorkHours],
credentials: [getGoogleCalendarCredential()],
selectedCalendars: [TestData.selectedCalendars.google],
destinationCalendar: {
integration: "google_calendar",
externalId: "second-host@google-calendar.com",
primaryEmail: "second-host@google-calendar.com",
},
},
],
bookings: [
{
uid: uidOfBookingToBeRescheduled,
eventTypeId: 1,
status: BookingStatus.ACCEPTED,
startTime: `${plus1DateString}T05:00:00.000Z`,
endTime: `${plus1DateString}T05:30:00.000Z`,
users: [
{
id: 101, // organizer
},
],
references: [
{
type: appStoreMetadata.dailyvideo.type,
uid: "MOCK_ID_1",
meetingId: "MOCK_ID_1",
meetingPassword: "MOCK_PASS_1",
meetingUrl: "http://mock-dailyvideo.example.com/meeting-1",
credentialId: null,
},
{
type: appStoreMetadata.googlecalendar.type,
uid: "MOCKED_GOOGLE_CALENDAR_EVENT_ID_1",
meetingId: "MOCKED_GOOGLE_CALENDAR_EVENT_ID_1",
meetingPassword: "MOCK_PASSWORD",
meetingUrl: "https://UNUSED_URL",
externalCalendarId: "event-type-1@google-calendar.com",
credentialId: undefined,
},
],
iCalUID,
},
],
apps: [TestData.apps["google-calendar"], TestData.apps["daily-video"]],
})
);
mockSuccessfulVideoMeetingCreation({
metadataLookupKey: appStoreMetadata.dailyvideo.dirName,
videoMeetingData: {
id: "MOCK_ID_1", // Video meeting ID is not updated during reschedule
password: "MOCK_PASS_1",
url: `http://mock-dailyvideo.example.com/meeting-1`,
},
});
const organizerCalendarMock = mockCalendarToHaveNoBusySlots("googlecalendar", {
update: {
id: "MOCKED_GOOGLE_CALENDAR_EVENT_ID_2",
iCalUID: "MOCKED_GOOGLE_CALENDAR_ICS_ID_2",
},
});
const rescheduleBookingData = getMockRequestDataForBooking({
data: {
start: `${plus2DateString}T11:00:00.000Z`,
end: `${plus2DateString}T11:30:00.000Z`,
eventTypeId: 1,
rescheduleUid: uidOfBookingToBeRescheduled,
responses: {
email: booker.email,
name: booker.name,
location: { optionValue: "", value: BookingLocations.CalVideo },
},
rescheduledBy: organizer.email,
},
});
const rescheduledBooking = await handleNewBooking({
bookingData: rescheduleBookingData,
});
await expectBookingToBeInDatabase({
description: "",
location: BookingLocations.CalVideo,
responses: expect.objectContaining({
email: booker.email,
name: booker.name,
}),
uid: rescheduledBooking.uid!,
eventTypeId: rescheduleBookingData.eventTypeId,
status: BookingStatus.ACCEPTED,
references: [
{
type: appStoreMetadata.dailyvideo.type,
uid: "MOCK_ID_1",
meetingId: "MOCK_ID_1",
meetingPassword: "MOCK_PASS_1",
meetingUrl: "http://mock-dailyvideo.example.com/meeting-1",
},
{
type: appStoreMetadata.googlecalendar.type,
uid: "MOCKED_GOOGLE_CALENDAR_EVENT_ID_1",
externalCalendarId: "event-type-1@google-calendar.com",
},
],
});
expect(organizerCalendarMock.updateEventCalls.length).toBe(1);
expect(organizerCalendarMock.createEventCalls.length).toBe(0);
// Check availability calls for both hosts
expect(organizerCalendarMock.getAvailabilityCalls.length).toBe(2);
},
timeout
);
});
});
@@ -107,6 +107,66 @@ const getLocationInfo = ({
return { locationAvailable, locationDetails };
};
const LocationInput = (props: {
eventLocationType: EventLocationType;
defaultValue?: string;
index: number;
customClassNames?: LocationInputCustomClassNames;
disableLocationProp?: boolean;
}) => {
const { t } = useLocale();
const { eventLocationType, index, customClassNames, disableLocationProp, ...remainingProps } = props;
if (eventLocationType?.organizerInputType === "text") {
const { defaultValue, ...rest } = remainingProps;
return (
<Controller
name={`locations.${index}.${eventLocationType.defaultValueVariable}`}
defaultValue={defaultValue}
render={({ field: { onChange, value } }) => {
return (
<Input
name={`locations[${index}].${eventLocationType.defaultValueVariable}`}
placeholder={t(eventLocationType.organizerInputPlaceholder || "")}
type="text"
required
onChange={onChange}
value={value}
{...(disableLocationProp ? { disabled: true } : {})}
className={classNames("my-0", customClassNames?.addressInput)}
{...rest}
/>
);
}}
/>
);
} else if (eventLocationType?.organizerInputType === "phone") {
const { defaultValue, ...rest } = remainingProps;
return (
<Controller
name={`locations.${index}.${eventLocationType.defaultValueVariable}`}
defaultValue={defaultValue}
render={({ field: { onChange, value } }) => {
return (
<PhoneInput
required
disabled={disableLocationProp}
placeholder={t(eventLocationType.organizerInputPlaceholder || "")}
name={`locations[${index}].${eventLocationType.defaultValueVariable}`}
className={customClassNames?.phoneInput}
value={value}
onChange={onChange}
{...rest}
/>
);
}}
/>
);
}
return null;
};
const Locations: React.FC<LocationsProps> = ({
isChildrenManagedEventType,
disableLocationProp,
@@ -168,64 +228,6 @@ const Locations: React.FC<LocationsProps> = ({
locationOptions: props.locationOptions,
});
const LocationInput = (props: {
eventLocationType: EventLocationType;
defaultValue?: string;
index: number;
customClassNames?: LocationInputCustomClassNames;
}) => {
const { eventLocationType, index, customClassNames, ...remainingProps } = props;
if (eventLocationType?.organizerInputType === "text") {
const { defaultValue, ...rest } = remainingProps;
return (
<Controller
name={`locations.${index}.${eventLocationType.defaultValueVariable}`}
defaultValue={defaultValue}
render={({ field: { onChange, value } }) => {
return (
<Input
name={`locations[${index}].${eventLocationType.defaultValueVariable}`}
placeholder={t(eventLocationType.organizerInputPlaceholder || "")}
type="text"
required
onChange={onChange}
value={value}
{...(disableLocationProp ? { disabled: true } : {})}
className={classNames("my-0", customClassNames?.addressInput)}
{...rest}
/>
);
}}
/>
);
} else if (eventLocationType?.organizerInputType === "phone") {
const { defaultValue, ...rest } = remainingProps;
return (
<Controller
name={`locations.${index}.${eventLocationType.defaultValueVariable}`}
defaultValue={defaultValue}
render={({ field: { onChange, value } }) => {
return (
<PhoneInput
required
disabled={disableLocationProp}
placeholder={t(eventLocationType.organizerInputPlaceholder || "")}
name={`locations[${index}].${eventLocationType.defaultValueVariable}`}
className={customClassNames?.phoneInput}
value={value}
onChange={onChange}
{...rest}
/>
);
}}
/>
);
}
return null;
};
const [showEmptyLocationSelect, setShowEmptyLocationSelect] = useState(false);
const defaultInitialLocation = defaultValue || null;
const [selectedNewOption, setSelectedNewOption] = useState<SingleValueLocationOption | null>(
@@ -459,6 +461,7 @@ const Locations: React.FC<LocationsProps> = ({
eventLocationType={eventLocationType}
index={index}
customClassNames={customClassNames?.organizerContactInput?.locationInput}
disableLocationProp={disableLocationProp}
/>
</div>
<ErrorMessage
+38 -16
View File
@@ -484,16 +484,34 @@ class EventsInsights {
: booking.attendees;
const formattedAttendees = attendeeList
.slice(0, 3)
.map((attendee) => (attendee ? `${attendee.name} (${attendee.email})` : null));
.map((attendee) => (attendee ? `${attendee.name} (${attendee.email})` : null))
.filter(Boolean);
return [
booking.uid,
{ attendeeList: formattedAttendees, noShowGuest: attendeeList[0]?.noShow || false },
];
})
);
const maxAttendees = Math.max(
...Array.from(bookingMap.values()).map((data) => data.attendeeList.length),
0
);
const finalBookingMap = new Map(
Array.from(bookingMap.entries()).map(([uid, data]) => {
const attendeeFields: Record<string, string | null> = {};
for (let i = 1; i <= maxAttendees; i++) {
attendeeFields[`attendee${i}`] = data.attendeeList[i - 1] || null;
}
return [
uid,
{
noShowGuest: attendeeList[0]?.noShow || false,
attendee1: formattedAttendees[0] || null,
attendee2: formattedAttendees[1] || null,
attendee3: formattedAttendees[2] || null,
noShowGuest: data.noShowGuest,
...attendeeFields,
},
];
})
@@ -502,33 +520,37 @@ class EventsInsights {
const data = csvData.map((bookingTimeStatus) => {
if (!bookingTimeStatus.uid) {
// should not be reached because we filtered above
const nullAttendeeFields: Record<string, null> = {};
for (let i = 1; i <= maxAttendees; i++) {
nullAttendeeFields[`attendee${i}`] = null;
}
return {
...bookingTimeStatus,
noShowGuest: false,
attendee1: null,
attendee2: null,
attendee3: null,
...nullAttendeeFields,
};
}
const attendeeData = bookingMap.get(bookingTimeStatus.uid);
const attendeeData = finalBookingMap.get(bookingTimeStatus.uid);
if (!attendeeData) {
const nullAttendeeFields: Record<string, null> = {};
for (let i = 1; i <= maxAttendees; i++) {
nullAttendeeFields[`attendee${i}`] = null;
}
return {
...bookingTimeStatus,
noShowGuest: false,
attendee1: null,
attendee2: null,
attendee3: null,
...nullAttendeeFields,
};
}
return {
...bookingTimeStatus,
noShowGuest: attendeeData.noShowGuest,
attendee1: attendeeData.attendee1,
attendee2: attendeeData.attendee2,
attendee3: attendeeData.attendee3,
...Object.fromEntries(Object.entries(attendeeData).filter(([key]) => key.startsWith("attendee"))),
};
});
+1 -8
View File
@@ -1,7 +1,5 @@
import type { CookieOption, CookiesOptions } from "next-auth";
import { isENVDev } from "@calcom/lib/env";
/**
* Copy from 'https://github.com/nextauthjs/next-auth/blob/227ff2259f/src/core/lib/cookie.ts' as we can't import it directly
*
@@ -20,12 +18,7 @@ export function defaultCookies(useSecureCookies: boolean): CookiesOptions {
const cookiePrefix = useSecureCookies ? "__Secure-" : "";
const defaultOptions: CookieOption["options"] = {
domain: isENVDev
? process.env.ORGANIZATIONS_ENABLED
? //FIXME: This is causing login to not work if someone uses anything other .cal.local for testing
".cal.local"
: undefined
: NEXTAUTH_COOKIE_DOMAIN,
domain: NEXTAUTH_COOKIE_DOMAIN || undefined,
// To enable cookies on widgets,
// https://stackoverflow.com/questions/45094712/iframe-not-reading-cookies-in-chrome
// But we need to set it as `lax` in development
+2
View File
@@ -104,6 +104,8 @@ export function getEventName(eventNameObj: EventNameObjectType, forAttendeeView
}
dynamicEventName = dynamicEventName.replace(`{${variable}}`, fieldValue || "");
} else {
dynamicEventName = dynamicEventName.replace(`{${variable}}`, "");
}
});
+12
View File
@@ -0,0 +1,12 @@
/**
* Checks if a string is a supported base64 image format
*/
export function isBase64Image(input: string | null | undefined): boolean {
if (!input) return false;
return (
input.startsWith("data:image/png;base64,") ||
input.startsWith("data:image/jpeg;base64,") ||
input.startsWith("data:image/jpg;base64,")
);
}
@@ -138,6 +138,70 @@ export class ScheduleRepository {
isDefault: !scheduleId || defaultScheduleId === schedule.id,
isLastSchedule: schedulesCount <= 1,
readOnly: schedule.userId !== userId && !isManagedEventType,
userId: schedule.userId,
};
}
static async findManyDetailedScheduleByUserId({
isManagedEventType,
userId,
defaultScheduleId,
timeZone: userTimeZone,
}: {
timeZone: string;
userId: number;
defaultScheduleId: number | null;
isManagedEventType?: boolean;
}) {
const schedules = await prisma.schedule.findMany({
where: {
userId: userId,
},
select: {
id: true,
userId: true,
name: true,
availability: true,
timeZone: true,
},
});
if (!schedules?.length) {
throw new Error("Schedules not found");
}
const isCurrentUserPartOfTeam = await hasReadPermissionsForUserId({
memberId: schedules[0].userId,
userId,
});
const schedulesFormatted = schedules.map((schedule) => {
const isCurrentUserOwner = schedule?.userId === userId;
if (!isCurrentUserPartOfTeam && !isCurrentUserOwner) {
throw new Error("UNAUTHORIZED");
}
const timeZone = schedule.timeZone || userTimeZone;
// disabling utc casting while fetching WorkingHours
return {
id: schedule.id,
name: schedule.name,
isManaged: schedule.userId !== userId,
workingHours: transformWorkingHoursForAtom(schedule),
schedule: schedule.availability,
availability: transformAvailabilityForAtom(schedule),
timeZone,
isDefault: schedule.id === defaultScheduleId,
dateOverrides: transformDateOverridesForAtom(schedule, timeZone),
readOnly: schedule.userId !== userId && !isManagedEventType,
isLastSchedule: schedules.length <= 1,
userId: schedule.userId,
};
});
return schedulesFormatted;
}
}
Binary file not shown.
@@ -0,0 +1,148 @@
import type { User, Team, EventType } from "@prisma/client";
import { describe, it, expect, afterAll, beforeAll } from "vitest";
import prisma from "@calcom/prisma";
import { MembershipRole } from "@calcom/prisma/enums";
import { listWithTeamHandler } from "./listWithTeam.handler";
let user1: User;
let user2: User;
let team1: Team;
let team2: Team;
let eventType1: EventType;
let eventType2: EventType;
let eventType3: EventType;
let eventType4: EventType;
describe("listWithTeamHandler", () => {
beforeAll(async () => {
// Create users, teams and event types
user1 = await prisma.user.create({
data: {
username: "testuser-lwt-1",
email: "testuser-lwt-1@example.com",
name: "Test User 1",
},
});
user2 = await prisma.user.create({
data: {
username: "testuser-lwt-2",
email: "testuser-lwt-2@example.com",
name: "Test User 2",
},
});
team1 = await prisma.team.create({
data: {
name: "Team 1 lwt",
slug: "team-1-lwt",
members: {
create: {
userId: user1.id,
role: MembershipRole.MEMBER,
accepted: true,
},
},
},
});
team2 = await prisma.team.create({
data: {
name: "Team 2 lwt",
slug: "team-2-lwt",
},
});
eventType1 = await prisma.eventType.create({
data: {
title: "User 1 Event",
slug: "user1-event-lwt",
length: 30,
userId: user1.id,
},
});
eventType2 = await prisma.eventType.create({
data: {
title: "Team 1 Event",
slug: "team1-event-lwt",
length: 30,
teamId: team1.id,
userId: user1.id,
},
});
eventType3 = await prisma.eventType.create({
data: {
title: "User 2 Event",
slug: "user2-event-lwt",
length: 30,
userId: user2.id,
},
});
eventType4 = await prisma.eventType.create({
data: {
title: "Team 2 Event",
slug: "team2-event-lwt",
length: 30,
teamId: team2.id,
userId: user2.id,
},
});
});
afterAll(async () => {
await prisma.eventType.deleteMany({
where: {
id: {
in: [eventType1.id, eventType2.id, eventType3.id, eventType4.id],
},
},
});
await prisma.team.deleteMany({
where: {
id: {
in: [team1.id, team2.id],
},
},
});
await prisma.user.deleteMany({
where: {
id: {
in: [user1.id, user2.id],
},
},
});
});
it("should return user's own event types and event types of teams they are a member of", async () => {
const result = await listWithTeamHandler({
ctx: {
// we only need the id from the user object
user: { id: user1.id } as any,
},
});
expect(result).toHaveLength(2);
const resultIds = result.map((e) => e.id);
expect(resultIds).toContain(eventType1.id);
expect(resultIds).toContain(eventType2.id);
const eventType1Result = result.find((e) => e.id === eventType1.id);
expect(eventType1Result).toBeDefined();
expect(eventType1Result?.title).toBe(eventType1.title);
expect(eventType1Result?.slug).toBe(eventType1.slug);
expect(eventType1Result?.team).toBeNull();
const eventType2Result = result.find((e) => e.id === eventType2.id);
expect(eventType2Result).toBeDefined();
expect(eventType2Result?.title).toBe(eventType2.title);
expect(eventType2Result?.slug).toBe(eventType2.slug);
expect(eventType2Result?.team).toBeDefined();
expect(eventType2Result?.team?.id).toBe(team1.id);
expect(eventType2Result?.team?.name).toBe(team1.name);
});
});
@@ -1,4 +1,6 @@
import { prisma } from "@calcom/prisma";
import { Prisma } from "@prisma/client";
import db from "@calcom/prisma";
import type { TrpcSessionUser } from "../../../types";
@@ -9,31 +11,26 @@ type ListWithTeamOptions = {
};
export const listWithTeamHandler = async ({ ctx }: ListWithTeamOptions) => {
return await prisma.eventType.findMany({
where: {
OR: [
{ userId: ctx.user.id },
{
team: {
members: {
some: {
userId: ctx.user.id,
},
},
},
},
],
},
select: {
id: true,
team: {
select: {
id: true,
name: true,
},
},
title: true,
slug: true,
},
});
const userId = ctx.user.id;
const query = Prisma.sql`SELECT "public"."EventType"."id", "public"."EventType"."teamId", "public"."EventType"."title", "public"."EventType"."slug", "j1"."name" as "teamName"
FROM "public"."EventType"
LEFT JOIN "public"."Team" AS "j1" ON ("j1"."id") = ("public"."EventType"."teamId")
WHERE "public"."EventType"."userId" = ${userId}
UNION
SELECT "public"."EventType"."id", "public"."EventType"."teamId", "public"."EventType"."title", "public"."EventType"."slug", "j1"."name" as "teamName"
FROM "public"."EventType"
INNER JOIN "public"."Team" AS "j1" ON ("j1"."id") = ("public"."EventType"."teamId")
INNER JOIN "public"."Membership" AS "t2" ON "t2"."teamId" = "j1"."id"
WHERE "t2"."userId" = ${userId} AND "t2"."accepted" = true`;
const result = await db.$queryRaw<
{ id: number; teamId: number | null; title: string; slug: string; teamName: string | null }[]
>(query);
return result.map((row) => ({
id: row.id,
team: row.teamId ? { id: row.teamId, name: row.teamName || "" } : null,
title: row.title,
slug: row.slug,
}));
};
@@ -9,6 +9,7 @@ import { StripeBillingService } from "@calcom/features/ee/billing/stripe-billlin
import { FeaturesRepository } from "@calcom/features/flags/features.repository";
import hasKeyInMetadata from "@calcom/lib/hasKeyInMetadata";
import { HttpError } from "@calcom/lib/http-error";
import { isBase64Image } from "@calcom/lib/isBase64Image";
import logger from "@calcom/lib/logger";
import { uploadAvatar } from "@calcom/lib/server/avatar";
import { checkUsername } from "@calcom/lib/server/checkUsername";
@@ -152,7 +153,7 @@ export const updateProfileHandler = async ({ ctx, input }: UpdateProfileOptions)
}
// if defined AND a base 64 string, upload and update the avatar URL
if (input.avatarUrl && input.avatarUrl.startsWith("data:image/png;base64,")) {
if (input.avatarUrl && isBase64Image(input.avatarUrl)) {
data.avatarUrl = await uploadAvatar({
avatar: await resizeBase64Image(input.avatarUrl),
userId: user.id,
@@ -2,6 +2,7 @@ import type { Prisma } from "@prisma/client";
import { IS_TEAM_BILLING_ENABLED } from "@calcom/lib/constants";
import { getMetadataHelpers } from "@calcom/lib/getMetadataHelpers";
import { isBase64Image } from "@calcom/lib/isBase64Image";
import { uploadLogo } from "@calcom/lib/server/avatar";
import { isOrganisationAdmin } from "@calcom/lib/server/queries/organisations";
import { resizeBase64Image } from "@calcom/lib/server/resizeBase64Image";
@@ -161,12 +162,7 @@ export const updateHandler = async ({ ctx, input }: UpdateOptions) => {
metadata: mergeMetadata({ ...input.metadata }),
};
if (
input.banner &&
(input.banner.startsWith("data:image/png;base64,") ||
input.banner.startsWith("data:image/jpeg;base64,") ||
input.banner.startsWith("data:image/jpg;base64,"))
) {
if (input.banner && isBase64Image(input.banner)) {
const banner = await resizeBase64Image(input.banner, { maxSize: 1500 });
data.bannerUrl = await uploadLogo({
logo: banner,
@@ -177,12 +173,7 @@ export const updateHandler = async ({ ctx, input }: UpdateOptions) => {
data.bannerUrl = null;
}
if (
input.logoUrl &&
(input.logoUrl.startsWith("data:image/png;base64,") ||
input.logoUrl.startsWith("data:image/jpeg;base64,") ||
input.logoUrl.startsWith("data:image/jpg;base64,"))
) {
if (input.logoUrl && isBase64Image(input.logoUrl)) {
data.logoUrl = await uploadLogo({
logo: await resizeBase64Image(input.logoUrl),
teamId: currentOrgId,
@@ -2,6 +2,7 @@ import type { Prisma, PrismaPromise, User, Membership, Profile } from "@prisma/c
import { ensureOrganizationIsReviewed } from "@calcom/ee/organizations/lib/ensureOrganizationIsReviewed";
import { checkAdminOrOwner } from "@calcom/features/auth/lib/checkAdminOrOwner";
import { isBase64Image } from "@calcom/lib/isBase64Image";
import { uploadAvatar } from "@calcom/lib/server/avatar";
import { checkRegularUsername } from "@calcom/lib/server/checkRegularUsername";
import { isOrganisationAdmin, isOrganisationOwner } from "@calcom/lib/server/queries/organisations";
@@ -119,7 +120,7 @@ export const updateUserHandler = async ({ ctx, input }: UpdateUserOptions) => {
timeZone: input.timeZone,
};
if (input.avatar && input.avatar.startsWith("data:image/png;base64,")) {
if (input.avatar && isBase64Image(input.avatar)) {
const avatar = await resizeBase64Image(input.avatar);
data.avatarUrl = await uploadAvatar({
avatar,
@@ -1,5 +1,6 @@
import { generateTeamCheckoutSession } from "@calcom/features/ee/teams/lib/payments";
import { IS_TEAM_BILLING_ENABLED, WEBAPP_URL } from "@calcom/lib/constants";
import { isBase64Image } from "@calcom/lib/isBase64Image";
import { uploadLogo } from "@calcom/lib/server/avatar";
import { ProfileRepository } from "@calcom/lib/server/repository/profile";
import { resizeBase64Image } from "@calcom/lib/server/resizeBase64Image";
@@ -106,7 +107,7 @@ export const createHandler = async ({ ctx, input }: CreateOptions) => {
},
});
// Upload logo, create doesn't allow logo removal
if (input.logo && input.logo.startsWith("data:image/png;base64,")) {
if (input.logo && isBase64Image(input.logo)) {
const logoUrl = await uploadLogo({
logo: await resizeBase64Image(input.logo),
teamId: createdTeam.id,
@@ -4,6 +4,7 @@ import { getOrgFullOrigin } from "@calcom/ee/organizations/lib/orgDomains";
import { IS_TEAM_BILLING_ENABLED } from "@calcom/lib/constants";
import type { IntervalLimit } from "@calcom/lib/intervalLimits/intervalLimitSchema";
import { validateIntervalLimitOrder } from "@calcom/lib/intervalLimits/validateIntervalLimitOrder";
import { isBase64Image } from "@calcom/lib/isBase64Image";
import { uploadLogo } from "@calcom/lib/server/avatar";
import { isTeamAdmin } from "@calcom/lib/server/queries/teams";
import { prisma } from "@calcom/prisma";
@@ -70,7 +71,7 @@ export const updateHandler = async ({ ctx, input }: UpdateOptions) => {
rrTimestampBasis: input.rrTimestampBasis,
};
if (input.logo && input.logo.startsWith("data:image/png;base64,")) {
if (input.logo && isBase64Image(input.logo)) {
data.logoUrl = await uploadLogo({ teamId: input.id, logo: input.logo });
} else if (typeof input.logo !== "undefined" && !input.logo) {
data.logoUrl = null;
+6 -18
View File
@@ -2519,8 +2519,7 @@ __metadata:
"@axiomhq/winston": ^1.2.0
"@calcom/platform-constants": "*"
"@calcom/platform-enums": "*"
"@calcom/platform-libraries": "npm:@calcom/platform-libraries@0.0.231"
"@calcom/platform-libraries-0.0.2": "npm:@calcom/platform-libraries@0.0.2"
"@calcom/platform-libraries": "npm:@calcom/platform-libraries@0.0.236"
"@calcom/platform-types": "*"
"@calcom/platform-utils": "*"
"@calcom/prisma": "*"
@@ -2712,8 +2711,7 @@ __metadata:
vite-plugin-node-polyfills: ^0.22.0
peerDependencies:
react: ^18.0.0 || ^19.0.0
react-awesome-query-builder: ^5.1.2
react-dom: ^18.0.0 || ^19.0.0
typescript: ^5.0.0
languageName: unknown
linkType: soft
@@ -3567,23 +3565,13 @@ __metadata:
languageName: unknown
linkType: soft
"@calcom/platform-libraries-0.0.2@npm:@calcom/platform-libraries@0.0.2":
version: 0.0.2
resolution: "@calcom/platform-libraries@npm:0.0.2"
"@calcom/platform-libraries@npm:@calcom/platform-libraries@0.0.236":
version: 0.0.236
resolution: "@calcom/platform-libraries@npm:0.0.236"
dependencies:
"@calcom/features": "*"
"@calcom/lib": "*"
checksum: 61b6be1b9d0be8a54ca8b1dff4b1e4db122b3c30d9203467f5347232eaf600ca3892da45a2de8abfe75327086406c33c1f6f75cd12a7147744437bc85a0ee755
languageName: node
linkType: hard
"@calcom/platform-libraries@npm:@calcom/platform-libraries@0.0.231":
version: 0.0.231
resolution: "@calcom/platform-libraries@npm:0.0.231"
dependencies:
"@calcom/features": "*"
"@calcom/lib": "*"
checksum: 0abd36b041cc2ed2068c56d36aa94286f381f425b88a0d0d7cdb14498263bbece4928162d8cea00bbf4fa01f4fb60a018933f3f8e4573331d7be2b22aab2eeaa
checksum: 7670da54aa73dfe39838ad370fd5c48ab7c8fd99f812cd68bcde76fe79d2079f8885cee24b04e3f232ef18e55c2ef9ebb940d5022751baf435d3be9c517710d9
languageName: node
linkType: hard